toiljs 0.0.15 → 0.0.19

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 (273) 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 +116 -5
  14. package/LICENSE +187 -187
  15. package/README.md +524 -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/backend/index.d.ts +1 -0
  21. package/build/backend/index.js +20 -1
  22. package/build/cli/.tsbuildinfo +1 -1
  23. package/build/cli/index.js +1320 -696
  24. package/build/client/.tsbuildinfo +1 -1
  25. package/build/client/dev/devtools.d.ts +6 -0
  26. package/build/client/dev/devtools.js +479 -0
  27. package/build/client/dev/error-overlay.d.ts +9 -0
  28. package/build/client/dev/error-overlay.js +19 -4
  29. package/build/client/errors.d.ts +1 -0
  30. package/build/client/errors.js +3 -0
  31. package/build/client/index.d.ts +2 -0
  32. package/build/client/index.js +2 -0
  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 +23 -0
  38. package/build/client/routing/loader.js +53 -7
  39. package/build/client/routing/mount.js +4 -3
  40. package/build/client/rpc.d.ts +1 -0
  41. package/build/client/rpc.js +37 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +16 -0
  44. package/build/compiler/config.js +9 -0
  45. package/build/compiler/docs.js +78 -21
  46. package/build/compiler/generate.js +5 -4
  47. package/build/compiler/index.d.ts +3 -2
  48. package/build/compiler/index.js +2 -2
  49. package/build/compiler/plugin.js +228 -0
  50. package/build/compiler/prerender.d.ts +1 -0
  51. package/build/compiler/prerender.js +1 -1
  52. package/build/compiler/seo.d.ts +1 -1
  53. package/build/compiler/seo.js +20 -5
  54. package/build/compiler/ssg.js +39 -2
  55. package/build/compiler/vite.js +25 -0
  56. package/build/io/.tsbuildinfo +1 -1
  57. package/build/io/codec.d.ts +54 -0
  58. package/build/io/codec.js +143 -0
  59. package/build/io/index.d.ts +1 -2
  60. package/build/io/index.js +1 -2
  61. package/build/logger/.tsbuildinfo +1 -1
  62. package/build/shared/.tsbuildinfo +1 -1
  63. package/eslint.config.js +48 -48
  64. package/examples/basic/client/404.tsx +11 -11
  65. package/examples/basic/client/components/.gitkeep +1 -1
  66. package/examples/basic/client/global-error.tsx +13 -13
  67. package/examples/basic/client/layout.tsx +25 -25
  68. package/examples/basic/client/public/images/.gitkeep +1 -1
  69. package/examples/basic/client/public/images/logo.svg +36 -36
  70. package/examples/basic/client/public/robots.txt +2 -2
  71. package/examples/basic/client/routes/docs/[...slug].tsx +12 -12
  72. package/examples/basic/client/routes/features/error/error.tsx +16 -16
  73. package/examples/basic/client/routes/features/index.tsx +1 -1
  74. package/examples/basic/client/routes/features/template/b.tsx +14 -14
  75. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -21
  76. package/examples/basic/client/routes/gallery/layout.tsx +13 -13
  77. package/examples/basic/client/routes/io.tsx +23 -24
  78. package/examples/basic/client/routes/loader-demo/loading.tsx +13 -13
  79. package/examples/basic/client/routes/rest.tsx +74 -0
  80. package/examples/basic/client/routes/rpc.tsx +43 -0
  81. package/examples/basic/client/routes/search.tsx +61 -61
  82. package/examples/basic/client/toil.tsx +5 -5
  83. package/package.json +167 -148
  84. package/presets/eslint.js +88 -88
  85. package/presets/no-uint8array-tostring.js +200 -200
  86. package/presets/prettier-plugin.js +51 -0
  87. package/presets/prettier.json +19 -18
  88. package/presets/tsconfig.json +37 -37
  89. package/server/runtime/README.md +97 -0
  90. package/server/runtime/abort/abort.ts +27 -0
  91. package/server/runtime/env/Server.ts +61 -0
  92. package/server/runtime/envelope.ts +191 -0
  93. package/server/runtime/exports/index.ts +52 -0
  94. package/server/runtime/handlers/ToilHandler.ts +34 -0
  95. package/server/runtime/index.ts +26 -0
  96. package/server/runtime/lang/Potential.ts +5 -0
  97. package/server/runtime/memory.ts +81 -0
  98. package/server/runtime/request.ts +55 -0
  99. package/server/runtime/response.ts +86 -0
  100. package/server/runtime/rest/Rest.ts +39 -0
  101. package/server/runtime/rest/RestHandler.ts +20 -0
  102. package/server/runtime/rest/RouteContext.ts +82 -0
  103. package/server/runtime/rest/match.ts +48 -0
  104. package/server/runtime/tsconfig.json +7 -0
  105. package/src/backend/index.ts +202 -160
  106. package/src/cli/create.ts +15 -5
  107. package/src/cli/diagnostics.ts +81 -0
  108. package/src/cli/doctor.ts +384 -7
  109. package/src/cli/index.ts +11 -2
  110. package/src/cli/proc.ts +50 -50
  111. package/src/cli/updates.ts +69 -69
  112. package/src/cli/validate.ts +31 -31
  113. package/src/client/channel/channel.ts +146 -146
  114. package/src/client/components/Form.tsx +65 -65
  115. package/src/client/components/Script.tsx +113 -113
  116. package/src/client/components/Slot.tsx +21 -21
  117. package/src/client/dev/devtools.tsx +1018 -0
  118. package/src/client/dev/error-overlay.tsx +30 -4
  119. package/src/client/errors.ts +11 -0
  120. package/src/client/head/head.ts +167 -167
  121. package/src/client/head/metadata.ts +112 -112
  122. package/src/client/index.ts +91 -89
  123. package/src/client/navigation/NavLink.tsx +86 -86
  124. package/src/client/navigation/navigation.ts +235 -235
  125. package/src/client/navigation/prefetch.ts +169 -130
  126. package/src/client/navigation/scroll.ts +53 -53
  127. package/src/client/routing/Router.tsx +8 -2
  128. package/src/client/routing/action.ts +122 -122
  129. package/src/client/routing/error-boundary.tsx +43 -43
  130. package/src/client/routing/hooks.ts +21 -6
  131. package/src/client/routing/loader.ts +325 -235
  132. package/src/client/routing/match.ts +47 -47
  133. package/src/client/routing/mount.tsx +54 -52
  134. package/src/client/routing/params-context.ts +10 -10
  135. package/src/client/routing/slot-context.ts +7 -7
  136. package/src/client/rpc.ts +64 -0
  137. package/src/client/search/search.ts +189 -189
  138. package/src/client/search/use-page-search.ts +73 -73
  139. package/src/client/types.ts +73 -73
  140. package/src/compiler/config.ts +221 -182
  141. package/src/compiler/docs.ts +285 -228
  142. package/src/compiler/generate.ts +395 -394
  143. package/src/compiler/index.ts +66 -57
  144. package/src/compiler/pages.ts +70 -70
  145. package/src/compiler/plugin.ts +258 -2
  146. package/src/compiler/prerender.ts +156 -156
  147. package/src/compiler/seo.ts +417 -390
  148. package/src/compiler/ssg.ts +171 -126
  149. package/src/compiler/vite.ts +34 -0
  150. package/src/io/FastMap.ts +151 -127
  151. package/src/io/FastSet.ts +15 -1
  152. package/src/io/codec.ts +217 -0
  153. package/src/io/index.ts +10 -11
  154. package/src/io/lengths.ts +14 -14
  155. package/src/io/types.ts +19 -18
  156. package/src/logger/index.ts +22 -22
  157. package/src/shared/index.ts +10 -10
  158. package/std/client/index.d.ts +15 -15
  159. package/std/client/package.json +3 -3
  160. package/test/assembly/example.spec.ts +17 -7
  161. package/test/channel.test.ts +21 -21
  162. package/test/doctor.test.ts +65 -0
  163. package/test/dom/Link.test.tsx +47 -47
  164. package/test/dom/NavLink.test.tsx +37 -37
  165. package/test/dom/error-overlay.test.tsx +44 -44
  166. package/test/dom/loader.test.tsx +121 -121
  167. package/test/dom/navigation.test.ts +59 -59
  168. package/test/dom/revalidate.test.tsx +38 -38
  169. package/test/dom/route-head.test.tsx +78 -78
  170. package/test/dom/router-loading.test.tsx +44 -44
  171. package/test/dom/scroll.test.ts +56 -56
  172. package/test/dom/use-metadata.test.tsx +58 -58
  173. package/test/errors.test.ts +21 -0
  174. package/test/io.test.ts +117 -93
  175. package/test/navlink.test.ts +28 -28
  176. package/test/placeholder.test.ts +9 -9
  177. package/test/prettier-plugin.test.ts +46 -0
  178. package/test/routes.test.ts +76 -76
  179. package/test/rpc.test.ts +50 -0
  180. package/test/seo.test.ts +175 -164
  181. package/test/slot-layouts.test.ts +69 -69
  182. package/test/ssg.test.ts +36 -36
  183. package/test/update.test.ts +44 -44
  184. package/test/validate.test.ts +42 -42
  185. package/tests/data-parity/generated-parity.ts +99 -0
  186. package/tests/data-parity/parity.ts +80 -0
  187. package/tests/data-parity/spec.ts +46 -0
  188. package/toil-routes.d.ts +7 -0
  189. package/tsconfig.backend.json +13 -13
  190. package/tsconfig.base.json +35 -35
  191. package/tsconfig.cli.json +13 -13
  192. package/tsconfig.client.json +14 -14
  193. package/tsconfig.compiler.json +13 -13
  194. package/tsconfig.io.json +12 -12
  195. package/tsconfig.json +22 -22
  196. package/tsconfig.logger.json +12 -12
  197. package/tsconfig.server.json +10 -10
  198. package/tsconfig.shared.json +12 -12
  199. package/vitest.config.ts +26 -26
  200. package/.idea/codeStyles/Project.xml +0 -54
  201. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  202. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  203. package/.idea/modules.xml +0 -8
  204. package/.idea/prettier.xml +0 -7
  205. package/.idea/toiljs.iml +0 -8
  206. package/.idea/vcs.xml +0 -6
  207. package/.toil/entry.tsx +0 -9
  208. package/.toil/index.html +0 -12
  209. package/.toil/routes.ts +0 -9
  210. package/build/cli/configure.d.ts +0 -16
  211. package/build/cli/configure.js +0 -272
  212. package/build/cli/create.d.ts +0 -16
  213. package/build/cli/create.js +0 -420
  214. package/build/cli/diagnostics.d.ts +0 -55
  215. package/build/cli/diagnostics.js +0 -333
  216. package/build/cli/doctor.d.ts +0 -6
  217. package/build/cli/doctor.js +0 -249
  218. package/build/cli/features.d.ts +0 -25
  219. package/build/cli/features.js +0 -107
  220. package/build/cli/index.d.ts +0 -2
  221. package/build/cli/proc.d.ts +0 -6
  222. package/build/cli/proc.js +0 -31
  223. package/build/cli/ui.d.ts +0 -9
  224. package/build/cli/ui.js +0 -75
  225. package/build/cli/update.d.ts +0 -7
  226. package/build/cli/update.js +0 -117
  227. package/build/cli/updates.d.ts +0 -10
  228. package/build/cli/updates.js +0 -45
  229. package/build/cli/validate.d.ts +0 -4
  230. package/build/cli/validate.js +0 -19
  231. package/build/client/Link.d.ts +0 -8
  232. package/build/client/Link.js +0 -44
  233. package/build/client/NavLink.d.ts +0 -14
  234. package/build/client/NavLink.js +0 -37
  235. package/build/client/Router.d.ts +0 -7
  236. package/build/client/Router.js +0 -55
  237. package/build/client/channel.d.ts +0 -23
  238. package/build/client/channel.js +0 -94
  239. package/build/client/error-boundary.d.ts +0 -16
  240. package/build/client/error-boundary.js +0 -19
  241. package/build/client/head.d.ts +0 -26
  242. package/build/client/head.js +0 -87
  243. package/build/client/hooks.d.ts +0 -17
  244. package/build/client/hooks.js +0 -48
  245. package/build/client/lazy.d.ts +0 -16
  246. package/build/client/lazy.js +0 -53
  247. package/build/client/match.d.ts +0 -2
  248. package/build/client/match.js +0 -32
  249. package/build/client/mount.d.ts +0 -2
  250. package/build/client/mount.js +0 -13
  251. package/build/client/navigation.d.ts +0 -13
  252. package/build/client/navigation.js +0 -97
  253. package/build/client/params-context.d.ts +0 -2
  254. package/build/client/params-context.js +0 -2
  255. package/build/client/prefetch.d.ts +0 -11
  256. package/build/client/prefetch.js +0 -100
  257. package/build/client/runtime.d.ts +0 -31
  258. package/build/client/runtime.js +0 -112
  259. package/build/client/scroll.d.ts +0 -8
  260. package/build/client/scroll.js +0 -36
  261. package/build/io/BinaryReader.d.ts +0 -44
  262. package/build/io/BinaryReader.js +0 -244
  263. package/build/io/BinaryWriter.d.ts +0 -44
  264. package/build/io/BinaryWriter.js +0 -297
  265. package/build/server/release.wasm +0 -0
  266. package/build/server/release.wat +0 -9
  267. package/src/io/BinaryReader.ts +0 -340
  268. package/src/io/BinaryWriter.ts +0 -385
  269. package/src/server/index.ts +0 -10
  270. package/src/server/main.ts +0 -13
  271. package/src/server/tsconfig.json +0 -4
  272. package/toil-env.d.ts +0 -16
  273. package/toilconfig.json +0 -30
