toiljs 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.babelrc +13 -13
  2. package/.gitattributes +2 -2
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +38 -38
  4. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -90
  5. package/.github/ISSUE_TEMPLATE/config.yml +8 -8
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -20
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -43
  8. package/.github/changelog-config.json +45 -45
  9. package/.github/dependabot.yml +27 -27
  10. package/.github/workflows/ci.yml +191 -191
  11. package/.prettierrc.json +11 -11
  12. package/.vscode/settings.json +9 -9
  13. package/CHANGELOG.md +5 -5
  14. package/LICENSE +187 -187
  15. package/README.md +339 -315
  16. package/as-pect.asconfig.json +34 -34
  17. package/as-pect.config.js +65 -65
  18. package/assets/logo.svg +36 -36
  19. package/build/backend/.tsbuildinfo +1 -1
  20. package/build/cli/.tsbuildinfo +1 -1
  21. package/build/cli/index.js +2926 -191
  22. package/build/client/.tsbuildinfo +1 -1
  23. package/build/client/dev/devtools.d.ts +6 -0
  24. package/build/client/dev/devtools.js +442 -0
  25. package/build/client/dev/error-overlay.d.ts +9 -0
  26. package/build/client/dev/error-overlay.js +19 -4
  27. package/build/client/head/metadata.d.ts +3 -1
  28. package/build/client/head/metadata.js +8 -0
  29. package/build/client/index.d.ts +4 -4
  30. package/build/client/index.js +2 -2
  31. package/build/client/navigation/navigation.d.ts +2 -0
  32. package/build/client/navigation/navigation.js +9 -1
  33. package/build/client/navigation/prefetch.d.ts +1 -0
  34. package/build/client/navigation/prefetch.js +35 -0
  35. package/build/client/routing/Router.js +1 -1
  36. package/build/client/routing/hooks.js +6 -2
  37. package/build/client/routing/loader.d.ts +25 -0
  38. package/build/client/routing/loader.js +53 -7
  39. package/build/client/routing/mount.js +4 -3
  40. package/build/compiler/.tsbuildinfo +1 -1
  41. package/build/compiler/config.d.ts +18 -0
  42. package/build/compiler/config.js +8 -0
  43. package/build/compiler/docs.js +16 -16
  44. package/build/compiler/generate.js +3 -0
  45. package/build/compiler/index.d.ts +2 -2
  46. package/build/compiler/index.js +3 -1
  47. package/build/compiler/plugin.js +156 -0
  48. package/build/compiler/prerender.d.ts +1 -0
  49. package/build/compiler/prerender.js +2 -1
  50. package/build/compiler/seo.d.ts +2 -2
  51. package/build/compiler/seo.js +8 -6
  52. package/build/compiler/ssg.d.ts +5 -0
  53. package/build/compiler/ssg.js +121 -0
  54. package/build/io/.tsbuildinfo +1 -1
  55. package/build/logger/.tsbuildinfo +1 -1
  56. package/build/shared/.tsbuildinfo +1 -1
  57. package/eslint.config.js +48 -48
  58. package/examples/basic/client/404.tsx +11 -11
  59. package/examples/basic/client/components/.gitkeep +1 -1
  60. package/examples/basic/client/global-error.tsx +13 -13
  61. package/examples/basic/client/layout.tsx +25 -25
  62. package/examples/basic/client/public/images/.gitkeep +1 -1
  63. package/examples/basic/client/public/images/logo.svg +36 -36
  64. package/examples/basic/client/public/robots.txt +2 -2
  65. package/examples/basic/client/routes/docs/[...slug].tsx +12 -12
  66. package/examples/basic/client/routes/features/error/error.tsx +16 -16
  67. package/examples/basic/client/routes/features/template/b.tsx +14 -14
  68. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -21
  69. package/examples/basic/client/routes/gallery/layout.tsx +13 -13
  70. package/examples/basic/client/routes/io.tsx +24 -24
  71. package/examples/basic/client/routes/loader-demo/loading.tsx +13 -13
  72. package/examples/basic/client/routes/search.tsx +61 -61
  73. package/examples/basic/client/toil.tsx +5 -5
  74. package/package.json +155 -147
  75. package/presets/eslint.js +88 -88
  76. package/presets/no-uint8array-tostring.js +200 -200
  77. package/presets/prettier.json +18 -18
  78. package/presets/tsconfig.json +37 -37
  79. package/src/backend/index.ts +160 -160
  80. package/src/cli/proc.ts +50 -50
  81. package/src/cli/updates.ts +69 -69
  82. package/src/cli/validate.ts +31 -31
  83. package/src/client/channel/channel.ts +146 -146
  84. package/src/client/components/Form.tsx +65 -65
  85. package/src/client/components/Script.tsx +113 -113
  86. package/src/client/components/Slot.tsx +21 -21
  87. package/src/client/dev/devtools.tsx +973 -0
  88. package/src/client/dev/error-overlay.tsx +30 -4
  89. package/src/client/head/head.ts +167 -167
  90. package/src/client/head/metadata.ts +19 -1
  91. package/src/client/index.ts +19 -9
  92. package/src/client/navigation/NavLink.tsx +86 -86
  93. package/src/client/navigation/navigation.ts +25 -5
  94. package/src/client/navigation/prefetch.ts +169 -130
  95. package/src/client/navigation/scroll.ts +53 -53
  96. package/src/client/routing/Router.tsx +8 -2
  97. package/src/client/routing/action.ts +122 -122
  98. package/src/client/routing/error-boundary.tsx +43 -43
  99. package/src/client/routing/hooks.ts +21 -6
  100. package/src/client/routing/loader.ts +325 -225
  101. package/src/client/routing/match.ts +47 -47
  102. package/src/client/routing/mount.tsx +54 -52
  103. package/src/client/routing/params-context.ts +10 -10
  104. package/src/client/routing/slot-context.ts +7 -7
  105. package/src/client/search/search.ts +189 -189
  106. package/src/client/search/use-page-search.ts +73 -73
  107. package/src/client/types.ts +73 -73
  108. package/src/compiler/config.ts +47 -1
  109. package/src/compiler/docs.ts +228 -228
  110. package/src/compiler/generate.ts +394 -391
  111. package/src/compiler/index.ts +64 -54
  112. package/src/compiler/pages.ts +70 -70
  113. package/src/compiler/plugin.ts +170 -2
  114. package/src/compiler/prerender.ts +5 -1
  115. package/src/compiler/seo.ts +23 -7
  116. package/src/compiler/ssg.ts +162 -0
  117. package/src/io/BinaryReader.ts +340 -340
  118. package/src/io/BinaryWriter.ts +385 -385
  119. package/src/io/FastMap.ts +127 -127
  120. package/src/io/index.ts +11 -11
  121. package/src/io/lengths.ts +14 -14
  122. package/src/io/types.ts +18 -18
  123. package/src/logger/index.ts +22 -22
  124. package/src/server/index.ts +10 -10
  125. package/src/server/main.ts +13 -13
  126. package/src/server/tsconfig.json +4 -4
  127. package/src/shared/index.ts +10 -10
  128. package/std/client/index.d.ts +15 -15
  129. package/std/client/package.json +3 -3
  130. package/test/assembly/example.spec.ts +7 -7
  131. package/test/channel.test.ts +21 -21
  132. package/test/dom/Link.test.tsx +47 -47
  133. package/test/dom/NavLink.test.tsx +37 -37
  134. package/test/dom/error-overlay.test.tsx +44 -44
  135. package/test/dom/loader.test.tsx +121 -121
  136. package/test/dom/navigation.test.ts +59 -59
  137. package/test/dom/revalidate.test.tsx +38 -38
  138. package/test/dom/route-head.test.tsx +78 -78
  139. package/test/dom/router-loading.test.tsx +44 -44
  140. package/test/dom/scroll.test.ts +56 -56
  141. package/test/dom/use-metadata.test.tsx +58 -0
  142. package/test/io.test.ts +93 -93
  143. package/test/navlink.test.ts +28 -28
  144. package/test/placeholder.test.ts +9 -9
  145. package/test/routes.test.ts +76 -76
  146. package/test/seo.test.ts +175 -164
  147. package/test/slot-layouts.test.ts +69 -69
  148. package/test/ssg.test.ts +36 -0
  149. package/test/update.test.ts +44 -44
  150. package/test/validate.test.ts +42 -42
  151. package/toil-routes.d.ts +7 -0
  152. package/toilconfig.json +30 -30
  153. package/tsconfig.backend.json +13 -13
  154. package/tsconfig.base.json +35 -35
  155. package/tsconfig.cli.json +13 -13
  156. package/tsconfig.client.json +14 -14
  157. package/tsconfig.compiler.json +13 -13
  158. package/tsconfig.io.json +12 -12
  159. package/tsconfig.json +22 -22
  160. package/tsconfig.logger.json +12 -12
  161. package/tsconfig.server.json +10 -10
  162. package/tsconfig.shared.json +12 -12
  163. package/vitest.config.ts +26 -26
  164. package/.idea/codeStyles/Project.xml +0 -54
  165. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  166. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  167. package/.idea/modules.xml +0 -8
  168. package/.idea/prettier.xml +0 -7
  169. package/.idea/toiljs.iml +0 -8
  170. package/.idea/vcs.xml +0 -6
  171. package/.toil/entry.tsx +0 -9
  172. package/.toil/index.html +0 -12
  173. package/.toil/routes.ts +0 -9
  174. package/build/cli/configure.d.ts +0 -16
  175. package/build/cli/configure.js +0 -272
  176. package/build/cli/create.d.ts +0 -16
  177. package/build/cli/create.js +0 -420
  178. package/build/cli/diagnostics.d.ts +0 -55
  179. package/build/cli/diagnostics.js +0 -333
  180. package/build/cli/doctor.d.ts +0 -6
  181. package/build/cli/doctor.js +0 -249
  182. package/build/cli/features.d.ts +0 -25
  183. package/build/cli/features.js +0 -107
  184. package/build/cli/index.d.ts +0 -2
  185. package/build/cli/proc.d.ts +0 -6
  186. package/build/cli/proc.js +0 -31
  187. package/build/cli/ui.d.ts +0 -9
  188. package/build/cli/ui.js +0 -75
  189. package/build/cli/update.d.ts +0 -7
  190. package/build/cli/update.js +0 -117
  191. package/build/cli/updates.d.ts +0 -10
  192. package/build/cli/updates.js +0 -45
  193. package/build/cli/validate.d.ts +0 -4
  194. package/build/cli/validate.js +0 -19
  195. package/build/client/Link.d.ts +0 -8
  196. package/build/client/Link.js +0 -44
  197. package/build/client/NavLink.d.ts +0 -14
  198. package/build/client/NavLink.js +0 -37
  199. package/build/client/Router.d.ts +0 -7
  200. package/build/client/Router.js +0 -55
  201. package/build/client/channel.d.ts +0 -23
  202. package/build/client/channel.js +0 -94
  203. package/build/client/error-boundary.d.ts +0 -16
  204. package/build/client/error-boundary.js +0 -19
  205. package/build/client/head.d.ts +0 -26
  206. package/build/client/head.js +0 -87
  207. package/build/client/hooks.d.ts +0 -17
  208. package/build/client/hooks.js +0 -48
  209. package/build/client/lazy.d.ts +0 -16
  210. package/build/client/lazy.js +0 -53
  211. package/build/client/match.d.ts +0 -2
  212. package/build/client/match.js +0 -32
  213. package/build/client/mount.d.ts +0 -2
  214. package/build/client/mount.js +0 -13
  215. package/build/client/navigation.d.ts +0 -13
  216. package/build/client/navigation.js +0 -97
  217. package/build/client/params-context.d.ts +0 -2
  218. package/build/client/params-context.js +0 -2
  219. package/build/client/prefetch.d.ts +0 -11
  220. package/build/client/prefetch.js +0 -100
  221. package/build/client/runtime.d.ts +0 -31
  222. package/build/client/runtime.js +0 -112
  223. package/build/client/scroll.d.ts +0 -8
  224. package/build/client/scroll.js +0 -36
  225. package/toil-env.d.ts +0 -16
