diff --git a/README.md b/README.md index a261408..16ea278 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,35 @@ const { text, status } = useStream( ); ``` +### Svelte (beta) + +Svelte 5 bindings are community-supported and in beta. They need `svelte` and +[`convex-svelte`](https://github.com/get-convex/convex-svelte), with +`setupConvex` called in a parent component. Reactive inputs are passed as +getters. See [`example-svelte/`](./example-svelte/) for the chat app. + +```svelte + + +

{stream.text}

+``` + +`stream.text` and `stream.status` update as the stream progresses. The HTTP +request only starts in the browser, never during server-side rendering. + ## Design Philosophy This component balances HTTP streaming with database persistence to try to diff --git a/example-svelte/README.md b/example-svelte/README.md new file mode 100644 index 0000000..3ddf677 --- /dev/null +++ b/example-svelte/README.md @@ -0,0 +1,18 @@ +# Svelte Chat Example App + +The chat app from [`example/`](../example/) built with Svelte 5 and the +`@convex-dev/persistent-text-streaming/svelte` bindings. It shares the Convex +backend in `example/convex` and the dependencies in the root `package.json`. + +## Running the app + +From the repository root: + +```bash +npm install +npm run dev # in one terminal +npm run dev:svelte # in another terminal +``` + +Set `OPENAI_API_KEY` as described in the +[React example's README](../example/README.md#establishing-your-openai_api_key). diff --git a/example-svelte/index.html b/example-svelte/index.html new file mode 100644 index 0000000..9fa6600 --- /dev/null +++ b/example-svelte/index.html @@ -0,0 +1,13 @@ + + + + + + Convex Chat Example (Svelte) + + +
+ + + + diff --git a/example-svelte/src/App.svelte b/example-svelte/src/App.svelte new file mode 100644 index 0000000..67b1188 --- /dev/null +++ b/example-svelte/src/App.svelte @@ -0,0 +1,12 @@ + + +
+ +
+ diff --git a/example-svelte/src/app.css b/example-svelte/src/app.css new file mode 100644 index 0000000..ab3e8d2 --- /dev/null +++ b/example-svelte/src/app.css @@ -0,0 +1,17 @@ +@import "tailwindcss"; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + diff --git a/example-svelte/src/components/ChatWindow.svelte b/example-svelte/src/components/ChatWindow.svelte new file mode 100644 index 0000000..69b13b5 --- /dev/null +++ b/example-svelte/src/components/ChatWindow.svelte @@ -0,0 +1,129 @@ + + +
+
+
+ {#if messages.isLoading} +
Loading messages...
+ {:else if messages.error} +
+ Error loading messages: {messages.error.message} +
+ {:else if messages.data && messages.data.length === 0} +
+ No messages yet. Start the conversation! +
+ {:else if messages.data} + {#each messages.data as message (message._id)} + + {message.prompt} + + + + + {/each} + {/if} +
+
+
+ +
+
+
+ + + +
+ {#if isStreaming} +
AI is responding...
+ {/if} +
+
+
+ diff --git a/example-svelte/src/components/MessageItem.svelte b/example-svelte/src/components/MessageItem.svelte new file mode 100644 index 0000000..b6b47a3 --- /dev/null +++ b/example-svelte/src/components/MessageItem.svelte @@ -0,0 +1,53 @@ + + +{#if isUser} +
+
+
+ {formattedDate} + {formattedTime} +
+
+
+{/if} + +
+
+
+ {isUser ? "U" : "AI"} +
+ +
+ {@render children()} +
+
+
+ diff --git a/example-svelte/src/components/ServerMessage.svelte b/example-svelte/src/components/ServerMessage.svelte new file mode 100644 index 0000000..3010810 --- /dev/null +++ b/example-svelte/src/components/ServerMessage.svelte @@ -0,0 +1,57 @@ + + +
+ + + {#snippet a({ href, children, ...rest }: HTMLAnchorAttributes)} + + {@render children?.()} + + {/snippet} + + {#if stream.status === "error"} +
Error loading response
+ {/if} +
diff --git a/example-svelte/src/lib/utils.ts b/example-svelte/src/lib/utils.ts new file mode 100644 index 0000000..8a5e888 --- /dev/null +++ b/example-svelte/src/lib/utils.ts @@ -0,0 +1,22 @@ +// Keeps relative, http(s), and mailto links and drops every other scheme, +// such as `javascript:`. Similar to react-markdown's default URL transform, +// which also allows a few more schemes and applies to images too. +export function safeHref(href: string) { + const scheme = /^([^:/?#]+):/.exec(href)?.[1]; + return !scheme || /^(https?|mailto)$/i.test(scheme) ? href : undefined; +} + +export function getConvexSiteUrl() { + let convexSiteUrl; + if (import.meta.env.VITE_CONVEX_URL.includes(".cloud")) { + convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace( + /\.cloud$/, + ".site", + ); + } else { + const url = new URL(import.meta.env.VITE_CONVEX_URL); + url.port = String(Number(url.port) + 1); + convexSiteUrl = url.toString(); + } + return convexSiteUrl; +} diff --git a/example-svelte/src/main.ts b/example-svelte/src/main.ts new file mode 100644 index 0000000..e8ddb4c --- /dev/null +++ b/example-svelte/src/main.ts @@ -0,0 +1,10 @@ +import App from "./App.svelte"; +import "./app.css"; +import { mount } from "svelte"; + +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; + diff --git a/example-svelte/src/vite-env.d.ts b/example-svelte/src/vite-env.d.ts new file mode 100644 index 0000000..f3c063b --- /dev/null +++ b/example-svelte/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// +/// + +interface ImportMetaEnv { + readonly VITE_CONVEX_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + diff --git a/example-svelte/tsconfig.json b/example-svelte/tsconfig.json new file mode 100644 index 0000000..09c3404 --- /dev/null +++ b/example-svelte/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noEmit": true + }, + "include": ["./src", "vite.config.ts"] +} diff --git a/example-svelte/vite.config.ts b/example-svelte/vite.config.ts new file mode 100644 index 0000000..f675023 --- /dev/null +++ b/example-svelte/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import tailwindcss from "@tailwindcss/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + envDir: "../", + plugins: [svelte(), tailwindcss()], + resolve: { + alias: { + "@": "/src", + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 77eb86c..bd30251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/vite": "4.2.2", "@types/node": "22.20.1", "@types/react": "19.2.14", @@ -21,17 +22,22 @@ "chokidar-cli": "3.0.0", "clsx": "2.1.1", "convex": "1.46.0", + "convex-svelte": "0.14.0", "convex-test": "0.0.64", "eslint": "9.39.4", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.5.2", "globals": "17.5.0", + "happy-dom": "20.14.5", "openai": "7.0.0", "pkg-pr-new": "0.0.66", "prettier": "3.8.3", "react": "19.2.5", "react-dom": "19.2.5", "react-markdown": "10.1.0", + "svelte": "5.57.2", + "svelte-check": "4.7.6", + "svelte-exmarkdown": "5.0.2", "tailwind-merge": "3.5.0", "tailwindcss": "4.2.2", "typescript": "5.9.3", @@ -41,8 +47,24 @@ }, "peerDependencies": { "convex": "^1.32.0", + "convex-svelte": ">=0.0.12 <0.16.0", "react": "~18.3.1 || ^19.0.0", - "react-dom": "~18.3.1 || ^19.0.0" + "react-dom": "~18.3.1 || ^19.0.0", + "svelte": "^5.0.0" + }, + "peerDependenciesMeta": { + "convex-svelte": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + } } }, "node_modules/@actions/core": { @@ -668,9 +690,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1273,6 +1295,56 @@ "dev": true, "license": "MIT" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1649,9 +1721,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1736,6 +1808,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", @@ -2178,9 +2267,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -2277,6 +2366,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2287,6 +2386,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2396,6 +2505,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -2696,6 +2818,21 @@ } } }, + "node_modules/convex-svelte": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/convex-svelte/-/convex-svelte-0.14.0.tgz", + "integrity": "sha512-eCaId++XnczQvEGc0sQi30BHkSEQ5AZjdDWaAqSlva2h9zKezGz8enp6MdFN7MIIMIlYZF3dKdzQ/LQ0d59KhQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esm-env": "^1.2.2", + "runed": "^0.37.1" + }, + "peerDependencies": { + "convex": "^1.30.0", + "svelte": "^5.19.0" + } + }, "node_modules/convex-test": { "version": "0.0.64", "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.64.tgz", @@ -3254,6 +3391,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -3281,6 +3428,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3323,6 +3477,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -3473,6 +3640,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3504,6 +3678,24 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -3758,6 +3950,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3999,6 +4210,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -4391,6 +4612,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4449,6 +4677,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4459,6 +4697,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -4484,6 +4763,113 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -4691,6 +5077,134 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -5109,6 +5623,16 @@ "ufo": "^1.6.3" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5607,6 +6131,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -5642,6 +6185,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5710,6 +6269,48 @@ "dev": true, "license": "MIT" }, + "node_modules/runed": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.37.1.tgz", + "integrity": "sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0", + "zod": "^4.1.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5911,6 +6512,104 @@ "node": ">=8" } }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-exmarkdown": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/svelte-exmarkdown/-/svelte-exmarkdown-5.0.2.tgz", + "integrity": "sha512-/GhWbh0TBd7OPsL6IhQZm4BVuglP5MP4lVwBDnIF8IYfmHfwfY0z/nu78L8FDwJM2pP+DW8045g+jUhM357U1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5" + }, + "peerDependencies": { + "svelte": "^5.1.3" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -6407,6 +7106,26 @@ } } }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", @@ -6497,6 +7216,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6744,6 +7473,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/package.json b/package.json index 59e41d1..6869192 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,13 @@ "scripts": { "dev": "convex dev --start 'npm run dev:build'", "dev:frontend": "cd example && vite --clearScreen false", + "dev:svelte": "cd example-svelte && vite --clearScreen false", "dev:build": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'npm run build:codegen' --initial", "predev": "convex init && npm run build:codegen", "build": "tsc --project ./tsconfig.build.json", "build:codegen": "npx convex codegen --component-dir ./src/component && npm run build", "build:clean": "rm -rf dist *.tsbuildinfo && npm run build:codegen", - "typecheck": "tsc --noEmit && tsc -p example && tsc -p example/convex", + "typecheck": "tsc --noEmit && tsc -p example && tsc -p example/convex && svelte-check --tsconfig example-svelte/tsconfig.json", "lint": "eslint .", "test": "vitest run --typecheck", "test:watch": "vitest --typecheck --clearScreen false", @@ -47,6 +48,11 @@ "types": "./dist/react/index.d.ts", "default": "./dist/react/index.js" }, + "./svelte": { + "types": "./dist/svelte/index.svelte.d.ts", + "svelte": "./dist/svelte/index.svelte.js", + "default": "./dist/svelte/index.svelte.js" + }, "./test": "./src/test.ts", "./_generated/component.js": { "types": "./dist/component/_generated/component.d.ts" @@ -62,14 +68,31 @@ }, "peerDependencies": { "convex": "^1.32.0", + "convex-svelte": ">=0.0.12 <0.16.0", "react": "~18.3.1 || ^19.0.0", - "react-dom": "~18.3.1 || ^19.0.0" + "react-dom": "~18.3.1 || ^19.0.0", + "svelte": "^5.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "convex-svelte": { + "optional": true + } }, "devDependencies": { "@convex-dev/eslint-plugin": "^5.0.0", "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/vite": "4.2.2", "@types/node": "22.20.1", "@types/react": "19.2.14", @@ -78,17 +101,22 @@ "chokidar-cli": "3.0.0", "clsx": "2.1.1", "convex": "1.46.0", + "convex-svelte": "0.14.0", "convex-test": "0.0.64", "eslint": "9.39.4", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.5.2", "globals": "17.5.0", + "happy-dom": "20.14.5", "openai": "7.0.0", "pkg-pr-new": "0.0.66", "prettier": "3.8.3", "react": "19.2.5", "react-dom": "19.2.5", "react-markdown": "10.1.0", + "svelte": "5.57.2", + "svelte-check": "4.7.6", + "svelte-exmarkdown": "5.0.2", "tailwind-merge": "3.5.0", "tailwindcss": "4.2.2", "typescript": "5.9.3", diff --git a/src/svelte/index.svelte.test.ts b/src/svelte/index.svelte.test.ts new file mode 100644 index 0000000..3bb83ec --- /dev/null +++ b/src/svelte/index.svelte.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment happy-dom +// Effects only run in client builds, which a DOM environment selects. +import { flushSync } from "svelte"; +import { SvelteMap } from "svelte/reactivity"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { StreamBody, StreamId } from "../client/index.js"; +import { useStream } from "./index.svelte.js"; + +// Stands in for the Convex subscription: a subscribed stream id returns the +// body stored for it, and "skip" returns nothing. The map is reactive, so a +// later write reaches the hook like a subscription update. The factory only +// reads `persisted` when a test runs, after this module has initialized it. +const persisted = new SvelteMap(); +vi.mock("convex-svelte", () => ({ + useQuery: ( + _query: unknown, + getArgs: () => { streamId: string } | "skip", + ) => ({ + get data() { + const args = getArgs(); + return args === "skip" ? undefined : persisted.get(args.streamId); + }, + }), +})); + +const query = {} as Parameters[0]; +const url = new URL("https://example.convex.site/chat-stream"); + +type FetchInit = { + body: string; + headers: Record; + signal: AbortSignal; +}; +type Options = { authToken: string; headers: Record }; +const fetchMock = vi.fn<(url: URL, init: FetchInit) => Promise>(); + +function controlledResponse() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + return { response: new Response(body), controller }; +} + +function render( + initial: { driven: boolean; streamId?: string }, + getOpts?: Parameters[4], +) { + const props = $state(initial); + let stream!: Readonly; + const destroy = $effect.root(() => { + stream = useStream( + query, + url, + () => props.driven, + () => props.streamId as StreamId | undefined, + getOpts, + ); + }); + flushSync(); + return { props, stream, destroy }; +} + +const requestedIds = () => + fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).streamId); + +let consoleError: ReturnType; + +beforeEach(() => { + persisted.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useStream (svelte)", () => { + test("decodes characters split across chunks", async () => { + const { response, controller } = controlledResponse(); + fetchMock.mockResolvedValue(response); + const { stream, destroy } = render({ driven: true, streamId: "a" }); + + const bytes = new TextEncoder().encode("héllo 👋"); + // Split inside "é" (2 bytes) and inside the emoji (4 bytes). + controller.enqueue(bytes.slice(0, 2)); + controller.enqueue(bytes.slice(2, 9)); + controller.enqueue(bytes.slice(9)); + controller.close(); + + await vi.waitFor(() => expect(stream.status).toBe("done")); + expect(stream.text).toBe("héllo 👋"); + destroy(); + }); + + test("a new streamId resets the text and starts a new request", async () => { + const first = controlledResponse(); + const second = controlledResponse(); + fetchMock + .mockResolvedValueOnce(first.response) + .mockResolvedValueOnce(second.response); + const { props, stream, destroy } = render({ driven: true, streamId: "a" }); + + first.controller.enqueue(new TextEncoder().encode("first")); + await vi.waitFor(() => expect(stream.text).toBe("first")); + + props.streamId = "b"; + flushSync(); + expect(stream).toMatchObject({ text: "", status: "pending" }); + expect(requestedIds()).toEqual(["a", "b"]); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + + second.controller.enqueue(new TextEncoder().encode("second")); + second.controller.close(); + await vi.waitFor(() => expect(stream.status).toBe("done")); + expect(stream.text).toBe("second"); + destroy(); + }); + + test("an undefined streamId shows empty text and pending", async () => { + const { response, controller } = controlledResponse(); + fetchMock.mockResolvedValue(response); + const { props, stream, destroy } = render({ driven: true, streamId: "a" }); + + controller.enqueue(new TextEncoder().encode("done text")); + controller.close(); + await vi.waitFor(() => expect(stream.status).toBe("done")); + + props.streamId = undefined; + flushSync(); + expect(stream).toMatchObject({ text: "", status: "pending" }); + destroy(); + }); + + test("switching to a stream this session does not drive hides the last one", async () => { + const { response, controller } = controlledResponse(); + fetchMock.mockResolvedValue(response); + const { props, stream, destroy } = render({ driven: true, streamId: "a" }); + + controller.enqueue(new TextEncoder().encode("done text")); + controller.close(); + await vi.waitFor(() => expect(stream.status).toBe("done")); + + props.driven = false; + props.streamId = "b"; + flushSync(); + expect(stream).toMatchObject({ text: "", status: "pending" }); + + persisted.set("b", { text: "from the database", status: "done" }); + expect(stream).toMatchObject({ text: "from the database", status: "done" }); + destroy(); + }); + + test("does not restart when the getter re-runs with the same streamId", () => { + fetchMock.mockReturnValue(new Promise(() => {})); + const message = $state({ current: { streamId: "a" } }); + const destroy = $effect.root(() => { + useStream( + query, + url, + () => true, + () => message.current.streamId as StreamId, + ); + }); + flushSync(); + + message.current = { streamId: "a" }; + flushSync(); + + expect(requestedIds()).toEqual(["a"]); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(false); + destroy(); + }); + + test("teardown aborts the request without reporting an error", async () => { + // Like the real fetch, reject once the request is aborted. + fetchMock.mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + const { stream, destroy } = render({ driven: true, streamId: "a" }); + + destroy(); + + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(stream.status).toBe("pending"); + expect(consoleError).not.toHaveBeenCalled(); + }); + + test("a response settling after a switch does not leak into the next stream", async () => { + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 205 })) + .mockReturnValueOnce(new Promise(() => {})); + const { props, stream, destroy } = render({ driven: true, streamId: "a" }); + + // Switch before the first response's continuation runs. + props.streamId = "b"; + flushSync(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(stream.status).toBe("pending"); + destroy(); + }); + + test("a rejected fetch falls back to the persisted body", async () => { + persisted.set("a", { text: "partial", status: "streaming" }); + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + const { stream, destroy } = render({ driven: true, streamId: "a" }); + + await vi.waitFor(() => expect(stream.text).toBe("partial")); + persisted.set("a", { text: "partial and more", status: "done" }); + expect(stream).toMatchObject({ text: "partial and more", status: "done" }); + destroy(); + }); + + test("resuming a stream before its first chunk persists does not POST again", async () => { + // Like the real fetch, reject once the request is aborted. + fetchMock.mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + persisted.set("a", { text: "", status: "pending" }); + const { props, stream, destroy } = render({ driven: true, streamId: "a" }); + + props.driven = false; + flushSync(); + props.driven = true; + flushSync(); + persisted.set("a", { text: "first chunk", status: "streaming" }); + + expect(requestedIds()).toEqual(["a"]); + expect(stream).toMatchObject({ text: "first chunk", status: "streaming" }); + + // A new stream still streams over HTTP, and its request stays open. + const b = controlledResponse(); + fetchMock.mockResolvedValueOnce(b.response); + props.streamId = "b"; + flushSync(); + expect(requestedIds()).toEqual(["a", "b"]); + expect(stream).toMatchObject({ text: "", status: "pending" }); + + b.controller.enqueue(new TextEncoder().encode("from http")); + await vi.waitFor(() => + expect(stream).toMatchObject({ text: "from http", status: "streaming" }), + ); + expect(fetchMock.mock.calls[1][1].signal.aborted).toBe(false); + destroy(); + }); + + test.each([ + ["the token", (o: Options) => (o.authToken = "new")], + ["a header", (o: Options) => (o.headers["X-Trace"] = "new")], + ])("changing %s does not restart the request", (_name, change) => { + fetchMock.mockReturnValue(new Promise(() => {})); + const options = $state({ + authToken: "old", + headers: { "X-Trace": "old" }, + }); + const { destroy } = render({ driven: true, streamId: "a" }, () => options); + + change(options); + flushSync(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(false); + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + Authorization: "Bearer old", + "X-Trace": "old", + }); + destroy(); + }); + + test.each([205, 500])( + "a %i response falls back to the persisted body", + async (status) => { + persisted.set("a", { text: "from the database", status: "done" }); + fetchMock.mockResolvedValue(new Response(null, { status })); + const { stream, destroy } = render({ driven: true, streamId: "a" }); + + await vi.waitFor(() => expect(stream.text).toBe("from the database")); + destroy(); + }, + ); +}); diff --git a/src/svelte/index.svelte.ts b/src/svelte/index.svelte.ts new file mode 100644 index 0000000..003e70b --- /dev/null +++ b/src/svelte/index.svelte.ts @@ -0,0 +1,186 @@ +/// Svelte helpers for persistent text streaming. +import type { StreamStatus } from "../component/schema.js"; +import { useQuery } from "convex-svelte"; +import { untrack } from "svelte"; +import type { StreamBody, StreamId } from "../client/index.js"; +import type { FunctionReference } from "convex/server"; + +/** + * Svelte 5 version of the React `useStream` hook. Call it while a component + * initializes. Reactive inputs are getters so Svelte can track them. + * + * @param getPersistentBody - A query function reference that returns the body + * of a stream using the component's `getStreamBody` method. + * @param streamUrl - The URL of the http action that will kick off the stream + * generation and stream the result back to the client using the component's + * `stream` method. + * @param getDriven - Returns whether this particular session is driving the + * stream. Return true if this is the client session that first created the + * stream using the component's `createStream` method. If you're simply + * reloading an existing stream, return false. + * @param getStreamId - Returns the ID of the stream. While it returns + * undefined, the text is empty and the status is `pending`. + * @param getOpts - Returns request options. They are read when a stream + * starts, so a changed auth token applies to the next stream without + * aborting the current one. + * @returns A reactive object with the `text` and `status` of the stream. + */ +export function useStream( + getPersistentBody: FunctionReference< + "query", + "public", + { streamId: string }, + StreamBody + >, + streamUrl: URL, + getDriven: () => boolean, + getStreamId: () => StreamId | undefined, + getOpts?: () => { + // If provided, this will be passed as the Authorization header. + authToken?: string | null; + // If provided, these will be passed as additional headers. + headers?: Record; + }, +): Readonly { + let streamBody = $state(""); + let streamEnded = $state(null); + // Set when this session drives a stream it already requested once. + let resumed = $state(false); + + // Every stream ID this hook has POSTed. Aborting a request does not stop + // the backend writer, so a second POST for the same ID could start a + // second writer on the same stream. Holds one entry per stream seen. + const attempted = new Set(); + + // Deriveds only notify dependents when their value changes, so the effect + // below never re-runs because a getter re-ran with the same id. + const driven = $derived(getDriven()); + const streamId = $derived(getStreamId()); + + const usePersistence = $derived( + // Something is wrong with the stream, so we need to use the database value. + streamEnded === false || + // The HTTP stream for this ID was already used, so only the database + // has the full text. + resumed || + // If we're not driving the stream, we must use the database value. + !driven, + ); + + const persistentBody = useQuery(getPersistentBody, () => + usePersistence && streamId ? { streamId } : "skip", + ); + + // Effects only run in the browser, so SSR never sends the request. + $effect(() => { + // Reset state from any previous stream, also when this one is missing or + // not driven, so the body never shows the last stream's text. + streamBody = ""; + streamEnded = null; + if (!driven || !streamId) { + return; + } + + // Branch on a local, not on `resumed`: reading the state here would make + // the effect re-run, and abort the new stream, when it resets to false. + const isResume = attempted.has(streamId); + resumed = isResume; + if (isResume) { + return; + } + attempted.add(streamId); + + const controller = new AbortController(); + // Read every option untracked: a changed token or header applies to the + // next stream and must not abort the current one. + const headers = untrack(() => { + const opts = getOpts?.(); + return { + "Content-Type": "application/json", + ...opts?.headers, + ...(opts?.authToken + ? { Authorization: `Bearer ${opts.authToken}` } + : {}), + }; + }); + + void (async () => { + try { + const response = await fetch(streamUrl, { + method: "POST", + body: JSON.stringify({ streamId }), + headers, + signal: controller.signal, + }); + // A response that settled just before teardown must not write into + // the state of the next stream. + if (controller.signal.aborted) return; + + if (response.status === 205) { + console.error("Stream already finished", response); + streamEnded = false; + return; + } + if (!response.ok) { + console.error("Failed to reach streaming endpoint", response); + streamEnded = false; + return; + } + if (!response.body) { + console.error("No body in response", response); + streamEnded = false; + return; + } + + const reader = response.body.getReader(); + // One decoder per stream keeps multi-byte characters intact when they + // are split across chunks. + const decoder = new TextDecoder(); + + for (;;) { + const { done, value } = await reader.read(); + if (controller.signal.aborted) return; + const text = decoder.decode(value, { stream: !done }); + if (text) { + streamBody += text; + } + if (done) { + streamEnded = true; + return; + } + } + } catch (e) { + if (!controller.signal.aborted) { + console.error("Error reading stream", e); + streamEnded = false; + } + } + })(); + + return () => { + controller.abort(); + }; + }); + + const body = $derived.by((): StreamBody => { + if (persistentBody.data) { + return persistentBody.data; + } + let status: StreamStatus; + if (streamEnded === null) { + status = streamBody.length > 0 ? "streaming" : "pending"; + } else { + status = streamEnded ? "done" : "error"; + } + return { text: streamBody, status }; + }); + + return { + get text() { + return body.text; + }, + get status() { + return body.status; + }, + }; +} diff --git a/vitest.config.js b/vitest.config.js index e9408f8..af01f24 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ + // Compiles runes in `*.svelte.ts` modules; other files are untouched. + plugins: [svelte({ configFile: false })], + // Only affects tests in a DOM environment, so Svelte loads its client + // runtime there. Edge-runtime tests resolve with the SSR conditions. + resolve: { conditions: ["browser"] }, test: { environment: "edge-runtime", typecheck: {