@@ -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
+ }
@@ -1,73 +1,73 @@
1
- /**
2
- * React binding for the page-metadata {@link searchPages search}. Gives a route component reactive,
3
- * memoized search results plus a `goTo` helper that navigates straight to a matched page, a drop-in
4
- * for a site-wide "jump to page" / command-palette style search box.
5
- */
6
- import { useMemo } from 'react';
7
-
8
- import { navigate, type NavigateOptions } from '../navigation/navigation.js';
9
- import type { Href } from '../types.js';
10
- import {
11
- getPages,
12
- type PageMeta,
13
- pagePath,
14
- type PageSearchOptions,
15
- type PageSearchResult,
16
- searchPages,
17
- } from './search.js';
18
-
19
- /** What {@link usePageSearch} returns. */
20
- export interface PageSearch {
21
- /** Ranked matches for the current query (best first); empty when the query is blank. */
22
- readonly results: readonly PageSearchResult[];
23
- /** The full registered page index (handy for rendering an "all pages" listing). */
24
- readonly pages: readonly PageMeta[];
25
- /**
26
- * Navigates to a result / page / raw path. A dynamic (`:param`) page can't be navigated to
27
- * as-is, so passing one (or its result) is a no-op unless you pass a concrete path string with
28
- * the params already filled in. A stable reference, safe to destructure.
29
- */
30
- readonly goTo: (
31
- target: string | PageMeta | PageSearchResult,
32
- options?: NavigateOptions,
33
- ) => void;
34
- }
35
-
36
- /** Whether a path can be navigated to directly (no unfilled dynamic segments). */
37
- function isNavigable(path: string): boolean {
38
- return !/[:*]/.test(path);
39
- }
40
-
41
- /**
42
- * Searches the project's pages by `query` and returns ranked {@link PageSearchResult}s, recomputed
43
- * only when the query or options change. Use the returned `goTo` to redirect to a match:
44
- *
45
- * ```tsx
46
- * const { results, goTo } = usePageSearch(query);
47
- * return results.map((r) => (
48
- * <button key={r.page.path} onClick={() => { goTo(r); }}>{r.page.metadata.title ?? r.page.path}</button>
49
- * ));
50
- * ```
51
- */
52
- export function usePageSearch(query: string, options: PageSearchOptions = {}): PageSearch {
53
- const { limit, includeDynamic, fields } = options;
54
- const fieldsKey = fields?.join(',');
55
- const results = useMemo(
56
- () => searchPages(query, { limit, includeDynamic, fields }),
57
- // `fields` is compared by content (fieldsKey) so a fresh array literal each render is fine.
58
- [query, limit, includeDynamic, fieldsKey],
59
- );
60
-
61
- return useMemo<PageSearch>(
62
- () => ({
63
- results,
64
- pages: getPages(),
65
- goTo(target, navOptions) {
66
- const path = pagePath(target);
67
- if (typeof target !== 'string' && !isNavigable(path)) return;
68
- navigate(path as Href, navOptions);
69
- },
70
- }),
71
- [results],
72
- );
73
- }
1
+ /**
2
+ * React binding for the page-metadata {@link searchPages search}. Gives a route component reactive,
3
+ * memoized search results plus a `goTo` helper that navigates straight to a matched page, a drop-in
4
+ * for a site-wide "jump to page" / command-palette style search box.
5
+ */
6
+ import { useMemo } from 'react';
7
+
8
+ import { navigate, type NavigateOptions } from '../navigation/navigation.js';
9
+ import type { Href } from '../types.js';
10
+ import {
11
+ getPages,
12
+ type PageMeta,
13
+ pagePath,
14
+ type PageSearchOptions,
15
+ type PageSearchResult,
16
+ searchPages,
17
+ } from './search.js';
18
+
19
+ /** What {@link usePageSearch} returns. */
20
+ export interface PageSearch {
21
+ /** Ranked matches for the current query (best first); empty when the query is blank. */
22
+ readonly results: readonly PageSearchResult[];
23
+ /** The full registered page index (handy for rendering an "all pages" listing). */
24
+ readonly pages: readonly PageMeta[];
25
+ /**
26
+ * Navigates to a result / page / raw path. A dynamic (`:param`) page can't be navigated to
27
+ * as-is, so passing one (or its result) is a no-op unless you pass a concrete path string with
28
+ * the params already filled in. A stable reference, safe to destructure.
29
+ */
30
+ readonly goTo: (
31
+ target: string | PageMeta | PageSearchResult,
32
+ options?: NavigateOptions,
33
+ ) => void;
34
+ }
35
+
36
+ /** Whether a path can be navigated to directly (no unfilled dynamic segments). */
37
+ function isNavigable(path: string): boolean {
38
+ return !/[:*]/.test(path);
39
+ }
40
+
41
+ /**
42
+ * Searches the project's pages by `query` and returns ranked {@link PageSearchResult}s, recomputed
43
+ * only when the query or options change. Use the returned `goTo` to redirect to a match:
44
+ *
45
+ * ```tsx
46
+ * const { results, goTo } = usePageSearch(query);
47
+ * return results.map((r) => (
48
+ * <button key={r.page.path} onClick={() => { goTo(r); }}>{r.page.metadata.title ?? r.page.path}</button>
49
+ * ));
50
+ * ```
51
+ */
52
+ export function usePageSearch(query: string, options: PageSearchOptions = {}): PageSearch {
53
+ const { limit, includeDynamic, fields } = options;
54
+ const fieldsKey = fields?.join(',');
55
+ const results = useMemo(
56
+ () => searchPages(query, { limit, includeDynamic, fields }),
57
+ // `fields` is compared by content (fieldsKey) so a fresh array literal each render is fine.
58
+ [query, limit, includeDynamic, fieldsKey],
59
+ );
60
+
61
+ return useMemo<PageSearch>(
62
+ () => ({
63
+ results,
64
+ pages: getPages(),
65
+ goTo(target, navOptions) {
66
+ const path = pagePath(target);
67
+ if (typeof target !== 'string' && !isNavigable(path)) return;
68
+ navigate(path as Href, navOptions);
69
+ },
70
+ }),
71
+ [results],
72
+ );
73
+ }