@@ -1,47 +1,47 @@
1
- /** Extracted dynamic route parameters, e.g. `{ id: "42" }` for `/blog/:id` matching `/blog/42`. */
2
- export type RouteParams = Record<string, string>;
3
-
4
- /**
5
- * Matches a route pattern against a pathname, returning extracted params or `null` if no match.
6
- * Pure and runtime-agnostic (used by the router and unit-tested directly).
7
- * matchRoute('/', '/') -> {}
8
- * matchRoute('/blog/:id', '/blog/42') -> { id: '42' }
9
- * matchRoute('/docs/*slug', '/docs/a/b') -> { slug: 'a/b' } (catch-all, 1+ segments)
10
- * matchRoute('/docs/**slug', '/docs') -> { slug: '' } (optional catch-all, 0+ segments)
11
- * matchRoute('/about', '/x') -> null
12
- */
13
- export function matchRoute(pattern: string, pathname: string): RouteParams | null {
14
- const patternSegs = pattern.split('/').filter(Boolean);
15
- const pathSegs = pathname.split('/').filter(Boolean);
16
-
17
- const params: RouteParams = {};
18
- for (let i = 0; i < patternSegs.length; i++) {
19
- const p = patternSegs[i];
20
-
21
- // Optional catch-all (`**slug`): captures the rest of the path, matching zero or more segments.
22
- if (p.startsWith('**')) {
23
- params[p.slice(2)] = pathSegs
24
- .slice(i)
25
- .map((s) => decodeURIComponent(s))
26
- .join('/');
27
- return params;
28
- }
29
-
30
- if (p.startsWith('*')) {
31
- const rest = pathSegs.slice(i);
32
- if (rest.length === 0) return null;
33
- params[p.slice(1)] = rest.map((s) => decodeURIComponent(s)).join('/');
34
- return params;
35
- }
36
-
37
- if (i >= pathSegs.length) return null;
38
- const value = pathSegs[i];
39
- if (p.startsWith(':')) {
40
- params[p.slice(1)] = decodeURIComponent(value);
41
- } else if (p !== value) {
42
- return null;
43
- }
44
- }
45
-
46
- return patternSegs.length === pathSegs.length ? params : null;
47
- }
1
+ /** Extracted dynamic route parameters, e.g. `{ id: "42" }` for `/blog/:id` matching `/blog/42`. */
2
+ export type RouteParams = Record<string, string>;
3
+
4
+ /**
5
+ * Matches a route pattern against a pathname, returning extracted params or `null` if no match.
6
+ * Pure and runtime-agnostic (used by the router and unit-tested directly).
7
+ * matchRoute('/', '/') -> {}
8
+ * matchRoute('/blog/:id', '/blog/42') -> { id: '42' }
9
+ * matchRoute('/docs/*slug', '/docs/a/b') -> { slug: 'a/b' } (catch-all, 1+ segments)
10
+ * matchRoute('/docs/**slug', '/docs') -> { slug: '' } (optional catch-all, 0+ segments)
11
+ * matchRoute('/about', '/x') -> null
12
+ */
13
+ export function matchRoute(pattern: string, pathname: string): RouteParams | null {
14
+ const patternSegs = pattern.split('/').filter(Boolean);
15
+ const pathSegs = pathname.split('/').filter(Boolean);
16
+
17
+ const params: RouteParams = {};
18
+ for (let i = 0; i < patternSegs.length; i++) {
19
+ const p = patternSegs[i];
20
+
21
+ // Optional catch-all (`**slug`): captures the rest of the path, matching zero or more segments.
22
+ if (p.startsWith('**')) {
23
+ params[p.slice(2)] = pathSegs
24
+ .slice(i)
25
+ .map((s) => decodeURIComponent(s))
26
+ .join('/');
27
+ return params;
28
+ }
29
+
30
+ if (p.startsWith('*')) {
31
+ const rest = pathSegs.slice(i);
32
+ if (rest.length === 0) return null;
33
+ params[p.slice(1)] = rest.map((s) => decodeURIComponent(s)).join('/');
34
+ return params;
35
+ }
36
+
37
+ if (i >= pathSegs.length) return null;
38
+ const value = pathSegs[i];
39
+ if (p.startsWith(':')) {
40
+ params[p.slice(1)] = decodeURIComponent(value);
41
+ } else if (p !== value) {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ return patternSegs.length === pathSegs.length ? params : null;
47
+ }
@@ -1,52 +1,54 @@
1
- import { createRoot } from 'react-dom/client';
2
-
3
- import {
4
- DevErrorBoundary,
5
- DevErrorOverlay,
6
- initDevErrorOverlay,
7
- isDevMode,
8
- } from '../dev/error-overlay.js';
9
- import { initNavigation } from '../navigation/navigation.js';
10
- import { startPrefetcher } from '../navigation/prefetch.js';
11
- import { Router } from './Router.js';
12
- import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
13
-
14
- /**
15
- * Mounts the toil client app into `#root` and starts idle link prefetching. Called by the
16
- * compiler-generated `.toil/entry.tsx`.
17
- */
18
- export function mount(
19
- routes: RouteDef[],
20
- layout: LayoutLoader = null,
21
- notFound: NotFoundLoader = null,
22
- globalError: ErrorComponentLoader = null,
23
- slots: Record<string, RouteDef[]> = {},
24
- ): void {
25
- const el = document.getElementById('root');
26
- if (!el) throw new Error('toil: #root element not found');
27
- initNavigation();
28
- const app = (
29
- <Router
30
- routes={routes}
31
- layout={layout}
32
- notFound={notFound}
33
- globalError={globalError}
34
- slots={slots}
35
- />
36
- );
37
- // In dev, wrap the app in the error overlay so uncaught render/async errors surface on screen
38
- // (not a blank page). In production it's omitted entirely.
39
- if (isDevMode()) {
40
- initDevErrorOverlay();
41
- createRoot(el).render(
42
- <>
43
- <DevErrorBoundary>{app}</DevErrorBoundary>
44
- <DevErrorOverlay />
45
- </>,
46
- );
47
- } else {
48
- createRoot(el).render(app);
49
- }
50
- // Prefetch across the main tree and every slot tree (one prefetcher owns the whole table).
51
- startPrefetcher([...routes, ...Object.values(slots).flat()]);
52
- }
1
+ import { createRoot } from 'react-dom/client';
2
+
3
+ import { DevToolbar } from '../dev/devtools.js';
4
+ import {
5
+ DevErrorBoundary,
6
+ DevErrorOverlay,
7
+ initDevErrorOverlay,
8
+ } from '../dev/error-overlay.js';
9
+ import { initNavigation } from '../navigation/navigation.js';
10
+ import { startPrefetcher } from '../navigation/prefetch.js';
11
+ import { Router } from './Router.js';
12
+ import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
13
+
14
+ /**
15
+ * Mounts the toil client app into `#root` and starts idle link prefetching. Called by the
16
+ * compiler-generated `.toil/entry.tsx`.
17
+ */
18
+ export function mount(
19
+ routes: RouteDef[],
20
+ layout: LayoutLoader = null,
21
+ notFound: NotFoundLoader = null,
22
+ globalError: ErrorComponentLoader = null,
23
+ slots: Record<string, RouteDef[]> = {},
24
+ ): void {
25
+ const el = document.getElementById('root');
26
+ if (!el) throw new Error('toil: #root element not found');
27
+ initNavigation();
28
+ const app = (
29
+ <Router
30
+ routes={routes}
31
+ layout={layout}
32
+ notFound={notFound}
33
+ globalError={globalError}
34
+ slots={slots}
35
+ />
36
+ );
37
+ // In dev, wrap the app in the error overlay + dev toolbar so uncaught errors surface and dev info
38
+ // is available. The guard is the literal `import.meta.env.DEV` (not `isDevMode()`) so the whole
39
+ // branch, and the dev-only imports, are dead-code-eliminated and tree-shaken from production.
40
+ if ((import.meta as unknown as { env: { DEV: boolean } }).env.DEV) {
41
+ initDevErrorOverlay();
42
+ createRoot(el).render(
43
+ <>
44
+ <DevErrorBoundary>{app}</DevErrorBoundary>
45
+ <DevErrorOverlay />
46
+ <DevToolbar routes={routes} slots={slots} />
47
+ </>,
48
+ );
49
+ } else {
50
+ createRoot(el).render(app);
51
+ }
52
+ // Prefetch across the main tree and every slot tree (one prefetcher owns the whole table).
53
+ startPrefetcher([...routes, ...Object.values(slots).flat()]);
54
+ }
@@ -1,10 +1,10 @@
1
- /**
2
- * React context carrying the current route's dynamic params. The provider is set by {@link Router};
3
- * read it with the {@link useParams} hook rather than consuming the context directly.
4
- */
5
- import { createContext } from 'react';
6
-
7
- import type { RouteParams } from './match.js';
8
-
9
- /** Holds the params extracted from the active route (e.g. `{ id }` for `/blog/:id`). */
10
- export const ParamsContext = createContext<RouteParams>({});
1
+ /**
2
+ * React context carrying the current route's dynamic params. The provider is set by {@link Router};
3
+ * read it with the {@link useParams} hook rather than consuming the context directly.
4
+ */
5
+ import { createContext } from 'react';
6
+
7
+ import type { RouteParams } from './match.js';
8
+
9
+ /** Holds the params extracted from the active route (e.g. `{ id }` for `/blog/:id`). */
10
+ export const ParamsContext = createContext<RouteParams>({});
@@ -1,7 +1,7 @@
1
- /**
2
- * Context carrying the rendered element for each parallel-route slot (`@slot`), keyed by name.
3
- * Provided by the Router for the current URL, consumed by {@link Slot}.
4
- */
5
- import { createContext, type ReactNode } from 'react';
6
-
7
- export const SlotContext = createContext<Record<string, ReactNode>>({});
1
+ /**
2
+ * Context carrying the rendered element for each parallel-route slot (`@slot`), keyed by name.
3
+ * Provided by the Router for the current URL, consumed by {@link Slot}.
4
+ */
5
+ import { createContext, type ReactNode } from 'react';
6
+
7
+ export const SlotContext = createContext<Record<string, ReactNode>>({});
@@ -1,189 +1,189 @@
1
- /**
2
- * Site-wide page search over route {@link Metadata}. The compiler bakes a static index of every
3
- * page's title/description/keywords/OpenGraph (extracted from each route's `export const metadata`)
4
- * into the generated bundle and registers it with {@link registerPages} at startup. User code then
5
- * queries it with {@link searchPages} (pure, framework-agnostic) or the {@link usePageSearch} hook,
6
- * getting back ranked pages with their `path` ready to feed to `Link` / `navigate`.
7
- *
8
- * Only statically-analyzable metadata is indexed: a route's `generateMetadata` (dynamic, per-request)
9
- * and computed values can't be known at build time. A dynamic route can still be made discoverable
10
- * by exporting static {@link SearchHints} (`export const searchHints = { title, keywords, … }`),
11
- * which the compiler merges over the route's static `metadata` when building the index.
12
- */
13
- import type { Metadata } from '../head/metadata.js';
14
-
15
- /**
16
- * Static search hints a route can `export const searchHints` to seed the search index, useful when
17
- * the route's real `<head>` is produced by a dynamic `generateMetadata` (so nothing else is
18
- * statically indexable). Merged over the route's static `metadata`, winning ties.
19
- */
20
- export interface SearchHints {
21
- /** Indexed as the page title (highest-weighted field). */
22
- readonly title?: string;
23
- /** Indexed as the page description. */
24
- readonly description?: string;
25
- /** Indexed keywords (string or array). */
26
- readonly keywords?: string | readonly string[];
27
- }
28
-
29
- /** A searchable page: its route pattern plus the statically-known metadata baked at build time. */
30
- export interface PageMeta {
31
- /** Route URL pattern, e.g. `'/'`, `'/about'`, `'/blog/:id'`. */
32
- readonly path: string;
33
- /** Whether `path` has dynamic (`:param` / `*catch-all`) segments, not navigable without params. */
34
- readonly dynamic: boolean;
35
- /** The page's statically-extracted metadata (empty object when the route declares none). */
36
- readonly metadata: Metadata;
37
- }
38
-
39
- /** A metadata field that {@link searchPages} can match against. */
40
- export type SearchField = 'title' | 'description' | 'keywords' | 'path' | 'openGraph';
41
-
42
- /** Options for {@link searchPages}. */
43
- export interface PageSearchOptions {
44
- /** Cap the number of results returned (after ranking). Default: no cap. */
45
- readonly limit?: number;
46
- /** Include dynamic (`:param` / `*`) routes, which can't be navigated to as-is. Default: `false`. */
47
- readonly includeDynamic?: boolean;
48
- /** Restrict matching to these fields. Default: every searchable field. */
49
- readonly fields?: readonly SearchField[];
50
- }
51
-
52
- /** A page that matched a query, with its relevance {@link score} and the fields that matched. */
53
- export interface PageSearchResult {
54
- readonly page: PageMeta;
55
- /** Relevance score (higher = better); always `> 0` for a returned result. */
56
- readonly score: number;
57
- /** The metadata fields that contributed to the match, e.g. `['title', 'keywords']`. */
58
- readonly matches: readonly SearchField[];
59
- }
60
-
61
- /** Relative weight of each field, title is the strongest signal, OpenGraph the weakest. */
62
- const FIELD_WEIGHT: Record<SearchField, number> = {
63
- title: 10,
64
- path: 6,
65
- keywords: 5,
66
- description: 3,
67
- openGraph: 2,
68
- };
69
-
70
- const ALL_FIELDS: readonly SearchField[] = [
71
- 'title',
72
- 'description',
73
- 'keywords',
74
- 'path',
75
- 'openGraph',
76
- ];
77
-
78
- /** The live page index, populated by {@link registerPages} from the compiler-generated bundle. */
79
- let registry: readonly PageMeta[] = [];
80
-
81
- /**
82
- * Registers the project's page index. Called once at startup by the generated `globals` module
83
- * (`Toil.registerPages(pages)`); replaces any previous registration. Rarely called by user code,
84
- * but exposed for tests and advanced setups that build their own index.
85
- */
86
- export function registerPages(pages: readonly PageMeta[]): void {
87
- registry = pages;
88
- }
89
-
90
- /** The registered page index (every page, including dynamic ones). Empty before registration. */
91
- export function getPages(): readonly PageMeta[] {
92
- return registry;
93
- }
94
-
95
- /** Normalizes a search target (a result, a page, or a raw path) to its route path string. */
96
- export function pagePath(target: string | PageMeta | PageSearchResult): string {
97
- if (typeof target === 'string') return target;
98
- return 'page' in target ? target.page.path : target.path;
99
- }
100
-
101
- /** Joins a page's keyword list (string or array) into one searchable string. */
102
- function keywordsText(keywords: Metadata['keywords']): string {
103
- if (keywords === undefined) return '';
104
- return typeof keywords === 'string' ? keywords : keywords.join(' ');
105
- }
106
-
107
- /** The searchable text for one field of a page (empty string when the field is unset). */
108
- function fieldText(page: PageMeta, field: SearchField): string {
109
- const m = page.metadata;
110
- switch (field) {
111
- case 'title':
112
- return m.title ?? '';
113
- case 'description':
114
- return m.description ?? '';
115
- case 'keywords':
116
- return keywordsText(m.keywords);
117
- case 'path':
118
- // Make slugs word-searchable: '/get-started' → 'get started', '/blog/:id' → 'blog id'.
119
- return page.path.replace(/[/:*\-_]+/g, ' ').trim();
120
- case 'openGraph': {
121
- const og = m.openGraph;
122
- if (!og) return '';
123
- return [og.title, og.description, og.siteName, og.type].filter(Boolean).join(' ');
124
- }
125
- }
126
- }
127
-
128
- /** Whether the character before `index` is a word boundary (start of string or non-alphanumeric). */
129
- function isWordStart(text: string, index: number): boolean {
130
- return index === 0 || !/[a-z0-9]/i.test(text[index - 1]);
131
- }
132
-
133
- /**
134
- * Scores a single field against one query term, returning `0` for no match. Substring matches count;
135
- * a whole-field exact match, a prefix match, and a word-boundary match each rank progressively higher.
136
- */
137
- function scoreTerm(text: string, term: string, weight: number): number {
138
- const index = text.indexOf(term);
139
- if (index === -1) return 0;
140
- if (text === term) return weight * 3; // the field IS the term
141
- if (index === 0) return weight * 1.6; // prefix of the field
142
- if (isWordStart(text, index)) return weight * 1.2; // start of a word within the field
143
- return weight; // mid-word substring
144
- }
145
-
146
- /**
147
- * Searches the registered page index for `query`, returning pages ranked by relevance (best first).
148
- * Matching is case-insensitive; the query is split on whitespace and every term must match somewhere
149
- * (AND semantics) for a page to be included. An empty query returns no results. Dynamic routes are
150
- * excluded unless {@link PageSearchOptions.includeDynamic} is set, since they need params to navigate.
151
- */
152
- export function searchPages(query: string, options: PageSearchOptions = {}): PageSearchResult[] {
153
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
154
- if (terms.length === 0) return [];
155
- const fields = options.fields ?? ALL_FIELDS;
156
-
157
- const results: PageSearchResult[] = [];
158
- for (const page of registry) {
159
- if (page.dynamic && !options.includeDynamic) continue;
160
-
161
- const texts = fields.map((field) => ({
162
- field,
163
- text: fieldText(page, field).toLowerCase(),
164
- }));
165
- const matched = new Set<SearchField>();
166
- let score = 0;
167
- // AND semantics: every term must hit at least one field, or the page is dropped.
168
- const allTermsMatch = terms.every((term) => {
169
- let termScore = 0;
170
- for (const { field, text } of texts) {
171
- if (!text) continue;
172
- const s = scoreTerm(text, term, FIELD_WEIGHT[field]);
173
- if (s > 0) {
174
- termScore += s;
175
- matched.add(field);
176
- }
177
- }
178
- score += termScore;
179
- return termScore > 0;
180
- });
181
- if (allTermsMatch && score > 0) {
182
- results.push({ page, score, matches: [...matched] });
183
- }
184
- }
185
-
186
- // Best score first; ties broken by path for a stable, deterministic order.
187
- results.sort((a, b) => b.score - a.score || a.page.path.localeCompare(b.page.path));
188
- return options.limit !== undefined ? results.slice(0, options.limit) : results;
189
- }
1
+ /**
2
+ * Site-wide page search over route {@link Metadata}. The compiler bakes a static index of every
3
+ * page's title/description/keywords/OpenGraph (extracted from each route's `export const metadata`)
4
+ * into the generated bundle and registers it with {@link registerPages} at startup. User code then
5
+ * queries it with {@link searchPages} (pure, framework-agnostic) or the {@link usePageSearch} hook,
6
+ * getting back ranked pages with their `path` ready to feed to `Link` / `navigate`.
7
+ *
8
+ * Only statically-analyzable metadata is indexed: a route's `generateMetadata` (dynamic, per-request)
9
+ * and computed values can't be known at build time. A dynamic route can still be made discoverable
10
+ * by exporting static {@link SearchHints} (`export const searchHints = { title, keywords, … }`),
11
+ * which the compiler merges over the route's static `metadata` when building the index.
12
+ */
13
+ import type { Metadata } from '../head/metadata.js';
14
+
15
+ /**
16
+ * Static search hints a route can `export const searchHints` to seed the search index, useful when
17
+ * the route's real `<head>` is produced by a dynamic `generateMetadata` (so nothing else is
18
+ * statically indexable). Merged over the route's static `metadata`, winning ties.
19
+ */
20
+ export interface SearchHints {
21
+ /** Indexed as the page title (highest-weighted field). */
22
+ readonly title?: string;
23
+ /** Indexed as the page description. */
24
+ readonly description?: string;
25
+ /** Indexed keywords (string or array). */
26
+ readonly keywords?: string | readonly string[];
27
+ }
28
+
29
+ /** A searchable page: its route pattern plus the statically-known metadata baked at build time. */
30
+ export interface PageMeta {
31
+ /** Route URL pattern, e.g. `'/'`, `'/about'`, `'/blog/:id'`. */
32
+ readonly path: string;
33
+ /** Whether `path` has dynamic (`:param` / `*catch-all`) segments, not navigable without params. */
34
+ readonly dynamic: boolean;
35
+ /** The page's statically-extracted metadata (empty object when the route declares none). */
36
+ readonly metadata: Metadata;
37
+ }
38
+
39
+ /** A metadata field that {@link searchPages} can match against. */
40
+ export type SearchField = 'title' | 'description' | 'keywords' | 'path' | 'openGraph';
41
+
42
+ /** Options for {@link searchPages}. */
43
+ export interface PageSearchOptions {
44
+ /** Cap the number of results returned (after ranking). Default: no cap. */
45
+ readonly limit?: number;
46
+ /** Include dynamic (`:param` / `*`) routes, which can't be navigated to as-is. Default: `false`. */
47
+ readonly includeDynamic?: boolean;
48
+ /** Restrict matching to these fields. Default: every searchable field. */
49
+ readonly fields?: readonly SearchField[];
50
+ }
51
+
52
+ /** A page that matched a query, with its relevance {@link score} and the fields that matched. */
53
+ export interface PageSearchResult {
54
+ readonly page: PageMeta;
55
+ /** Relevance score (higher = better); always `> 0` for a returned result. */
56
+ readonly score: number;
57
+ /** The metadata fields that contributed to the match, e.g. `['title', 'keywords']`. */
58
+ readonly matches: readonly SearchField[];
59
+ }
60
+
61
+ /** Relative weight of each field, title is the strongest signal, OpenGraph the weakest. */
62
+ const FIELD_WEIGHT: Record<SearchField, number> = {
63
+ title: 10,
64
+ path: 6,
65
+ keywords: 5,
66
+ description: 3,
67
+ openGraph: 2,
68
+ };
69
+
70
+ const ALL_FIELDS: readonly SearchField[] = [
71
+ 'title',
72
+ 'description',
73
+ 'keywords',
74
+ 'path',
75
+ 'openGraph',
76
+ ];
77
+
78
+ /** The live page index, populated by {@link registerPages} from the compiler-generated bundle. */
79
+ let registry: readonly PageMeta[] = [];
80
+
81
+ /**
82
+ * Registers the project's page index. Called once at startup by the generated `globals` module
83
+ * (`Toil.registerPages(pages)`); replaces any previous registration. Rarely called by user code,
84
+ * but exposed for tests and advanced setups that build their own index.
85
+ */
86
+ export function registerPages(pages: readonly PageMeta[]): void {
87
+ registry = pages;
88
+ }
89
+
90
+ /** The registered page index (every page, including dynamic ones). Empty before registration. */
91
+ export function getPages(): readonly PageMeta[] {
92
+ return registry;
93
+ }
94
+
95
+ /** Normalizes a search target (a result, a page, or a raw path) to its route path string. */
96
+ export function pagePath(target: string | PageMeta | PageSearchResult): string {
97
+ if (typeof target === 'string') return target;
98
+ return 'page' in target ? target.page.path : target.path;
99
+ }
100
+
101
+ /** Joins a page's keyword list (string or array) into one searchable string. */
102
+ function keywordsText(keywords: Metadata['keywords']): string {
103
+ if (keywords === undefined) return '';
104
+ return typeof keywords === 'string' ? keywords : keywords.join(' ');
105
+ }
106
+
107
+ /** The searchable text for one field of a page (empty string when the field is unset). */
108
+ function fieldText(page: PageMeta, field: SearchField): string {
109
+ const m = page.metadata;
110
+ switch (field) {
111
+ case 'title':
112
+ return m.title ?? '';
113
+ case 'description':
114
+ return m.description ?? '';
115
+ case 'keywords':
116
+ return keywordsText(m.keywords);
117
+ case 'path':
118
+ // Make slugs word-searchable: '/get-started' → 'get started', '/blog/:id' → 'blog id'.
119
+ return page.path.replace(/[/:*\-_]+/g, ' ').trim();
120
+ case 'openGraph': {
121
+ const og = m.openGraph;
122
+ if (!og) return '';
123
+ return [og.title, og.description, og.siteName, og.type].filter(Boolean).join(' ');
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Whether the character before `index` is a word boundary (start of string or non-alphanumeric). */
129
+ function isWordStart(text: string, index: number): boolean {
130
+ return index === 0 || !/[a-z0-9]/i.test(text[index - 1]);
131
+ }
132
+
133
+ /**
134
+ * Scores a single field against one query term, returning `0` for no match. Substring matches count;
135
+ * a whole-field exact match, a prefix match, and a word-boundary match each rank progressively higher.
136
+ */
137
+ function scoreTerm(text: string, term: string, weight: number): number {
138
+ const index = text.indexOf(term);
139
+ if (index === -1) return 0;
140
+ if (text === term) return weight * 3; // the field IS the term
141
+ if (index === 0) return weight * 1.6; // prefix of the field
142
+ if (isWordStart(text, index)) return weight * 1.2; // start of a word within the field
143
+ return weight; // mid-word substring
144
+ }
145
+
146
+ /**
147
+ * Searches the registered page index for `query`, returning pages ranked by relevance (best first).
148
+ * Matching is case-insensitive; the query is split on whitespace and every term must match somewhere
149
+ * (AND semantics) for a page to be included. An empty query returns no results. Dynamic routes are
150
+ * excluded unless {@link PageSearchOptions.includeDynamic} is set, since they need params to navigate.
151
+ */
152
+ export function searchPages(query: string, options: PageSearchOptions = {}): PageSearchResult[] {
153
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
154
+ if (terms.length === 0) return [];
155
+ const fields = options.fields ?? ALL_FIELDS;
156
+
157
+ const results: PageSearchResult[] = [];
158
+ for (const page of registry) {
159
+ if (page.dynamic && !options.includeDynamic) continue;
160
+
161
+ const texts = fields.map((field) => ({
162
+ field,
163
+ text: fieldText(page, field).toLowerCase(),
164
+ }));
165
+ const matched = new Set<SearchField>();
166
+ let score = 0;
167
+ // AND semantics: every term must hit at least one field, or the page is dropped.
168
+ const allTermsMatch = terms.every((term) => {
169
+ let termScore = 0;
170
+ for (const { field, text } of texts) {
171
+ if (!text) continue;
172
+ const s = scoreTerm(text, term, FIELD_WEIGHT[field]);
173
+ if (s > 0) {
174
+ termScore += s;
175
+ matched.add(field);
176
+ }
177
+ }
178
+ score += termScore;
179
+ return termScore > 0;
180
+ });
181
+ if (allTermsMatch && score > 0) {
182
+ results.push({ page, score, matches: [...matched] });
183
+ }
184
+ }
185
+
186
+ // Best score first; ties broken by path for a stable, deterministic order.
187
+ results.sort((a, b) => b.score - a.score || a.page.path.localeCompare(b.page.path));
188
+ return options.limit !== undefined ? results.slice(0, options.limit) : results;
189
+ }