From e9b92bec462ce1b08b87ba30d022c54914cc8f7f Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 9 Sep 2026 18:52:38 +0400 Subject: [PATCH] docs: add agent skills for AI coding agents Seven skills in `skills/`, one per package: a guidance-only `SKILL.md` with conventions, choices and pitfalls, and a generated `DOCS.md` with the released documentation of the package. The `skills-docs` workspace script in `scripts/skills-docs` builds `DOCS.md` from the site sources through the MDX AST, keeps the tabs a skill prefers, drops the sections listed in `--drop`, and verifies the files with `--check` (`pnpm skills:docs`). The Getting Started section of the site gets an Agent Skills page and the README a skills list. `articles/**` are excluded from `llms-small.txt`, and the `kida` README uses the `for_` name. --- README.md | 23 + package.json | 2 + packages/kida/README.md | 2 +- pnpm-lock.yaml | 417 ++--- pnpm-workspace.yaml | 1 + scripts/skills-docs/convert.js | 244 +++ scripts/skills-docs/frontmatter.js | 12 + scripts/skills-docs/generate.js | 31 + scripts/skills-docs/index.js | 108 ++ scripts/skills-docs/mdast.js | 84 + scripts/skills-docs/package.json | 29 + scripts/skills-docs/skills.js | 77 + skills/nano-kit-intl/DOCS.md | 897 ++++++++++ skills/nano-kit-intl/SKILL.md | 68 + skills/nano-kit-platform-web/DOCS.md | 356 ++++ skills/nano-kit-platform-web/SKILL.md | 66 + skills/nano-kit-query/DOCS.md | 1365 ++++++++++++++ skills/nano-kit-query/SKILL.md | 81 + skills/nano-kit-react-router/DOCS.md | 1399 +++++++++++++++ skills/nano-kit-react-router/SKILL.md | 74 + skills/nano-kit-react-ssr/DOCS.md | 583 ++++++ skills/nano-kit-react-ssr/SKILL.md | 84 + skills/nano-kit-react/DOCS.md | 355 ++++ skills/nano-kit-react/SKILL.md | 74 + skills/nano-kit-store/DOCS.md | 1564 +++++++++++++++++ skills/nano-kit-store/SKILL.md | 107 ++ website/astro.config.js | 4 +- .../docs/getting-started/agent-skills.mdx | 42 + .../content/docs/getting-started/index.mdx | 2 + 29 files changed, 7943 insertions(+), 208 deletions(-) create mode 100644 scripts/skills-docs/convert.js create mode 100644 scripts/skills-docs/frontmatter.js create mode 100644 scripts/skills-docs/generate.js create mode 100755 scripts/skills-docs/index.js create mode 100644 scripts/skills-docs/mdast.js create mode 100644 scripts/skills-docs/package.json create mode 100644 scripts/skills-docs/skills.js create mode 100644 skills/nano-kit-intl/DOCS.md create mode 100644 skills/nano-kit-intl/SKILL.md create mode 100644 skills/nano-kit-platform-web/DOCS.md create mode 100644 skills/nano-kit-platform-web/SKILL.md create mode 100644 skills/nano-kit-query/DOCS.md create mode 100644 skills/nano-kit-query/SKILL.md create mode 100644 skills/nano-kit-react-router/DOCS.md create mode 100644 skills/nano-kit-react-router/SKILL.md create mode 100644 skills/nano-kit-react-ssr/DOCS.md create mode 100644 skills/nano-kit-react-ssr/SKILL.md create mode 100644 skills/nano-kit-react/DOCS.md create mode 100644 skills/nano-kit-react/SKILL.md create mode 100644 skills/nano-kit-store/DOCS.md create mode 100644 skills/nano-kit-store/SKILL.md create mode 100644 website/src/content/docs/getting-started/agent-skills.mdx diff --git a/README.md b/README.md index ca04bc99..3043cc5b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,29 @@ A lightweight, modular, and performant state management ecosystem for building m | [`@nano_kit/svelte-kit`](packages/svelte-kit#readme) | SvelteKit integration for stores, router, and SSR. | [![NPM version][svelte-kit-npm]][svelte-kit-npm-url] | | [`@nano_kit/next-router`](packages/next-router#readme) | Next.js integration for @nano_kit/router. | [![NPM version][next-router-npm]][next-router-npm-url] | +## Agent Skills + +Universal agent skills for AI coding agents, one per package, live in the [skills](skills) directory: + +- [`nano-kit-store`](skills/nano-kit-store) +- [`nano-kit-query`](skills/nano-kit-query) +- [`nano-kit-intl`](skills/nano-kit-intl) +- [`nano-kit-platform-web`](skills/nano-kit-platform-web) +- [`nano-kit-react`](skills/nano-kit-react) +- [`nano-kit-react-router`](skills/nano-kit-react-router) +- [`nano-kit-react-ssr`](skills/nano-kit-react-ssr) + +Install a skill with either package runner: + +```sh +# pnpm +pnpx skills add TrigenSoftware/nano_kit --skill nano-kit-store +# npm +npx skills add TrigenSoftware/nano_kit --skill nano-kit-store +``` + +See the [Agent Skills](https://nano-kit.js.org/getting-started/agent-skills/) page for details. + [store-npm]: https://img.shields.io/npm/v/%40nano_kit%2Fstore.svg diff --git a/package.json b/package.json index 70078cfc..36df15cc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:types": "pnpm -r --parallel --if-present --filter './packages/*' test:types", "test": "run -p lint test:unit test:types", "docs:build": "pnpm -r --parallel --filter './website' build", + "skills:docs": "skills-docs --tabs pnpm --drop 'See Also,Next Steps,Low-Level API'", "examples:sync": "pnpm -r --parallel --if-present --filter './examples/*/common' sync", "examples:test": "pnpm -r --sequential --if-present --filter './examples/*/common' test", "commit": "cz", @@ -60,6 +61,7 @@ "react": "catalog:", "react-dom": "catalog:", "simple-git-hooks": "^2.7.0", + "skills-docs": "workspace:*", "typescript": "catalog:" } } diff --git a/packages/kida/README.md b/packages/kida/README.md index 4114a714..da244577 100644 --- a/packages/kida/README.md +++ b/packages/kida/README.md @@ -63,7 +63,7 @@ import { $admins } from '../stores/admins.js' export function Admins() { return ul()( - for$($admins, user => user.id)( + for_($admins, user => user.id)( $admin => li()(record($admin).$name) ) ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a53970f0..a8c73ea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 21.2.0 '@commitlint/cz-commitlint': specifier: ^21.0.0 - version: 21.2.0(@types/node@24.13.2)(commitizen@4.3.1(@types/node@24.13.2)(typescript@7.0.2))(typescript@7.0.2) + version: 21.2.0(@types/node@24.13.2)(commitizen@4.3.1(@types/node@24.13.2)(typescript@7.0.2))(inquirer@8.2.5)(typescript@7.0.2) '@nano_kit/query': specifier: workspace:^ version: link:packages/query @@ -185,6 +185,9 @@ importers: simple-git-hooks: specifier: ^2.7.0 version: 2.13.0 + skills-docs: + specifier: workspace:* + version: link:scripts/skills-docs typescript: specifier: 'catalog:' version: 7.0.2 @@ -337,7 +340,7 @@ importers: devDependencies: '@preact/preset-vite': specifier: ^2.10.2 - version: 2.10.5(@babel/core@7.29.0(supports-color@7.2.0))(preact@10.29.1)(rollup@4.60.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) typescript: specifier: ^7.0.0 version: 7.0.2 @@ -508,7 +511,7 @@ importers: version: 2.1.1 next: specifier: ^16.2.11 - version: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.2.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -551,7 +554,7 @@ importers: version: 2.1.1 next: specifier: ^16.2.11 - version: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.2.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -714,7 +717,7 @@ importers: version: 1.167.1(@tanstack/query-core@5.101.4)(@tanstack/react-query@5.101.4(react@19.2.8))(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/router-core@1.171.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-start': specifier: ^1.168.32 - version: 1.168.32(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 1.168.32(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1214,7 +1217,7 @@ importers: version: 8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.13 - version: 2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) examples/weather/svelte-nano_kit: dependencies: @@ -1338,7 +1341,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/benchmarks: @@ -1360,7 +1362,7 @@ importers: version: 23.4.4 jotai: specifier: ^2.20.2 - version: 2.20.2(@babel/core@7.29.0(supports-color@7.2.0))(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.8) + version: 2.20.2(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.8) mobx: specifier: ^6.16.1 version: 6.16.1 @@ -1409,7 +1411,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/kida: @@ -1436,7 +1437,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/next-router: @@ -1480,7 +1480,7 @@ importers: version: 20.8.9 next: specifier: ^16.1.6 - version: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.2.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 'catalog:' version: 19.2.8 @@ -1499,7 +1499,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/platform-web: @@ -1534,7 +1533,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/preact: @@ -1544,7 +1542,7 @@ importers: version: link:../store '@preact/preset-vite': specifier: 'catalog:' - version: 2.10.5(@babel/core@7.29.0(supports-color@7.2.0))(preact@10.29.1)(rollup@4.60.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@size-limit/preset-small-lib': specifier: 'catalog:' version: 13.0.3(size-limit@13.0.3) @@ -1578,7 +1576,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/preact-router: @@ -1594,7 +1591,7 @@ importers: version: link:../store '@preact/preset-vite': specifier: 'catalog:' - version: 2.10.5(@babel/core@7.29.0(supports-color@7.2.0))(preact@10.29.1)(rollup@4.60.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@size-limit/preset-small-lib': specifier: 'catalog:' version: 13.0.3(size-limit@13.0.3) @@ -1628,7 +1625,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/preact-ssr: @@ -1651,7 +1647,7 @@ importers: version: link:../store '@preact/preset-vite': specifier: 'catalog:' - version: 2.10.5(@babel/core@7.29.0(supports-color@7.2.0))(preact@10.29.1)(rollup@4.60.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) @@ -1670,7 +1666,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/query: @@ -1699,7 +1694,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/react: @@ -1749,7 +1743,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/react-router: @@ -1805,7 +1798,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/react-ssr: @@ -1850,7 +1842,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/router: @@ -1885,7 +1876,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/ssr: @@ -1915,7 +1905,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/store: @@ -1942,7 +1931,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/svelte: @@ -1980,7 +1968,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/svelte-kit: @@ -2025,7 +2012,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/svelte-router: @@ -2069,7 +2055,6 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package packages/svelte-ssr: @@ -2114,9 +2099,32 @@ importers: vitest: specifier: 'catalog:' version: 5.0.0(@types/node@24.13.2)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - linkDirectory: false publishDirectory: package + scripts/skills-docs: + dependencies: + mdast-util-to-markdown: + specifier: ^2.1.2 + version: 2.1.2 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-stringify: + specifier: ^11.0.0 + version: 11.0.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + website: dependencies: '@astrojs/check': @@ -2124,22 +2132,22 @@ importers: version: 0.9.9(prettier@3.8.1)(typescript@6.0.3) '@astrojs/markdown-remark': specifier: ^7.3.0 - version: 7.3.0(supports-color@7.2.0) + version: 7.3.0 '@astrojs/starlight': specifier: ^0.42.0 - version: 0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3) + version: 0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3) astro: specifier: ^7.3.1 - version: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + version: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) astro-vtbot: specifier: ^3.0.0 version: 3.0.0 starlight-llms-txt: specifier: ^0.11.0 - version: 0.11.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0) + version: 0.11.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) starlight-sidebar-topics: specifier: ^0.9.0 - version: 0.9.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.9.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) devDependencies: sharp: specifier: ^0.35.0 @@ -8497,7 +8505,7 @@ snapshots: transitivePeerDependencies: - typescript - '@astrojs/markdown-remark@7.2.0(supports-color@7.2.0)': + '@astrojs/markdown-remark@7.2.0': dependencies: '@astrojs/internal-helpers': 0.10.0 '@astrojs/prism': 4.0.2 @@ -8507,8 +8515,8 @@ snapshots: mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 - remark-gfm: 4.0.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + remark-gfm: 4.0.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 unified: 11.0.5 @@ -8519,11 +8527,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/markdown-remark@7.3.0(supports-color@7.2.0)': + '@astrojs/markdown-remark@7.3.0': dependencies: '@astrojs/internal-helpers': 0.11.0 '@astrojs/prism': 4.0.2 - '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 estree-util-visit: 2.0.0 github-slugger: 2.0.0 @@ -8533,8 +8541,8 @@ snapshots: mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 - remark-gfm: 4.0.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + remark-gfm: 4.0.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 source-map: 0.7.6 @@ -8553,19 +8561,19 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)': + '@astrojs/mdx@7.0.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.0 - '@astrojs/markdown-remark': 7.2.0(supports-color@7.2.0) - '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) + '@astrojs/markdown-remark': 7.2.0 + '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) es-module-lexer: 2.0.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 piccolore: 0.1.3 rehype-raw: 7.0.0 - remark-gfm: 4.0.1(supports-color@7.2.0) + remark-gfm: 4.0.1 remark-smartypants: 3.0.2 source-map: 0.7.6 unist-util-visit: 5.1.0 @@ -8573,14 +8581,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@8.0.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))': + '@astrojs/mdx@8.0.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.11.0 '@astrojs/markdown-satteri': 0.4.0 - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) es-module-lexer: 2.3.2 optionalDependencies: - '@astrojs/markdown-remark': 7.3.0(supports-color@7.2.0) + '@astrojs/markdown-remark': 7.3.0 '@astrojs/prism@4.0.2': dependencies: @@ -8592,17 +8600,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astrojs/markdown-satteri': 0.4.0 - '@astrojs/mdx': 8.0.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) + '@astrojs/mdx': 8.0.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) - astro-expressive-code: 0.44.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + astro-expressive-code: 0.44.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) bcp-47: 2.1.0 hast-util-format: 1.1.0 hast-util-select: 6.0.4 @@ -8613,18 +8621,18 @@ snapshots: js-yaml: 4.1.1 klona: 2.0.6 magic-string: 1.2.3 - mdast-util-directive: 3.1.0(supports-color@7.2.0) + mdast-util-directive: 3.1.0 mdast-util-to-markdown: 2.1.2 mdast-util-to-string: 4.0.0 pagefind: 1.5.2 - remark-directive: 4.0.0(supports-color@7.2.0) + remark-directive: 4.0.0 satteri: 0.10.5 ultrahtml: 1.6.0 unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 optionalDependencies: - '@astrojs/markdown-remark': 7.3.0(supports-color@7.2.0) + '@astrojs/markdown-remark': 7.3.0 transitivePeerDependencies: - supports-color - typescript @@ -8654,20 +8662,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0(supports-color@7.2.0)': + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -8700,19 +8708,19 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -8741,25 +8749,25 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -8772,7 +8780,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0(supports-color@7.2.0)': + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -8780,7 +8788,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8878,12 +8886,13 @@ snapshots: '@commitlint/types': 21.2.0 ajv: 8.17.1 - '@commitlint/cz-commitlint@21.2.0(@types/node@24.13.2)(commitizen@4.3.1(@types/node@24.13.2)(typescript@7.0.2))(typescript@7.0.2)': + '@commitlint/cz-commitlint@21.2.0(@types/node@24.13.2)(commitizen@4.3.1(@types/node@24.13.2)(typescript@7.0.2))(inquirer@8.2.5)(typescript@7.0.2)': dependencies: '@commitlint/ensure': 21.2.0 '@commitlint/load': 21.2.0(@types/node@24.13.2)(typescript@7.0.2) '@commitlint/types': 21.2.0 commitizen: 4.3.1(@types/node@24.13.2)(typescript@7.0.2) + inquirer: 8.2.5 is-plain-obj: 4.1.0 picocolors: 1.1.1 word-wrap: 1.2.5 @@ -9570,7 +9579,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -9582,14 +9591,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.17.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -9833,15 +9842,15 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@preact/preset-vite@2.10.5(@babel/core@7.29.0(supports-color@7.2.0))(preact@10.29.1)(rollup@4.60.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) - '@prefresh/vite': 2.4.12(preact@10.29.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0(supports-color@7.2.0)) - debug: 4.4.3(supports-color@7.2.0) + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) + debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 vite: 8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) @@ -9862,9 +9871,9 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.29.1)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@prefresh/babel-plugin': 0.5.3 '@prefresh/core': 1.5.9(preact@10.29.1) '@prefresh/utils': 1.2.1 @@ -10096,7 +10105,7 @@ snapshots: dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/themes@4.3.0': dependencies: @@ -10261,14 +10270,14 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@tanstack/react-start-rsc@0.1.31(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@tanstack/react-start-rsc@0.1.31(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.11.22)) '@tanstack/start-storage-context': 1.167.17 pathe: 2.0.3 @@ -10292,15 +10301,15 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.32(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@tanstack/react-start@1.168.32(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-start-client': 1.168.16(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tanstack/react-start-rsc': 0.1.31(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@tanstack/react-start-rsc': 0.1.31(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@tanstack/react-start-server': 1.167.22(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.11.22)) pathe: 2.0.3 react: 19.2.8 @@ -10329,11 +10338,11 @@ snapshots: seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) - '@tanstack/router-generator@1.167.21(supports-color@7.2.0)': + '@tanstack/router-generator@1.167.21': dependencies: '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2 '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -10342,21 +10351,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 - '@tanstack/router-generator': 1.167.21(supports-color@7.2.0) - '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/router-generator': 1.167.21 + '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) vite: 8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - vite-plugin-solid: 2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vite-plugin-solid: 2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -10365,13 +10374,13 @@ snapshots: '@tanstack/query-core': 5.101.4 '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils@1.162.2(supports-color@7.2.0)': + '@tanstack/router-utils@1.162.2': dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 '@babel/types': 7.29.0 ansis: 4.2.0 - babel-dead-code-elimination: 1.0.12(supports-color@7.2.0) + babel-dead-code-elimination: 1.0.12 diff: 8.0.3 pathe: 2.0.3 tinyglobby: 0.2.17 @@ -10387,15 +10396,15 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(crossws@0.4.10(srvx@0.11.22))(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 - '@tanstack/router-generator': 1.167.21(supports-color@7.2.0) - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/router-generator': 1.167.21 + '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.2 '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.11.22)) exsolve: 1.1.0 lightningcss: 1.33.0 @@ -10940,9 +10949,9 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)): + astro-expressive-code@0.44.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) rehype-expressive-code: 0.44.0 url-extras: 0.1.0 @@ -10954,7 +10963,7 @@ snapshots: '@vtbag/turn-signal': 1.3.1 '@vtbag/utensil-drawer': 1.2.16 - astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0): + astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.11.0 @@ -11010,7 +11019,7 @@ snapshots: yargs-parser: 22.0.0 zod: 4.5.4 optionalDependencies: - '@astrojs/markdown-remark': 7.3.0(supports-color@7.2.0) + '@astrojs/markdown-remark': 7.3.0 sharp: 0.35.4(@types/node@24.13.2) transitivePeerDependencies: - '@azure/app-configuration' @@ -11056,32 +11065,32 @@ snapshots: axobject-query@4.1.0: {} - babel-dead-code-elimination@1.0.12(supports-color@7.2.0): + babel-dead-code-elimination@1.0.12: dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/parser': 7.29.0 - '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.0(supports-color@7.2.0)): + babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.0): dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/types': 7.29.0 html-entities: 2.3.3 parse5: 7.3.0 - babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.0(supports-color@7.2.0)): + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.0): dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 - babel-preset-solid@1.9.10(@babel/core@7.29.0(supports-color@7.2.0))(solid-js@1.9.14): + babel-preset-solid@1.9.10(@babel/core@7.29.0)(solid-js@1.9.14): dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/core': 7.29.0 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.0) optionalDependencies: solid-js: 1.9.14 @@ -11442,11 +11451,9 @@ snapshots: db0@0.3.4: {} - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 decode-named-character-reference@1.3.0: dependencies: @@ -12086,7 +12093,7 @@ snapshots: hast-util-is-body-ok-link@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: @@ -12146,7 +12153,7 @@ snapshots: unist-util-visit: 5.1.0 zwitch: 2.0.4 - hast-util-to-estree@3.1.3(supports-color@7.2.0): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -12156,9 +12163,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -12181,7 +12188,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 @@ -12190,9 +12197,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -12460,9 +12467,9 @@ snapshots: jiti@2.7.0: {} - jotai@2.20.2(@babel/core@7.29.0(supports-color@7.2.0))(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.8): + jotai@2.20.2(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.8): optionalDependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/template': 7.28.6 '@types/react': 19.2.17 react: 19.2.8 @@ -12628,13 +12635,13 @@ snapshots: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - mdast-util-directive@3.1.0(supports-color@7.2.0): + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -12649,14 +12656,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2(supports-color@7.2.0): + mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -12674,67 +12681,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.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.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -12742,7 +12749,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -12751,23 +12758,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@7.2.0): + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -13073,10 +13080,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -13144,7 +13151,7 @@ snapshots: neotraverse@1.0.1: {} - next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.2.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.11 '@swc/helpers': 0.5.15 @@ -13153,7 +13160,7 @@ snapshots: postcss: 8.4.31 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@7.2.0))(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: '@next/swc-darwin-arm64': 16.2.11 '@next/swc-darwin-x64': 16.2.11 @@ -13607,7 +13614,7 @@ snapshots: rehype-minify-whitespace@6.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace: 1.0.1 rehype-parse@9.0.1: @@ -13622,11 +13629,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@7.2.0): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3(supports-color@7.2.0) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color @@ -13644,37 +13651,37 @@ snapshots: hast-util-to-html: 9.0.5 unified: 11.0.5 - remark-directive@4.0.0(supports-color@7.2.0): + remark-directive@4.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-directive: 3.1.0(supports-color@7.2.0) + mdast-util-directive: 3.1.0 micromark-extension-directive: 4.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@7.2.0): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@7.2.0) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.2 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -14075,10 +14082,10 @@ snapshots: seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) - solid-refresh@0.6.3(solid-js@1.9.14)(supports-color@7.2.0): + solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.1 - '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6 '@babel/types': 7.29.0 solid-js: 1.9.14 transitivePeerDependencies: @@ -14106,19 +14113,19 @@ snapshots: stackback@0.0.2: {} - starlight-llms-txt@0.11.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0): + starlight-llms-txt@0.11.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@astrojs/mdx': 7.0.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0) - '@astrojs/starlight': 0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3) + '@astrojs/mdx': 7.0.0(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)) + '@astrojs/starlight': 0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3) '@types/hast': 3.0.4 '@types/micromatch': 4.0.10 - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) github-slugger: 2.0.0 hast-util-select: 6.0.4 micromatch: 4.0.8 rehype-parse: 9.0.1 rehype-remark: 10.0.1 - remark-gfm: 4.0.1(supports-color@7.2.0) + remark-gfm: 4.0.1 remark-stringify: 11.0.0 unified: 11.0.5 unist-util-remove: 4.0.0 @@ -14126,10 +14133,10 @@ snapshots: - '@astrojs/markdown-satteri' - supports-color - starlight-sidebar-topics@0.9.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)): + starlight-sidebar-topics@0.9.0(@astrojs/starlight@0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@astrojs/starlight': 0.42.0(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(astro@7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(supports-color@7.2.0)(typescript@6.0.3) - astro: 7.3.1(@astrojs/markdown-remark@7.3.0(supports-color@7.2.0))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) + '@astrojs/starlight': 0.42.0(@astrojs/markdown-remark@7.3.0)(astro@7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0))(typescript@6.0.3) + astro: 7.3.1(@astrojs/markdown-remark@7.3.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.2)(db0@0.3.4)(idb-keyval@6.2.2)(jiti@2.7.0)(yaml@2.9.0) picomatch: 4.0.7 std-env@4.2.0: {} @@ -14194,12 +14201,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0(supports-color@7.2.0))(react@19.2.8): + styled-jsx@5.1.6(react@19.2.8): dependencies: client-only: 0.0.1 react: 19.2.8 - optionalDependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) supports-color@5.5.0: dependencies: @@ -14527,14 +14532,14 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-plugin-solid@2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vite-plugin-solid@2.11.13(solid-js@1.9.14)(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.10(@babel/core@7.29.0(supports-color@7.2.0))(solid-js@1.9.14) + babel-preset-solid: 1.9.10(@babel/core@7.29.0)(solid-js@1.9.14) merge-anything: 5.1.7 solid-js: 1.9.14 - solid-refresh: 0.6.3(solid-js@1.9.14)(supports-color@7.2.0) + solid-refresh: 0.6.3(solid-js@1.9.14) vite: 8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.1(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f9f1904..46e31489 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - scripts/* - website - examples/*/* - examples/common diff --git a/scripts/skills-docs/convert.js b/scripts/skills-docs/convert.js new file mode 100644 index 00000000..9445d914 --- /dev/null +++ b/scripts/skills-docs/convert.js @@ -0,0 +1,244 @@ +import { readFile } from 'node:fs/promises' +import { + basename, + relative +} from 'node:path' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkMdx from 'remark-mdx' +import remarkStringify from 'remark-stringify' +import { defaultHandlers } from 'mdast-util-to-markdown' +import { frontmatter } from './frontmatter.js' +import { + BLOCK_TYPES, + DROPPED_TYPES, + blockquote, + code, + heading, + hoist, + paragraph, + strong, + text +} from './mdast.js' + +const CONTAINER_TYPES = new Set(['blockquote', 'list', 'listItem']) +const STRINGIFY_OPTIONS = { + bullet: '-', + emphasis: '*', + fences: true, + listItemIndent: 'one', + rule: '-', + handlers: { + // Underscores inside words (`@nano_kit`) stay readable; the other escapes are kept + text: (node, parent, state, info) => defaultHandlers.text(node, parent, state, info).replace(/(?<=\w)\\_(?=\w)/g, '_') + } +} +const mdx = unified().use(remarkParse).use(remarkGfm).use(remarkMdx).use(remarkStringify, STRINGIFY_OPTIONS) +const md = unified().use(remarkParse).use(remarkGfm).use(remarkStringify, STRINGIFY_OPTIONS) + +function location(page, node) { + return `${page.path}:${node.position?.start.line ?? '?'}` +} + +// Static value of a JSX attribute: a string, or a string literal or substitution-free template expression +function attribute(node, name, page) { + const found = node.attributes.find(item => item.type === 'mdxJsxAttribute' && item.name === name) + + if (!found || typeof found.value === 'string') { + return found?.value + } + + const expression = found.value.data?.estree?.body[0]?.expression + + if (expression?.type === 'Literal' && typeof expression.value === 'string') { + return expression.value + } + + if (expression?.type === 'TemplateLiteral' && !expression.expressions.length) { + return expression.quasis[0].value.cooked + } + + throw new Error(`${location(page, node)}: the \`${name}\` attribute of <${node.name}> must be a static string`) +} + +function label(kind) { + return kind[0].toUpperCase() + kind.slice(1) +} + +// Tab items of a group, including compact ones that MDX wraps in paragraphs +function tabItems(node) { + return node.children.flatMap((child) => { + if (child.name === 'TabItem') { + return [child] + } + + return child.type === 'paragraph' + ? child.children.filter(item => item.name === 'TabItem') + : [] + }) +} + +// Keeps the preferred tab of a group, or flattens every tab with its label when none is preferred +function convertTabs(node, page) { + const items = tabItems(node) + const labels = items.map(item => attribute(item, 'label', page) ?? '') + const preferred = labels.some(item => page.tabs.has(item)) + + return items.flatMap((item, index) => { + if (preferred && !page.tabs.has(labels[index])) { + return [] + } + + const children = hoist(convertNodes(item.children, page)) + + return preferred || items.length === 1 + ? children + : [strong(labels[index]), ...children] + }) +} + +function convertAside(node, page) { + const children = hoist(convertNodes(node.children, page)) + const prefix = `${attribute(node, 'title', page) ?? label(attribute(node, 'type', page) ?? 'note')}: ` + const first = children[0]?.type === 'paragraph' ? children[0].children[0] : undefined + + if (first?.type === 'text') { + first.value = prefix + first.value.trimStart() + } else if (children[0]?.type === 'paragraph') { + children[0].children.unshift(text(prefix)) + } else { + children.unshift(paragraph([text(prefix.trim())])) + } + + return [blockquote(children)] +} + +// Turns a Starlight component into plain markdown nodes +function convertElement(node, page) { + switch (node.name) { + case 'Tabs': + return convertTabs(node, page) + case 'Aside': + return convertAside(node, page) + case 'Code': + return [code( + attribute(node, 'lang', page), + attribute(node, 'code', page) ?? '', + attribute(node, 'title', page) ? `title="${attribute(node, 'title', page)}"` : undefined + )] + case 'Card': + return [strong(attribute(node, 'title', page) ?? ''), ...hoist(convertNodes(node.children, page))] + case 'Image': + return [] + case 'Steps': + case 'CardGrid': + case 'TabItem': + return hoist(convertNodes(node.children, page)) + default: + console.warn(`${location(page, node)}: unwrapped <${node.name}>`) + return hoist(convertNodes(node.children, page)) + } +} + +function convertNodes(nodes, page) { + return nodes.flatMap((node) => { + if (DROPPED_TYPES.has(node.type)) { + return [] + } + + if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { + return convertElement(node, page) + } + + if (node.type === 'link' || node.type === 'definition') { + if (page.url) { + node.url = new URL(node.url, page.url).href + } else if (node.type === 'link' && isInternal(node.url)) { + return convertNodes(node.children, page) + } + } + + if (node.children) { + node.children = convertNodes(node.children, page) + + if (node.type === 'paragraph' && node.children.some(child => BLOCK_TYPES.has(child.type))) { + return hoist(node.children) + } + + if (node.type === 'paragraph' && !node.children.length) { + return [] + } + } + + return [node] + }) +} + +// A link into the documentation itself: no scheme, not protocol-relative, not a same-page anchor +function isInternal(url) { + return !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//') && !url.startsWith('#') +} + +function textOf(node) { + return node.value ?? node.children?.map(textOf).join('') ?? '' +} + +// Removes the sections whose heading is listed, up to the next heading of the same or a higher level +function dropSections(nodes, dropped) { + let depth = 0 + + return nodes.filter((node) => { + if (node.type === 'heading' && node.depth <= depth) { + depth = 0 + } + + if (node.type === 'heading' && !depth && dropped.has(textOf(node).trim().toLowerCase())) { + ({ depth } = node) + } + + if (!depth && CONTAINER_TYPES.has(node.type)) { + node.children = dropSections(node.children, dropped) + + return node.children.length > 0 + } + + return !depth + }) +} + +// Public URL of a docs page on the site, for resolving links when `--site` is given +function pageUrl(site, docs, path) { + const slug = relative(docs, path).replace(/\.mdx?$/, '').replace(/(^|\/)index$/, '') + + return `${site}/${slug ? `${slug}/` : ''}` +} + +// Converts one page into markdown: a title and description from the frontmatter, then the body; +// a page whose title is listed in `drop` yields `null` +export async function convertPage(path, { docs, site, tabs, drop }) { + const [data, source] = frontmatter(await readFile(path, 'utf8')) + const title = data.title ?? basename(path).replace(/\.mdx?$/, '') + const dropped = new Set(drop.map(item => item.toLowerCase())) + + if (dropped.has(title.toLowerCase())) { + return null + } + + const processor = path.endsWith('.mdx') ? mdx : md + const tree = processor.parse(source) + const page = { + path, + tabs, + url: site ? pageUrl(site, docs, path) : '' + } + const head = [heading(1, title)] + + if (data.description) { + head.push(paragraph([text(data.description)])) + } + + tree.children = [...head, ...dropSections(convertNodes(tree.children, page), dropped)] + + return processor.stringify(tree).trim() +} diff --git a/scripts/skills-docs/frontmatter.js b/scripts/skills-docs/frontmatter.js new file mode 100644 index 00000000..7e5f51c7 --- /dev/null +++ b/scripts/skills-docs/frontmatter.js @@ -0,0 +1,12 @@ +import YAML from 'yaml' + +const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n/ + +// Splits a markdown source into its parsed YAML frontmatter and the body +export function frontmatter(source) { + const match = FRONTMATTER_RE.exec(source) + + return match + ? [YAML.parse(match[1]) ?? {}, source.slice(match[0].length)] + : [{}, source] +} diff --git a/scripts/skills-docs/generate.js b/scripts/skills-docs/generate.js new file mode 100644 index 00000000..d7083389 --- /dev/null +++ b/scripts/skills-docs/generate.js @@ -0,0 +1,31 @@ +import { join } from 'node:path' +import { convertPage } from './convert.js' +import { + pagesOf, + skillsOf +} from './skills.js' + +const HEADER = '' + +async function generateSkill(skill, { docs, skills, site, drop, out }) { + const pages = (await Promise.all(skill.entries.map(entry => pagesOf(docs, entry)))).flat() + const sections = await Promise.all(pages.map(path => convertPage(path, { + docs, + site, + tabs: skill.tabs, + drop + }))) + + return { + name: skill.name, + target: join(skills, skill.name, out), + content: `${HEADER}\n\n${sections.filter(Boolean).join('\n\n---\n\n')}\n` + } +} + +// Builds the documentation file of every skill that declares docs pages +export async function generate(options) { + const skills = await skillsOf(options.skills, options.tabs) + + return Promise.all(skills.map(skill => generateSkill(skill, options))) +} diff --git a/scripts/skills-docs/index.js b/scripts/skills-docs/index.js new file mode 100755 index 00000000..ea93bc05 --- /dev/null +++ b/scripts/skills-docs/index.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { + readFile, + writeFile +} from 'node:fs/promises' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { generate } from './generate.js' + +/** + * Generates a documentation file next to every agent skill that declares `metadata.docs` + * in its `SKILL.md` frontmatter. `docs.paths` lists Starlight docs pages (`.md` or `.mdx`) or directories in + * reading order (a directory expands to its pages sorted by `sidebar.order`); `docs.tabs` + * lists tab labels the skill prefers, and together with `--tabs` they decide which tab of a + * `Tabs` group is kept, groups without a preferred tab are flattened with their labels. + * + * skills-docs [--docs ] [--skills ] [--site ] [--tabs ] [--out ] [--drop ] [--check] + * + * `--docs` is the Starlight content directory (default `website/src/content/docs`), `--skills` + * the directory with the skills (default `skills`), both relative to the working directory. + * `--site` makes links absolute; without it, links into the documentation are unwrapped to their + * text and only external links and same-page anchors stay. `--tabs` is a comma-separated list of tab labels + * every skill prefers, `--out` the generated file name (default `DOCS.md`), `--drop` a + * comma-separated list of section and page titles to leave out (a section ends at the next + * heading of the same or a higher level), `--check` fails when a generated file is stale. + */ +const { values: options } = parseArgs({ + options: { + docs: { + type: 'string', + default: 'website/src/content/docs' + }, + skills: { + type: 'string', + default: 'skills' + }, + site: { + type: 'string', + default: '' + }, + tabs: { + type: 'string', + default: '' + }, + out: { + type: 'string', + default: 'DOCS.md' + }, + drop: { + type: 'string', + default: '' + }, + check: { + type: 'boolean', + default: false + } + } +}) + +function list(value) { + return value.split(',').map(item => item.trim()).filter(Boolean) +} + +const files = await generate({ + docs: resolve(options.docs), + skills: resolve(options.skills), + site: options.site.replace(/\/$/, ''), + tabs: list(options.tabs), + drop: list(options.drop), + out: options.out +}) + +async function current(target) { + try { + return await readFile(target, 'utf8') + } catch (error) { + if (error.code === 'ENOENT') { + return null + } + + throw error + } +} + +async function check({ name, target, content }) { + const stale = await current(target) !== content + + if (stale) { + console.error(`${name}/${options.out} is stale`) + } + + return stale +} + +async function write({ name, target, content }) { + await writeFile(target, content) + console.info(`${name}/${options.out}: ${content.length} chars`) +} + +if (options.check) { + const results = await Promise.all(files.map(check)) + + if (results.includes(true)) { + process.exit(1) + } +} else { + await Promise.all(files.map(write)) +} diff --git a/scripts/skills-docs/mdast.js b/scripts/skills-docs/mdast.js new file mode 100644 index 00000000..894dcf2d --- /dev/null +++ b/scripts/skills-docs/mdast.js @@ -0,0 +1,84 @@ +export const DROPPED_TYPES = new Set(['mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression', 'image', 'yaml']) +export const BLOCK_TYPES = new Set(['blockquote', 'code', 'heading', 'html', 'list', 'paragraph', 'table', 'thematicBreak']) + +export function text(value) { + return { + type: 'text', + value + } +} + +export function paragraph(children) { + return { + type: 'paragraph', + children + } +} + +export function heading(depth, value) { + return { + type: 'heading', + depth, + children: [text(value)] + } +} + +export function strong(value) { + return paragraph([{ + type: 'strong', + children: [text(value)] + }]) +} + +export function blockquote(children) { + return { + type: 'blockquote', + children + } +} + +export function code(lang, value, meta) { + return { + type: 'code', + lang, + meta, + value + } +} + +// Splits phrasing content around the block nodes that inline components produced +export function hoist(children) { + const result = [] + let phrasing = [] + const flush = () => { + const first = phrasing[0] + const last = phrasing.at(-1) + + if (first?.type === 'text') { + first.value = first.value.trimStart() + } + + if (last?.type === 'text') { + last.value = last.value.trimEnd() + } + + if (phrasing.some(node => node.type !== 'text' || node.value)) { + result.push(paragraph(phrasing)) + } + + phrasing = [] + } + + for (const child of children) { + if (BLOCK_TYPES.has(child.type)) { + flush() + result.push(child) + } else { + phrasing.push(child) + } + } + + flush() + + return result +} diff --git a/scripts/skills-docs/package.json b/scripts/skills-docs/package.json new file mode 100644 index 00000000..f7ef658a --- /dev/null +++ b/scripts/skills-docs/package.json @@ -0,0 +1,29 @@ +{ + "name": "skills-docs", + "type": "module", + "private": true, + "version": "0.0.0", + "description": "Generates DOCS.md next to agent skills from Starlight docs sources.", + "author": "dangreen", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/nano_kit.git", + "directory": "scripts/skills-docs" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/nano_kit/issues" + }, + "bin": { + "skills-docs": "./index.js" + }, + "dependencies": { + "mdast-util-to-markdown": "^2.1.2", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "yaml": "^2.9.0" + } +} diff --git a/scripts/skills-docs/skills.js b/scripts/skills-docs/skills.js new file mode 100644 index 00000000..e039b48b --- /dev/null +++ b/scripts/skills-docs/skills.js @@ -0,0 +1,77 @@ +import { + readdir, + readFile +} from 'node:fs/promises' +import { join } from 'node:path' +import { frontmatter } from './frontmatter.js' + +const PAGE_RE = /\.mdx?$/ + +function isStringList(value) { + return Array.isArray(value) && value.every(item => typeof item === 'string') +} + +async function readFrontmatter(path) { + return frontmatter(await readFile(path, 'utf8'))[0] +} + +async function skillOf(directory, name, tabs) { + const path = join(directory, name, 'SKILL.md') + let metadata + + try { + ({ metadata = {} } = await readFrontmatter(path)) + } catch (error) { + if (error.code === 'ENOENT') { + return [] + } + + throw error + } + + if (metadata.docs === undefined) { + return [] + } + + const { docs } = metadata + + if (!isStringList(docs?.paths) || !docs.paths.length || docs.tabs !== undefined && !isStringList(docs.tabs)) { + throw new Error(`${path}: metadata.docs needs a non-empty \`paths\` list and an optional \`tabs\` list of strings`) + } + + return [{ + name, + entries: docs.paths, + tabs: new Set([ + ...tabs, + ...docs.tabs ?? [] + ]) + }] +} + +// Skills that declare `metadata.docs.paths`, with their pages and the tab labels they prefer +export async function skillsOf(directory, tabs) { + const entries = await readdir(directory, { + withFileTypes: true + }) + const skills = await Promise.all(entries.filter(entry => entry.isDirectory()).map(entry => skillOf(directory, entry.name, tabs))) + + return skills.flat() +} + +// Expands a docs entry into page paths: a directory yields its pages sorted by `sidebar.order` +export async function pagesOf(docs, entry) { + const path = join(docs, entry) + + if (PAGE_RE.test(entry)) { + return [path] + } + + const names = (await readdir(path)).filter(name => PAGE_RE.test(name)) + const orders = await Promise.all(names.map(async name => Number((await readFrontmatter(join(path, name))).sidebar?.order ?? Infinity))) + + return names + .map((name, index) => [join(path, name), orders[index]]) + .sort((a, b) => a[1] - b[1]) + .map(([page]) => page) +} diff --git a/skills/nano-kit-intl/DOCS.md b/skills/nano-kit-intl/DOCS.md new file mode 100644 index 00000000..79a703cb --- /dev/null +++ b/skills/nano-kit-intl/DOCS.md @@ -0,0 +1,897 @@ + + +# Getting Started + +@nano_kit/intl is a small, reactive, and type-safe internationalization library for Nano Kit apps. + +`@nano_kit/intl` is a lightweight internationalization library for @nano_kit/store. It keeps translations reactive, lets message types flow from translation data, and composes small formatters instead of introducing a separate message syntax runtime. + +- **Reactive**. Locale changes update messages through @nano_kit/store signals. +- **Type-safe**. Message schemas are checked against translation namespace data. +- **Composable**. Build messages from formats like `text`, `params`, `plural`, `match`, `number`, and `datetime`. +- **Tree-shakeable**. Import only the formats your app uses. +- **SSR-ready**. Use @nano_kit/query for cached translation loading and dehydration. + +## Installation + +Install the package with @nano_kit/store: + +```bash +pnpm add @nano_kit/store @nano_kit/intl +``` + +## Quick Start + +For SPA-style apps or small demos, `resolved` is the simplest loader. It can return static data, a signal-derived value, or a promise. + +```ts +import { + resolved, + signal +} from '@nano_kit/store' +import { + intl, + params, + plural, + text +} from '@nano_kit/intl' + +const $locale = signal<'en' | 'de'>('en') + +const translations = { + en: { + common: { + title: 'Event Board', + greeting: 'Hello, {name}!', + unread: { + one: '{count} unread message', + other: '{count} unread messages' + } + } + }, + de: { + common: { + title: 'Event Board', + greeting: 'Hallo, {name}!', + unread: { + one: '{count} ungelesene Nachricht', + other: '{count} ungelesene Nachrichten' + } + } + } +} + +const { messages } = intl( + $locale, + resolved(() => translations[$locale()]) +) + +const [$t] = messages('common', { + greeting: params({ + name: text() + }), + unread: plural('count') +}) + +$t().title +// "Event Board" + +$t().greeting({ + name: 'Ada' +}) +// "Hello, Ada!" + +$t().unread({ + count: 5 +}) +// "5 unread messages" + +$locale('de') +``` + +--- + +# Core Concepts + +Learn how @nano_kit/intl models translation data, namespaces, message schemes, and composable formats. + +`@nano_kit/intl` has three main pieces: + +- **Translation data** - locale-specific objects grouped by namespace. +- **Loaders** - full-data or namespace loaders that return translation data. +- **Formats** - small functions that convert translation values into messages. + +## Translation Data + +Translations are plain locale-specific objects grouped by namespace. A namespace is usually a page, layout, feature, or shared block. + +```ts +const en = { + layout: { + title: 'Event Board', + events: 'Events' + }, + home: { + title: 'Find your next frontend event', + attendees: { + one: '{count} going', + other: '{count} going' + } + } +} +``` + +If a translation pipeline exports flat dotted keys, use `deflat` to turn them into nested namespace objects. + +```ts +import { deflat } from '@nano_kit/intl' + +const en = deflat({ + 'layout.title': 'Event Board', + 'home.title': 'Find your next frontend event', + 'home.attendees.one': '{count} going', + 'home.attendees.other': '{count} going' +}) +``` + +When only nested namespace objects contain dotted keys, pass the depth where deflating should start. + +```ts +const en = deflat({ + home: { + 'title': 'Find your next frontend event', + 'attendees.one': '{count} going', + 'attendees.other': '{count} going' + } +}, 1) +``` + +## Namespaces + +A namespace is one translation object inside the full translation data. + +Use `messages(namespace, scheme)` to bind a namespace to the active locale: + +```ts +const [$t] = messages('home', { + attendees: plural('count') +}) +``` + +## Full Data Loader + +A full-data loader returns the entire locale object. This is the easiest shape when translations are small, bundled, or loaded as one JSON file per locale. + +```ts +import { resolved } from '@nano_kit/store' +import { intl } from '@nano_kit/intl' + +export type SupportedLocale = 'en' | 'ru' + +export async function load(locale: SupportedLocale) { + return locale === 'en' + ? (await import('./en.json')).default + : (await import('./ru.json')).default +} + +export type Translations = Awaited> + +const { messages, $loading, $error } = intl( + $locale, + resolved(() => load($locale())) +) +``` + +With a full-data loader, `$loading` and `$error` describe the current locale data load. + +Since `resolved` keeps its factory unstarted until the first read, translation loading does not begin at `intl(...)` creation. It starts on the first read of `$t`, `$loading` or `$error`. + +## Namespace Loader + +A namespace loader loads only the namespace requested by `messages()`. This is useful for code splitting translations by route or feature. + +```ts +import { resolved } from '@nano_kit/store' +import { intl } from '@nano_kit/intl' + +const { messages } = intl( + $locale, + namespace => resolved(() => load($locale(), namespace)) +) +``` + +With a namespace loader, the global `$loading` is settled because there is no single global translation request. Each `messages()` call returns its own `$pending` and `$error`. + +```ts +const [$t, $pending, $error] = messages('home', { + title: text() +}) +``` + +## Typed Translation Data + +When the loader has a concrete return type, message types are inferred from translation data. + +```ts +export type Translations = Awaited> + +const { messages } = intl( + $locale, + resolved(() => load($locale())) +) + +const [$t] = messages('layout') + +$t().title +// string | undefined +``` + +If only some messages need formatting, provide a partial scheme. Raw fields stay available from the inferred namespace type. + +```ts +const [$t] = messages('home', { + attendees: plural('count'), + eventDate: format(datetime({ + dateStyle: 'medium', + timeStyle: 'short' + })) +}) + +$t().title +$t().attendees({ count: 25 }) +$t().eventDate(new Date()) +``` + +## Anonymous Translation Data + +If translations are anonymous, for example `AnyTranslationData` from dynamically selected namespace JSON, TypeScript cannot infer concrete message fields. In this case the scheme becomes the source of message types. + +```ts +import { + raw, + text, + plural +} from '@nano_kit/intl' + +const { messages } = intl( + $locale, + namespace => resolved(() => load($locale(), namespace)) +) + +const [$t] = messages('home', { + title: text(), + categories: raw>(), + attendees: plural('count') +}) + +$t().title +// string | undefined + +$t().categories?.conference +// string | undefined +``` + +## Message Access + +`messages(namespace, scheme)` returns a tuple: + +```ts +const [$t, $pending, $error] = messages('common', { + greeting: params({ + name: text() + }) +}) +``` + +Use `$t()` to read the whole namespace. Messages are also available as signal properties like `$t.$title`. For parameterized messages, `$t.key(params)` creates a computed signal. If params contain signals, the computed message subscribes to them. + +```ts +$t().title +$t.$title() + +$t().greeting({ name: 'Ada' }) + +const $greeting = $t.greeting({ + name: $name +}) + +$greeting() +``` + +## Message Schemes + +A scheme maps message keys to formats. + +```ts +const [$t] = messages('event', { + pageTitle: params({ + title: text() + }), + attendees: format(number()), + eventDate: format(capitalize(datetime({ + dateStyle: 'full', + timeStyle: 'short' + }))) +}) +``` + +Formats are checked against the translation value type when translation data is typed. For example, `duration()` expects a `Duration` object, so using it directly for a string translation field is a type error, while `datetime()` accepts a `Date`, a timestamp or a date string. + +## Formats + +`@nano_kit/intl` exports small formats: + +- **`raw`** - returns the input value as-is. +- **`text`** - string values and string fallbacks. +- **`uppercase`**, **`lowercase`**, **`capitalize`** - locale-aware text transforms. +- **`params`** - replaces `{name}` placeholders with formatted parameters. +- **`number`**, **`datetime`**, **`relativetime`**, **`duration`**, **`list`** - wrappers around Intl formatters. +- **`range`** - range formatting for date and number formatters. +- **`format`** - turns a format into a callable message. +- **`plural`** - selects an LDML plural form with `Intl.PluralRules`. +- **`match`** - selects a case by parameter value. +- **`rich`** and **`markup`** - map lightweight tags inside translated strings. + +See API for format signatures, callable formatter messages, and composition patterns. + +--- + +# API + +Reference for @nano_kit/intl formats, helpers, and composition patterns. + +This page describes the public building blocks exported by `@nano_kit/intl`: context helpers, message formats, translation utilities, and common composition patterns. + +## Context + +### `intl($locale, loader)` + +Creates an internationalization context with a bound `messages` method. + +```ts +import { intl } from '@nano_kit/intl' + +const { messages, $loading, $error } = intl( + $locale, + loader +) +``` + +The loader can return full translation data or load namespaces on demand. + +### `messages(namespace, scheme?)` + +Creates reactive messages for a namespace. + +```ts +const [$t, $pending, $error] = messages('home', { + title: text(), + attendees: plural('count') +}) +``` + +## Basic Formats + +### `raw(fallback?)` + +Returns the input value as-is. Use it for objects, arrays, dictionaries, or values that do not need formatting. + +```ts +const [$t] = messages('home', { + categories: raw>() +}) + +$t().categories?.conference +``` + +### `text(fallback?)` + +Formats string messages and optional string fallbacks. + +```ts +const [$t] = messages('layout', { + title: text('Untitled') +}) + +$t().title +// string | 'Untitled' +``` + +### `uppercase(format)`, `lowercase(format)`, `capitalize(format)` + +Transforms text formatter output with the active locale. + +```ts +const [$t] = messages('home', { + title: capitalize(text()) +}) + +$t().title +// 'Hello' +``` + +## Parameterized Messages + +### `params(params, preformat?)` + +Replaces `{name}` placeholders with formatted values. + +```ts +const [$t] = messages('home', { + greeting: params({ + name: text() + }) +}) + +$t().greeting({ + name: 'Ada' +}) +``` + +`params` can preformat another formatter before replacing placeholders. That makes it possible to share parameters across nested formats. + +```ts +const translations = { + home: { + mailbox: { + one: '{name} has {count} message', + other: '{name} has {count} messages' + } + } +} + +const [$t] = messages('home', { + mailbox: params({ + name: text() + }, plural('count', forms({ + one: text(), + other: text() + }))) +}) + +$t().mailbox({ + name: 'Ada', + count: 2 +}) +// 'Ada has 2 messages' +``` + +## Callable Formatter Messages + +### `format(type)` + +Use `format()` when the message should format an application value instead of formatting a value from translation data. It binds another format to the current locale and returns a callable message. + +```ts +const [$t] = messages('home', { + formatPrice: format(number({ + style: 'currency', + currency: 'USD' + })), + eventDate: format(capitalize(datetime({ + dateStyle: 'medium', + timeZone: 'UTC' + }))) +}) + +$t().formatPrice(12) +// '$12.00' + +$t().eventDate(new Date('2024-01-02T00:00:00.000Z')) +// 'Jan 2, 2024' +``` + +This is useful for dates, numbers, and other UI values that come from app state. + +## Intl Formats + +Intl formats can be used inside `params(...)` for translated templates, or wrapped with `format(...)` when the value comes from runtime state, API data, or a database. + +A fallback is always passed together with options, for example `number(0, {})` or `datetime(fallback, false)`: a lone argument is always read as options. + +### `number(fallback?, options?)` + +Formats numbers with `Intl.NumberFormat`. + +```ts +const [$t] = messages('stats', { + formatCount: format(number({ + maximumFractionDigits: 1 + })) +}) + +$t().formatCount(1234.56) +// '1,234.6' +``` + +### `datetime(fallback?, options?)` + +Formats `Date` or timestamp values with `Intl.DateTimeFormat`. + +```ts +const [$t] = messages('event', { + formatDate: format(datetime({ + dateStyle: 'medium', + timeZone: 'UTC' + })) +}) + +$t().formatDate(new Date('2024-01-02T00:00:00.000Z')) +// 'Jan 2, 2024' +``` + +### `relativetime(fallback?, options?)` + +Formats relative time values with `Intl.RelativeTimeFormat`. + +```ts +const [$t] = messages('event', { + formatStartsIn: format(relativetime({ + unit: 'day', + numeric: 'auto' + })) +}) + +$t().formatStartsIn(1) +// 'tomorrow' +``` + +### `duration(fallback?, options?)` + +Formats duration objects with `Intl.DurationFormat`. + +```ts +const [$t] = messages('media', { + formatRuntime: format(duration({ + style: 'short' + })) +}) + +$t().formatRuntime({ + hours: 1, + minutes: 30 +}) +// '1 hr, 30 min' +``` + +### `list(fallback?, options?)` + +Formats string iterables with `Intl.ListFormat`. + +```ts +const [$t] = messages('filters', { + formatTags: format(list({ + type: 'conjunction' + })) +}) + +$t().formatTags(['react', 'ssr', 'signals']) +// 'react, ssr, and signals' +``` + +### `range(format, fallback?, options?)` + +Formats ranges through an Intl formatter that supports `formatRange`. + +```ts +const [$t] = messages('price', { + formatAmount: format(range(number, { + style: 'currency', + currency: 'USD' + })) +}) + +$t().formatAmount([1, 5]) +// '$1.00 – $5.00' +``` + +## Match And Plural + +### `plural(param, forms?)` + +Matches an LDML plural form with `Intl.PluralRules`. If `forms(...)` is omitted, `plural` reads forms directly from the translation input. A plain string translation serves every form, which suits locales without plural distinctions. + +```ts +const [$t] = messages('home', { + attendees: plural('count') +}) + +$t().attendees({ + count: 3 +}) +// '3 going' + +/* Also works with the value itself. */ +$t().attendees(3) +// '3 going' +``` + +Use `forms(...)` when nested forms need their own formatters. + +```ts +const [$t] = messages('home', { + attendees: plural('count', forms({ + one: text('{count} attendee'), + other: text('{count} attendees') + })) +}) + +$t().attendees({ + count: 1 +}) +// '1 attendee' +``` + +Exact numeric forms are checked before plural rules. + +```ts +const [$t] = messages('home', { + guests: plural('count', forms({ + 0: text('No guests'), + one: text('{count} guest'), + other: text('{count} guests') + })) +}) + +$t().guests({ + count: 0 +}) +// 'No guests' +``` + +### `match(param, cases?)` + +Matches a case by parameter value. If `cases(...)` is omitted, `match` reads cases directly from the translation input. A plain string translation serves every case. + +```ts +const [$t] = messages('invite', { + message: match('gender') +}) + +$t().message({ + gender: 'female' +}) +// 'She invited female.' + +/* Also works with the value itself. */ +$t().message('female') +// 'She invited female.' +``` + +Use `cases(...)` when cases need their own formatters. + +```ts +const [$t] = messages('account', { + status: match('status', cases({ + active: text('Active {status}'), + disabled: text('Disabled {status}') + })) +}) + +$t().status({ + status: 'active' +}) +// 'Active active' +``` + +Use `other(key)` when unknown values should fall back to a known case instead of returning `undefined`. + +```ts +const [$t] = messages('account', { + role: match('role', other('user')) +}) + +$t().role({ + role: 'guest' +}) +// 'User area' +``` + +It also works together with `cases(...)`. + +```ts +const [$t] = messages('account', { + role: match('role', cases({ + admin: text(), + user: text() + }), other('user')) +}) + +$t().role({ + role: 'guest' +}) +// 'User area' +``` + +`match` can compose with `plural` when one case depends on another formatter. + +```ts +const [$t] = messages('tasks', { + message: plural('count', forms({ + one: match('gender'), + other: match('gender') + })) +}) + +$t().message({ + gender: 'female', + count: 3 +}) +// 'She has 3 tasks' +``` + +`match` can also wrap `plural` when the translation shape starts with cases and nests forms inside them. + +```ts +const [$t] = messages('tasks', { + message: match('gender', cases({ + male: plural('count', forms({ + one: text('He has one task'), + other: text('He has {count} tasks') + })), + female: plural('count', forms({ + one: text('She has one task'), + other: text('She has {count} tasks') + })) + })) +}) + +$t().message({ + gender: 'female', + count: 3 +}) +// 'She has 3 tasks' +``` + +## Rich Text + +### `rich(tags)` / `rich(fallback, tags)` + +Maps lightweight tags inside translated strings to rich chunks. Self-closing tags like `
` are supported and mapped with empty chunks. Each tag handler also receives an unique index, usable as a key for framework nodes. + +```ts +const [$t] = messages('docs', { + message: rich('Read the docs
today', { + link: (chunks, i) => {chunks}, + br: (_, i) =>
+ }) +}) + +$t().message +// ['Read ', the docs,
, 'today'] +``` + +### `markup(tags)` / `markup(fallback, tags)` + +Maps lightweight tags to strings. + +```ts +const [$t] = messages('docs', { + message: markup('Read the docs', { + strong: chunks => `${chunks}` + }) +}) + +$t().message +// 'Read the docs' +``` + +## Utilities + +### `deflat(input, depth?)` + +Turns dotted object keys into nested objects. + +```ts +const translations = deflat({ + 'layout.title': 'Event Board', + 'home.title': 'Find your next frontend event', + 'home.attendees.one': '{count} going', + 'home.attendees.other': '{count} going' +}) +``` + +### `direction($locale)` and `getDirection(locale)` + +Use `direction($locale)` when the UI needs to react to left-to-right and right-to-left locales. + +```ts +import { direction } from '@nano_kit/intl' + +const $dir = direction($locale) + +$dir() +// 'ltr' or 'rtl' +``` + +`getDirection(locale)` is available for non-reactive code. + +--- + +# SSR + +Learn how to load @nano_kit/intl translations with @nano_kit/query during server rendering. + +SSR apps should load translations through @nano_kit/query so locale data can be preloaded before HTML is rendered and dehydrated into the SSR payload. Without that preload, the server can render fallback or wrong-locale messages and the client has to fix them after hydration. + +This page only covers the `@nano_kit/intl` side. Query client setup, SSR, and dehydration are covered in the `@nano_kit/query` documentation. + +## SSR Setup Example + +Here is an example of how to set up an `Intl$` DI store that loads translations with `@nano_kit/query` and combines them with SSR-aware locale detection and cookie storage. It usually combines: + +- `Locales$` from SSR Locale for request-bound language detection. +- `CookieStore$` from SSR Cookies for persisted locale selection. +- `query(...)` for cached translation loading. +- `intl($locale, loader)` for message access. + +```ts +import { inject } from '@nano_kit/store' +import { queryKey } from '@nano_kit/query' +import { intl } from '@nano_kit/intl' +import { + Locales$, + CookieStore$, + browserLocale, + cookieStored +} from '@nano_kit/platform-web' +import { Client$ } from './query' +import { + type SupportedLocale, + type Translations, + load, + supportedLocales +} from '../translations' + +const TranslationsKey = queryKey<[locale: SupportedLocale], Translations>('translations') + +export function Intl$() { + const locales = inject(Locales$) + const cookieStore = inject(CookieStore$) + const { query } = inject(Client$) + const $locale = cookieStored(cookieStore, { + name: 'locale', + path: '/', + sameSite: 'lax' + }, browserLocale(locales, supportedLocales, 'en')) + const { + messages, + $loading + } = intl( + $locale, + query(TranslationsKey, [$locale], load) + ) + + return { + supportedLocales, + messages, + $locale, + $loading + } +} +``` + +...and then use `Intl$` on some app page: + +```ts +/* ... */ +function Messages$() { + const { messages } = inject(Intl$) + + return messages('event', { + /* ... */ + }) +} + +export function Stores$() { + const [$t] = inject(Messages$) + const { $event } = inject(EventDetails$) + + return [$t, $event] +} +/* ... */ +``` + +In this setup, translations are loaded through `query(...)`. The active locale is read from the `locale` cookie; when the cookie is missing, `browserLocale(...)` resolves it from the request `Accept-Language` header. `intl(...)` then exposes `messages`, and each page or layout declares the messages it needs in a local `Messages$` store. + +For SSR preload, return `$t` from `Stores$` the same way you return regular data signals that should be awaited and dehydrated. This lets the renderer load translations before producing HTML, so the response already contains messages for the resolved locale. diff --git a/skills/nano-kit-intl/SKILL.md b/skills/nano-kit-intl/SKILL.md new file mode 100644 index 00000000..6a866fbc --- /dev/null +++ b/skills/nano-kit-intl/SKILL.md @@ -0,0 +1,68 @@ +--- +name: nano-kit-intl +description: How to internationalize a Nano Kit app with @nano_kit/intl, the reactive, type-safe message layer built on @nano_kit/store. Covers translation data and namespaces, full-data and namespace loaders, the intl context and messages tuples, message schemes composed from formats (text, params, plural, match, number, datetime, relativetime, duration, list, range, rich, markup), typed and anonymous translation data, locale direction, deflat, SSR loading through @nano_kit/query with cookie and Accept-Language locale detection, and testing. Apply when adding translations, formatting locale-aware values or wiring locale selection in a Nano Kit app. For signals, stores and DI see the nano-kit-store skill. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - intl + - i18n + - internationalization + - signals + - ssr + docs: + paths: + - intl +--- + +# @nano_kit/intl + +`@nano_kit/intl` turns translation objects into reactive, typed messages: a message is a plain string, an object of plural forms or cases, or a format applied to a translation value. Signals, stores and DI are described in the `nano-kit-store` skill. + +## Reference + +`DOCS.md` next to this file is the documentation of the released package as published on https://nano-kit.js.org, generated from the site sources. Read its relevant sections for the formats, their options and examples before writing code. This skill adds only conventions, choices and pitfalls. + +## Structure + +- Group translation data by namespace (a page, a layout, a feature) and bind each namespace with `messages(namespace, scheme)` in a small injectable factory next to the page that uses it. Components read `$t` through the framework hook. +- Choose the loader by bundle shape: a full-data loader (`resolved` or a query) for small translations shipped together, a namespace loader when translations should split by route or feature. With a namespace loader the context `$loading` is meaningless; use the `$pending` returned per namespace. +- A scheme entry is needed only where a key needs a format: parameters, plural forms, cases, Intl formatting. Keys without a format come back raw and typed from the translation data. +- `format(...)` messages format application values and take a value argument; `params(...)` messages fill placeholders in translated text and take an object of parameters. Do not mix them up. + +## Locale + +- Negotiate the initial locale from `Locales$` (`navigator` in the browser, the `Accept-Language` header on the server) and let a cookie-backed `$locale` override it, so the choice survives reloads and reaches the server. +- Load translations through `@nano_kit/query` when the app has SSR: they are cached, deduplicated and dehydrated like any other data, and keep their key out of session-wide invalidation sweeps. +- Feed `direction($locale)` to the router's `dir()` head descriptor and `$locale` to `lang()`. + +## SSR + +- Return `$t` from the page `Stores$` factory, so the server waits for the translations and renders in the resolved locale. +- The SSR renderers provide `Locales$` and the cookie store per request through their `inject` options (see the `nano-kit-react-ssr` skill). + +## Testing + +- Provide fixed data: `intl(signal('en'), resolved(en))` needs no DI. With DI, provide the cookie store and `Locales$` tokens in an `InjectionContext` and inject the intl store. +- Start mount-triggered loaders before waiting on messages; waiting does not mount them for you. + +## Pitfalls + +- A scheme key missing from the loaded data receives `undefined`; `text()` then yields `undefined` and `text('x')` the fallback. +- Read `$t` reactively (effects, hooks, `$t.$key`); a value cached in module scope misses locale and data changes. +- `Intl.DurationFormat` and `Intl.ListFormat` depend on the runtime; check Node and browser support before relying on `duration` and `list`. diff --git a/skills/nano-kit-platform-web/DOCS.md b/skills/nano-kit-platform-web/DOCS.md new file mode 100644 index 00000000..6aaab4b3 --- /dev/null +++ b/skills/nano-kit-platform-web/DOCS.md @@ -0,0 +1,356 @@ + + +# Web + +Reactive Web API helpers for @nano_kit/store. + +`@nano_kit/platform-web` provides small reactive wrappers around browser APIs. The package is built on top of `@nano_kit/store`, so every helper returns a signal that can be used with `effect`, `computed`, framework integrations, query settings, or your own store utilities. + +Instead of wiring browser event listeners by hand, use these helpers when you want Web API state to participate in Nano Kit reactivity. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store @nano_kit/platform-web +``` + +## Quick Start + +```ts +import { + BooleanCodec, + effect +} from '@nano_kit/store' +import { + $networkOnline, + $pageVisible, + localStored, + mediaQuery +} from '@nano_kit/platform-web' + +const $dark = localStored('dark', false, BooleanCodec) +const $wide = mediaQuery('(min-width: 768px)', false) + +const stop = effect(() => { + console.log({ + dark: $dark(), + online: $networkOnline(), + pageVisible: $pageVisible(), + wide: $wide() + }) +}) + +$dark(true) + +stop() +``` + +## Storage + +`localStored` and `sessionStored` create writable signals backed by `localStorage` and `sessionStorage`. + +```ts +import { BooleanCodec, debounce } from '@nano_kit/store' +import { localStored, sessionStored } from '@nano_kit/platform-web' + +const $dark = localStored('dark', false, BooleanCodec) +const $draft = sessionStored('draft', '', debounce(300)) + +$dark(true) +$draft('Hello') +$draft(null) /* Removes sessionStorage.draft */ +``` + +`syncedLocalStored` and `syncedSessionStored` also listen for storage events, so the signal can react to changes from other browsing contexts. + +```ts +import { effect } from '@nano_kit/store' +import { syncedLocalStored } from '@nano_kit/platform-web' + +const $language = syncedLocalStored('language', 'en') + +const stop = effect(() => { + document.documentElement.lang = $language() +}) +``` + +All storage helpers support the same optional arguments as `stored`: default values, codecs, and setter rate limiters. + +Assigning `null` or `undefined` removes the underlying `localStorage` or `sessionStorage` entry. If the signal has a default value, the signal falls back to that default after deletion. + +## Media Queries + +`mediaQuery` wraps `window.matchMedia(...)`. + +```ts +import { mediaQuery } from '@nano_kit/platform-web' + +const $wide = mediaQuery('(min-width: 768px)', false) +const $reducedMotion = mediaQuery('(prefers-reduced-motion: reduce)', false) +``` + +The optional second argument is the fallback value used when `window` is not available. + +## Browser Properties + +The package exports shared singleton signals for common browser state. + +```ts +import { + $devicePixelRatio, + $fullscreen, + $innerHeight, + $innerWidth, + $networkOnline, + $outerHeight, + $outerWidth, + $pageVisible, + $screenLeft, + $screenOrientation, + $screenTop, + $scrollX, + $scrollY +} from '@nano_kit/platform-web' +``` + +Window size and position: + +- `$innerWidth` +- `$innerHeight` +- `$outerWidth` +- `$outerHeight` +- `$scrollX` +- `$scrollY` +- `$screenLeft` +- `$screenTop` +- `$devicePixelRatio` + +Page and device state: + +- `$networkOnline` +- `$pageVisible` +- `$screenOrientation` +- `$fullscreen` + +Numeric window values use `NaN` when the browser value is not available. Boolean values use browser-friendly fallbacks: `$networkOnline` and `$pageVisible` start as `true`, and `$fullscreen` starts as `false`. + +## Permissions + +`permission` creates a writable signal for the current Permissions API state. The value is `'granted'`, `'denied'`, `'prompt'`, `Error`, or `undefined` before the first result. + +```ts +import { effect } from '@nano_kit/store' +import { permission } from '@nano_kit/platform-web' + +const $geolocationPermission = permission('geolocation') + +const stop = effect(() => { + const state = $geolocationPermission() + + if (state === 'granted') { + console.log('Geolocation is available') + } +}) +``` + +The argument can be a permission name string or a full [`PermissionDescriptor`](https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query#permissiondescriptor). + +## Geolocation + +`$geolocation` is backed by `navigator.geolocation.watchPosition(...)` and updates while the signal is active. + +`$staticGeolocation` is backed by `navigator.geolocation.getCurrentPosition(...)` and reads a single snapshot when the signal starts. + +```ts +import { effect } from '@nano_kit/store' +import { $geolocation } from '@nano_kit/platform-web' + +const stopLive = effect(() => { + const result = $geolocation() + + if (result && 'coords' in result) { + console.log(result.coords.latitude, result.coords.longitude) + } +}) +``` + +Both signals can contain `GeolocationPosition`, `GeolocationPositionError`, or `undefined`. + +## Cookies + +`cookieStored` and `syncedCookieStored` adapt a [`CookieStore`](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API)-compatible object to Nano Kit storage signals. + +```ts +import { JsonCodec } from '@nano_kit/store' +import { cookieStored, syncedCookieStored } from '@nano_kit/platform-web' + +const $theme = cookieStored(cookieStore, { + name: 'theme', + maxAge: 60 * 60 * 24 * 30, + path: '/' +}, 'light') + +const $profile = syncedCookieStored(cookieStore, 'profile', {}, JsonCodec) + +$theme('dark') +$theme(null) /* Calls cookieStore.delete({ name: 'theme', path: '/' }) */ +``` + +`maxAge` is given in seconds and takes precedence over `expires`, so `maxAge: 0` expires the cookie immediately. + +Use `cookieStored` when you only need reads and writes. Use `syncedCookieStored` when the cookie store supports change events and you want the signal to react to external cookie changes. + +Assigning `null` or `undefined` deletes the underlying cookie through the provided [`CookieStore`](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API)-compatible object. When `cookieStored` was created from options, deletion reuses the matching `path`, `domain`, and `partitioned` attributes so the browser removes the same cookie scope. If a default value was provided, the signal falls back to it after deletion. + +For universal code, use the `CookieStore$` injection token. By default it returns the browser `cookieStore`, so browser-only code does not need any setup. + +```ts +import { inject } from '@nano_kit/store' +import { + CookieStore$, + cookieStored +} from '@nano_kit/platform-web' + +export function Session$() { + const cookieStore = inject(CookieStore$) + const $session = cookieStored(cookieStore, { + name: 'session', + path: '/', + sameSite: 'lax' + }, null) + + return { + $session + } +} +``` + +During SSR and tests, provide `CookieStore$` with `VirtualCookieStore`. It implements the CookieStore API from an incoming `Cookie` header and collects pending `Set-Cookie` headers from mutations. + +```ts +import { InjectionContext, provide } from '@nano_kit/store' +import { + CookieStore$, + VirtualCookieStore +} from '@nano_kit/platform-web' + +const cookieStore = new VirtualCookieStore( + 'theme=dark; session=abc123', + '/dashboard' +) + +const context = new InjectionContext([ + provide(CookieStore$, cookieStore) +]) + +await cookieStore.set({ + name: 'theme', + value: 'light', + path: '/', + sameSite: 'lax' +}) + +cookieStore.peek('theme') // 'light' +cookieStore.drainSetCookieHeaders() +// ['theme=light; Path=/; SameSite=Lax'] +``` + +`serializeCookies` reads named cookies from any `CookieStore`-compatible object and serializes them into a `Cookie` request header value. Use it to forward request cookies to a backend fetch during SSR: + +```ts +import { serializeCookies } from '@nano_kit/platform-web' + +const cookie = await serializeCookies(cookieStore, ['session', 'locale']) +// 'session=abc123; locale=en' +``` + +## Broadcast Channel + +`broadcasted` creates a signal synchronized through `BroadcastChannel`. It is useful for transient cross-tab messages such as logout, refresh, or UI coordination events. + +```ts +import { effect } from '@nano_kit/store' +import { broadcasted } from '@nano_kit/platform-web' + +const $authEvent = broadcasted<'logout' | 'refresh'>('auth') + +const stop = effect(() => { + if ($authEvent() === 'logout') { + console.log('Log out this tab') + } +}) + +$authEvent('logout') +``` + +`broadcasted` supports the same default value, codec, and setter rate limiter overloads as the storage helpers. + +## Locale + +`browserLocale` picks the best locale from a browser-like language container. In the browser, pass `navigator` directly. + +```ts +import { browserLocale } from '@nano_kit/platform-web' + +const locale = browserLocale(navigator, ['en', 'ru'], 'en') +``` + +For universal code, use the `Locales$` injection token. By default it returns `navigator`, so browser-only code does not need any setup. + +```ts +import { inject } from '@nano_kit/store' +import { + Locales$, + browserLocale +} from '@nano_kit/platform-web' + +export function Locale$() { + const locales = inject(Locales$) + + return browserLocale(locales, ['en', 'ru'], 'en') +} +``` + +On the server, provide `Locales$` with `parseLocales(...)` from the request `Accept-Language` header. + +```ts +import { provide } from '@nano_kit/store' +import { + Locales$, + parseLocales +} from '@nano_kit/platform-web' + +const context = [ + provide(Locales$, parseLocales(request.headers.get('accept-language'))) +] +``` + +`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. Empty headers fall back to `{ language: 'en', languages: ['en'] }`. + +## User Agent + +Use `UserAgent$` when universal code needs the browser user agent string. In the browser it returns `navigator.userAgent`. + +```ts +import { inject } from '@nano_kit/store' +import { UserAgent$ } from '@nano_kit/platform-web' + +export function Device$() { + const userAgent = inject(UserAgent$) + + return userAgent.includes('Mobile') ? 'mobile' : 'desktop' +} +``` + +On the server, provide `UserAgent$` with the incoming `User-Agent` header. + +```ts +import { provide } from '@nano_kit/store' +import { UserAgent$ } from '@nano_kit/platform-web' + +const context = [ + provide(UserAgent$, request.headers.get('user-agent') ?? '') +] +``` diff --git a/skills/nano-kit-platform-web/SKILL.md b/skills/nano-kit-platform-web/SKILL.md new file mode 100644 index 00000000..7127eaee --- /dev/null +++ b/skills/nano-kit-platform-web/SKILL.md @@ -0,0 +1,66 @@ +--- +name: nano-kit-platform-web +description: How to use @nano_kit/platform-web, the reactive browser API helpers for Nano Kit. Covers localStorage, sessionStorage, cookie and BroadcastChannel backed signals, media queries, window and document state signals (size, scroll, online, visibility, orientation, fullscreen, pixel ratio), permissions and geolocation, and the universal tokens CookieStore$, Locales$ and UserAgent$ with their server implementations (VirtualCookieStore, parseLocales, serializeCookies) for SSR and tests. Apply when a store needs browser state, persisted settings, cookies or locale detection that must also work during server rendering. For signals, stored signals, codecs and DI see the nano-kit-store skill. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - platform-web + - browser + - cookies + - storage + - locale + - ssr + docs: + paths: + - platform +--- + +# @nano_kit/platform-web + +`@nano_kit/platform-web` wraps browser APIs in `@nano_kit/store` signals: storage-backed settings, media queries, window and document state, permissions, geolocation, cookies, cross-tab messages, locale and user agent. Signals, stores and DI are described in the `nano-kit-store` skill. + +## Reference + +`DOCS.md` next to this file is the documentation of the released package as published on https://nano-kit.js.org, generated from the site sources. Read it for the helpers, their overloads and examples before writing code. This skill adds only conventions, choices and pitfalls. + +## Which helper for which job + +- User settings that must survive a reload: `localStored`; per-tab drafts: `sessionStored`. The `synced` variants follow changes made in other documents; use them when two tabs must agree. +- Responsive and environment decisions: `mediaQuery` and the window and document singletons, derived with `computed`, instead of ad-hoc `window` reads in components. +- Session and preferences shared with the server: `cookieStored` on the `CookieStore$` token, never `document.cookie`. +- Transient cross-tab events (logout, refresh): `broadcasted`, which carries structured clones, so plain objects need no codec. +- Locale negotiation: `browserLocale` on the `Locales$` token; `UserAgent$` for the user agent. Both resolve to `navigator` in the browser. +- Storage and cookie values are strings unless a codec is given; use `JsonCodec` or `BooleanCodec` for anything else, and the rate limiter overloads for chatty writers. + +## Universal code + +- Stores inject the tokens (`CookieStore$`, `Locales$`, `UserAgent$`) instead of reading globals, so the same store runs in the browser, on the server and in tests. +- On the server the SSR layers provide the tokens per request (the `inject` options of the Vite plugins, the SvelteKit adapter); provide `VirtualCookieStore` and `parseLocales(header)` yourself in custom setups and tests. Without a provider `CookieStore$` resolves to the missing global and the first read throws. +- Storage helpers, media queries and the singletons have server fallbacks (inert storage, the `fallback` argument, `NaN`, `true`, `false` or `undefined`), so reading them during SSR is safe; the values just are not the browser's. + +## Testing + +- Provide the tokens with `new VirtualCookieStore('cookie=header', '/')` and `parseLocales('ru,en;q=0.8')` in an `InjectionContext` and assert on the store's signals. +- Storage helpers use the global storage; clear it between tests and create fresh store instances, because created signals keep their values. + +## Pitfalls + +- `browserLocale` matches language tags exactly (`ru-RU` is not `ru`); list the tags you support or normalize before matching. +- Singleton signals are shared across the app; derive from them with `computed`, do not write to them. +- `syncedSessionStored` follows other documents that share the tab's session storage, not other tabs. diff --git a/skills/nano-kit-query/DOCS.md b/skills/nano-kit-query/DOCS.md new file mode 100644 index 00000000..ccf89e2d --- /dev/null +++ b/skills/nano-kit-query/DOCS.md @@ -0,0 +1,1365 @@ + + +# Getting Started + +@nano_kit/query is a lightweight and powerful remote data management library built on top of @nano_kit/store. + +`@nano_kit/query` is a small and powerful remote data management library for @nano_kit/store. + +- **Small**. Minimal footprint with tree-shakeable architecture. +- **Type-safe**. Full TypeScript support with type inference for queries and mutations. +- **Signal-based**. Built on top of @nano_kit/store's reactive signals for automatic UI updates. +- **Flexible**. Supports queries, infinite queries, mutations, and operations with cache management. +- **Extensible**. Customizable with settings and extensions. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store @nano_kit/query +``` + +## Quick Start + +Here is a minimal example demonstrating reactive data fetching with automatic cache management: + +```ts +import { signal, effect } from '@nano_kit/store' +import { queryKey, client } from '@nano_kit/query' + +/* Define a cache key for your data */ +const PostKey = queryKey<[postId: number], Post | null>('post') + +/* Create a signal with the post ID to fetch */ +const $postId = signal(1) + +/* Create a query client */ +const { query } = client() + +/* Create a reactive query */ +const [$post, $postError, $postLoading] = query(PostKey, [$postId], postId => fetch(`/api/posts/${postId}`).then(r => r.json())) + +/* React to data changes (mounting $post triggers data fetching) */ +const unsub = effect(() => { + if ($postLoading()) { + console.log('Loading...') + } else if ($postError()) { + console.log('Error:', $postError()) + } else { + console.log('Post:', $post()) + } +}) +// Loading... +// Post: { id: 1, title: 'First Post', ... } + +/* Update triggers automatic refetch */ +$postId(2) +// Loading... +// Post: { id: 2, title: 'Second Post', ... } + +/* Cleanup: removes listener and stops data fetching */ +unsub() +``` + +--- + +# Core Concepts + +Learn the core concepts of @nano_kit/query. + +## Client + +The client is the main entry point for `@nano_kit/query`. It creates a centralized store for managing queries, cache, and data mutations. + +Use the `client()` function to create a query client. It returns an object with methods for working with queries and cache: + +```ts +import { client } from '@nano_kit/query' + +const { + query, /* Create reactive queries */ + invalidate, /* Invalidate cache entries */ + revalidate, /* Revalidate cache entries */ + $data, /* Get/set cached data */ + $error, /* Get cached error */ + $loading /* Get cached loading state */ +} = client() +``` + +### Settings and Extensions + +The client accepts settings and extensions that modify its behavior: + +- **Client Settings** — apply globally to all queries and mutations, but can be overridden per query +- **Client Extensions** — add new methods to the client object + +```ts +import { client, cacheTime, dedupeTime, mutations } from '@nano_kit/query' + +const { + query, + mutation, /* Added by mutations() extension */ + $data +} = client( + cacheTime(300000), /* Setting: cache for 5 minutes */ + dedupeTime(8000), /* Setting: dedupe window of 8 seconds */ + mutations() /* Extension: adds mutation method */ +) +``` + +## Query + +A query is a reactive data fetcher that automatically loads data when mounted and refetches when parameters change. It manages loading states, errors, and caching automatically. + +Use the `query()` method from the client to create a query: + +```ts +import { signal, effect } from '@nano_kit/store' +import { queryKey, client } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post | null>('post') +const $postId = signal(1) +const { query } = client() + +const [$post, $error, $loading] = query(PostKey, [$postId], (id) => fetch(`/api/posts/${id}`).then(r => r.json())) +``` + +The `query()` function accepts: + +1. **Cache key builder** — identifies the data in cache +2. **Parameter signals** — reactive parameters that trigger refetch when changed +3. **Fetcher function** — async function that fetches the data +4. **Settings** (optional) — query-specific settings + +It returns a tuple with: + +- `$data` — signal with fetched data (or `null`) +- `$error` — signal with error message (or `null`) +- `$loading` — signal indicating loading state +- `$key` — signal with current cache key + +### Automatic Fetching and Reactivity + +Queries fetch data automatically when mounted (when they have listeners) and refetch when parameters change: + +```ts +const $postId = signal(1) +const [$post, $error, $loading] = query(PostKey, [$postId], fetchPost) + +/* Query starts fetching when mounted */ +const off = effect(() => { + if ($loading()) { + console.log('Loading...') + } else if ($error()) { + console.log('Error:', $error()) + } else { + console.log('Post:', $post()) + } +}) +// Loading... +// Post: { id: 1, title: 'Hello' } + +/* Change parameter to trigger refetch */ +$postId(2) +// Loading... +// Post: { id: 2, title: 'World' } + +/* Query stops when unmounted */ +off() +``` + +Multiple parameters work the same way: + +```ts +const $userId = signal(1) +const $postId = signal(10) + +const [$post] = query(PostKey, [$userId, $postId], (userId, postId) => fetchUserPost(userId, postId)) + +/* Change any parameter to trigger refetch */ +$userId(2) // Refetches with new userId +$postId(20) // Refetches with new postId +``` + +### Cache Behavior + +Queries automatically cache data based on cache keys. When you refetch with the same parameters, the previous cached data is shown while new data loads: + +```ts +const $postId = signal(1) +const [$post, , $loading] = query(PostKey, [$postId], fetchPost) + +effect(() => { + console.log('Post:', $post(), 'Loading:', $loading()) +}) +// Post: null Loading: true +// Post: { id: 1, title: 'First' } Loading: false + +$postId(2) +// Post: null Loading: true <- Cache miss, null shown +// Post: { id: 2, title: 'Second' } Loading: false + +$postId(1) +// Post: { id: 1, title: 'First' } Loading: true <- Cache hit, data shown +// Post: { id: 1, title: 'First' } Loading: false +``` + +Cache can be controlled with `invalidate()` and `revalidate()` methods (detailed in [Cache Keys](#cache-keys) section). + +### Query Settings + +You can pass settings as the fourth argument to override client defaults: + +```ts +import { cacheTime, dedupeTime } from '@nano_kit/query' + +const [$post] = query(PostKey, [$postId], fetchPost, [ + cacheTime(60000), /* Cache for 1 minute */ + dedupeTime(5000) /* Dedupe window of 5 seconds */ +]) +``` + +## Basic Settings + +Settings control the behavior of queries and mutations. They can be applied globally at the client level or per individual query/mutation. + +### `cacheTime` + +Specifies how long (in milliseconds) fetched data stays valid, counted from the moment the request settles. Valid data survives the next fetch and stays visible while it loads. Once the cache time has passed, the data is cleared to `null` the moment the next fetch starts. + +Default: `Infinity` (data never expires). + +```ts +import { client, cacheTime } from '@nano_kit/query' + +/* Global setting */ +const { query } = client( + cacheTime(300000) /* 5 minutes */ +) + +/* Per-query override */ +const [$post] = query(PostKey, [$postId], fetchPost, [ + cacheTime(60000) /* 1 minute for this query */ +]) +``` + +Expiration by itself does not schedule a fetch. Fetches are triggered by observation, parameter changes and `revalidate()`, and they are throttled by [`dedupeTime`](#dedupetime), so unexpired data is refetched as soon as the dedupe window closes, and expired data only disappears when such a fetch begins. + +### `dedupeTime` + +Sets a time window (in milliseconds) for deduplicating identical requests. If multiple requests with the same cache key are made within this window, only one request is sent and the result is shared. + +Default: `4000` (4 seconds). + +```ts +import { client, dedupeTime } from '@nano_kit/query' + +/* Global setting */ +const { query } = client( + dedupeTime(8000) /* 8 seconds */ +) + +/* Per-query override */ +const [$post] = query(PostKey, [$postId], fetchPost, [ + dedupeTime(10000) /* 10 seconds for this query */ +]) +``` + +This prevents unnecessary duplicate requests when multiple components or effects subscribe to the same data simultaneously. + +## Cache Keys + +Cache keys are identifiers used by `@nano_kit/query` to manage cached data. They serve as unique addresses for storing, retrieving, and invalidating query results. + +### Why Cache Keys? + +Cache keys enable: + +- **Data identification** — uniquely identify different pieces of data in the cache +- **Automatic refetching** — when parameters change, the query refetches with new key +- **Cache manipulation** — directly read, update, or invalidate cached data +- **Type safety** — TypeScript infers parameter and return types from the key + +### Creating Cache Keys + +Use `queryKey()` to create a cache key builder: + +```ts +import { queryKey } from '@nano_kit/query' + +/* Simple key without parameters */ +const UsersKey = queryKey<[], User[]>('users') + +/* Key with single parameter */ +const UserKey = queryKey<[id: number], User>('user') + +/* Key with multiple parameters */ +const PostKey = queryKey<[userId: number, postId: number], Post>('post') +``` + +The first type parameter defines the parameters array, the second defines the type of data stored in cache. + +### Building Cache Keys + +Call the key builder with parameters to create a concrete cache key: + +```ts +const UserKey = queryKey<[id: number], User>('user') + +/* Build key for user with ID 1 */ +const userKey = UserKey(1) +/* Build key for user with ID 2 */ +const userKey2 = UserKey(2) +``` + +### Filtering Parameters + +Sometimes you want to ignore certain parameters for caching. Use the filter function: + +```ts +import { queryKey } from '@nano_kit/query' + +/* Only cache by query string, ignore page number */ +const SearchKey = queryKey<[query: string, page: number], SearchResult>( + 'search', + ([query]) => [query] /* Only use query for cache key */ +) + +/* Both create the same cache key */ +SearchKey('react', 1) +SearchKey('react', 2) +``` + +This allows different page requests to share the same cache entry. + +### Using Cache Keys with Client Methods + +#### Reading and Writing Data + +Use `$data()` to read or write cached data: + +```ts +import { client, queryKey } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const { $data, $error } = client() + +/* Read data from cache */ +const post = $data(PostKey(1)) +/* null if not cached, or Post object */ + +/* Write data to cache */ +const revert = $data(PostKey(1), { id: 1, title: 'New Post' }) + +/* Restore the previous cached value */ +revert() + +/* Update with function */ +$data(PostKey(1), post => post && ({ ...post, views: post.views + 1 })) + +/* Reducers receive the cache key params as the second argument */ +$data(PostKey(1), (post, [id]) => post && ({ ...post, id })) + +/* Update all posts in shard */ +$data(PostKey, null) /* Clear all posts */ + +/* Read and write cached errors */ +$error(PostKey(1)) +$error(PostKey(1), 'Failed to load post') +``` + +Both `$data(key, value)` and `$error(key, value)` return a function that restores the previous cached value. This is useful for optimistic updates: write the optimistic value immediately, then call the returned function in an error callback. + +#### Revalidating Cache + +Use `revalidate()` to mark cache entries as stale, triggering active queries to refetch: + +```ts +import { client, queryKey } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const { query, revalidate } = client() + +const $postId = signal(1) +const [$post] = query(PostKey, [$postId], (id) => fetchPost(id)) + +/* Mark specific post to refresh */ +revalidate(PostKey(1)) +/* Active query will refetch post 1 */ + +/* Mark all posts to refresh */ +revalidate(PostKey) +/* All active post queries will refetch */ +``` + +**What happens**: Revalidation doesn't remove data from cache, it just marks it as stale. Active queries (those with listeners) will automatically refetch. + +#### Invalidating Cache + +Use `invalidate()` to remove cache entries completely: + +```ts +import { client, queryKey } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const { query, invalidate } = client() + +const $postId = signal(1) +const [$post] = query(PostKey, [$postId], (id) => fetchPost(id)) + +/* Remove specific post from cache */ +invalidate(PostKey(1)) +/* Data is removed, active query will refetch */ + +/* Remove all posts from cache */ +invalidate(PostKey) +/* All post data is removed, all active queries will refetch */ +``` + +**What happens**: Invalidation removes data from cache. Data signal immediately returns `null`, and active queries refetch. + +### Revalidate vs Invalidate + +| Action | Data Removal | Immediate Effect | Use Case | +| ---------- | ------------ | --------------------------------- | --------------------------------------- | +| revalidate | No | Active queries refetch | Soft refresh, data might still be valid | +| invalidate | Yes | Data becomes `null`, then refetch | Hard refresh, ensure fresh data | + +Example: + +```ts +const { query, revalidate, invalidate, $data } = client() +const [$post] = query(PostKey, [signal(1)], fetchPost) + +effect(() => console.log('Post:', $post())) +// Post: null +// Loading... +// Post: { id: 1, title: 'Hello' } + +revalidate(PostKey(1)) +// Post: { id: 1, title: 'Hello' } <- data still visible +// Loading... +// Post: { id: 1, title: 'Hello' } + +invalidate(PostKey(1)) +// Post: null <- data removed immediately +// Loading... +// Post: { id: 1, title: 'Hello' } +``` + +## Mutation + +A mutation is a method for performing data modifications. Unlike queries, mutations are executed on demand and do not automatically refetch when parameters change. + +To use mutations, add the `mutations()` extension to the client: + +```ts +import { client, mutations } from '@nano_kit/query' + +const { mutation } = client( + mutations() +) +``` + +Then create a mutation using the `mutation()` method: + +```ts +const [updatePost, $result, $error, $loading] = mutation<[params: UpdatePostParams], Post>( + (params) => PostsService.update(params) +) +``` + +The `mutation()` function accepts: + +1. **Mutator function** — async function that performs the modification +2. **Settings** (optional) — mutation-specific settings + +It returns a tuple with: + +- `mutate` — function to execute the mutation +- `$data` — signal with mutation result (or `null`) +- `$error` — signal with error message (or `null`) +- `$loading` — signal indicating loading state + +### Executing Mutations + +Call the mutate function with parameters to execute the mutation: + +```ts +const [updatePost, $result, $error, $loading] = mutation<[params: UpdatePostParams], Post>( + (params) => PostsService.update(params) +) + +/* Execute mutation */ +const [result, error] = await updatePost({ + title: 'New Title' +}) + +if (error) { + console.error('Update failed:', error) +} else { + console.log('Updated:', result) +} +``` + +### Cache Invalidation + +After a successful mutation, you typically want to refresh related queries. Use `revalidate()` in the success callback: + +```ts +import { client, mutations, queryKey, onSuccess } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const { mutation, revalidate } = client(mutations()) + +const $postId = signal(1) + +const [updatePost] = mutation<[params: UpdatePostParams], Post>( + (params, ctx) => { + onSuccess(ctx, () => { + /* Refresh post query after update */ + revalidate(PostKey($postId())) + }) + + return PostsService.update($postId(), params) + } +) +``` + +> Note: Learn more about request context and lifecycle hooks on the Advanced page. + +### Optimistic Updates + +For better user experience, update the cache immediately before the mutation completes. Revert changes if the mutation fails: + +```ts +import { client, mutations, queryKey, onError } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const { mutation, $data } = client(mutations()) + +const $postId = signal(1) + +const [updatePost] = mutation<[params: UpdatePostParams], Post>( + (params, ctx) => { + const postId = $postId() + const postKey = PostKey(postId) + const currentPost = $data(postKey) + + if (currentPost) { + /* Optimistically update cache */ + $data(postKey, { + ...currentPost, + ...params + }) + + /* Revert on error */ + onError(ctx, () => { + $data(postKey, currentPost) + }) + } + + return PostsService.update(postId, params) + } +) + +/* User sees update immediately */ +updatePost({ title: 'New Title' }) +``` + +> Note: Learn more about request context and lifecycle hooks on the Advanced page. + +--- + +# Advanced + +Explore advanced features of @nano_kit/query. + +## Advanced Settings + +### `dedupe` + +Controls request deduplication behavior. There are two deduplication strategies: + +- **By loading state** — prevents new requests if one is already in progress +- **By time window** — prevents requests within the dedupe time window (queries only, not mutations) + +Default: both enabled. + +```ts +import { client, dedupe } from '@nano_kit/query' + +/* Disable time-based deduplication, keep loading state deduplication */ +const { query } = client( + dedupe(true, false) +) + +/* Disable all deduplication for a specific query */ +const [$post] = query(PostKey, [$postId], fetchPost, [ + dedupe(false) +]) +``` + +For mutations, only loading state deduplication is available: + +```ts +const [updatePost] = mutation(updatePostFn, [ + dedupe(false) /* Allow concurrent mutation calls */ +]) +``` + +**When to use:** Allow multiple simultaneous requests for the same data, such as polling or real-time updates. + +### `disabled` + +Disables request execution based on a signal value. When the signal returns `true`, requests are not made. + +```ts +import { signal, computed } from '@nano_kit/store' +import { client, disabled } from '@nano_kit/query' + +const $isAuthenticated = signal(false) +const $userId = signal(null) + +const { query } = client() + +/* Disable query when user is not authenticated */ +const [$user] = query(UserKey, [$userId], fetchUser, [ + disabled(computed(() => !$isAuthenticated())) +]) +``` + +**When to use:** Conditionally enable queries based on application state, such as authentication status or availability of required parameters. + +### `mapError` + +Customizes how errors are converted to strings for storage in cache signals. By default, errors are mapped to `error.message`. + +```ts +import { client, mapError } from '@nano_kit/query' + +/* Global error mapping */ +const { query } = client( + mapError((err) => { + if (err instanceof NetworkError) { + return `Network error: ${err.code}` + } + + return `Error: ${err.message}` + }) +) + +/* Per-query error mapping */ +const [$post] = query(PostKey, [$postId], fetchPost, [ + mapError((err) => { + if (err.status === 404) { + return 'Post not found' + } + + return 'Failed to load post' + }) +]) +``` + +**When to use:** Custom error formatting, localization, or extracting specific information from custom error types. + +### `codec` + +Sets a `Codec` for query cache entry data when the cache crosses a serialization boundary. + +You do not need this for ordinary in-memory browser cache usage. While the app is running in the browser, cache entries can hold any JavaScript value directly. A codec is only needed when query cache data must be serialized: for SSR dehydration/hydration or for persistent storage. + +Use it when serialized cache entries contain values that need custom encoding, such as `Date`, `Map`, `Set`, class instances, or encrypted/compact payloads. + +`hydratable()`, `ssr()`, and [`persistence()`](#persistence) use the codec when they dehydrate data, hydrate it back, write it to persistent storage, or read it back. + +```ts +import type { Codec } from '@nano_kit/store' +import { parse, stringify } from 'devalue' +import { client, codec, ssr } from '@nano_kit/query' + +const DevalueCodec: Codec = { + encode: stringify, + decode: value => value === null ? null : parse(value) +} + +const { query } = client( + codec(DevalueCodec), + ssr() +) +``` + +With a codec like this, data containing values such as `Date`, `Map`, or `Set` can be dehydrated on the server and restored on the client without losing those types. + +The codec applies to cached `data`. Query metadata such as revision, dedupe time, and expiration time is handled by the query package itself. + +**When to use:** SSR hydration or persistent storage for cache entries that contain non-plain values or require a custom serialized format. + +### `onEveryError` + +Registers a global error handler called for every query or mutation error. The callback receives the error and a `stopped` boolean indicating if error propagation was stopped. + +```ts +import { client, onEveryError } from '@nano_kit/query' + +const { query } = client( + onEveryError((error, stopped) => { + if (!stopped) { + /* Log to error tracking service */ + errorTracker.log(error) + + /* Show user notification */ + showErrorToast(String(error)) + } + }) +) +``` + +You can prevent the global handler from running using `stopErrorPropagation` in request context: + +```ts +import { onError, stopErrorPropagation } from '@nano_kit/query' + +const [$post] = query(PostKey, [$postId], (id, ctx) => { + /* Stop error conditionally */ + onError(ctx, (error) => { + if (error.message.includes('404')) { + /* Handle 404 silently */ + stopErrorPropagation(ctx) + } + }) + /* Or stop all errors */ + stopErrorPropagation(ctx) + + return fetchPost(id) +}) +``` + +**When to use:** Centralized error handling, logging, or user notifications. Combine with `stopErrorPropagation` to handle specific errors locally without triggering global handlers. + +## Registered Cache Keys + +Every `queryKey()` builder is registered automatically. Use `keys()` to iterate over cache key builders: + +```ts +import { client, keys, queryKey } from '@nano_kit/query' + +const PostKey = queryKey<[id: number], Post>('post') +const UserKey = queryKey<[id: number], User>('user') +const { revalidate, invalidate } = client() + +/* Mark every registered query shard as stale */ +keys(revalidate) + +/* Or remove every registered query shard from cache */ +keys(invalidate) +``` + +This is useful after broad application changes, such as sign in, sign out, locale changes, or any mutation that can affect several query domains at once. + +## Cache Mutation Helpers + +For comfortable cache mutations, use helpers that fit naturally into `$data` reducers. They are especially handy for optimistic updates. + +### Array Helpers + +Use `append`, `prepend`, `drop`, `map`, and `sort` for array cache values: + +```ts +import { + append, + client, + drop, + map, + prepend, + queryKey, + sort +} from '@nano_kit/query' + +const PostsKey = queryKey<[], Post[]>('posts') +const { $data } = client() + +/* Add to the end. If cache is empty, creates a one-item array. */ +$data(PostsKey(), posts => append(posts, newPost)) + +/* Add to the beginning. */ +$data(PostsKey(), posts => prepend(posts, pinnedPost)) + +/* Remove matching items. */ +$data(PostsKey(), posts => drop(posts, post => post.id === removedPostId)) + +/* Transform items. */ +$data(PostsKey(), posts => map(posts, post => ({ + ...post, + stale: true +}))) + +/* Sort without mutating the previous array. */ +$data(PostsKey(), posts => sort(posts, (a, b) => b.createdAt - a.createdAt)) +``` + +The helpers return the original empty value for operations where that makes sense, or create a new array when adding a value. + +### Infinite Page Helpers + +Use `mapPages`, `mapPageAt`, `mapFirstPage`, `mapLastPage`, and `sortPages` for [`InfinitePages`](#infinite) data: + +```ts +import { + type InfinitePages, + client, + mapFirstPage, + mapPages, + prepend, + queryKey, + sortPages +} from '@nano_kit/query' + +interface PostsPage { + posts: Post[] + nextCursor?: number +} + +const FeedKey = queryKey<[], InfinitePages>('feed') +const { $data } = client() + +/* Update every page. */ +$data(FeedKey(), data => mapPages(data, page => ({ + ...page, + posts: page.posts.map(post => ( + post.id === updatedPost.id ? updatedPost : post + )) +}))) + +/* Add an item to the first loaded page. */ +$data(FeedKey(), data => mapFirstPage(data, page => ({ + ...page, + posts: prepend(page.posts, newPost) +}))) + +/* Sort items across all pages while preserving page sizes. */ +$data(FeedKey(), data => sortPages( + data, + (a: Post, b: Post) => b.createdAt - a.createdAt, + (sort, page) => ({ + ...page, + posts: sort(page.posts) + }) +)) +``` + +`sortPages` collects items from every page, sorts the combined list, and writes items back into the original page chunks. Pagination metadata such as `next` and `more` stays intact. + +## Request Context + +Query and mutation fetcher functions receive request context as last parameter. It provides methods to manage request lifecycle: + +- `onSuccess(ctx, fn)` - register success callback +- `onError(ctx, fn)` - register error callback +- `onSettled(ctx, fn)` - register settled callback +- `stopErrorPropagation(ctx)` - stop error propagation to global `onEveryError` handler + +```ts +import { client, mutations, onSuccess, onError, onSettled, stopErrorPropagation } from '@nano_kit/query' + +const { mutation } = client(mutations()) +/* ... */ +const [updatePost] = mutation<[params: UpdatePostParams], Post>( + (params, ctx) => { + onSuccess(ctx, (data) => { + /* Data updated successfully */ + }) + onError(ctx, (error) => { + /* Handle error */ + }) + onSettled(ctx, (data, error) => { + /* Always executed */ + }) + /* Mark error as stopped to prevent global error handling */ + /* Can be invoked in onError callback as well */ + stopErrorPropagation(ctx) + + return PostsService.update(postId, params) + } +) +``` + +Also query's context can be used as a cache key for advanced scenarios: + +```ts +import { client, queryKey } from '@nano_kit/query' + +const TagsKey = queryKey<[postId: number], Tag[] | null>('tags') +const $postId = signal(1) +const { + query, + $data +} = client() +const [$tags, $tagsError] = query(TagsKey, [$postId], async (postId, ctx) => { + /* Use ctx as current query key to accumulate data in the cache */ + const prevTags = $data(ctx) || [] + const newTags = await PostsService.fetchTags(postId) + + return [...new Set(prevTags.concat(newTags))] +}) +``` + +## Operations + +Operations are a special type of reactive primitive that combines features of queries and mutations. They are essentially "manual queries" — they don't fetch automatically on mount or parameter changes, but they maintain state in the cache like queries. + +Use `operation()` from the client with the `operations()` extension. Like queries, you need to define an `operationKey`: + +```ts +import { client, operations, operationKey } from '@nano_kit/query' + +/* Define operation key */ +const GenerateKey = operationKey<[], [prompt: string], GeneratedPost>('generate') + +const { operation } = client( + operations() +) + +/* Create operation */ +const [generate, $result, $error, $loading] = operation(GenerateKey, [], (prompt) => PostsService.generateWithAi(prompt)) +``` + +Operations are executed manually like mutations, but their result is stored in the cache identified by the key constructed from arguments: + +```ts +/* Execute operation manually */ +const [result, error] = await generate('Write a post about React') +``` + +### When to use what? + +| Feature | Query | Mutation | Operation | +| ----------------- | -------------------- | ------------------------ | --------------------------------------------------- | +| **Execution** | Automatic (reactive) | Manual | Manual | +| **Caching** | Yes (all calls) | No (result only) | Yes (all calls) | +| **Deduplication** | Yes | Loading only | Yes | +| **Use Case** | Fetching data for UI | Modifying data on server | "Heavy" computations, on-demand fetching with cache | + +**Use operations for:** + +- Expensive calculations that should be cached but triggered manually (e.g., AI generation, reports). +- Lazy loading data that shouldn't be loaded immediately on mount. +- Search actions where you want to cache results for specific search terms but trigger the search manually (e.g., on button click submit). + +## Infinite + +Infinite queries enable pagination and "Load More" patterns by maintaining an array of pages in the cache. + +Use `infinite()` from `infinites()` extension. It requires a `getNextCursor` function to determine the next page's cursor. + +```ts +import { type InfinitePages, client, infinites, queryKey } from '@nano_kit/query' + +interface PostsPage { + posts: Post[] + nextCursor?: number +} + +/* 1. Define key with InfinitePages type */ +const PostsKey = queryKey<[], InfinitePages>('posts') + +const { infinite } = client(infinites()) + +/* 2. Create infinite query */ +const [fetchNext, $data, $error, $loading] = infinite( + PostsKey, + [], + (lastPage) => lastPage.nextCursor, /* Extract next cursor from page */ + (cursor) => fetchPosts({ cursor }) /* Fetcher receives cursor */ +) +``` + +**Returns a tuple with:** + +- `fetchNext`: Function to load the next page. +- `$data`: Signal with `InfinitePages` object: + - `pages`: Array of all loaded pages `P[]`. + - `next`: The next cursor value `C`. + - `more`: Boolean indicating if `next` cursor is present. +- `$error`: Signal with error message (or `null`). +- `$loading`: Signal indicating loading state. +- `$key`: Signal with current cache key. + +Here is a small usage example in a React component: + +```tsx +import { useSignal } from '@nano_kit/react' + +export function PostsList() { + const data = useSignal($data) + const loading = useSignal($loading) + + if (!data) { + return null + } + + /* Access all items from all pages */ + const allPosts = data.pages.flatMap(page => page.posts) + + return ( +
+ {allPosts.map(post => ( + + ))} + + {data.more && ( + + )} +
+ ) +} +``` + +## Extensions + +Extensions allow you to add extra functionality to the client or individual queries. + +### `retryOnError` + +Enables automatic retries when a request fails. It uses an exponential backoff strategy by default to determine the delay between attempts. + +```ts +import { client, retryOnError } from '@nano_kit/query' + +/* Enable retries globally */ +const { query } = client( + retryOnError() +) + +/* Enable retries for a specific query */ +const [$post] = query(PostKey, [$postId], fetchPost, [ + retryOnError() +]) +``` + +By default it uses a jittered exponential backoff. You can provide a custom delay calculator: + +```ts +retryOnError((count, error) => { + /* Linear backoff: 1s, 2s, 3s... */ + return count * 1000 +}) +``` + +**When to use:** Improve resilience for unstable network connections or transient server errors. + +### `abortable` + +Adds support for request cancellation using `AbortController`. It injects an `AbortSignal` into the request context, which you can pass to `fetch` or other async APIs. + +```ts +import { client, abortable, abortSignal, abortPrevious, abort } from '@nano_kit/query' + +const { query } = client( + abortable() +) + +const [$post] = query(PostKey, [$postId], async (id, ctx) => { + /* Abort previous running request for this query */ + abortPrevious(ctx) + + /* Pass signal to fetch */ + return fetch(`/api/posts/${id}`, { + signal: abortSignal(ctx) + }).then(r => r.json()) +}) +``` + +You can also manually abort a running request: + +```ts +const promise = fetchPost() + +abort(promise) +``` + +**When to use:** Data fetching where cancellation prevents race conditions and saves bandwidth when the user navigates away or parameters change quickly (e.g., search-as-you-type). + +### `revalidateOn` + +Revalidates queries when one or more reactive conditions become truthy. Pass any accessor that represents the moment when cached data should be refreshed: page visibility, network state, polling ticks, or your own domain-specific condition. + +```ts +import { interval } from '@nano_kit/store' +import { + $networkOnline, + $pageVisible +} from '@nano_kit/platform-web' +import { client, revalidateOn } from '@nano_kit/query' + +const { query } = client( + revalidateOn( + /* Revalidate when page becomes visible again... */ + $pageVisible, + /* ...or when network comes back online... */ + $networkOnline, + /* ...or every minute */ + interval(60 * 1000) + ) +) +``` + +Use built-in browser signals from `@nano_kit/platform-web` for common app lifecycle events: + +- `$pageVisible` revalidates when the user returns to the page. +- `$networkOnline` revalidates when the browser comes back online. +- `interval(ms)` from `@nano_kit/store` revalidates on a timer. + +You can also pass your own signal or computed condition: + +```ts +import { computed, signal } from '@nano_kit/store' +import { client, revalidateOn } from '@nano_kit/query' + +const $userId = signal(null) +const $ready = computed(() => $userId() !== null) + +const { query } = client( + revalidateOn($ready) +) +``` + +**When to use:** Keep data fresh when the page becomes visible again, recover after network outages, poll server state, or revalidate from any domain-specific condition. + +### `persistence` + +Persists the query cache using any storage adapter that implements the `Storage` interface. The second argument is the data lifetime in milliseconds. + +If the client has a [`codec`](#codec) setting, persisted entry data is encoded before writing and decoded after reading. + +```ts +import { client, persistence, indexedDbStorage } from '@nano_kit/query' + +const { query } = client( + /* Keep cache for 24 hours */ + persistence(indexedDbStorage(), 24 * 60 * 60 * 1000) +) +``` + +### `indexedDbStorage` + +Creates an IndexedDB storage adapter for [`persistence()`](#persistence), allowing cached data to survive page reloads and act as a cache for offline mode. + +It stores entries prepared by [`persistence()`](#persistence), including data encoded by the current [`codec`](#codec) setting. + +```ts +import { client, persistence, indexedDbStorage } from '@nano_kit/query' + +const { query } = client( + /* Keep cache for 24 hours */ + persistence(indexedDbStorage(), 24 * 60 * 60 * 1000) +) +``` + +**When to use:** Offline-first applications, or faster startup by showing cached data immediately while fetching fresh data. + +### `entities` + +The `entities` extension allows you to map query or mutation results to entity references for better cache management and data consistency. Thus you can update entity data in one place and have it reflected across all queries that reference that entity. + +```ts +import { client, mutations, entity, entities, onError } from '@nano_kit/query' + +const PostEntity = entity('post') + +const { query, mutation, $data } = client( + mutations() +) + +/* 1. Map individual entity in query */ +const [$post] = query(PostKey, [$postId], (postId) => ( + PostsService.fetch(postId) +), [ + /* Map entity to entity reference */ + /* Also every refetch will update entity in the cache */ + entities(PostEntity) +]) + +/* 2. Map list of entities in query */ +const [$posts] = query(PostsKey, [], () => ( + PostsService.fetchPosts() +), [ + /* Map entities in the page to entity references */ + /* Also every refetch will update entities in the cache */ + entities((capture, page) => ({ + ...page, + posts: page.posts.map(capture(PostEntity)) + })) +]) + +/* 3. Optimistic update via mutation */ +const [updatePost] = mutation<[params: UpdatePostParams], Post>( + (params, ctx) => { + const postId = $postId() + + /* Optimistically update entity data, will update all references */ + const revert = $data(PostEntity(postId), post => post && ({ + ...post, + ...params + })) + + /* Revert changes on error */ + onError(ctx, revert) + + return PostsService.update(postId, params) + } +) +``` + +With this setup: + +1. `$post` (individual post) and `$posts` (list of posts) share the same data source for the post entities. +2. Fetching specific post via `$post` updates the entity in `$posts` as well. +3. `updatePost` optimistically updates the entity, instantly reflecting changes in both `$post` and `$posts`. + +The `capture` helper stores each entity in the shared entity cache and returns a reference for the query result. Passing `entities(PostEntity)` is the shorthand for a single entity result. + +**When to use:** Complex applications where the same data (e.g., a "User" or "Product") appears in multiple places or lists and needs to stay synchronized. + +### `tasks` + +Integrates with `@nano_kit/store`'s task tracking system. This is mainly used for server-side rendering (SSR) to wait for all data fetches to complete before rendering the HTML. + +```ts +import { tasksRunner, waitTasks } from '@nano_kit/store' +import { client, tasks } from '@nano_kit/query' + +const tasksPool = new Set() +const runTask = tasksRunner(tasksPool) + +const { query } = client( + tasks(runTask) +) + +/* ... application runs ... */ + +/* Wait for all queries to finish */ +await waitTasks(tasksPool) +``` + +Without arguments, `tasks()` reads `TasksRunner$` from the current injection context. Outside DI, pass the runner explicitly. + +**When to use:** SSR setups where the server should send a fully populated page to the client. + +--- + +# SSR + +Learn how to use @nano_kit/query for server-side rendering (SSR). + +Server-side rendering in `@nano_kit/query` is built around two pieces: task tracking and cache dehydration. + +Queries started during server render should be awaited before HTML is returned, and the resulting cache state should be transferred to the client for hydration. + +## `hydratable` + +Use `hydratable()` when query cache should be dehydrated on the server and rehydrated on the client. + +Without arguments, it reads hydration dependencies from the current injection context. Outside DI, pass them explicitly. + +In a manual setup, it is typically paired with `tasks()`, which works the same way: without arguments it reads `TasksRunner$` from the current injection context, and outside DI you can pass the runner explicitly. + +If the client uses `codec(...)`, `hydratable()` encodes cached data before dehydration and decodes it during hydration. + +```ts +import { client, tasks, hydratable } from '@nano_kit/query' + +const { query } = client( + tasks(), + hydratable() +) +``` + +Use this when you want to customize task handling or hydration setup independently. + +## `ssr` + +`ssr()` is a convenience setting that combines `tasks()` and `hydratable()`. + +Inside an SSR injection context, it reads the task runner and hydration dependencies automatically. If you need explicit control instead, use `tasks(...)` and `hydratable(...)` directly. + +Because `ssr()` uses `hydratable()` internally, it also respects the current `codec(...)` setting. + +```ts +import { client, dedupeTime, ssr } from '@nano_kit/query' + +const DEDUPE_TIME = 300_000 // 5 minutes + +export function Client$() { + return client( + dedupeTime(DEDUPE_TIME), + ssr() + ) +} +``` + +This enables the usual SSR flow: + +1. Queries started during server render are tracked as tasks. +2. The renderer waits for those tasks to finish. +3. Query cache state is dehydrated into the hydration payload. +4. The client rehydrates the cache and reuses the prefetched data. + +--- + +# Testing + +Learn how to test queries in @nano_kit/query. + +Since `@nano_kit/query` is built on top of `@nano_kit/store`, all testing principles and best practices for stores apply here as well. + +Please refer to the Store Testing documentation for general guidelines on: + +- Mocking dependencies (Dependency Injection). +- Testing lifecycle events (`onMount`). +- Managing timers and unmount delays. + +## Waiting for Queries + +Tests often need to wait for asynchronous data fetching to resolve. The `tasks` extension provides a reliable way to await all pending operations in your query client, ensuring your assertions run only after data is loaded. + +```ts +import { describe, it, expect, vi } from 'vitest' +import { tasksRunner, waitTasks, start } from '@nano_kit/store' +import { client, tasks, queryKey } from '@nano_kit/query' + +const TestKey = queryKey('test') + +it('should fetch and update state', async () => { + /* 1. Create a pool to track active tasks */ + const tasksPool = new Set() + + /* 2. Configure client with tasks extension */ + const { query } = client( + tasks(tasksRunner(tasksPool)) + ) + + /* Mock fetcher */ + const fetcher = vi.fn().mockResolvedValue('success') + + /* 3. Start the query */ + const [$data, $error, $loading] = query(TestKey, [], fetcher) + + /* Mount the signal to trigger the fetch */ + const stop = start($data) + + expect($loading()).toBe(true) + + /* 4. Wait for all fetch tasks to complete */ + await waitTasks(tasksPool) + + /* 5. Assert final state */ + expect($loading()).toBe(false) + expect($data()).toBe('success') + + stop() +}) +``` diff --git a/skills/nano-kit-query/SKILL.md b/skills/nano-kit-query/SKILL.md new file mode 100644 index 00000000..54fd4c31 --- /dev/null +++ b/skills/nano-kit-query/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-kit-query +description: How to fetch, cache and mutate remote data with @nano_kit/query, the data layer built on @nano_kit/store signals. Covers the client and its settings, typed cache keys, reactive queries, mutations, operations and infinite queries, request context hooks, cache reads and optimistic updates, revalidate versus invalidate, entities, retries, cancellation, revalidation triggers, persistence, SSR dehydration and testing. Apply when loading server data, submitting changes or managing cached state in a Nano Kit app. For signals, stores, DI and hydration internals see the nano-kit-store skill. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - query + - data-fetching + - cache + - signals + - ssr + docs: + paths: + - query +--- + +# @nano_kit/query + +`@nano_kit/query` is the data layer on top of `@nano_kit/store`: cached, deduplicated queries bound to reactive parameters, mutations, operations, infinite queries and SSR hydration of the cache. Signals, effects, stores and DI are described in the `nano-kit-store` skill. + +## Reference + +`DOCS.md` next to this file is the documentation of the released package as published on https://nano-kit.js.org, generated from the site sources. Read its relevant sections for exact signatures, settings and examples before writing code. This skill adds only conventions, choices and pitfalls. + +## Which primitive for which job + +- `query`: data that depends on reactive parameters and should load whenever something shows it. It fetches only while its data signal is observed, refetches on parameter changes, revalidation and invalidation, and never at creation time. +- `mutation`: a write on demand with the last result, error and loading state. +- `operation`: manual work whose result deserves the cache (AI generation, reports, explicit search submits, lazy loading that must not start on mount). +- `infinite`: pages behind a cursor; a parameter change restarts from the first page. +- Create the client once per app as an injectable `Client$()` factory with the extensions the app needs (`mutations()`, `infinites()`, `operations()`) and, when the app renders on the server, the SSR settings from the SSR section of `DOCS.md`. + +## Keys and parameters + +- A key builder names a cache shard and types its parameters and data. Shard names must be unique across the app; two builders with one name share a cache. +- Parameters must be JSON-serializable. Give the parameter tuple exactly the builder's parameter types, plain values included. +- Exclude paging or presentation parameters from the key when every page of one request should share an entry. +- Derive parameters from route and search state through the router's signals (skill `nano-kit-react-router`), not from `window.location`. + +## Cache updates + +- After a mutation, `revalidate` the affected keys: cached data stays visible while the refetch runs. `invalidate` blanks the entries and causes loading flashes; use it for session changes and hard resets. +- Optimistic update: write the cache first, register the revert with the error callback of the request context, then send the request. +- `keys(...)` sweeps every registered builder at once; on login, logout or locale change invalidate everything except keys that must survive, such as translations. + +## Settings that matter + +- `revalidateOn` with the visibility, network and interval signals keeps data fresh; `retryOnError` and `abortable` handle flaky and superseded requests. + +## SSR + +- Configure the client for server rendering exactly as the SSR section of `DOCS.md` shows; that setup is what makes the renderer wait for the requests and dehydrate the cache, and what restores it in the browser. +- Return the query data signals from page-level `Stores$` factories (see the `nano-kit-react-ssr` skill). + +## Testing + +- `start` the data signal like a component would and wait for the request with the helpers from the Testing section of `DOCS.md`; for mutations await the mutate promise. +- Each `client()` has its own cache, so tests do not leak into each other. Mock the transport through a DI token, not `fetch`. + +## Pitfalls + +- A query without an observer never fetches. Return its data signal to the UI or `start` it; `await` on it does nothing. +- `mutate` resolves to `undefined` when the call was muted (a call still loading, or the mutation disabled). Destructure with a fallback or check the loading state first. +- Reading the cache with `$data(key)` inside an effect subscribes the effect to that entry; use an untracked read for one-off reads in actions. +- Entities normalize only what the mapper captures; nested records that are not captured stay plain copies. diff --git a/skills/nano-kit-react-router/DOCS.md b/skills/nano-kit-react-router/DOCS.md new file mode 100644 index 00000000..4eb1733f --- /dev/null +++ b/skills/nano-kit-react-router/DOCS.md @@ -0,0 +1,1399 @@ + + +# Getting Started + +@nano_kit/router is a small and powerful router for @nano_kit/store. + +`@nano_kit/router` is a small and powerful router for the @nano_kit/store state management library. + +- **Small**. Around 2 kB (minified & brotlied). Zero dependencies except @nano_kit/store. +- **Type-safe**. Full TypeScript support with type inference for routes and parameters. +- **Signal-based**. Built on top of @nano_kit/store's reactive signals for automatic UI updates. +- **Flexible**. Supports nested layouts, parameterized routes, optional parameters, wildcards, and query parameters. +- **SSR-ready**. Works seamlessly with server-side rendering. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store @nano_kit/router +``` + +## Quick Start + +Here is a minimal example demonstrating navigation, routing, and reactive page rendering: + +```js +import { effect } from '@nano_kit/store' +import { browserNavigation, router, page, layout, notFound } from '@nano_kit/router' + +/* Define your routes */ +const routes = { + home: '/', + user: '/users/:id', + posts: '/users/:id/posts', + admin: '/admin/*' +} + +/* Setup navigation with browser history */ +const [$location, navigation] = browserNavigation(routes) + +/* Define page components */ +const HomePage = () => 'Welcome Home!' +const UserPage = () => `User ID: ${$location().params.id}` +const UserPostsPage = () => `Posts for User: ${$location().params.id}` +const AdminLayout = ($page) => `Admin Layout: ${$page()}` +const AdminPage = () => `Admin Page: ${$location().params.splat || 'dashboard'}` +const NotFoundPage = () => 'Page Not Found' + +/* Create router with pages and layouts */ +const $page = router($location, [ + page('home', HomePage), + page('user', UserPage), + page('posts', UserPostsPage), + layout(AdminLayout, [ + page('admin', AdminPage) + ]), + notFound(NotFoundPage) +], composeLayoutFunction) + +/* React to route changes (mounting $page triggers router) */ +const unsub = effect(() => { + const PageComponent = $page()?.default + + console.log('Current page:', PageComponent?.()) + // Render PageComponent in your app +}) +// Current page: Welcome Home! + +/* Navigate programmatically */ +navigation.push('/users/123') +// Current page: User ID: 123 + +navigation.push('/admin/settings/profile') +// Current page: Admin Layout: Admin Page: settings/profile + +navigation.back() +// Current page: User ID: 123 + +/* Cleanup */ +unsub() +``` + +--- + +# Core Concepts + +Learn the core concepts of @nano_kit/router. + +## Navigation + +Navigation is the core of the router that manages the current location, route matching, and provides methods to navigate between pages. It creates a reactive signal that tracks the current URL and matched route, automatically updating when the location changes. + +### Browser Navigation + +`browserNavigation` creates a navigation instance that works with the browser's History API. It synchronizes with the browser's address bar and back/forward buttons. + +```ts +import { effect } from '@nano_kit/store' +import { browserNavigation } from '@nano_kit/router' + +/* Define your routes */ +const routes = { + home: '/', + user: '/users/:id', + post: '/posts/:id/:slug?', + files: '/files/*' +} as const + +/* Create navigation instance */ +const [$location, navigation] = browserNavigation(routes) + +/* Location signal contains matched route and parameters */ +effect(() => { + const location = $location() + + console.log('Route:', location.route) // 'home', 'user', etc. + console.log('Params:', location.params) // { id: '123' } + console.log('Path:', location.pathname) // '/users/123' + console.log('Search:', location.search) // '?page=2' + console.log('Hash:', location.hash) // '#section' + console.log('Action:', location.action) // 'push', 'replace', or 'pop' +}) + +/* Navigate using browser history */ +navigation.push('/users/123') // Adds new history entry +navigation.replace('/users/456') // Replaces current entry +navigation.back() // Go back +navigation.forward() // Go forward +console.log(navigation.length) // History length +``` + +Navigation actions are also available as constants for reuse: + +- **`PushHistoryAction`** (`'push'`) — new entry added to history +- **`ReplaceHistoryAction`** (`'replace'`) — current entry replaced +- **`PermanentReplaceHistoryAction`** (`'permanent-replace'`) — used in SSR to signal a permanent redirect (301); on the client behaves as a regular replace +- **`PopHistoryAction`** (`'pop'`) — navigation via back/forward buttons + +The `$location` signal is a record signal with individual properties that can be accessed separately: + +```ts +const [$location, navigation] = browserNavigation(routes) + +/* Access individual properties */ +const { + $route, + $params, + $pathname, + $search, + $hash, + $action +} = $location +``` + +### Virtual Navigation + +`virtualNavigation` creates a navigation instance that doesn't interact with the browser. It maintains its own history stack in memory. This is useful for testing or server-side rendering. + +```ts +import { virtualNavigation } from '@nano_kit/router' + +const routes = { + home: '/', + user: '/users/:id' +} as const + +/* Start with initial path */ +const [$location, navigation] = virtualNavigation('/users/123', routes) + +console.log($location().pathname) // '/users/123' +console.log($location().route) // 'user' +console.log($location().params) // { id: '123' } + +/* Navigate works the same way */ +navigation.push('/users/456') +console.log($location().params) // { id: '456' } + +navigation.back() +console.log($location().params) // { id: '123' } +``` + +### Route Parameters + +`param` creates a computed signal for extracting a specific route parameter. It automatically updates when the location changes. + +```ts +import { effect } from '@nano_kit/store' +import { browserNavigation, param } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation({ + user: '/users/:id', + post: '/posts/:id/:slug?' +}) + +/* Extract 'id' parameter */ +const $userId = param($location, 'id') + +effect(() => { + console.log('User ID:', $userId()) +}) + +navigation.push('/users/123') +// User ID: 123 + +navigation.push('/users/456') +// User ID: 456 +``` + +You can provide a parser function to transform the parameter value: + +```ts +/* Parse as number */ +const $userId = param($location, 'id', Number) + +navigation.push('/users/123') +console.log($userId()) // 123 (number) + +/* Custom parsing */ +const $slug = param($location, 'slug', value => value ? value.split('-') : []) + +navigation.push('/posts/42/hello-world') +console.log($slug()) // ['hello', 'world'] +``` + +### Query Parameters + +#### searchParams + +`searchParams` creates a computed signal that provides access to URL query parameters as a `URLSearchParams` instance. It updates automatically when the search string changes. + +```ts +import { effect } from '@nano_kit/store' +import { browserNavigation, searchParams } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation() +const $searchParams = searchParams($location) + +effect(() => { + const params = $searchParams() + + console.log('Page:', params.get('page')) + console.log('Sort:', params.get('sort')) + console.log('All params:', params.toString()) +}) + +navigation.push('/?page=1&sort=name') +// Page: 1 +// Sort: name +// All params: page=1&sort=name + +navigation.replace('/?page=2&sort=date') +// Page: 2 +// Sort: date +// All params: page=2&sort=date +``` + +#### searchParam + +`searchParam` creates a computed signal for a specific query parameter. This is more efficient than using `searchParams` when you only need one parameter. + +```ts +import { effect } from '@nano_kit/store' +import { browserNavigation, searchParams, searchParam } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation() +const $searchParams = searchParams($location) + +/* Extract specific parameter */ +const $page = searchParam($searchParams, 'page') + +effect(() => { + console.log('Page:', $page()) +}) + +navigation.push('/?page=1') +// Page: 1 + +navigation.push('/?page=2') +// Page: 2 + +navigation.push('/') +// Page: null +``` + +You can provide a parser function to transform the parameter value: + +```ts +/* Parse as number with default value */ +const $page = searchParam($searchParams, 'page', value => value ? parseInt(value, 10) : 1) + +navigation.push('/?page=5') +console.log($page()) // 5 (number) + +navigation.push('/') +console.log($page()) // 1 (default) + +/* Parse as boolean */ +const $enabled = searchParam($searchParams, 'enabled', value => value === 'true') + +navigation.push('/?enabled=true') +console.log($enabled()) // true (boolean) +``` + +### Route-Scoped Signals + +`forRoute` creates a computed signal that only updates when the current route matches a specific route. When the route doesn't match it returns the previous value or a `fallback`. This prevents stale values from triggering effects when navigating away to a different route. + +```ts +import { browserNavigation, searchParams, searchParam, forRoute } from '@nano_kit/router' + +const routes = { + characters: '/characters', + locations: '/locations', + episodes: '/episodes' +} as const +const [$location, navigation] = browserNavigation(routes) +const $searchParams = searchParams($location) +/* Shared ?page= param — updates on every route change */ +const $page = searchParam($searchParams, 'page', v => (v ? Number(v) : 1)) +/* Scoped to each route — only updates when that route is active */ +const $charactersPage = forRoute($location, 'characters', $page, 1) +const $episodesPage = forRoute($location, 'episodes', $page, 1) + +navigation.push('/characters?page=2') +console.log($charactersPage()) // 2 +console.log($episodesPage()) // 1 + +navigation.push('/episodes?page=3') +console.log($charactersPage()) // 2 - unchanged because route doesn't match, will not trigger effects +console.log($episodesPage()) // 3 +``` + +## Router + +The `router` maps routes to components or values, creating a reactive system that updates when the location changes with nested layouts support. + +```ts +import { effect } from '@nano_kit/store' +import { browserNavigation, router, page, layout, notFound } from '@nano_kit/router' + +/* Setup navigation with browser history */ +const [$location, navigation] = browserNavigation({ + home: '/', + user: '/users/:id', + posts: '/users/:id/posts', + admin: '/admin/*' +}) + +/* Define page components */ +const HomePage = () => 'Welcome Home!' +const UserPage = () => `User ID: ${$location().params.id}` +const UserPostsPage = () => `Posts for User: ${$location().params.id}` +const AdminLayout = ($outlet) => `Admin Layout: ${$outlet()?.()}` +const AdminPage = () => `Admin Page: ${$location().params.splat || 'dashboard'}` +const NotFoundPage = () => 'Page Not Found' + +/* Create router with pages and layouts */ +const $page = router($location, [ + page('home', HomePage), + page('user', UserPage), + page('posts', UserPostsPage), + layout(AdminLayout, [ + page('admin', AdminPage) + ]), + notFound(NotFoundPage) +], composeLayoutFunction) + +/* React to route changes (mounting $page triggers router) */ +effect(() => { + const PageComponent = $page()?.default + + console.log('Current page:', PageComponent?.()) + // Render PageComponent in your app +}) +// Current page: Welcome Home! + +/* Navigate programmatically */ +navigation.push('/users/123') +// Current page: User ID: 123 + +navigation.push('/admin/settings/profile') +// Current page: Admin Layout: Admin Page: settings/profile + +navigation.back() +// Current page: User ID: 123 +``` + +- **`page`** defines a route match that returns a component when the route matches. +- **`layout`** wraps multiple pages with a common layout component, creating a nested structure. Layouts can be nested within other layouts for complex hierarchies. +- **`notFound`** defines a fallback component for routes that don't match any defined page. It works like `page` but matches when no other route matches. + +### Compose Function + +The `compose` function is **framework-specific** and determines how layouts wrap nested content. It receives: + +- **`$outlet`** — signal containing the nested component +- **`layout`** — the layout component + +Here is a simple implementation for the example above: + +```ts +function compose($outlet, layout) { + return () => layout($outlet) +} +``` + +Here is a simple implementation for React: + +```tsx +import { useSignal } from '@nano_kit/react' + +function composeLayoutFunction($nested, Layout) { + return function Composed() { + const Nested = useSignal($nested) + + return ( + + + + ) + } +} +``` + +`@nano_kit/react-router` has a built-in compose function that works with React components, so you can use it directly without implementing your own. + +If you're building an adapter for another framework, you'll need to implement your own compose function that works with your framework's component system. + +## Links + +Working with links involves two main utilities: generating URLs from route definitions and handling link clicks for SPA navigation. + +### buildPaths + +`buildPaths` generates path functions from route definitions, making it type-safe to create URLs. + +```ts +import { buildPaths } from '@nano_kit/router' + +const routes = { + home: '/', + about: '/about', + user: '/users/:id', + post: '/posts/:id/:slug?', + files: '/files/*' +} as const + +const paths = buildPaths(routes) + +/* Static routes return strings */ +paths.home // '/' +paths.about // '/about' + +/* Routes with parameters return functions */ +paths.user({ id: '123' }) // '/users/123' +paths.post({ id: '42', slug: 'hello-world' }) // '/posts/42/hello-world' +paths.post({ id: '42' }) // '/posts/42' (optional slug omitted) + +/* Splat routes */ +paths.files({ splat: 'docs/readme.md' }) // '/files/docs/readme.md' +``` + +**Parameter encoding:** Named parameter values are automatically URL-encoded; splat values are inserted as-is, so encode them yourself when needed: + +```ts +paths.user({ id: 'hello world' }) // '/users/hello%20world' +paths.files({ splat: 'a/b c/d+e.pdf' }) // '/files/a/b c/d+e.pdf' +``` + +### listenLinks + +`listenLinks` enables SPA navigation by intercepting clicks on links and using the router's navigation instead of full page reloads. + +```ts +import { onMount } from '@nano_kit/store' +import { browserNavigation, listenLinks } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation() + +/* Start listening to link clicks when location is mounted */ +onMount($location, () => listenLinks(navigation)) + +/* Now all internal links use router navigation */ +``` + +--- + +# Advanced + +Explore advanced features of @nano_kit/router. + +## Code Splitting + +Router provides built-in support for code splitting and lazy loading of pages using the `loadable` function. + +### `loadable` + +Defines a lazy-loaded component or page that is fetched only when needed. It accepts a module loader function (returning a promise) and an optional fallback view to display while loading. + +The loaded module must export a `default` component/view and optionally a `Stores$` function for data prefetching and dehydration in SSR. + +```ts +import { router, loadable, page } from '@nano_kit/router' + +/* Fallback component */ +const Loader = () => 'Loading...' + +export const $page = router($location, [ + page('home', loadable(() => import('./pages/Home.js'), Loader)), + page('user', loadable(() => import('./pages/User.js'), Loader)) +]) +``` + +### `loadPage` + +Forces the loading of a specific page's code by its route name. This is useful for preloading the next likely page (e.g., on link hover). + +It takes the pages tree: the same array of `page()` / `layout()` refs you pass to `router()`: + +```ts +import { loadPage } from '@nano_kit/router' +import { pages } from './pages.js' + +/* Preload user page code */ +await loadPage(pages, 'user') +``` + +### `loadPages` + +Loads the code for all defined pages in the route tree. This is commonly used in server-side rendering (SSR) to ensure all async components are resolved before rendering the application. + +```ts +import { loadPages } from '@nano_kit/router' +import { pages } from './pages.js' + +/* Preload all pages */ +await loadPages(pages) +``` + +## Head Management + +The router ships with a reactive head management API for controlling document metadata (title, meta tags, link tags, scripts) on a per-page basis. + +### Page Module `Head$` + +Add a `Head$` factory to a page module to declare which head descriptors it provides. The router exposes it on the current `$page()` ref, where [`syncHead`](#synchead) can read and apply it. + +```ts +import { Location$, title, meta } from '@nano_kit/router' +import { inject } from '@nano_kit/store' + +export function Head$() { + const $location = inject(Location$) + + return [ + title(() => `User ${$location().params.id}`), + meta({ name: 'description', content: 'User profile page' }) + ] +} + +export default function UserPage() { /* ... */ } +``` + +### `syncHead` + +`syncHead` subscribes to the current page accessor and applies all head descriptors reactively. Call it once during app initialization. + +```ts +import { syncHead } from '@nano_kit/router' + +const $page = router($location, pages) + +syncHead($page) +``` + +You can pass an optional `InjectionContext` as the second argument to resolve `Head$` factories with DI: + +```ts +syncHead($page, context) +``` + +### Tag Descriptors + +| Function | Updates | +| --------------- | ---------------------- | +| `title($value)` | `` in `<head>` | +| `lang($value)` | `<html lang>` | +| `dir($value)` | `<html dir>` | +| `link(props)` | `<link>` in `<head>` | +| `meta(props)` | `<meta>` in `<head>` | +| `script(props)` | `<script>` in `<head>` | + +`title`, `lang`, and `dir` accept a plain value or a reactive accessor. For `link` and `meta`, only certain props support reactive accessors — the ones that are likely to change at runtime: + +- **`link`** — `href`, `media`, `disabled`, `title` +- **`meta`** — `content`, `media` + +```ts +import { title, lang, link, meta } from '@nano_kit/router' + +export function Head$() { + const $user = inject(User$) + + return [ + title(() => `${$user().name} — My App`), + lang('en'), + link({ rel: 'canonical', href: () => $user().profileUrl }), + meta({ property: 'og:title', content: () => $user().name }) + ] +} +``` + +## Scroll Management + +Since scroll restoration and behavior can vary significantly between applications, the library provides a set of "Do It Yourself" (DIY) utilities instead of a one-size-fits-all solution. You can combine these tools to implement the exact scrolling behavior your app needs. + +### `resetScroll` + +The router does not reset scroll automatically. Use `resetScroll` to move the window scroll position to the top `(0, 0)`, for example when navigating to a new page. + +```ts +import { onMountEffect } from '@nano_kit/store' +import { resetScroll } from '@nano_kit/router' + +const { $route } = $location + +/* Reset scroll whenever the route changes */ +onMountEffect($route, () => { + $route() + resetScroll() +}) +``` + +To skip scroll resets for specific navigations, you can use the navigation action as part of your own scroll policy. For example, this pattern resets scroll for `push` navigations but keeps the current scroll position for `replace` navigations: + +```ts +import { onMountEffect } from '@nano_kit/store' +import { ReplaceHistoryAction, resetScroll } from '@nano_kit/router' + +const { $route, $action } = $location + +/* Reset scroll on route changes, except replace navigations */ +onMountEffect($route, () => { + $route() + + if ($action() !== ReplaceHistoryAction) { + resetScroll() + } +}) + +/* Keeps the current scroll position with the effect above */ +navigation.replace('/characters?page=2') +``` + +### `scrollToAnchor` + +Scrolls the window to a specific element identified by a URL hash (e.g., `#section`). It handles looking up the element by ID or name and scrolling it into view. + +```ts +import { onMountEffect } from '@nano_kit/store' +import { scrollToAnchor } from '@nano_kit/router' + +/* Scroll to element with id="features" smoothly */ +scrollToAnchor('#features', { behavior: 'smooth' }) + +const { $hash } = $location + +/* Or automatically handle hash from location */ +onMountEffect($hash, () => { + const hash = $hash() + + if (hash) { + scrollToAnchor(hash) + } +}) +``` + +### `ScrollRestorator` + +A utility class that saves and restores scroll positions using `sessionStorage`. It helps maintain the user's scroll position when navigating back and forth within the history. + +```ts +import { ScrollRestorator } from '@nano_kit/router' + +/* 1. Create instance (optional prefix) */ +const scrollRestorator = new ScrollRestorator('my-app-scroll-') + +/* 2. Save scroll position before leaving the current view */ +/* (e.g., in a cleanup function or before navigation) */ +scrollRestorator.save($location()) + +/* 3. Restore scroll position when returning to a view */ +const restored = scrollRestorator.restore($location()) + +if (!restored) { + /* If no position was saved, default to top */ + resetScroll() +} +``` + +## Transitions + +> Caution: This is a raw API that exposes the internal transition mechanism. It might change in future versions or be replaced by higher-level abstractions. Use it with caution. + +The `transition` method on the `navigation` object allows you to intercept and control the navigation process. This is powerful for implementing global behaviors like scroll restoration, page transitions, or navigation guards (confirmation dialogs). + +By default, it simply executes the transition. You can override it to add custom logic. + +```ts +import { browserNavigation } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation(routes) + +/* Example 1: Scroll Restoration */ +navigation.transition = (proceed, nextLocation, prevLocation) => { + /* Save scroll position for the page we are leaving */ + scrollRestorator.save(prevLocation) + + /* Proceed with the navigation */ + proceed(nextLocation) + + /* Restore scroll position for the new page */ + scrollRestorator.restore(nextLocation) +} + +/* Example 2: Navigation Guard / Confirmation */ +navigation.transition = (proceed, nextLocation, prevLocation) => { + const isDirty = formIsDirty() + + if (!isDirty || confirm('Are you sure you want to leave? Unsaved changes will be lost.')) { + proceed(nextLocation) + } +} +``` + +The arguments are: + +1. `proceed`: A function that must be called to complete the navigation. Pass `nextLocation` to it. +2. `nextLocation`: The target location object (or `null` if unknown). +3. `prevLocation`: The current location object before navigation. + +## Utils + +### `basePath` + +The `basePath` helper allows you to prefix all routes with a common base path. This is useful when your application is hosted in a subdirectory (e.g., GitHub Pages) or behind a specific path. + +```ts +import { basePath } from '@nano_kit/router' + +/* All routes will be prefixed with /admin */ +const routes = basePath('/admin', { + dashboard: '/', + users: '/users' +}) + +/* Resulting patterns: */ +/* dashboard -> /admin */ +/* users -> /admin/users */ +``` + +### `updateHref` + +A utility to update parts of a URL string (pathname, search, or hash) while preserving the rest. It accepts a current href and an update object or string. + +```ts +import { updateHref } from '@nano_kit/router' + +const current = '/users?sort=name#top' + +/* Update query params */ +const next = updateHref(current, { search: '?sort=date' }) +// /users?sort=date#top + +/* Update path */ +const moved = updateHref(current, { pathname: '/admins' }) +// /admins?sort=name#top +``` + +### `removeTrailingSlash` + +Removes the trailing slash from a path string, ensuring consistent path handling. + +```ts +import { removeTrailingSlash } from '@nano_kit/router' + +removeTrailingSlash('/path/') // '/path' +removeTrailingSlash('/path') // '/path' +``` + +### `onLinkClick` + +A helper function to handle click events on anchor tags (`<a>`) in a Single Page Application (SPA) way. It intercepts the click, prevents the default browser navigation, and instead calls `navigation.push()`. + +Use this if you need to build a custom `Link` component for your framework. + +```tsx +import { onLinkClick } from '@nano_kit/router' +import { navigation } from './router.js' + +const handleClick = onLinkClick.bind(navigation) + +function CustomLink({ href, children }) { + return <a href={href} onClick={handleClick}>{children}</a> +} +``` + +### `canGoBack` + +`canGoBack` returns an `Accessor<boolean>` that is `true` when back navigation is possible (i.e. the history stack has more than one entry). + +```ts +import { inject } from '@nano_kit/store' +import { canGoBack } from '@nano_kit/router' + +const [$location, navigation] = browserNavigation(routes) +const $canGoBack = canGoBack($location, navigation) + +effect(() => { + console.log('Can go back:', $canGoBack()) +}) +/* Output: Can go back: false (initially) */ + +navigation.push('/users') +/* Output: Can go back: true */ +``` + +## Dependency Injection + +The router provides global injection tokens for navigation state. Use them in stores or components to access routing data without passing signals as props. + +### Tokens + +- **`LocationNavigation$`** - provides the current location signal and navigation API as one tuple. Provide this token when you want to set up both `Location$` and `Navigation$` at once. +- **`Location$`** - provides the current location signal. Derived from `LocationNavigation$`. +- **`Navigation$`** - provides the navigation API. Derived from `LocationNavigation$`. +- **`Page$`** - provides the current page reference from the router. Should be provided by the app. +- **`Pages$`** - provides the array of page definitions passed to the router. Should be provided by the app. +- **`Paths$`** - provides typed path builder functions derived from the navigation. Depends on `Navigation$`. +- **`CanGoBack$`** - provides a boolean signal indicating if back navigation is possible. Depends on `Location$` and `Navigation$`. + +Not every token needs to be provided — it depends on your app's needs. For example, if you don't use path builders, you can skip providing `Paths$`. + +### `AppContext` + +The router uses TypeScript declaration merging to infer app-specific types across all tokens. Extend the `AppContext` interface in your project to enable typed params, paths, and components: + +```ts +import type { routes } from './routes.js' +import type { MyComponent } from './types.js' + +declare module '@nano_kit/router' { + interface AppContext { + routes: typeof routes // types Location$, Navigation$, Paths$ + component: MyComponent // types Page$, Pages$ + } +} +``` + +- **`routes`** — must be `typeof yourRoutesObject`. Enables types for `Location$`, `Navigation$`, and `Paths$` tokens based on your route definitions. +- **`component`** — your framework's component type (e.g. `() => ReactNode`). Enables typed `$page().default` in `Page$`. + +If `AppContext` is not extended, all tokens fall back to generic types. + +--- + +# SSR + +Learn how to use @nano_kit/router for server-side rendering (SSR). + +Server-side rendering with `@nano_kit/router` is handled by a dedicated SSR renderer package for your framework. The router itself provides the building blocks — page-level `Stores$` and `Head$` — while the adapter takes care of the server renderer and client hydration. + +> Note: For app code, prefer the matching framework integration instead of wiring the renderer manually. + +## Stores Data Dehydration + +Export a `Stores$` function from a page module to declare which signals should be dehydrated on the server and rehydrated on the client. The SSR renderer discovers it automatically. + +Mark individual signals as hydratable using `hydratable` from `@nano_kit/store`: + +```tsx +import { signal, hydratable } from '@nano_kit/store' +import { useSignal } from '@nano_kit/react' + +/* Tie the signal to a unique key for dehydration */ +export const $data = hydratable('homeData', signal({ text: 'Hello World' })) + +export function Stores$() { + return [$data] +} + +export default function Home() { + const data = useSignal($data) + + return <h1>{data.text}</h1> +} +``` + +For pages that use DI, `Stores$` is called within the request's `InjectionContext`, so you can use `inject()` inside it: + +```tsx +import { inject, hydratable } from '@nano_kit/store' +import { User$ } from './stores/user' + +export function Stores$() { + const { $user } = inject(User$) + + return [$user] +} +``` + +## Head Management + +Export a `Head$` function from a page module to declare per-page document metadata. See Head Management for all available tag descriptors. + +```tsx +import { title, meta } from '@nano_kit/router' + +export function Head$() { + return [ + title('Home — My App'), + meta({ name: 'description', content: 'Welcome to my app' }) + ] +} + +export default function Home() { /* ... */ } +``` + +`Head$` is also called within the request's `InjectionContext`, so you can use `inject()` inside it: + +```tsx +import { inject } from '@nano_kit/store' +import { title } from '@nano_kit/router' +import { User$ } from './stores/user' + +export function Head$() { + const { $user } = inject(User$) + + return [ + title(() => `${$user().name} — My App`) + ] +} +``` + +The SSR renderer reads `Head$` and injects the tags into the HTML response automatically. + +## Setting Up SSR + +The low-level setup (virtual navigation, renderer class, client hydration) is handled by the SSR renderer. See the SSR renderer docs for a complete setup guide. + +--- + +# Testing + +Learn how to test routing in @nano_kit/router. + +`@nano_kit/router` is designed to be easily testable by decoupling navigation logic from the browser's History API. + +## Virtual Navigation + +For testing environments (unit tests, integration tests) or isolated environments (Storybook), use `virtualNavigation` instead of `browserNavigation`. + +`virtualNavigation` creates an in-memory history stack that behaves exactly like the browser router but without touching the window URL. + +Provide the global `Location$` and `Navigation$` tokens — exported directly from `@nano_kit/router` — with virtual instances to isolate routing in tests: + +```tsx +import { render, screen } from '@testing-library/react' +import { InjectionContext, provide } from '@nano_kit/store' +import { InjectionContextProvider } from '@nano_kit/react' +import { virtualNavigation, Location$, Navigation$ } from '@nano_kit/router' +import { routes } from './routes' +import { App } from './App' + +it('should render the admin page', async () => { + /* Create virtual navigation for the test */ + const [$location, navigation] = virtualNavigation('/admin', routes) + + /* Override tokens with virtual instances */ + const context = new InjectionContext([ + provide(Location$, $location), + provide(Navigation$, navigation) + ]) + + render( + <InjectionContextProvider context={context}> + <App /> + </InjectionContextProvider> + ) + + expect(screen.getByText('Admin Panel')).toBeInTheDocument() +}) +``` + +--- + +# React Router + +Integrate @nano_kit/router with React using @nano_kit/react-router. + +The `@nano_kit/react-router` package provides React integration for `@nano_kit/router`. It allows you to use the router's powerful features like code splitting, Dependency Injection, and state management directly within your React application. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store @nano_kit/router @nano_kit/react @nano_kit/react-router +``` + +## Setup + +Basically, `@nano_kit/react-router` re-exports everything from `@nano_kit/router`, so you can use all base router functions. However, it enhances some of them and provides new utilities specifically for React. + +A typical setup looks like this: + +```tsx +import { createRoot } from 'react-dom/client' +import { browserNavigation, router, layout, page, loadable, usePageSignal } from '@nano_kit/react-router' +import { MainLayout } from './MainLayout' + +/* Define routes config */ +const routes = { + home: '/', + user: '/users/:id' +} as const + +/* Define loader fallback */ +const Loader = () => <div>Loading...</div> + +/* Create navigation */ +const [$location, navigation] = browserNavigation(routes) + +/* Create page signal */ +const $page = router($location, [ + layout(MainLayout, [ + page('home', loadable(() => import('./pages/Home'), Loader)), + page('user', loadable(() => import('./pages/User'), Loader)) + ]) +]) + +function App() { + const Page = usePageSignal($page) + + return Page ? <Page /> : null +} + +/* Render App */ +createRoot(document.getElementById('root')!).render(<App />) +``` + +If you want to use DI-based setup, pass the router tokens through `InjectionContextProvider`: + +```tsx +import { createRoot } from 'react-dom/client' +import { provide } from '@nano_kit/store' +import { InjectionContextProvider } from '@nano_kit/react' +import { App, browserNavigation, router, LocationNavigation$, Page$, Pages$ } from '@nano_kit/react-router' +import { pages } from './pages' +import { routes } from './routes' + +/* Define global routes types for DI */ +declare module '@nano_kit/router' { + interface AppContext { + routes: typeof routes + } +} + +/* Create navigation and page accessor */ +const locationNavigation = browserNavigation(routes) +const [$location] = locationNavigation +const $page = router($location, pages) + +/* Render App with DI */ +createRoot(document.getElementById('root')!).render( + <InjectionContextProvider + context={[ + provide(LocationNavigation$, locationNavigation), + provide(Page$, $page) // required for App component + ]} + > + <App /> + </InjectionContextProvider> +) +``` + +## React Specifics + +### `router` + +The `router` function in this package is an enhanced version of the core router. It supports React components as views and correctly handles nested layouts using the `Outlet` component. + +```tsx +import { router, page } from '@nano_kit/react-router' + +const $page = router($location, [ + page('home', HomePage), + page('user', UserPage) +]) +``` + +### `usePageSignal` & `usePage` + +These hooks return the currently matched page component. + +Without DI, use `usePageSignal` with the `$page` accessor returned by `router()`: + +```tsx +import { usePageSignal } from '@nano_kit/react-router' + +function App() { + const Page = usePageSignal($page) + + return Page ? <Page /> : null +} +``` + +With DI, use `usePage` and let it read `Page$` from the current injection context: + +```tsx +import { usePage } from '@nano_kit/react-router' + +function App() { + const Page = usePage() + + return Page ? <Page /> : null +} +``` + +### `App` + +`App` is a ready-made React component that reads `Page$` from the current injection context and renders the matched page. + +```tsx +import { provide } from '@nano_kit/store' +import { router, page, Page$, App } from '@nano_kit/react-router' +import { InjectionContextProvider } from '@nano_kit/react' + +const $page = router($location, [ + page('home', HomePage), + page('user', UserPage) +]) + +<InjectionContextProvider + context={[ + provide(Page$, $page) + ]} +> + <App /> +</InjectionContextProvider> +``` + +### `Outlet` + +Used within Layout components to define where the nested child route should be rendered. + +```tsx +import { Outlet } from '@nano_kit/react-router' + +export function MainLayout() { + return ( + <div className='layout'> + <Sidebar /> + <main> + {/* Nested page will be rendered here */} + <Outlet /> + </main> + </div> + ) +} +``` + +### `useNavigationListenLinks` & `useListenLinks` + +These hooks intercept clicks on native `<a>` elements and route them through the router navigation layer. Use them when you want to use the router without a custom link component. + +Without DI, pass the navigation instance explicitly: + +```tsx +import { useNavigationListenLinks, usePageSignal } from '@nano_kit/react-router' + +function App() { + const Page = usePageSignal($page) + + useNavigationListenLinks(navigation) + + return Page ? <Page /> : null +} +``` + +With DI, use `useListenLinks` and let it read `Navigation$` from the current injection context: + +```tsx +import { useListenLinks } from '@nano_kit/react-router' + +function App() { + const Page = usePage() + + useListenLinks() + + return Page ? <Page /> : null +} +``` + +### `linkComponent` & `Link` + +`linkComponent` creates a type-safe `Link` component bound to a specific navigation instance and paths object. `Link` is the DI-based variant that reads router dependencies from the current injection context. + +```tsx +import { linkComponent, buildPaths, preloadable } from '@nano_kit/react-router' + +/* Create Link component */ +const Link = linkComponent( + navigation, + buildPaths(routes), + /* Optional: enable preloading on interaction */ + [preloadable(pages)] +) + +/* Usage */ +<Link to='user' params={{ id: '123' }} preload> + View User +</Link> +``` + +For DI usage, use the built-in `Link` component inside `InjectionContextProvider`: + +```tsx +import { Link } from '@nano_kit/react-router' + +<Link to='user' params={{ id: '123' }}> + View User +</Link> +``` + +The DI variant uses the provided `Navigation$` and `Paths$` tokens under the hood. + +### `preloadable` & `useLinkComponentPreload` + +These link extensions enable page preloading on hover and focus. + +Without DI, use `preloadable` when creating your custom `Link` component. It accepts either the pages array or a function returning it: + +```tsx +import { linkComponent, buildPaths, preloadable } from '@nano_kit/react-router' +import { pages } from './pages' + +const Link = linkComponent( + navigation, + buildPaths(routes), + [ + /* true = default value of Link's preload prop */ + preloadable(() => pages, true) + ] +) +``` + +With DI, use `useLinkComponentPreload` to enable the same behavior for the built-in `Link` component: + +```tsx +import { useLinkComponentPreload } from '@nano_kit/react-router' + +function App() { + const Page = usePage() + + /* true = default value of built-in Link's preload prop */ + useLinkComponentPreload(true) + + return Page ? <Page /> : null +} +``` + +### `ariaCurrent` & `useLinkComponentAriaCurrent` + +These link extensions enable automatic `aria-current` handling for active links. + +Without DI, use `ariaCurrent` when creating your custom `Link` component: + +The optional second argument is a predicate that receives the link URL and current location and decides whether the link should be treated as current. By default, it checks `url.pathname === location.pathname`. + +```tsx +import { ariaCurrent, linkComponent, buildPaths } from '@nano_kit/react-router' + +const Link = linkComponent( + navigation, + buildPaths(routes), + [ariaCurrent($location)] +) +``` + +With DI, use `useLinkComponentAriaCurrent` to enable the same behavior for the built-in `Link` component: + +It accepts the same optional predicate, but reads the current location from DI instead of taking `$location` explicitly. + +```tsx +import { useLinkComponentAriaCurrent, usePage } from '@nano_kit/react-router' + +function App() { + const Page = usePage() + + useLinkComponentAriaCurrent() + + return Page ? <Page /> : null +} +``` + +### `usePageSyncHead` & `useSyncHead` + +These hooks synchronize the current page's `Head$` descriptors. + +Without DI, use `usePageSyncHead` with an explicit `$page` accessor: + +```tsx +import { usePageSyncHead } from '@nano_kit/react-router' + +function App() { + const Page = usePageSignal($page) + + usePageSyncHead($page) + + return Page ? <Page /> : null +} +``` + +With DI, use `useSyncHead` and let it read `Page$` from the current injection context: + +```tsx +import { useSyncHead } from '@nano_kit/react-router' + +function App() { + const Page = usePage() + + useSyncHead() + + return Page ? <Page /> : null +} +``` + +### `useLocation` + +`useLocation` subscribes to the current route location from the injection context. + +```tsx +import { useLocation } from '@nano_kit/react-router' + +function RouteInfo() { + const location = useLocation() + + return <span>{location.pathname}</span> +} +``` + +### `useNavigation` + +`useNavigation` returns the navigation API from the injection context. + +```tsx +import { useNavigation } from '@nano_kit/react-router' + +function BackButton() { + const navigation = useNavigation() + + return <button onClick={() => navigation.back()}>Back</button> +} +``` + +### `usePaths` + +`usePaths` returns the typed path builders derived from your route definitions. + +```tsx +import { usePaths } from '@nano_kit/react-router' + +function UserLink({ id }: { id: string }) { + const paths = usePaths() + + return <a href={paths.user({ id })}>View user</a> +} +``` + +### `useCanGoBack` + +`useCanGoBack` subscribes to whether back navigation is currently possible. + +```tsx +import { useCanGoBack, useNavigation } from '@nano_kit/react-router' + +function BackButton() { + const navigation = useNavigation() + const canGoBack = useCanGoBack() + + return ( + <button disabled={!canGoBack} onClick={() => navigation.back()}> + Back + </button> + ) +} +``` diff --git a/skills/nano-kit-react-router/SKILL.md b/skills/nano-kit-react-router/SKILL.md new file mode 100644 index 00000000..6146e597 --- /dev/null +++ b/skills/nano-kit-react-router/SKILL.md @@ -0,0 +1,74 @@ +--- +name: nano-kit-react-router +description: How to route a React app with @nano_kit/react-router and the @nano_kit/router core it re-exports. Covers route tables, browser and virtual navigation, location and parameter signals, pages, layouts, code splitting, the DI tokens and hooks, typed Link components with preloading and aria-current, document head management, scroll utilities, page-level Stores$ and Head$ exports for SSR, and testing with virtual navigation. Apply when adding or changing routing, links or URL-derived state in a React app built on Nano Kit. For signals, stores and DI see the nano-kit-store skill. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - react + - router + - routing + - signals + - ssr + docs: + paths: + - router + - router-integrations/react.mdx + tabs: + - React + - React SSR +--- + +# @nano_kit/react-router + +`@nano_kit/router` is a small signal-based router: the current location is a signal, routes are a typed table, pages and layouts are matched into a page signal, and links, head tags and scroll are utilities on top. `@nano_kit/react-router` re-exports it and adds the React pieces. Signals, stores and DI are described in the `nano-kit-store` skill, component bindings in `nano-kit-react`. + +## Reference + +`DOCS.md` next to this file is the documentation of the released packages as published on https://nano-kit.js.org, generated from the site sources. Read it for the route syntax, tokens, hooks, components and descriptors before writing code. This skill adds only conventions, choices and pitfalls. + +## Setup contract + +- Declare the route table once, `as const`, and augment `AppContext.routes` exactly once, so `Location$`, `Navigation$`, `Paths$` and `Link` are typed by it. +- Provide `LocationNavigation$` (from `browserNavigation(routes)`), `Page$` (from `router($location, pages)`) and `Pages$` together at the root; the derived tokens come for free. +- Render with the ready-made `App`, let layouts render children through `Outlet`, and call `useSyncHead()`, `useLinkComponentPreload()` and `useLinkComponentAriaCurrent()` once in the root layout. +- Page modules export the component as `default` and, when needed, `Stores$` (what SSR awaits and dehydrates), `Head$` (head descriptors) and `statusCode`. + +## URL-derived state + +- Derive signals from `Location$` in an injectable `Params$` factory with `param`, `searchParams` and `searchParam`, and let stores and queries read those instead of parsing the URL in components. +- Scope a parameter to its route with `forRoute` when several pages share a parameter name; otherwise a query keyed by that parameter, still observed on the page being left, receives the destination page's value during navigation. +- Update search parameters with `navigation.replace` while the user types into a filter, so Back leaves the page instead of replaying keystrokes. +- Use `navigation.transition` for confirmation dialogs and scroll bookkeeping around programmatic navigation. + +## Pages and head + +- Split by route with `loadable(() => import(...), Fallback)`; preload on hover with the link preload hooks. +- Scroll utilities touch `window`, and stores returned from `Stores$` are mounted on the server during dehydration: start scroll effects only in the browser. + +## Testing + +- Provide `virtualNavigation('/users/1', routes)` as `LocationNavigation$` (plus `Page$` and `Pages$` for component tests) and render `App`; store tests provide only `LocationNavigation$` and navigate through the returned `navigation`. + +## Pitfalls + +- Forgetting `as const` on the route table loses parameter typing for `params`, `paths` and `Link`. +- `App`, `usePage` and `useSyncHead` need `Page$`; `useLinkComponentPreload` needs `Pages$`. +- The `Link` built with `linkComponent` and the DI `Link` are different components; do not mix the hooks of one with the settings of the other. +- `useSyncHead` belongs in the outermost layout, once; calling it per page re-syncs the same descriptors repeatedly. +- Splat values are not encoded by the path builders; encode user input before building a splat path. diff --git a/skills/nano-kit-react-ssr/DOCS.md b/skills/nano-kit-react-ssr/DOCS.md new file mode 100644 index 00000000..9e157c3e --- /dev/null +++ b/skills/nano-kit-react-ssr/DOCS.md @@ -0,0 +1,583 @@ +<!-- Generated by skills-docs from the documentation sources. Do not edit by hand. --> + +# React SSR + +Add server-side rendering to a React + Nano Kit app using @nano_kit/react-ssr. + +`@nano_kit/react-ssr` is the React adapter for server-side rendering in Nano Kit. + +## Installation + +```bash +pnpm add @nano_kit/store @nano_kit/router @nano_kit/react @nano_kit/react-router @nano_kit/react-ssr react react-dom +``` + +## Quick Start + +1. **Define your app** + + Create an index file that exports `routes` and `pages`. This file is the single source of truth consumed by both the server renderer and the Vite plugin. + + ```ts + // src/index.ts + import { page, layout, loadable } from '@nano_kit/router' + import * as Layout from './Layout.jsx' + + export const routes = { + home: '/', + about: '/about' + } as const + + export const pages = [ + layout(Layout, [ + page('home', loadable(() => import('./pages/Home.jsx'))), + page('about', loadable(() => import('./pages/About.jsx'))) + ]) + ] + + declare module '@nano_kit/router' { + interface AppContext { + routes: typeof routes + } + } + ``` + +2. **Set up the Vite plugin** + + Add `@nano_kit/react-ssr/vite-plugin` to your Vite config. No extra configuration is needed for a standard setup — the plugin uses built-in client and renderer templates automatically. + + ```js + // vite.config.js + import { defineConfig } from 'vite' + import react from '@vitejs/plugin-react' + import ssr from '@nano_kit/react-ssr/vite-plugin' + + export default defineConfig({ + plugins: [ + react(), + ssr({ index: 'src/index.ts' }) + ] + }) + ``` + + - **`vite dev`** — starts the development server with SSR rendering handled in-process. + - **`vite build`** — produces `dist/client/` (browser assets) and `dist/renderer/` (SSR renderer bundle). + +3. **Write your production HTTP server** + + For production, write your own HTTP server that imports the built renderer and calls `renderer.render(url)` for every incoming request: + + ```js + // server.js + import { renderer } from './dist/renderer/index.js' + + // Express example + app.get('*', async (req, res) => { + const result = await renderer.render(req.url, { + cookie: req.headers.cookie, + acceptLanguage: req.headers['accept-language'], + userAgent: req.headers['user-agent'] + }) + + if (result.setCookieHeaders) { + res.setHeader('Set-Cookie', result.setCookieHeaders) + } + + if (result.redirect) { + return res.redirect(result.statusCode, result.redirect) + } + + if (result.html !== null) { + return res.status(result.statusCode).send(result.html) + } + + res.status(result.statusCode).send('Not Found') + }) + ``` + +## SSR Options + +The SSR plugin can provide request-bound browser-like dependencies for stores that run during server rendering. + +### `cookieStore` + +Use `inject.cookieStore: true` when the renderer should provide a request-bound `CookieStore$` during SSR. This feature uses the optional peer package `@nano_kit/platform-web`, so install it in the app before enabling the option. See SSR Cookies for the full setup, including store code and `Set-Cookie` forwarding. + +```js title="vite.config.js" +ssr({ + index: 'src/index.ts', + inject: { + cookieStore: true + } +}) +``` + +### `browserLocale` + +Use `inject.browserLocale: true` when the renderer should provide request-bound `Locales$` during SSR. This feature uses the optional peer package `@nano_kit/platform-web`, so install it in the app before enabling the option. The renderer parses the incoming `Accept-Language` header with `parseLocales`, so universal stores can inject `Locales$` and resolve the same locale shape on the server and in the browser. See SSR Locale for the full setup. + +```js title="vite.config.js" +ssr({ + index: 'src/index.ts', + inject: { + browserLocale: true + } +}) +``` + +### `userAgent` + +Use `inject.userAgent: true` when the renderer should provide request-bound `UserAgent$` during SSR. This feature uses the optional peer package `@nano_kit/platform-web`, so install it in the app before enabling the option. The renderer reads the incoming `User-Agent` header, so universal stores can inject `UserAgent$` and read a user agent string on the server and in the browser. + +```js title="vite.config.js" +ssr({ + index: 'src/index.ts', + inject: { + userAgent: true + } +}) +``` + +## Custom Renderer + +To customize the HTML output, extend `ReactRenderer` and override `renderToString`: + +```tsx title="src/renderer.tsx" +import { ReactRenderer, type RenderData } from '@nano_kit/react-ssr/renderer' +import { routes, pages } from './index.js' + +class AppRenderer extends ReactRenderer { + renderToString(data: RenderData) { + // call the default implementation or build your own HTML document + return super.renderToString(data) + } +} + +export const renderer = new AppRenderer({ + base: import.meta.env.BASE_URL, + manifestPath: import.meta.env.MANIFEST, + inject: import.meta.env.SSR_INJECT, + routes, + pages +}) +``` + +Then point the plugin to your custom renderer file: + +```js title="vite.config.js" +ssr({ + index: 'src/index.ts', + renderer: 'src/renderer.tsx' +}) +``` + +## Custom Client + +To customize client-side hydration, provide your own client entry: + +```tsx title="src/client.tsx" +import { hydrateRoot } from 'react-dom/client' +import { InjectionContextProvider } from '@nano_kit/react' +import { App } from '@nano_kit/react-router' +import { ROOT_ID, ready } from '@nano_kit/react-ssr/client' +import { routes, pages } from './index.js' + +ready({ routes, pages }).then((context) => { + hydrateRoot( + document.getElementById(ROOT_ID)!, + <InjectionContextProvider context={context}> + <App /> + </InjectionContextProvider> + ) +}) +``` + +Then point the plugin to your client file: + +```js title="vite.config.js" +ssr({ + index: 'src/index.ts', + client: 'src/client.tsx' +}) +``` + +--- + +# Cookies + +Use request cookies during Nano Kit SSR with @nano_kit/platform-web. + +`@nano_kit/platform-web` provides a request-bound, [`CookieStore`](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API)-compatible implementation for SSR and tests. + +Use it when server-rendered code needs to read incoming cookies, write `Set-Cookie` headers, or provide cookie state through Nano Kit dependency injection. You can use it directly through the [`CookieStore` API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API), or combine it with `cookieStored` from `@nano_kit/platform-web` when you want cookie-backed signals. + +## Installation + +`@nano_kit/platform-web` is an optional peer dependency of `@nano_kit/ssr`. Install it only when your SSR app uses cookies. + +```bash +pnpm add @nano_kit/platform-web +``` + +## Vite Plugin + +Enable request-bound cookie stores with the SSR plugin option: + +```ts title="vite.config.ts" +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import ssr from '@nano_kit/react-ssr/vite-plugin' + +export default defineConfig({ + plugins: [ + react(), + ssr({ + index: 'src/index.tsx', + inject: { + cookieStore: true + } + }) + ] +}) +``` + +The option makes the renderer create a `VirtualCookieStore` for each request and provide it through `CookieStore$`. + +> Note: `inject.cookieStore: true` requires `@nano_kit/platform-web` to be installed. Keep the option disabled when the app does not read or write cookies during SSR. + +## Direct Cookie Store Usage + +Read `CookieStore$` inside a store factory when you want to work with the [`CookieStore` API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API) directly. + +```ts title="src/stores/session.ts" +import { action, inject } from '@nano_kit/store' +import { CookieStore$ } from '@nano_kit/platform-web' + +const SESSION_MAX_AGE = 60 * 60 * 24 * 30 + +export function Session$() { + const cookieStore = inject(CookieStore$) + const getUsername = () => cookieStore.get('session') + const login = action((username: string) => { + const value = username.trim() + + if (value) { + void cookieStore.set({ + name: 'session', + value, + path: '/', + sameSite: 'lax', + expires: Date.now() + SESSION_MAX_AGE * 1000 + }) + } + }) + const logout = action(() => { + void cookieStore.delete({ + name: 'session', + path: '/' + }) + }) + + return { + getUsername, + login, + logout + } +} +``` + +In the browser, `CookieStore$` resolves to the native browser `cookieStore`. During SSR, it resolves to a virtual store created from the incoming `Cookie` header for the current request. + +## Cookie-Backed Signals + +If you want a writable signal backed by cookies, pass the injected `CookieStore$` value to `cookieStored`: + +```ts title="src/stores/session.ts" +import { action, inject } from '@nano_kit/store' +import { + CookieStore$, + cookieStored +} from '@nano_kit/platform-web' + +const SESSION_MAX_AGE = 60 * 60 * 24 * 30 + +export function Session$() { + const cookieStore = inject(CookieStore$) + const $username = cookieStored<string | null>(cookieStore, { + name: 'session', + path: '/', + sameSite: 'lax', + maxAge: SESSION_MAX_AGE + }, null) + const login = action((username: string) => { + const value = username.trim() + + if (value) { + $username(value) + } + }) + const logout = action(() => { + $username(null) + }) + + return { + $username, + login, + logout + } +} +``` + +## Forwarding Cookies to an API + +In the browser, `fetch` attaches the user's cookies automatically. During SSR the request is server-to-server, so a store that reads a cookie-authenticated backend must forward the incoming cookies itself. `serializeCookies` reads the named cookies from a `CookieStore` and serializes them into a `Cookie` request header value. + +A common pattern is a server-only API service that adds the forwarded headers to every request: + +```ts title="src/services/api.server.ts" +import { + Injectable$, + inject +} from '@nano_kit/store' +import { + CookieStore$, + serializeCookies +} from '@nano_kit/platform-web' + +export class ServerApi$ extends Injectable$ { + cookieStore = inject(CookieStore$) + + async fetch(path: string, options?: RequestInit) { + const cookie = await serializeCookies(this.cookieStore, ['session']) + + return fetch(`https://api.example.com/${path}`, { + ...options, + headers: { + ...options?.headers, + Cookie: cookie + } + }) + } +} +``` + +Swap it for a browser implementation with `import.meta.env.SSR`. The client version skips `serializeCookies` — the browser attaches the cookies for you: + +```ts title="src/services/api.ts" +import { ClientApi$ } from './api.client' +import { ServerApi$ } from './api.server' + +export const Api$ = import.meta.env.SSR ? ServerApi$ : ClientApi$ +``` + +`serializeCookies(cookieStore, ['session', 'locale'])` resolves to a value like `session=abc123; locale=en`, and only the requested cookies that exist in the store are included. + +## Production Server + +Pass the incoming `Cookie` header to `renderer.render(url, { cookie })`, and forward returned `Set-Cookie` headers to the HTTP response. + +```js title="server.js" +import { renderer } from './dist/renderer/index.js' + +app.get('*', async (req, res) => { + const result = await renderer.render(req.url, { + cookie: req.headers.cookie + }) + + if (result.setCookieHeaders) { + res.setHeader('Set-Cookie', result.setCookieHeaders) + } + + if (result.redirect) { + return res.redirect(result.statusCode, result.redirect) + } + + if (result.html !== null) { + return res.status(result.statusCode).send(result.html) + } + + res.status(result.statusCode).send('Not Found') +}) +``` + +## Server-Side Mutations + +Because cookies are available in `Stores$`, a route can mutate cookies on the server and redirect without rendering UI. + +```tsx title="src/pages/Logout.tsx" +import { Navigation$ } from '@nano_kit/router' +import { inject } from '@nano_kit/store' +import { Session$ } from '../stores/session' + +export function Stores$() { + const navigation = inject(Navigation$) + const { logout } = inject(Session$) + + logout() + navigation.replace('/') + + return [] +} + +export default function Logout() { + return <></> +} +``` + +The renderer returns a redirect and a deletion `Set-Cookie` header. The browser receives both in the same response. + +## Low-Level Usage + +You can also use `VirtualCookieStore` directly in tests or custom render pipelines: + +```ts +import { InjectionContext, provide } from '@nano_kit/store' +import { + CookieStore$, + VirtualCookieStore +} from '@nano_kit/platform-web' + +const cookieStore = new VirtualCookieStore( + 'theme=dark; session=abc123', + '/dashboard' +) + +const context = new InjectionContext([ + provide(CookieStore$, cookieStore) +]) + +await cookieStore.set({ + name: 'theme', + value: 'light', + path: '/', + sameSite: 'lax' +}) + +cookieStore.peek('theme') // 'light' +cookieStore.drainSetCookieHeaders() +// ['theme=light; Path=/; SameSite=Lax'] +``` + +## Examples + +See the Session Cookies example for a complete React SSR app using request cookies, cookie-backed signals, server-side logout, and hydration without mismatches. + +See the Event Board example for session authentication with an HttpOnly cookie: a login flow, a route guard with SSR redirects, and an injectable API service that forwards the session cookie to the API during SSR. + +--- + +# Locale + +Use request locales during Nano Kit SSR with @nano_kit/platform-web. + +`@nano_kit/platform-web` provides browser-like locale helpers for SSR and tests. + +Use them when server-rendered code needs to resolve the user's preferred locale from the incoming `Accept-Language` header, or provide locale state through Nano Kit dependency injection. You can use `browserLocale` directly with `navigator`, or combine `Locales$` with `parseLocales` when the same store should work in both the browser and SSR. + +## Installation + +`@nano_kit/platform-web` is an optional peer dependency of `@nano_kit/ssr`. Install it only when your SSR app uses request-bound browser helpers such as `Locales$`. + +```bash +pnpm add @nano_kit/platform-web +``` + +## Vite Plugin + +Enable request-bound locale containers with the SSR plugin option: + +```ts title="vite.config.ts" +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import ssr from '@nano_kit/react-ssr/vite-plugin' + +export default defineConfig({ + plugins: [ + react(), + ssr({ + index: 'src/index.tsx', + inject: { + browserLocale: true + } + }) + ] +}) +``` + +The option makes the renderer parse the incoming `Accept-Language` header and provide the result through `Locales$`. + +> Note: `inject.browserLocale: true` requires `@nano_kit/platform-web` to be installed. Keep the option disabled when the app does not resolve locale during SSR. + +## Usage + +Read `Locales$` inside a store factory when you want to resolve the current locale from the context. + +```ts title="src/stores/locale.ts" +import { inject } from '@nano_kit/store' +import { + Locales$, + browserLocale +} from '@nano_kit/platform-web' + +const SUPPORTED_LOCALES = ['en', 'ru'] as const + +export function Locale$() { + const locales = inject(Locales$) + const locale = browserLocale(locales, SUPPORTED_LOCALES, 'en') + + return { + locale + } +} +``` + +In the browser, `Locales$` resolves to `navigator`. During SSR, it resolves to a parsed `Accept-Language` container for the current request. + +## Production Server + +Pass the incoming `Accept-Language` header to `renderer.render(url, { acceptLanguage })`. + +```js title="server.js" +import { renderer } from './dist/renderer/index.js' + +app.get('*', async (req, res) => { + const result = await renderer.render(req.url, { + acceptLanguage: req.headers['accept-language'] + }) + + if (result.redirect) { + return res.redirect(result.statusCode, result.redirect) + } + + if (result.html !== null) { + return res.status(result.statusCode).send(result.html) + } + + res.status(result.statusCode).send('Not Found') +}) +``` + +The Vite dev server passes this header automatically when `inject.browserLocale: true` is enabled. + +## Low-Level Usage + +You can also use `parseLocales` directly in tests or custom render pipelines: + +```ts +import { InjectionContext, provide } from '@nano_kit/store' +import { + Locales$, + browserLocale, + parseLocales +} from '@nano_kit/platform-web' + +const locales = parseLocales('ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7') +const context = new InjectionContext([ + provide(Locales$, locales) +]) + +browserLocale(context.get(Locales$), ['en', 'ru-RU'], 'en') +// 'ru-RU' +``` + +`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. Empty headers fall back to `{ language: 'en', languages: ['en'] }`. diff --git a/skills/nano-kit-react-ssr/SKILL.md b/skills/nano-kit-react-ssr/SKILL.md new file mode 100644 index 00000000..327573b0 --- /dev/null +++ b/skills/nano-kit-react-ssr/SKILL.md @@ -0,0 +1,84 @@ +--- +name: nano-kit-react-ssr +description: How to server-render a React + Nano Kit app with @nano_kit/react-ssr on Vite. Covers the app index file with routes and pages, the Vite plugin and its options, the renderer and production HTTP server, page-level Stores$ and Head$ exports, request-bound cookies, locale and user agent, redirects and status codes, query cache dehydration, custom renderer and client entries, and what the client build strips. Apply when adding SSR to a React app that uses @nano_kit/react-router, or when changing an existing @nano_kit/react-ssr setup. For signals, DI and hydratable see nano-kit-store; for routing see nano-kit-react-router. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - react + - ssr + - vite + - server-rendering + - hydration + docs: + paths: + - ssr-integrations/react.mdx + - ssr/cookies.mdx + - ssr/locale.mdx + tabs: + - React + - React SSR +--- + +# @nano_kit/react-ssr + +`@nano_kit/react-ssr` renders a React app built on `@nano_kit/react-router` on the server with Vite: a plugin that builds client, renderer and server entries, a renderer that awaits page stores, collects head tags and cookies, and a client entry that hydrates. Routing is described in the `nano-kit-react-router` skill, stores and hydration in `nano-kit-store`. + +## Reference + +`DOCS.md` next to this file is the documentation of the released packages as published on https://nano-kit.js.org, generated from the site sources. Read it for the plugin options, the renderer API, the client entry and the request-bound tokens before writing code. This skill adds only conventions, choices and pitfalls. + +## App shape + +- One index file exports `routes` and `pages`; the plugin, the renderer and the client entry all read it. +- Every store is an injectable factory, never a module-level signal: one process serves many requests and each request gets its own `InjectionContext`. +- Page and layout modules export `Stores$` (the accessors the server must await and dehydrate) and `Head$` next to the default component; layout and page factories are merged. +- Select services that differ between browser and server with `import.meta.env.SSR`, and keep server-only code behind it. + +## Request flow + +- The renderer creates a virtual navigation for the URL, builds the request context (router tokens plus the platform tokens enabled with the plugin's `inject` options), runs `Stores$` through dehydration, reads `Head$`, and only then renders. The result carries the HTML, the status code, a redirect target and the collected `Set-Cookie` headers. +- Forward the request headers the plugin expects (`cookie`, `accept-language`, `user-agent`) into `renderer.render`, append every returned `Set-Cookie` value, and honour `redirect` and `statusCode`. +- `vite dev` serves SSR in-process; the production `server` entry is only used by the built bundle. + +## Data and request state + +- Configure the query client for server rendering as the SSR section of the `nano-kit-query` skill's `DOCS.md` shows and return the query data signals from `Stores$`, so the renderer waits for them and the browser restores the cache. +- Read cookies, locale and user agent through the `@nano_kit/platform-web` tokens; enable the matching `inject` options so the server provides them per request. Cookie writes made while the stores run become `Set-Cookie` headers of the response. +- A store that navigates during server rendering (an auth guard in `Stores$`) turns into an HTTP redirect; a URL that matches no route renders the `notFound` page with status 404 unless the module exports `statusCode`. + +## Client entry and renderer + +- The default client entry hydrates `App` from the dehydrated snapshot; write your own only to add providers or wrap the tree, and set providers before hydrating. +- Extend the renderer class only to change the HTML document; keep everything it escapes escaped when you build markup by hand. + +## Avoiding hydration mismatches + +- Do not read `window`, `document`, storage or the current time while creating stores, in `onMount` hooks of stores returned from `Stores$`, or during rendering; use `@nano_kit/platform-web` signals or React `useEffect` for browser-only wiring. +- Resolve locale and session from the request so the server renders the same text the client will. +- Skip a fetch that already ran on the server with the hydration guard pattern from the `nano-kit-store` skill, or rely on the query cache. + +## Testing + +- Test store logic with an `InjectionContext` holding `virtualNavigation` and mocked services; for the server side call the store dehydration directly and assert on the snapshot, keeping dehydration enabled. + +## Pitfalls + +- `Stores$` must cover the data the page needs for its first paint; a missing accessor renders empty on the server and refetches on the client. +- Async work the server cannot see (a bare promise in `onMount`) is not awaited: use `resolved`, `@nano_kit/query` or the task helpers from the Working with Tasks section of the `nano-kit-store` skill's `DOCS.md`. +- `Set-Cookie` values are returned as an array; append each one instead of joining them. diff --git a/skills/nano-kit-react/DOCS.md b/skills/nano-kit-react/DOCS.md new file mode 100644 index 00000000..2ddb146f --- /dev/null +++ b/skills/nano-kit-react/DOCS.md @@ -0,0 +1,355 @@ +<!-- Generated by skills-docs from the documentation sources. Do not edit by hand. --> + +# React + +Integrate @nano_kit/store with React using @nano_kit/react. + +The `@nano_kit/react` package integrates `@nano_kit/store` signals and Dependency Injection with React components. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store @nano_kit/react +``` + +## Signals + +### `useSignal` + +The `useSignal` hook subscribes a React component to any accessor. The component will re-render automatically whenever the value changes. + +```tsx +import { signal } from '@nano_kit/store' +import { useSignal } from '@nano_kit/react' + +const $count = signal(0) + +export function Counter() { + const count = useSignal($count) + + return ( + <button onClick={() => $count(count => count + 1)}> + Count: {count} + </button> + ) +} +``` + +## Dependency Injection + +### `InjectionContextProvider` + +To use the Dependency Injection system within React, wrap your application (or part of it) in `InjectionContextProvider`. This component initializes an `InjectionContext` and makes it available to child components via the React Context API. + +You can provide an existing context instance or an array of providers/values. + +```tsx +import { provide } from '@nano_kit/store' +import { InjectionContextProvider } from '@nano_kit/react' +import { Theme$ } from './tokens' +import { App } from './App' + +function Root() { + return ( + <InjectionContextProvider + context={[ + provide(Theme$, 'dark') + ]} + > + <App /> + </InjectionContextProvider> + ) +} +``` + +### `useInject` + +The `useInject` hook retrieves a dependency from the current injection context. It throws an error if the context is missing, ensuring your dependencies are always resolved. + +```tsx +import { useInject } from '@nano_kit/react' +import { Theme$ } from './tokens' + +export function ThemedButton() { + const theme = useInject(Theme$) + + return <button className={`btn-${theme}`}>Click me</button> +} +``` + +### `injectHook` + +`injectHook` creates a reusable hook for a given injection token. This is useful for encapsulating injection logic away from components. + +```tsx +import { injectHook } from '@nano_kit/react' + +/* Define token */ +function Theme$(): 'light' | 'dark' { + return 'light' +} + +/* Create custom hook */ +const useTheme = injectHook(Theme$) + +export function ThemedButton() { + const theme = useTheme() // Type-safe 'light' | 'dark' + + return <button className={`btn-${theme}`}>Click me</button> +} +``` + +### `signalHook` + +`signalHook` creates a hook that subscribes to a signal returned by a getter function. Useful for wrapping signal-valued injection tokens into stable hooks. + +```tsx +import { signalHook, injectHook } from '@nano_kit/react' +import { Location$ } from '@nano_kit/router' + +/* Creates a hook that reads and subscribes to the location signal */ +const useLocation = signalHook(injectHook(Location$)) + +export function ActiveLink({ route }) { + const location = useLocation() + /* ... */ +} +``` + +### `Isolate` + +`Isolate` renders children with a fresh injection boundary. Use it when a subtree must not inherit dependencies from the parent context. + +```tsx +import { Isolate } from '@nano_kit/react' + +export function Preview({ children }) { + return ( + <Isolate> + {children} + </Isolate> + ) +} +``` + +## SSR + +### RSC-Side Dehydration + +#### `Dehydration` + +`Dehydration` is an async RSC component that dehydrates stores on the server and streams the snapshot to the client via [`HydrationProvider`](#hydrationprovider). It works on both initial page loads and RSC flight requests (client-side navigations), pushing fresh data to the client each time. + +`Dehydration` boundaries can be nested and repeated in the same RSC tree. For example, a layout can dehydrate shared stores, while a page dehydrates page-specific stores. Boundaries in the same RSC request reuse the shared request context and skip stores that were already dehydrated. + +```tsx +import { inject } from '@nano_kit/store' +import { Dehydration } from '@nano_kit/react' +import { User$ } from './stores' + +function Stores$() { + const { $user } = inject(User$) + + /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + return [$user] +} + +export default function Page() { + return ( + <Dehydration stores={Stores$}> + <UserProfile /> + </Dehydration> + ) +} +``` + +If you need to run dehydration manually before rendering, use [`dehydrate`](#dehydrate) directly and pass the snapshot via `dehydrated`: + +```tsx +import { inject } from '@nano_kit/store' +import { + Dehydration, + dehydrate, + getDehydrationContext +} from '@nano_kit/react' +import { User$ } from './stores' + +function Stores$() { + const { $user } = inject(User$) + + /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + return [$user] +} + +export default async function Page() { + const dehydrated = await dehydrate(Stores$) + const context = getDehydrationContext() + + /* ...You can use `context` or `dehydrated` here to implement some logic before rendering... */ + + return ( + <Dehydration dehydrated={dehydrated}> + <UserProfile /> + </Dehydration> + ) +} +``` + +Props: + +- `stores?` — function returning an array of accessors to dehydrate +- `dehydrated?` — pre-dehydrated data (skips `stores` dehydration if provided) +- `context?` — additional providers to inject into the server context before dehydration +- `isolate?` — create a new `InjectionContext` on the client instead of reusing the existing one + +#### `StaticDehydration` + +`StaticDehydration` is like [`Dehydration`](#dehydration) but performs dehydration only on the initial full-page request and skips it on RSC flight requests (client-side navigations where React fetches only the server component payload, not a full HTML page), detected via [`FlightDetector`](#flightdetector). In that case, during client-side navigation, stores will fetch and resolve data in the browser instead of the server. + +Like `Dehydration`, `StaticDehydration` can be nested and repeated. This makes it valid to place one boundary in a layout and another boundary in a page. + +```tsx +import { inject } from '@nano_kit/store' +import { StaticDehydration } from '@nano_kit/react' +import { User$ } from './stores' + +function Stores$() { + const { $user } = inject(User$) + + /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + return [$user] +} + +export default function Page() { + return ( + <StaticDehydration stores={Stores$}> + <UserProfile /> + </StaticDehydration> + ) +} +``` + +Props: + +- `stores?` — function returning an array of accessors to dehydrate +- `dehydrated?` — pre-dehydrated data (skips `stores` dehydration if provided) +- `context?` — additional providers to inject into the server context before dehydration +- `isolate?` — create a new `InjectionContext` on the client instead of reusing the existing one +- `flight?` — override flight detection; when `false`, dehydration is always included + +#### `FlightDetector` + +`FlightDetector` marks the current RSC request as a non-flight render. Place it in the root layout so [`StaticDehydration`](#staticdehydration) knows when to include dehydrated data. + +```tsx +import { FlightDetector } from '@nano_kit/react' + +export default function RootLayout({ children }) { + return ( + <html> + <body> + <FlightDetector> + {children} + </FlightDetector> + </body> + </html> + ) +} +``` + +#### `dehydrate` + +Low-level RSC helper that runs stores in the **shared per-request injection context** and returns the dehydrated snapshot. Use it when you need to manually prepare a snapshot before rendering instead of letting `Dehydration` or `StaticDehydration` run stores from their `stores` prop. + +After `dehydrate(Stores$)` resolves, the stores have already run and populated their data. Use `getDehydrationContext()` when you need request-time logic based on those populated stores, such as redirecting when a loaded user is missing. + +```tsx +import { redirect } from 'next/navigation' +import { inject } from '@nano_kit/store' +import { + Dehydration, + dehydrate, + getDehydrationContext +} from '@nano_kit/react' +import { User$ } from './stores' + +function Stores$() { + const { $user } = inject(User$) + + return [$user] +} + +export default async function Page() { + const dehydrated = await dehydrate(Stores$) + const context = getDehydrationContext() + const { $user } = context.get(User$) + + if (!$user()) { + redirect('/login') + } + + return ( + <Dehydration dehydrated={dehydrated}> + <UserProfile /> + </Dehydration> + ) +} +``` + +### Client-Side Hydration + +#### `HydrationProvider` + +`HydrationProvider` sets up a reactive hydration context for client-side hydration. It uses `ActiveHydrator` under the hood, which supports streaming — the `dehydrated` prop can be updated on re-renders to feed new data. + +```tsx +import { HydrationProvider } from '@nano_kit/react' + +function App({ dehydrated }) { + return ( + <HydrationProvider dehydrated={dehydrated}> + <UserProfile /> + </HydrationProvider> + ) +} +``` + +Props: + +- `dehydrated?` — dehydrated key-value pairs `[string, unknown][]`, or a falsy value to skip hydration +- `context?` — additional injection providers +- `reuse?` — reuse an existing `InjectionContext` instead of creating a new one; `true` by default + +#### `StaticHydrationProvider` + +`StaticHydrationProvider` is a simpler one-shot variant using `StaticHydrator`. It applies the initial `dehydrated` snapshot once and never updates. Use this for classic SSR where the full snapshot is available at first render. + +```tsx +import { StaticHydrationProvider } from '@nano_kit/react' + +function App({ dehydrated }) { + return ( + <StaticHydrationProvider dehydrated={dehydrated}> + <UserProfile /> + </StaticHydrationProvider> + ) +} +``` + +### Utils + +#### `isFlight` + +Utility to detect flight requests from HTTP headers. Returns `true` when the `accept` header does not include `text/html`. + +```ts +import { isFlight } from '@nano_kit/react' +import { headers } from 'next/headers' + +/* Works with promise-based headers */ +const flightFromHeaders = await isFlight(headers()) +/* Or with plain header objects */ +const flightFromRequest = isFlight(req.headers) +``` diff --git a/skills/nano-kit-react/SKILL.md b/skills/nano-kit-react/SKILL.md new file mode 100644 index 00000000..cad45ca8 --- /dev/null +++ b/skills/nano-kit-react/SKILL.md @@ -0,0 +1,74 @@ +--- +name: nano-kit-react +description: How to use @nano_kit/react, the React bindings for Nano Kit stores. Covers reading signals in components with useSignal, dependency injection through InjectionContextProvider and useInject, client hydration providers, React Server Components dehydration for the Next.js App Router, flight detection and testing components with mocked stores. Apply when building or reviewing React components that consume @nano_kit/store stores. For the reactive core (signals, effects, mountable stores, DI, tasks, hydration) see the nano-kit-store skill. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - react + - signals + - state-management + - dependency-injection + - ssr + docs: + paths: + - store-integrations/react.mdx + tabs: + - React + - React SSR +--- + +# @nano_kit/react + +`@nano_kit/react` binds `@nano_kit/store` to React: hooks that read signals, dependency injection through context, and hydration providers for SSR and React Server Components. Signals, stores, DI and hydration themselves are described in the `nano-kit-store` skill. + +## Reference + +`DOCS.md` next to this file is the documentation of the released package as published on https://nano-kit.js.org, generated from the site sources. Read it for the hooks, providers and server components before writing code. This skill adds only conventions, choices and pitfalls. + +## Components + +- `useSignal($accessor)` subscribes the component; writing is a plain call of the signal, no hook needed. +- Pass stable accessors. A computed or arrow function created during render is a new subscription on every render: create derived accessors in the store, or `useMemo` them when they really depend on props. +- Keep logic out of components: they read signals and call actions. Validation, derived values, loading and error states come from the store, usually through `@nano_kit/query`. +- Per-row state (selected, expanded) comes from a `selector` in the store; give each row a memoized accessor for its key. +- Navigation and links come from `@nano_kit/react-router` hooks, not from `window.location`. + +## Dependency injection + +- Wrap the app in `InjectionContextProvider` with the providers that differ per environment (theme, API base, mocks) and read stores with `useInject`. Components inject stores, not raw services. +- `injectHook` and `signalHook` build reusable hooks for tokens that are read in many places. +- A nested provider with providers creates a child context that falls back to the parent; `Isolate` cuts a subtree off from the context (previews, embedded widgets). + +## Hydration + +- Both providers create the injection context; pass the tokens that must be provided (navigation, cookie store, mocks) through their `context` prop instead of adding another provider below. +- React Server Components: wrap pages in `Dehydration` (or `StaticDehydration` with `FlightDetector` in the root layout to skip client-side navigations); call `dehydrate(Stores$)` directly when request logic such as a redirect must run after the stores loaded. + +## Testing + +- Render inside `InjectionContextProvider` with mocked tokens; no module mocking when transport, navigation and cookies are injectable. +- Test store logic with plain store tests (see `nano-kit-store`); component tests only check rendering. + +## Pitfalls + +- `useSignal` with an inline arrow function re-subscribes on every render. +- Writing a signal during render causes extra renders or loops; write in event handlers, effects in stores or actions. +- `useInject` outside `InjectionContextProvider`, `HydrationProvider` or `StaticHydrationProvider` throws; in tests always wrap the tree. +- The server components and `dehydrate` are server-only; do not import them from client components. +- Stores that read browser globals during creation or in `onMount` break on the server, because dehydration mounts the stores it awaits. Guard browser access, use `@nano_kit/platform-web` signals, or keep view-only wiring in `useEffect`. diff --git a/skills/nano-kit-store/DOCS.md b/skills/nano-kit-store/DOCS.md new file mode 100644 index 00000000..35d09adc --- /dev/null +++ b/skills/nano-kit-store/DOCS.md @@ -0,0 +1,1564 @@ +<!-- Generated by skills-docs from the documentation sources. Do not edit by hand. --> + +# Getting Started + +@nano_kit/store is a lightweight state management library inspired by Nano Stores. + +`@nano_kit/store` is a lightweight state management library inspired by [Nano Stores](https://github.com/nanostores/nanostores) and built around [a push-pull based reactivity system](https://github.com/stackblitz/alien-signals). + +- **Tiny**. \~2 kB (minified & brotlied). Zero dependencies. +- **Fast**. Optimized for performance using the push-pull algorithm. +- **Tree-Shakeable**. Only the methods you import end up in your bundle. +- **TypeScript**. First-class type support out of the box. + +## Installation + +Install the package using your favorite package manager: + +```bash +pnpm add @nano_kit/store +``` + +## Quick Start + +Here is a minimal example demonstrating signals, computed values, and effects in action: + +```ts +import { signal, mountable, onMount, computed, effect } from '@nano_kit/store' + +/* Create independent mountable atomic store */ +const $count = mountable(signal(1)) + +/* Mountable: Run logic only when store has listeners */ +onMount($count, () => { + console.log('Mounted: Store is active') + /* e.g., open websocket, start timer, etc. */ + + return () => { + console.log('Unmounted: Store is idle') + /* e.g., close websocket, stop timer */ + } +}) + +/* Derive state (computed values are lazy & cached) */ +const $double = computed(() => $count() * 2) + +/* React to changes (triggers onMount) */ +const unsub = effect(() => { + console.log(`Count: ${$count()}, Double: ${$double()}`) +}) + +/* Update triggers granular propagation */ +$count(2) + +/* Cleanup: removes listener and triggers onMount destructor */ +unsub() +``` + +--- + +# Core Concepts + +Learn the core concepts of @nano_kit/store. + +## Signals and Effects + +Signals are atomic state containers that notify subscribers about changes. Effects are functions that track these signals and re-execute when values update. + +### Basic Usage + +Use `signal` to define a reactive state and `effect` to observe its changes. An effect runs immediately when created and re-runs whenever accessed signals change. To read a signal's value, call it as a function. To update it, pass a new value or a callback function. + +```ts +import { signal, effect } from '@nano_kit/store' + +const $count = signal(0) + +const stop = effect(() => { + /* Automatically tracks $count because it's called synchronously */ + console.log('Count is:', $count()) +}) + +/* Update with value. Output: Count is: 1 */ +$count(1) +/* Update with function. Output: Count is: 2 */ +$count(prev => prev + 1) + +/* Stop the effect */ +stop() +``` + +### Effect Cleanup + +Effects can return a cleanup function. This function is executed before the effect re-runs (due to dependency changes) and when the effect is manually stopped. This is useful for clearing timers, event listeners, or other side effects. + +```ts +const $timeout = signal(1000) + +effect(() => { + const timer = setInterval(() => { + console.log('Tick') + }, $timeout()) + + /* Cleanup function */ + return () => { + clearInterval(timer) + console.log('Timer cleared') + } +}) +``` + +### Granularity + +It is crucial to keep your state granular. **Do not** store unrelated domains (like users and posts) in a single monolithic signal. If you do, updating a post might trigger calculations for users, leading to poor performance. + +```ts +/* ❌ Bad: Monolithic store mixes unrelated domains */ +const $appState = signal({ + users: [{ id: 1, name: 'John' }], + posts: [{ id: 101, title: 'Hello World' }] +}) + +/* ✅ Good: Separate signals for independent domains */ +const $users = signal([{ id: 1, name: 'John' }]) +const $posts = signal([{ id: 101, title: 'Hello World' }]) +``` + +> Note: Storing a cohesive entity (like a single `user` object with `name` and `email`) or collection in one signal is fine. + +### Batching Updates + +When you need to update multiple signals synchronously, use `batch` to prevent intermediate side effects. This ensures that dependent effects run only once after all updates are applied, rather than running for each individual change. + +```ts +import { signal, effect, batch } from '@nano_kit/store' + +const $firstName = signal('John') +const $lastName = signal('Doe') + +effect(() => { + console.log(`Full name: ${$firstName()} ${$lastName()}`) +}) + +/* ...later */ +batch(() => { + /* Updates are applied atomically */ + $firstName('Jane') + $lastName('Smith') +}) +/* Output: "Full name: Jane Smith" (runs only once) */ +``` + +### Manual Trigger + +Signals typically check for value equality to decide if they should update. If you mutate an object or array in place (keeping the same reference), the signal won't automatically detect the change. You can use `trigger` to force an update. + +```ts +import { signal, effect, trigger } from '@nano_kit/store' + +const $tags = signal(['news', 'tech']) + +effect(() => { + console.log('Tags:', $tags().join(', ')) +}) + +/* Mutate the array in place (reference remains the same) */ +trigger(() => $tags().push('release')) +// or +$tags().push('release') +trigger($tags) + +/* Trigger multiple signals */ +trigger(() => { + $tags() + $users() +}) +``` + +## Actions + +Wrapping a function with `action` ensures that any signal read inside it is automatically untracked. This allows you to structure business logic that can be safely called from any effect without causing infinite loops or unwanted side effects. + +```ts +import { signal, effect, action } from '@nano_kit/store' + +const $count = signal(0) +const $logs = signal<string[]>([]) + +// Define an action +const printCount = action(() => { + /* Reading $count here will NOT cause the caller to track it */ + const val = $count() + + $logs(logs => [...logs, `Count is ${val}`]) +}) + +effect(() => { + /* This effect runs once. */ + /* It calls printCount, which reads $count. */ + /* Because printCount is an action, this effect DOES NOT track $count. */ + printCount() +}) +``` + +### Using `untracked` + +Normally, reading a signal inside an effect tracks it. If you want to read the current value without subscribing to future updates, use `untracked`. + +```ts +import { signal, effect, untracked } from '@nano_kit/store' + +const $a = signal(1) +const $b = signal(10) + +effect(() => { + /* Tracks $a. Re-runs when $a changes */ + console.log('A changed:', $a()) + + /* Reads $b but does NOT track it */ + console.log('Current B:', untracked($b)) +}) +``` + +## Accessors and Computeds + +An **accessor** is simply a function that returns a value (`() => T`). It doesn't need to be a special object. Because effects track any signal accessed during their execution, you can create derived state just by defining a function that reads signals. + +```ts +import { signal, effect } from '@nano_kit/store' + +const $a = signal(1) +const $b = signal(2) + +/* A simple accessor */ +const $sum = () => $a() + $b() + +effect(() => { + /* Automatically tracks $a and $b */ + console.log('Sum is', $sum()) +}) + +/* Output: Sum is 4 */ +$a(2) +``` + +### Using `computed` + +If you need to memoize the result of a calculation, use `computed`. A computed signal caches its value and only recalculates when its dependencies change. This is useful for expensive calculations or when you want to ensure downstream effects run only when the *result* changes, not just the dependencies. + +```ts +import { signal, effect, computed } from '@nano_kit/store' + +const $users = signal(['Alice', 'Bob', 'Charlie']) +const $filter = signal('a') + +/* Re-runs only when $users or $filter change */ +const $filteredUsers = computed(() => { + const filter = $filter().toLowerCase() + + return $users().filter( + user => user.toLowerCase().includes(filter) + ) +}) + +effect(() => { + console.log('Filtered:', $filteredUsers().join(', ')) +}) +``` + +`computed` also receives the previous value as its first argument, which can be useful for accumulation or comparing changes. + +```ts +const $count = signal(1) + +/* Accumulate sum of all values $count has ever had */ +const $total = computed((sum = 0) => sum + $count()) +``` + +### Using `selector` + +When many things ask the same question about one value — which row is selected, which tab is open, which item is being dragged — a `computed` per asker subscribes them all to that value, and every change wakes all of them so that all but one can conclude that nothing changed. `selector` inverts that: the source gets one subscriber, and a change reaches only the keys whose answer actually moved. + +```ts +import { signal, selector, effect } from '@nano_kit/store' + +const $selected = signal<number>() +const $isSelected = selector($selected) + +/* Each row asks about its own key and is woken only for its own key */ +const stop = effect(() => { + console.log('row 2 selected:', $isSelected(2)) +}) + +$selected(1) /* row 2 is not woken */ +$selected(2) /* row 2 is woken, row 1 is woken to unselect */ +``` + +`selector` returns a plain function, not a signal: it has no value of its own, and calling it inside a tracked context subscribes the caller to that key alone. Called outside one, it just answers. + +By default a key is selected when it is strictly equal to the source value. A second argument replaces that rule with any derivation of `(key, value)`: + +```ts +const $range = signal(0) +const $distance = selector($range, (key: number, value) => Math.abs(key - value)) + +effect(() => { + console.log('distance from 5:', $distance(5)) +}) +``` + +A custom derivation is asked for every live key on each change, while the default comparison moves exactly two — so keep the default where it fits. + +## Mountable Stores + +Wrap a signal (or computed) with `mountable` to control its lifecycle using the `onMount` hook. This allows you to execute side effects—like subscribing to external data sources or fetching data—when the signal becomes active, and clean up resources when it's no longer in use. This pattern moves business logic from components to stores, making it framework-agnostic and easier to test. + +The `onMount` callback runs when an effect starts tracking the signal and can return a cleanup function that runs when the signal is no longer used. + +```ts +import { signal, mountable, onMount, effect } from '@nano_kit/store' + +const $temperature = mountable(signal(20)) + +onMount($temperature, () => { + const interval = setInterval(() => { + $temperature(prev => prev + Math.random() - 0.5) + }, 1000) + + return () => clearInterval(interval) +}) + +/* Start effect to listen to temperature changes */ +const stop = effect(() => { + console.log('Current temperature:', $temperature()) +}) +/* $temperature is now mounted */ + +stop() +/* $temperature is now unmounted */ +``` + +### Debounced Unmounting + +Unmounting is debounced to avoid rapid mount/unmount cycles during component re-renders. The delay is defined in `STORE_UNMOUNT_DELAY` (1000ms). Mount events fire immediately. + +```ts +const stop1 = effect(() => $data()) + +stop1() +/* Unmount scheduled */ + +const stop2 = effect(() => $data()) +/* Remount before delay expires - cleanup NOT called */ + +stop2() +/* After STORE_UNMOUNT_DELAY, cleanup runs */ +``` + +> Tip: When testing, use `STORE_UNMOUNT_DELAY` or fake timers to control unmount timing. + +## Dependency Injection + +The Dependency Injection system enables modular architecture and makes testing easier by allowing dependencies to be easily replaced with mocks. It also plays a critical role in SSR scenarios by isolating state between requests. + +Use an injectable function or class with `inject` to retrieve dependencies. For example, in React, use `InjectionContextProvider` to create a context and `useInject` to access dependencies. + +```tsx +import { inject, signal, mountable, onMountEffect, action, effect, TasksRunner$ } from '@nano_kit/store' +import { InjectionContextProvider, useInject, useSignal } from '@nano_kit/react' + +/* Injectable function that defines a user store */ +function User$() { + const task = inject(TasksRunner$) + const $userId = signal(null) + const $user = mountable(signal(null)) + const fetchUser = action((id) => task(async () => { + if (typeof id !== 'number') { + $user(null) + return + } + + const response = await fetch(`/user/${id}`) + const user = await response.json() + + $user(user) + })) + + onMountEffect($user, () => { + fetchUser($userId()) + }) + + return { $userId, $user } +} + +/* Component that provides the injection context */ +function App() { + return ( + <InjectionContextProvider> + <UserProfile /> + </InjectionContextProvider> + ) +} + +/* Component that uses the User$ store */ +function UserProfile() { + const { $user } = useInject(User$) + const user = useSignal($user) + + /* ... */ +} +``` + +### Class Tokens + +Use `Injectable$` when a dependency is naturally represented as a class. Class fields can call `inject(...)`, so services can compose other dependencies in the same context. + +```ts +import { Injectable$, inject } from '@nano_kit/store' +import { CookieStore$ } from '@nano_kit/platform-web' + +class MyService$ extends Injectable$ { + cookieStore = inject(CookieStore$) + + getSession() { + return this.cookieStore.get('session') + } +} +``` + +### Providing Custom Values + +Use `provide` to override dependencies with custom values. This is useful for testing or when you want to inject a different value / implementation. + +```tsx +import { provide } from '@nano_kit/store' +import { InjectionContextProvider } from '@nano_kit/react' + +function Theme$() { + return 'light' +} + +function App() { + return ( + <InjectionContextProvider context={[provide(Theme$, 'dark')]}> + <TopBar /> + </InjectionContextProvider> + ) +} +``` + +### Required Dependencies + +If an injectable token has no meaningful default and must always be provided via `provide`, throw `DependencyNotFound` to make the error explicit: + +```tsx +import { DependencyNotFound, inject, provide } from '@nano_kit/store' +import { InjectionContextProvider, useInject } from '@nano_kit/react' + +/* Token that must be provided — no default value exists */ +function Theme$(): 'light' | 'dark' { + throw new DependencyNotFound('Theme$') +} + +/* Consumer */ +function Button() { + const theme = useInject(Theme$) /* throws if not provided */ + /* ... */ +} + +/* Provider */ +function App() { + return ( + <InjectionContextProvider context={[provide(Theme$, 'dark')]}> + <TopBar /> + </InjectionContextProvider> + ) +} +``` + +## Naming Convention + +The library follows consistent naming conventions to distinguish between different types of reactive entities. Highly recommended to follow these conventions in your own code to maintain consistency, improve readability and make reactive dependencies immediately recognizable: + +### Reactive Values + +Any entity prefixed with `$` subscribes effects to changes when called. This includes signals, computeds, accessors, and utility functions that work with signals. + +```ts +import { signal, computed, $get } from '@nano_kit/store' + +const $count = signal(0) +const $doubled = computed(() => $count() * 2) +const $sum = () => $count() + $doubled() +/* Utility that tracks signals */ +const value = $get($count) +``` + +### DI Injectable Tokens + +Injectable tokens that start with an uppercase letter and end with `$` are Dependency Injection tokens. + +```ts +import { Injectable$, TasksRunner$, inject } from '@nano_kit/store' + +function User$() { + const task = inject(TasksRunner$) + /* ... */ +} + +class UserService$ extends Injectable$ { + task = inject(TasksRunner$) +} +``` + +--- + +# Advanced + +Explore advanced features of @nano_kit/store. + +## Advanced Effects + +Beyond the basic `effect`, the library provides advanced tools for managing effects lifecycle and subscribing to changes in different ways. + +### Effect Scope + +`effectScope` creates a scope for multiple effects, allowing you to stop them all at once. This is useful when you need to manage multiple subscriptions together and dispose of them as a group. + +```ts +import { signal, effect, effectScope } from '@nano_kit/store' + +const $firstName = signal('John') +const $lastName = signal('Doe') +const $age = signal(30) + +const stop = effectScope(() => { + effect(() => { + console.log('Name:', `${$firstName()} ${$lastName()}`) + }) + + effect(() => { + console.log('Age:', $age()) + }) +}) + +/* All effects in the scope are stopped */ +stop() +``` + +### onMountEffect and onMountEffectScope + +`onMountEffect` runs an effect only when a mountable signal becomes active. This combines lifecycle management with reactive effects. + +```ts +import { signal, mountable, onMountEffect } from '@nano_kit/store' + +const $user = mountable(signal(null)) +const $userId = signal(1) + +/* Effect runs only when $user is mounted */ +onMountEffect($user, () => { + console.log('Fetching user:', $userId()) + /* Effect re-runs when $userId changes */ +}) +``` + +`onMountEffectScope` runs an entire effect scope on mount: + +```ts +import { signal, mountable, onMountEffectScope, effect } from '@nano_kit/store' + +const $data = mountable(signal(null)) +const $status = signal('idle') + +onMountEffectScope($data, () => { + effect(() => console.log('Data changed:', $data())) + effect(() => console.log('Status:', $status())) +}) +``` + +### Subscribe, Listen, and Observe + +These functions provide different ways to react to signal changes: + +**`subscribe`** - Calls the callback immediately and on every change. Triggers mount if the accessor is mountable. + +```ts +import { subscribe } from '@nano_kit/store' + +const stop = subscribe($count, (value) => { + console.log('Current value:', value) +}) +/* Called immediately with current value, then on every change */ +``` + +**`listen`** - Calls the callback only on changes, skipping the initial call. Triggers mount. + +```ts +import { listen } from '@nano_kit/store' + +const stop = listen($count, (value) => { + console.log('Changed to:', value) +}) +/* Called only when $count changes, not immediately */ +``` + +**`observe`** - Calls the callback only on changes, without triggering mount. Useful for passive observation. + +```ts +import { observe } from '@nano_kit/store' + +const $data = mountable(signal(0)) + +const stop = observe($data, (value) => { + console.log('Observed:', value) +}) +/* $data remains unmounted until something else mounts it */ +``` + +**`subscribeAny`** - Subscribes to a signal or calls the callback immediately with a plain value. Useful when you have a value that may or may not be reactive. + +```ts +import { signal, subscribeAny } from '@nano_kit/store' + +const $count = signal(0) + +/* Works with a signal */ +subscribeAny($count, (value) => console.log('Value:', value)) + +/* Works with a plain value */ +subscribeAny(42, (value) => console.log('Value:', value)) +/* Calls callback immediately with 42 */ +``` + +## Complex Data Types + +When working with objects and arrays, the library provides tools to create reactive child signals for individual properties or elements. This enables fine-grained reactivity while keeping your code organized. + +### Record + +`record` wraps an object signal and exposes its properties as individual signals. Each property becomes accessible through a `$`-prefixed accessor. + +```ts +import { signal, record } from '@nano_kit/store' + +const $user = signal({ name: 'Dan', age: 30 }) +const $userRecord = record($user) + +/* Access properties as signals */ +console.log($userRecord.$name()) /* Dan */ +console.log($userRecord.$age()) /* 30 */ + +/* Update individual properties */ +$userRecord.$name('Alice') +console.log($user()) /* { name: 'Alice', age: 30 } */ +``` + +`record` can also wrap plain objects: + +```ts +const $user = record({ name: 'Dan', age: 30 }) + +$user.$name('Bob') +``` + +> Tip: Child signals are cached in the parent signal, so calling `record()` multiple times on the same signal is safe and efficient. + +### Deep Record + +`deepRecord` recursively wraps nested objects, providing signals for properties at any depth. + +```ts +import { deepRecord } from '@nano_kit/store' + +const $user = deepRecord({ + name: 'Dan', + address: { + city: 'Batumi', + country: 'Georgia' + } +}) + +/* Access nested properties */ +console.log($user.$address.$city()) /* Batumi */ + +/* Update nested properties */ +$user.$address.$city('Tbilisi') +``` + +### Array Operations + +For arrays, use `atIndex` to create a signal for a specific element. The index can be static or dynamic. + +```ts +import { signal, atIndex } from '@nano_kit/store' + +const $users = signal(['Dan', 'John', 'Alice']) +const $firstUser = atIndex($users, 0) + +console.log($firstUser()) /* Dan */ + +/* Update through child signal */ +$firstUser('Bob') +console.log($users()) /* ['Bob', 'John', 'Alice'] */ +``` + +Dynamic indexes: + +```ts +const $index = signal(1) +const $user = atIndex($users, $index) + +console.log($user()) /* John */ + +$index(2) +console.log($user()) /* Alice */ +``` + +Finding elements by predicate with `atFoundIndex`: + +```ts +import { signal, atFoundIndex } from '@nano_kit/store' + +const $users = signal([ + { id: 1, name: 'Dan' }, + { id: 2, name: 'John' }, + { id: 3, name: 'Alice' } +]) + +/* Select user with id 2 */ +const $targetUser = atFoundIndex($users, (user) => user.id === 2) + +console.log($targetUser()) /* { id: 2, name: 'John' } */ +``` + +Additional array helpers: + +- `updateArray($array, fn)` - update the array signal using a function +- `push($array, ...values)` - add elements to the end +- `pop($array)` - remove and return the last element +- `shift($array)` - remove and return the first element +- `unshift($array, ...values)` - add elements to the start +- `setIndex($array, index, value)` - update element at index +- `deleteIndex($array, index)` - remove element at index + +### Object Operations + +`atKey` creates a signal for a specific key in an object. Works with both static and dynamic keys. + +```ts +import { signal, atKey } from '@nano_kit/store' + +const $userMap = signal({ + 2: 'Dan', + 4: 'John', + 6: 'Alice' +}) + +const $user4 = atKey($userMap, 4) +console.log($user4()) /* John */ + +$user4('Bob') +console.log($userMap()) /* { 2: 'Dan', 4: 'Bob', 6: 'Alice' } */ +``` + +Dynamic keys: + +```ts +const $userId = signal(4) +const $user = atKey($userMap, $userId) + +$userId(6) +console.log($user()) /* Alice */ +``` + +Additional object helpers: + +- `setKey($object, key, value)` - set a property value +- `deleteKey($object, key)` - remove a property + +### Signals Map + +`SignalsMap` is a reactive `Map` where each entry's value is a signal. + +```ts +import { type SignalsMap, $getMapKey, setMapKey, deleteMapKey, clearMap } from '@nano_kit/store' + +const userMap: SignalsMap<number, User> = new Map() + +/* Set entry */ +setMapKey(userMap, 1, { name: 'Dan', age: 30 }) + +/* Get entry reactively */ +effect(() => { + console.log('User:', $getMapKey(userMap, 1)) +}) + +/* Delete entry */ +deleteMapKey(userMap, 1) + +/* Clear all */ +clearMap(userMap) +``` + +## Functional Operators + +Functional operators (fops) are pure functions that create accessors from other accessors or values. Unlike `computed`, they don't memoize results — they recalculate on every access. For memoization, wrap them in `computed`. + +These operators accept either static values or accessors, making them flexible for building derived state without immediate caching. + +### Logical Operations + +```ts +import { signal, or, and, not, some, every } from '@nano_kit/store' + +const $isAdmin = signal(false) +const $isModerator = signal(true) + +/* OR: returns first truthy value */ +const $hasPermissions = or($isAdmin, $isModerator) + +/* AND: returns last value if all truthy */ +const $canEdit = and($isAdmin, $hasPermissions) + +/* NOT: logical negation */ +const $isGuest = not($hasPermissions) + +/* SOME: first truthy from multiple values */ +const $primaryRole = some($isAdmin, $isModerator, 'guest') + +/* EVERY: last value if all truthy, otherwise first falsy */ +const $allChecks = every($isAdmin, $isModerator, $hasPermissions) +``` + +### Comparison Operations + +```ts +import { signal, is, isNot, gt, gte, lt, lte } from '@nano_kit/store' + +const $age = signal(25) +const $limit = signal(18) + +/* Strict equality */ +const $isAdult = gte($age, 18) + +/* Strict inequality */ +const $notEqual = isNot($age, $limit) + +/* Comparisons */ +const $olderThanLimit = gt($age, $limit) +const $atLeastLimit = gte($age, $limit) +const $youngerThanLimit = lt($age, $limit) +const $atMostLimit = lte($age, $limit) +``` + +### Conditional Operations + +```ts +import { signal, when } from '@nano_kit/store' + +const $isLoggedIn = signal(true) +const $username = signal('Alice') + +/* Ternary: condition ? then : otherwise */ +const $greeting = when( + $isLoggedIn, + () => `Hello, ${$username()}`, + 'Please log in' +) + +console.log($greeting()) /* "Hello, Alice" */ +``` + +### Memoization + +Since fops don't cache results, wrap them in `computed` for expensive operations: + +```ts +import { signal, computed, and, gt } from '@nano_kit/store' + +const $age = signal(25) +const $hasLicense = signal(true) + +/* Without memoization - recalculates on every access */ +const $canDrive = and(gt($age, 18), $hasLicense) + +/* With memoization - caches until dependencies change */ +const $canDriveCached = computed($canDrive) +``` + +## Codecs + +A codec is a small universal interface for converting values from one representation to another and back. + +```ts +interface Codec<D, E> { + encode(value: D): E + decode(value: E | null): D | null +} +``` + +Codecs are used by storage, hydration, and other APIs that need to serialize values without owning the serialization format. + +```ts +import { JsonCodec, BooleanCodec, NoopCodec, isCodec } from '@nano_kit/store' + +JsonCodec.encode({ count: 1 }) /* '{"count":1}' */ +JsonCodec.decode<{ count: number }>('{"count":1}') /* { count: 1 } */ + +BooleanCodec.encode(true) /* '1' */ +BooleanCodec.decode('0') /* false */ + +NoopCodec.encode('value') /* 'value' */ +NoopCodec.decode('value') /* 'value' */ + +isCodec(JsonCodec) /* true */ +``` + +Built-in codecs: + +- `JsonCodec` - encodes values with `JSON.stringify` and decodes with `JSON.parse` +- `BooleanCodec` - encodes booleans as `'1'` / `'0'` +- `NoopCodec` - passes values through unchanged + +## Utilities + +The library provides a set of utility functions for common tasks like rate limiting, value extraction, type checking, and working with signal properties. + +### Async State + +**`resolved`** unwraps a promise accessor into a `[$result, $error, $pending]` tuple of signals. Stale data is preserved while a new promise is pending, and falsy values reset the state. + +```ts +import { signal, computed, resolved } from '@nano_kit/store' + +function getPosition(options?: PositionOptions) { + return new Promise<GeolocationPosition>((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, options) + }) +} + +const $highAccuracy = signal(false) +const [$position, $error, $pending] = resolved( + () => getPosition({ enableHighAccuracy: $highAccuracy() }) +) +``` + +A falsy source resets all signals to their initial state (`result: undefined`, `error: undefined`, `pending: false`). + +> Note: For remote data fetching with caching, request deduplication, cancellation, refetching, and other advanced features, consider using `@nano_kit/query` instead. + +### Rate Limiting + +**`pace`** creates a rate-limited accessor from another accessor. Wrap it with `computed` to memoize the paced value and avoid extra effect runs while the rate limiter is waiting. + +```ts +import { signal, computed, pace, effect, debounce } from '@nano_kit/store' + +const $search = signal('') +const $searchPaced = computed(pace($search, debounce(300))) + +effect(() => { + console.log('Search:', $searchPaced()) +}) + +$search('a') +$search('ab') +$search('abc') +/* Only logs "abc" after debounce completes */ +``` + +**`paced`** creates a proxy signal that updates the original signal using a rate limiter. This allows you to control how frequently a signal propagates updates while keeping immediate local updates. The proxy signal updates instantly, but the original signal updates only after the rate limiter allows it. + +```ts +import { signal, paced, effect, debounce } from '@nano_kit/store' + +const $search = signal('') +const $searchPaced = paced($search, debounce(300)) + +effect(() => { + console.log('Search:', $search()) +}) + +/* $searchPaced updates immediately, $search updates after 300ms */ +$searchPaced('a') +$searchPaced('ab') +$searchPaced('abc') +/* Only logs "abc" after debounce completes */ +``` + +**`debounce`** and **`throttle`** are rate limiters that work with `pace` and `paced`: + +**`debounce`** delays updates until after a specified time has passed since the last change. Useful for expensive operations like search queries or API calls. + +```ts +import { signal, paced, debounce } from '@nano_kit/store' + +const $input = signal('') +const $debouncedInput = paced($input, debounce(300)) + +/* Only the last value within 300ms window propagates to $input */ +``` + +**`throttle`** limits updates to once per time interval. First update executes immediately, subsequent updates are queued until the interval elapses. + +```ts +import { signal, paced, throttle } from '@nano_kit/store' + +const $scrollY = signal(0) +const $throttledScroll = paced($scrollY, throttle(100)) + +/* Updates $scrollY at most once per 100ms */ +``` + +You can also use `debounce` and `throttle` standalone to wrap regular functions: + +```ts +import { debounce, throttle } from '@nano_kit/store' + +const performSearch = debounce(300)((query: string) => { + console.log('Searching for:', query) +}) + +const handleScroll = throttle(100)(() => { + console.log('Scroll position:', window.scrollY) +}) +``` + +### Interval + +**`interval`** creates a signal with a tick counter. The timer starts when the signal becomes active and is cleared when it becomes inactive. + +```ts +import { interval, effect } from '@nano_kit/store' + +const $tick = interval(1000) + +const stop = effect(() => { + console.log('Tick:', $tick()) +}) + +/* Later: clears the underlying interval */ +stop() +``` + +### Previous Value + +**`previous`** creates a computed that tracks the previous value of a signal. Returns `undefined` for the first read. + +```ts +import { signal, previous, effect } from '@nano_kit/store' + +const $count = signal(1) +const $prevCount = previous($count) + +effect(() => { + console.log(`Changed from ${$prevCount()} to ${$count()}`) +}) + +$count(2) /* Changed from 1 to 2 */ +$count(3) /* Changed from 2 to 3 */ +``` + +### Computed Properties + +**`length`** creates a computed for the `length` property of arrays or strings. + +```ts +import { signal, length, effect } from '@nano_kit/store' + +const $items = signal(['a', 'b', 'c']) +const $itemCount = length($items) + +console.log('Count:', $itemCount()) /* Count: 3 */ +``` + +**`boolean`** converts a signal's value to a boolean. + +```ts +import { signal, boolean } from '@nano_kit/store' + +const $user = signal(null) +const $hasUser = boolean($user) + +console.log($hasUser()) /* false */ +$user({ name: 'Dan' }) +console.log($hasUser()) /* true */ +``` + +**`concat`** concatenates multiple values or accessors into a string. + +```ts +import { signal, concat, effect } from '@nano_kit/store' + +const $firstName = signal('John') +const $lastName = signal('Doe') +const $fullName = concat($firstName, ' ', $lastName) + +effect(() => { + console.log($fullName()) +}) + +$firstName('Jane') /* Jane Doe */ +``` + +### Type Checking + +**`isFunction`** checks if a value is a function. + +```ts +import { isFunction } from '@nano_kit/store' + +isFunction(() => {}) /* true */ +isFunction(42) /* false */ +``` + +**`isAccessor`** checks if a value is an accessor (function). + +```ts +import { isAccessor } from '@nano_kit/store' + +const $value = () => 42 + +isAccessor($value) /* true */ +isAccessor(42) /* false */ +``` + +**`isSignal`** checks if a value is a signal. + +```ts +import { signal, computed, isSignal } from '@nano_kit/store' + +const $count = signal(0) +const $doubled = computed(() => $count() * 2) +const $accessor = () => 42 + +isSignal($count) /* true */ +isSignal($doubled) /* true */ +isSignal($accessor) /* false */ +``` + +**`isEmpty`** checks if a value is `null` or `undefined`. + +```ts +import { isEmpty } from '@nano_kit/store' + +isEmpty(null) /* true */ +isEmpty(undefined) /* true */ +isEmpty(0) /* false */ +isEmpty('') /* false */ +``` + +### Value Extraction + +**`$get`** extracts a value from either a plain value or an accessor. Tracks dependencies when called inside an effect. + +```ts +import { signal, $get } from '@nano_kit/store' + +const $count = signal(5) + +$get($count) /* 5 */ +$get(10) /* 10 */ +$get(() => 15) /* 15 */ +``` + +**`get`** — same as `$get` but always reads without tracking dependencies (untracked). Useful when you need the current value inside an effect without subscribing to future changes. + +```ts +import { signal, effect, get } from '@nano_kit/store' + +const $a = signal(1) +const $b = signal(10) + +effect(() => { + /* Tracks $a, re-runs when $a changes */ + console.log('A:', $a()) + + /* Reads $b without tracking */ + console.log('B:', get($b)) +}) +``` + +**`toSignal`** converts a value, accessor, or signal into a writable signal. + +```ts +import { signal, computed, toSignal } from '@nano_kit/store' + +const $a = toSignal(42) /* Creates signal(42) */ +const $b = toSignal(signal(10)) /* Returns input signal */ +const $c = toSignal(computed(() => 5)) /* Returns input computed */ +const $d = toSignal(() => 20) /* Creates computed(() => 20) */ +``` + +**`toAccessor`** converts a value into an accessor, or returns the accessor if already a function. + +```ts +import { signal, computed, toAccessor } from '@nano_kit/store' + +const $a = toAccessor(42) /* Creates () => 42 */ +const $b = toAccessor(signal(10)) /* Returns input signal */ +const $c = toAccessor(computed(() => 5)) /* Returns input computed */ +const $d = toAccessor(() => 20) /* Returns input accessor */ +``` + +**`toAccessorOrSignal`** converts a value into a signal, or returns the accessor/signal if already a function. + +```ts +import { signal, computed, toAccessorOrSignal } from '@nano_kit/store' + +const $a = toAccessorOrSignal(42) /* Creates signal(42) */ +const $b = toAccessorOrSignal(signal(10)) /* Returns input signal */ +const $c = toAccessorOrSignal(computed(() => 5)) /* Returns input computed */ +const $d = toAccessorOrSignal(() => 20) /* Returns input accessor */ +``` + +### Cleanup Composition + +**`composeDestroys`** combines multiple cleanup functions into a single function. + +```ts +import { effect, composeDestroys } from '@nano_kit/store' + +const cleanup1 = () => console.log('Cleanup 1') +const cleanup2 = () => console.log('Cleanup 2') + +const stop = effect(() => { + /* ... */ + return composeDestroys(cleanup1, cleanup2) +}) + +stop() /* Logs: Cleanup 1, Cleanup 2 */ +``` + +### Reusable Helpers + +**`noop`** is a shared empty function, and **`identity`** returns the value it receives. Use them when you need a reusable no-op or identity callback instead of creating new local functions. + +```ts +import { noop, identity } from '@nano_kit/store' + +const cleanup = enabled + ? subscribe($value, onValue) + : noop + +const codec = { + encode: identity, + decode: identity +} +``` + +--- + +# SSR + +Learn how to use @nano_kit/store for server-side rendering (SSR). + +Server-side rendering (SSR) with stores requires isolating state per request and dehydrating data for client hydration. The Dependency Injection system ensures that each request gets its own signal instances, preventing state leakage between users. + +## Marking Signals for Hydration + +Use `hydratable` to mark signals that should participate in dehydration/hydration. This assigns a unique key to each signal for later identification. + +```ts +import { signal, hydratable, mountable } from '@nano_kit/store' + +function User$() { + const $userId = hydratable('userId', signal(null)) + const $user = hydratable('user', signal(null)) + + return { $userId, $user } +} +``` + +On the server, `hydratable` registers the signal in an internal map with its key. On the client, if dehydrated data exists for that key, the signal is immediately initialized with the hydrated value instead of the default. + +`hydratable` also accepts a `Codec` as the third argument. You only need it when the dehydrated payload must serialize values that need custom encoding, such as `Date`, `Map`, `Set`, class instances, or encrypted/compact payloads. + +```ts +import type { Codec } from '@nano_kit/store' +import { parse, stringify } from 'devalue' +import { signal, hydratable } from '@nano_kit/store' + +interface DashboardSnapshot { + generatedAt: Date + selectedUserIds: Set<number> + usersById: Map<number, { name: string }> +} + +const DevalueCodec: Codec<unknown, string> = { + encode: stringify, + decode: value => value === null ? null : parse(value) +} + +function Dashboard$() { + const $snapshot = hydratable( + 'dashboard', + signal<DashboardSnapshot | null>(null), + DevalueCodec + ) + + return { $snapshot } +} +``` + +On the server, the codec encodes the signal value before it is added to the dehydrated data. On the client, it decodes the dehydrated value before writing it into the signal. + +## Checking Hydration State + +Use `isHydrated` to check whether a signal has been populated from hydration data. The hydrated flag is cleared on the signal's first user-driven update. + +```ts +import { isHydrated, onMountEffect } from '@nano_kit/store' + +function User$() { + /* ... */ + onMountEffect($user, () => { + /* Subscribe to userId changes */ + const userId = $userId() + /* Only fetch user data if it hasn't been hydrated from the server */ + if (!isHydrated($user)) { + fetchUser(userId) + } + }) + /* ... */ +} +``` + +## Working with Tasks + +SSR requires waiting for all async operations to complete before dehydrating. Use `TasksRunner$` to create a task runner that automatically tracks async operations in `TasksPool$`. + +```ts +import { inject, signal, hydratable, mountable, onMountEffect, action, TasksRunner$ } from '@nano_kit/store' + +function User$() { + const task = inject(TasksRunner$) + const $userId = hydratable('userId', signal(null)) + const $user = hydratable('user', mountable(signal(null))) + + const fetchUser = action((id) => task(async () => { + if (typeof id !== 'number') { + $user(null) + return + } + + const response = await fetch(`/user/${id}`) + const user = await response.json() + + $user(user) + })) + + onMountEffect($user, () => { + fetchUser($userId()) + }) + + return { $userId, $user } +} +``` + +The `task` function wraps async operations and adds them to the tasks pool. The `dehydrate` function waits for all tasks in the pool to complete before extracting signal values. + +## Manual Hydration Flow + +> Note: Most apps do not need to call `dehydrate` or wire `Hydrator$` manually. Framework integrations use these mechanisms under the hood. This manual flow is useful when you are building a custom SSR setup or want to understand how hydration works internally. + +### Server-Side Dehydration + +On the server, use `dehydrate` to execute your store logic, wait for all async tasks to complete, and extract the dehydrated key-value pairs. + +```ts +import { dehydrate, inject } from '@nano_kit/store' + +/* Server-side handler */ +const dehydrated = await dehydrate(() => { + const { $userId, $user } = inject(User$) + + /* Set initial data */ + $userId(1) + + /* Return signals to trigger mount and start async operations */ + return [$user] +}) + +/* dehydrated = [['userId', 1], ['user', { name: 'John', email: '...' }]] */ +``` + +The `dehydrate` function: + +1. Creates an injection context with a task pool, or uses the context passed as the second argument +2. Runs your store factories within that context +3. Starts effects to trigger `onMount` callbacks (which start async tasks) +4. Waits for all tasks in `TasksPool$` to complete +5. Collects all signals marked with `hydratable` +6. Returns the dehydrated key-value pairs + +If your stores need preconfigured dependencies, pass them as the second argument. It can be an `InjectionContext` instance or an array of providers for a new context. + +```ts +import { dehydrate, provide } from '@nano_kit/store' + +const dehydrated = await dehydrate(Stores$, [ + provide(UserId$, 1) +]) +``` + +### Client-Side Hydration + +On the client, pass the dehydrated data to a `StaticHydrator` and provide it via the `Hydrator$` injection token. + +```tsx +import { Hydrator$, StaticHydrator, provide } from '@nano_kit/store' +import { InjectionContextProvider, useInject, useSignal } from '@nano_kit/react' + +/* Dehydrated data passed from the server (e.g. via a script tag or window variable) */ +const hydrator = new StaticHydrator(window.__DEHYDRATED__) + +/* Component that provides the injection context with hydration data */ +function App() { + return ( + <InjectionContextProvider context={[provide(Hydrator$, hydrator)]}> + <UserProfile /> + </InjectionContextProvider> + ) +} + +/* Component that uses the User$ store with hydrated data */ +function UserProfile() { + const { $user } = useInject(User$) + const user = useSignal($user) + + /* ... */ +} +``` + +`StaticHydrator` is a one-shot hydrator: it applies values from the initial dehydrated snapshot and discards them after use. For streaming SSR where chunks of dehydrated data arrive after the initial render, use `ActiveHydrator` instead — it exposes a `push(dehydrated)` method to feed additional data reactively. + +--- + +# Testing + +Learn how to test stores in @nano_kit/store. + +Testing stores requires understanding lifecycle behavior, waiting for async operations, and mocking dependencies through Dependency Injection. + +## Debounced Unmounting + +Unmounting in `onMount` is debounced to handle rapid mount/unmount cycles during component re-renders. The delay between unmount and mount events is defined in `STORE_UNMOUNT_DELAY` (1000ms). Mount events fire immediately without delay. + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { STORE_UNMOUNT_DELAY, mountable, signal, onMount, start } from '@nano_kit/store' + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +it('should cleanup after unmount delay', () => { + const $data = mountable(signal(0)) + const cleanup = vi.fn() + + onMount($data, () => cleanup) + + const stop = start($data) + + expect(cleanup).toHaveBeenCalledTimes(0) + + stop() + + /* Cleanup not called immediately */ + expect(cleanup).toHaveBeenCalledTimes(0) + + /* Advance timers to trigger unmount */ + vi.advanceTimersByTime(STORE_UNMOUNT_DELAY) + + expect(cleanup).toHaveBeenCalledTimes(1) +}) +``` + +## Triggering Mount Events + +Use `start` to create an effect that keeps a signal alive, triggering its mount event. This is useful for testing mountable stores. + +```ts +import { expect, vi } from 'vitest' +import { start, mountable, signal, onMount } from '@nano_kit/store' + +const $data = mountable(signal(0)) +const mounted = vi.fn() + +onMount($data, mounted) + +/* Start keeps $data mounted */ +const stop = start($data) + +expect(mounted).toHaveBeenCalledTimes(1) + +/* Cleanup triggers unmount after debounce delay */ +stop() +``` + +For one-time execution without keeping the signal alive, use `exec`: + +```ts +import { expect, vi } from 'vitest' +import { exec, mountable, signal, onMount } from '@nano_kit/store' + +const $data = mountable(signal(0)) +const mounted = vi.fn() + +onMount($data, mounted) + +/* Triggers mount, then immediately unmounts */ +exec($data) + +expect(mounted).toHaveBeenCalledTimes(1) +``` + +## Waiting for Async Operations + +Tasks are useful not only for SSR but also in tests to wait for async operations inside effects. Use tasks pool with `waitTasks` to ensure all async work completes before assertions. + +```ts +import { type TasksPool, waitTasks, tasksRunner } from '@nano_kit/store' +import { client, tasks } from '@nano_kit/query' + +const tasksPool: TasksPool = new Set() +const { query } = client(tasks(tasksRunner(tasksPool))) + +/* ... trigger async operations ... */ + +/* Wait for all tasks to complete */ +await waitTasks(tasksPool) +``` + +The `waitTasks` function waits for all tasks in the pool to complete, including tasks that spawn new tasks. For simpler cases, use `waitCurrentTasks` to wait only for currently running tasks. + +## Mocking Dependencies + +Use the DI mechanism to mock dependencies in tests. Create a custom `InjectionContext` with `provide` to inject test doubles. + +```ts +import { InjectionContext, provide, inject } from '@nano_kit/store' +import { virtualNavigation } from '@nano_kit/router' +import { Location$, Navigation$ } from './router' +import { Episodes$ } from './episodes' + +/* Create virtual navigation for testing */ +const [$location, navigation] = virtualNavigation('/episodes/1', { + episodes: '/episodes/:id' +}) + +/* Inject mocked dependencies */ +const context = new InjectionContext([ + provide(Location$, $location), + provide(Navigation$, navigation) +]) + +/* Inject store with mocked context */ +const { $episodes } = inject(Episodes$, context) +``` diff --git a/skills/nano-kit-store/SKILL.md b/skills/nano-kit-store/SKILL.md new file mode 100644 index 00000000..9d71ddd4 --- /dev/null +++ b/skills/nano-kit-store/SKILL.md @@ -0,0 +1,107 @@ +--- +name: nano-kit-store +description: How to use @nano_kit/store, the signals-based state management core of Nano Kit. Covers signals, computed values, effects, mountable stores with lifecycle hooks, dependency injection with injectable store factories, async tasks, SSR hydration, storage-backed and external signals, structured state helpers, functional operators and testing. Apply when writing, reviewing or testing stores and business logic built on @nano_kit/store. Every other nano_kit skill refers here for the reactive core instead of repeating it. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - nano_kit + - store + - signals + - state-management + - dependency-injection + - ssr + docs: + paths: + - store +--- + +# @nano_kit/store + +`@nano_kit/store` is the signals core of Nano Kit: signals, computeds, effects, mountable stores with lifecycle hooks, dependency injection, async helpers and SSR hydration. Every other nano_kit package builds on it, and their skills point here instead of repeating it. + +## Reference + +`DOCS.md` next to this file is the documentation of the released package as published on https://nano-kit.js.org, generated from the site sources. Read its relevant sections for exact signatures, options and examples before writing code. This skill adds only what the documentation leaves open: conventions, which tool to pick for which job, and pitfalls. + +## Mental model + +- State is many small signals, not one object: one signal per independent piece of state. A cohesive entity (a user, a list) in one signal is fine. +- A store is a factory named `Something$`: usually a function that creates signals and actions and returns them as an object, sometimes a class extending `Injectable$` whose fields hold them. Stores get their dependencies through `inject()` so that tests, SSR requests and platforms can swap them. +- Resources (timers, sockets, requests) live in the lifecycle of a mountable signal, not in components: `onMount` opens them when the first consumer appears and its cleanup closes them after the last one leaves. +- Components only read signals and call actions. Derived values, validation and requests are computeds and actions in a store. + +## Naming + +- `$name` for anything that tracks signals when called: signals, computeds, accessors and helpers such as `$get`. +- `Name$` (capitalized, `$` suffix) for DI tokens: store factories, service classes, provided values. +- Plain names for actions and non-reactive values: `loadUser`, `paths`, `navigation`. + +```ts +import { signal, computed, action, inject, Injectable$ } from '@nano_kit/store' + +export function Counter$() { + const $count = signal(0) + const $double = computed(() => $count() * 2) + const increment = action(() => $count(n => n + 1)) + + return { $count, $double, increment } +} + +export class Api$ extends Injectable$ { + fetch(url: string) { + return globalThis.fetch(`/api/${url}`) + } +} +``` + +## Which tool for which job + +- React to state with `effect` in stores and with the framework hooks in components. The callback subscriptions (`subscribe`, `listen`, `observe`) exist for adapters and bindings; in app code `effect` and `onMountEffect` track and clean up for you. +- Wrap every store action in `action`, including one that only writes today. An effect that calls the action then never subscribes to what the action reads, now or after the next refactor. +- "Fetch when the parameters change while someone is looking" is `onMountEffect` on a mountable result signal: read the parameters inside the effect, do the request in an `action`. +- `computed` for anything derived, and keep it pure; side effects belong in `effect` or `onMount`. +- One-off async values: `resolved`. Remote data that needs caching, deduplication and invalidation: `@nano_kit/query` (skill `nano-kit-query`). +- Many consumers asking the same question about one value (which row is selected): `selector` instead of a computed per consumer. +- Rate limiting: `paced` when the signal itself should be written at a limited rate, `pace` inside a `computed` for a limited read of another signal. +- The low-level exports (raw lifecycle hooks `onStart`, `onStop`, `onMounted`; `subscribe`, `listen`, `observe`; `start`, `exec`; `readonly`; `SignalsMap`; `getContext`; `external` and `stored`) are for adapters, framework bindings and tests. Reaching for them inside an app store is a sign that the design is off. `start` and `exec` are how a test mounts a store. + +## Dependency injection + +- Prefer injectable factories over module-level singletons as soon as the app has SSR or tests: module state is shared by every request of a server process, a context is per request and per test. +- Inject stores into stores and components; provide services (transport, cookies, navigation) at the root, so a test replaces them with `provide(Token$, mock)` instead of module mocking. +- A token without a sensible default should throw `DependencyNotFound`, so misconfiguration is loud. +- Keep `inject()` inside factories, `run(context, fn)` or framework hooks; outside a context it throws. + +## SSR + +- Register the signals the server must ship with `hydratable`; pass a codec only for values that do not survive JSON. +- `isHydrated` is cleared by the signal's own next change and knows nothing about parameters. A guard that skips a fetch after hydration must remember which input the hydrated value belongs to, otherwise the first parameter change after hydration fetches nothing. +- Hydration ids are global to the snapshot, not per store: prefix them with the store name (`user.profile`), two signals with one id overwrite each other. + +## Testing + +- Test stores without components: build an `InjectionContext` with mocks, `inject` the store, mount the signal under test with `start` and wait for its async work as the Testing section of `DOCS.md` shows. +- Unmount cleanup is debounced by `STORE_UNMOUNT_DELAY`; tests that expect immediate teardown use fake timers. + +## Pitfalls + +- Effects track only synchronous reads: a signal read after an `await` or inside a timer callback is not a dependency. Read what you need first or pass it as an argument. +- Writing the same value does not notify, and mutating an object in place keeps the same reference: replace the value, or notify with `trigger` when mutation is deliberate. +- Creating signals inside a render function creates a new store on every render. Create them in factories and read them through the framework hook. +- Forgetting `mountable()` means `onMount` and `onMountEffect` never fire. +- Reading `window`, `document` or storage while creating a store or in `onMount` breaks SSR, because the server mounts the stores it awaits. Use `@nano_kit/platform-web` signals, they have server fallbacks. diff --git a/website/astro.config.js b/website/astro.config.js index 71c262da..ea451d12 100644 --- a/website/astro.config.js +++ b/website/astro.config.js @@ -112,7 +112,9 @@ export default defineConfig({ baseUrl: 'https://github.com/TrigenSoftware/nano_kit/edit/main/website/' }, plugins: [ - llmsTxt(), + llmsTxt({ + exclude: ['articles/**'] + }), viewTransitions(), starlightSidebarTopics( [ diff --git a/website/src/content/docs/getting-started/agent-skills.mdx b/website/src/content/docs/getting-started/agent-skills.mdx new file mode 100644 index 00000000..68d93d44 --- /dev/null +++ b/website/src/content/docs/getting-started/agent-skills.mdx @@ -0,0 +1,42 @@ +--- +title: Agent Skills +description: Universal agent skills shipped with Nano Kit, one per package, each bundled with the documentation of the released package. +sidebar: + order: 2 +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components' + +The repository ships **universal agent skills**: instructions that teach an AI coding agent how to build with Nano Kit. They are compatible with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Windsurf, Cline and other agents that support the [skills](https://github.com/vercel-labs/skills) format. + +## Skills + +- [`nano-kit-store`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-store) +- [`nano-kit-query`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-query) +- [`nano-kit-intl`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-intl) +- [`nano-kit-platform-web`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-platform-web) +- [`nano-kit-react`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-react) +- [`nano-kit-react-router`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-react-router) +- [`nano-kit-react-ssr`](https://github.com/TrigenSoftware/nano_kit/tree/main/skills/nano-kit-react-ssr) + +Install `nano-kit-store` first, then the skills for the packages your app uses. The framework and SSR skills refer to the store skill for the reactive core instead of repeating it. + +## Installation + +Install a skill into your project with either package runner, replacing the skill name as needed: + +<Tabs syncKey='pkg'> + <TabItem label='pnpm' icon='pnpm'> + <Code frame='none' lang='bash' code='pnpx skills add TrigenSoftware/nano_kit --skill nano-kit-store' /> + </TabItem> + <TabItem label='Yarn' icon='seti:yarn'> + <Code frame='none' lang='bash' code='yarn dlx skills add TrigenSoftware/nano_kit --skill nano-kit-store' /> + </TabItem> + <TabItem label='npm' icon='seti:npm'> + <Code frame='none' lang='bash' code='npx skills add TrigenSoftware/nano_kit --skill nano-kit-store' /> + </TabItem> +</Tabs> + +<Aside title='What gets installed'> + The command copies the skill, `SKILL.md` and `DOCS.md`, into the skills directory of your agent. From then on, asking the agent to add a store, wire a query or set up server rendering follows the conventions and the released API of the package instead of an improvised setup. +</Aside> diff --git a/website/src/content/docs/getting-started/index.mdx b/website/src/content/docs/getting-started/index.mdx index 6b8c760f..20d737d0 100644 --- a/website/src/content/docs/getting-started/index.mdx +++ b/website/src/content/docs/getting-started/index.mdx @@ -1,6 +1,8 @@ --- title: Introduction description: An introduction to the Nano Kit ecosystem, exploring its unified architecture, high performance, and lightweight footprint. +sidebar: + order: 1 next: label: Store ---