toiljs 0.0.15 → 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 (217) 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 +0 -0
  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/navigation/prefetch.d.ts +1 -0
  28. package/build/client/navigation/prefetch.js +35 -0
  29. package/build/client/routing/Router.js +1 -1
  30. package/build/client/routing/hooks.js +6 -2
  31. package/build/client/routing/loader.d.ts +23 -0
  32. package/build/client/routing/loader.js +53 -7
  33. package/build/client/routing/mount.js +4 -3
  34. package/build/compiler/.tsbuildinfo +1 -1
  35. package/build/compiler/config.d.ts +16 -0
  36. package/build/compiler/config.js +7 -0
  37. package/build/compiler/docs.js +16 -16
  38. package/build/compiler/index.d.ts +2 -2
  39. package/build/compiler/index.js +1 -1
  40. package/build/compiler/plugin.js +156 -0
  41. package/build/compiler/prerender.d.ts +1 -0
  42. package/build/compiler/prerender.js +1 -1
  43. package/build/compiler/seo.d.ts +1 -1
  44. package/build/compiler/seo.js +5 -4
  45. package/build/compiler/ssg.js +32 -1
  46. package/build/io/.tsbuildinfo +1 -1
  47. package/build/logger/.tsbuildinfo +1 -1
  48. package/build/shared/.tsbuildinfo +1 -1
  49. package/eslint.config.js +48 -48
  50. package/examples/basic/client/404.tsx +11 -11
  51. package/examples/basic/client/components/.gitkeep +1 -1
  52. package/examples/basic/client/global-error.tsx +13 -13
  53. package/examples/basic/client/layout.tsx +25 -25
  54. package/examples/basic/client/public/images/.gitkeep +1 -1
  55. package/examples/basic/client/public/images/logo.svg +36 -36
  56. package/examples/basic/client/public/robots.txt +2 -2
  57. package/examples/basic/client/routes/docs/[...slug].tsx +12 -12
  58. package/examples/basic/client/routes/features/error/error.tsx +16 -16
  59. package/examples/basic/client/routes/features/template/b.tsx +14 -14
  60. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -21
  61. package/examples/basic/client/routes/gallery/layout.tsx +13 -13
  62. package/examples/basic/client/routes/io.tsx +24 -24
  63. package/examples/basic/client/routes/loader-demo/loading.tsx +13 -13
  64. package/examples/basic/client/routes/search.tsx +61 -61
  65. package/examples/basic/client/toil.tsx +5 -5
  66. package/package.json +155 -148
  67. package/presets/eslint.js +88 -88
  68. package/presets/no-uint8array-tostring.js +200 -200
  69. package/presets/prettier.json +18 -18
  70. package/presets/tsconfig.json +37 -37
  71. package/src/backend/index.ts +160 -160
  72. package/src/cli/proc.ts +50 -50
  73. package/src/cli/updates.ts +69 -69
  74. package/src/cli/validate.ts +31 -31
  75. package/src/client/channel/channel.ts +146 -146
  76. package/src/client/components/Form.tsx +65 -65
  77. package/src/client/components/Script.tsx +113 -113
  78. package/src/client/components/Slot.tsx +21 -21
  79. package/src/client/dev/devtools.tsx +973 -0
  80. package/src/client/dev/error-overlay.tsx +30 -4
  81. package/src/client/head/head.ts +167 -167
  82. package/src/client/head/metadata.ts +112 -112
  83. package/src/client/index.ts +89 -89
  84. package/src/client/navigation/NavLink.tsx +86 -86
  85. package/src/client/navigation/navigation.ts +235 -235
  86. package/src/client/navigation/prefetch.ts +169 -130
  87. package/src/client/navigation/scroll.ts +53 -53
  88. package/src/client/routing/Router.tsx +8 -2
  89. package/src/client/routing/action.ts +122 -122
  90. package/src/client/routing/error-boundary.tsx +43 -43
  91. package/src/client/routing/hooks.ts +21 -6
  92. package/src/client/routing/loader.ts +325 -235
  93. package/src/client/routing/match.ts +47 -47
  94. package/src/client/routing/mount.tsx +54 -52
  95. package/src/client/routing/params-context.ts +10 -10
  96. package/src/client/routing/slot-context.ts +7 -7
  97. package/src/client/search/search.ts +189 -189
  98. package/src/client/search/use-page-search.ts +73 -73
  99. package/src/client/types.ts +73 -73
  100. package/src/compiler/config.ts +219 -182
  101. package/src/compiler/docs.ts +228 -228
  102. package/src/compiler/generate.ts +394 -394
  103. package/src/compiler/index.ts +64 -57
  104. package/src/compiler/pages.ts +70 -70
  105. package/src/compiler/plugin.ts +170 -2
  106. package/src/compiler/prerender.ts +156 -156
  107. package/src/compiler/seo.ts +397 -390
  108. package/src/compiler/ssg.ts +162 -126
  109. package/src/io/BinaryReader.ts +340 -340
  110. package/src/io/BinaryWriter.ts +385 -385
  111. package/src/io/FastMap.ts +127 -127
  112. package/src/io/index.ts +11 -11
  113. package/src/io/lengths.ts +14 -14
  114. package/src/io/types.ts +18 -18
  115. package/src/logger/index.ts +22 -22
  116. package/src/server/index.ts +10 -10
  117. package/src/server/main.ts +13 -13
  118. package/src/server/tsconfig.json +4 -4
  119. package/src/shared/index.ts +10 -10
  120. package/std/client/index.d.ts +15 -15
  121. package/std/client/package.json +3 -3
  122. package/test/assembly/example.spec.ts +7 -7
  123. package/test/channel.test.ts +21 -21
  124. package/test/dom/Link.test.tsx +47 -47
  125. package/test/dom/NavLink.test.tsx +37 -37
  126. package/test/dom/error-overlay.test.tsx +44 -44
  127. package/test/dom/loader.test.tsx +121 -121
  128. package/test/dom/navigation.test.ts +59 -59
  129. package/test/dom/revalidate.test.tsx +38 -38
  130. package/test/dom/route-head.test.tsx +78 -78
  131. package/test/dom/router-loading.test.tsx +44 -44
  132. package/test/dom/scroll.test.ts +56 -56
  133. package/test/dom/use-metadata.test.tsx +58 -58
  134. package/test/io.test.ts +93 -93
  135. package/test/navlink.test.ts +28 -28
  136. package/test/placeholder.test.ts +9 -9
  137. package/test/routes.test.ts +76 -76
  138. package/test/seo.test.ts +175 -164
  139. package/test/slot-layouts.test.ts +69 -69
  140. package/test/ssg.test.ts +36 -36
  141. package/test/update.test.ts +44 -44
  142. package/test/validate.test.ts +42 -42
  143. package/toil-routes.d.ts +7 -0
  144. package/toilconfig.json +30 -30
  145. package/tsconfig.backend.json +13 -13
  146. package/tsconfig.base.json +35 -35
  147. package/tsconfig.cli.json +13 -13
  148. package/tsconfig.client.json +14 -14
  149. package/tsconfig.compiler.json +13 -13
  150. package/tsconfig.io.json +12 -12
  151. package/tsconfig.json +22 -22
  152. package/tsconfig.logger.json +12 -12
  153. package/tsconfig.server.json +10 -10
  154. package/tsconfig.shared.json +12 -12
  155. package/vitest.config.ts +26 -26
  156. package/.idea/codeStyles/Project.xml +0 -54
  157. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  158. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  159. package/.idea/modules.xml +0 -8
  160. package/.idea/prettier.xml +0 -7
  161. package/.idea/toiljs.iml +0 -8
  162. package/.idea/vcs.xml +0 -6
  163. package/.toil/entry.tsx +0 -9
  164. package/.toil/index.html +0 -12
  165. package/.toil/routes.ts +0 -9
  166. package/build/cli/configure.d.ts +0 -16
  167. package/build/cli/configure.js +0 -272
  168. package/build/cli/create.d.ts +0 -16
  169. package/build/cli/create.js +0 -420
  170. package/build/cli/diagnostics.d.ts +0 -55
  171. package/build/cli/diagnostics.js +0 -333
  172. package/build/cli/doctor.d.ts +0 -6
  173. package/build/cli/doctor.js +0 -249
  174. package/build/cli/features.d.ts +0 -25
  175. package/build/cli/features.js +0 -107
  176. package/build/cli/index.d.ts +0 -2
  177. package/build/cli/proc.d.ts +0 -6
  178. package/build/cli/proc.js +0 -31
  179. package/build/cli/ui.d.ts +0 -9
  180. package/build/cli/ui.js +0 -75
  181. package/build/cli/update.d.ts +0 -7
  182. package/build/cli/update.js +0 -117
  183. package/build/cli/updates.d.ts +0 -10
  184. package/build/cli/updates.js +0 -45
  185. package/build/cli/validate.d.ts +0 -4
  186. package/build/cli/validate.js +0 -19
  187. package/build/client/Link.d.ts +0 -8
  188. package/build/client/Link.js +0 -44
  189. package/build/client/NavLink.d.ts +0 -14
  190. package/build/client/NavLink.js +0 -37
  191. package/build/client/Router.d.ts +0 -7
  192. package/build/client/Router.js +0 -55
  193. package/build/client/channel.d.ts +0 -23
  194. package/build/client/channel.js +0 -94
  195. package/build/client/error-boundary.d.ts +0 -16
  196. package/build/client/error-boundary.js +0 -19
  197. package/build/client/head.d.ts +0 -26
  198. package/build/client/head.js +0 -87
  199. package/build/client/hooks.d.ts +0 -17
  200. package/build/client/hooks.js +0 -48
  201. package/build/client/lazy.d.ts +0 -16
  202. package/build/client/lazy.js +0 -53
  203. package/build/client/match.d.ts +0 -2
  204. package/build/client/match.js +0 -32
  205. package/build/client/mount.d.ts +0 -2
  206. package/build/client/mount.js +0 -13
  207. package/build/client/navigation.d.ts +0 -13
  208. package/build/client/navigation.js +0 -97
  209. package/build/client/params-context.d.ts +0 -2
  210. package/build/client/params-context.js +0 -2
  211. package/build/client/prefetch.d.ts +0 -11
  212. package/build/client/prefetch.js +0 -100
  213. package/build/client/runtime.d.ts +0 -31
  214. package/build/client/runtime.js +0 -112
  215. package/build/client/scroll.d.ts +0 -8
  216. package/build/client/scroll.js +0 -36
  217. 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
+ }