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.
- package/.babelrc +13 -13
- package/.gitattributes +2 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +38 -38
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -90
- package/.github/ISSUE_TEMPLATE/config.yml +8 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -20
- package/.github/PULL_REQUEST_TEMPLATE.md +43 -43
- package/.github/changelog-config.json +45 -45
- package/.github/dependabot.yml +27 -27
- package/.github/workflows/ci.yml +191 -191
- package/.prettierrc.json +11 -11
- package/.vscode/settings.json +9 -9
- package/CHANGELOG.md +5 -5
- package/LICENSE +187 -187
- package/README.md +339 -315
- package/as-pect.asconfig.json +34 -34
- package/as-pect.config.js +65 -65
- package/assets/logo.svg +36 -36
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +0 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.d.ts +6 -0
- package/build/client/dev/devtools.js +442 -0
- package/build/client/dev/error-overlay.d.ts +9 -0
- package/build/client/dev/error-overlay.js +19 -4
- package/build/client/navigation/prefetch.d.ts +1 -0
- package/build/client/navigation/prefetch.js +35 -0
- package/build/client/routing/Router.js +1 -1
- package/build/client/routing/hooks.js +6 -2
- package/build/client/routing/loader.d.ts +23 -0
- package/build/client/routing/loader.js +53 -7
- package/build/client/routing/mount.js +4 -3
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +16 -0
- package/build/compiler/config.js +7 -0
- package/build/compiler/docs.js +16 -16
- package/build/compiler/index.d.ts +2 -2
- package/build/compiler/index.js +1 -1
- package/build/compiler/plugin.js +156 -0
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +1 -1
- package/build/compiler/seo.d.ts +1 -1
- package/build/compiler/seo.js +5 -4
- package/build/compiler/ssg.js +32 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/eslint.config.js +48 -48
- package/examples/basic/client/404.tsx +11 -11
- package/examples/basic/client/components/.gitkeep +1 -1
- package/examples/basic/client/global-error.tsx +13 -13
- package/examples/basic/client/layout.tsx +25 -25
- package/examples/basic/client/public/images/.gitkeep +1 -1
- package/examples/basic/client/public/images/logo.svg +36 -36
- package/examples/basic/client/public/robots.txt +2 -2
- package/examples/basic/client/routes/docs/[...slug].tsx +12 -12
- package/examples/basic/client/routes/features/error/error.tsx +16 -16
- package/examples/basic/client/routes/features/template/b.tsx +14 -14
- package/examples/basic/client/routes/files/[[...slug]].tsx +21 -21
- package/examples/basic/client/routes/gallery/layout.tsx +13 -13
- package/examples/basic/client/routes/io.tsx +24 -24
- package/examples/basic/client/routes/loader-demo/loading.tsx +13 -13
- package/examples/basic/client/routes/search.tsx +61 -61
- package/examples/basic/client/toil.tsx +5 -5
- package/package.json +155 -148
- package/presets/eslint.js +88 -88
- package/presets/no-uint8array-tostring.js +200 -200
- package/presets/prettier.json +18 -18
- package/presets/tsconfig.json +37 -37
- package/src/backend/index.ts +160 -160
- package/src/cli/proc.ts +50 -50
- package/src/cli/updates.ts +69 -69
- package/src/cli/validate.ts +31 -31
- package/src/client/channel/channel.ts +146 -146
- package/src/client/components/Form.tsx +65 -65
- package/src/client/components/Script.tsx +113 -113
- package/src/client/components/Slot.tsx +21 -21
- package/src/client/dev/devtools.tsx +973 -0
- package/src/client/dev/error-overlay.tsx +30 -4
- package/src/client/head/head.ts +167 -167
- package/src/client/head/metadata.ts +112 -112
- package/src/client/index.ts +89 -89
- package/src/client/navigation/NavLink.tsx +86 -86
- package/src/client/navigation/navigation.ts +235 -235
- package/src/client/navigation/prefetch.ts +169 -130
- package/src/client/navigation/scroll.ts +53 -53
- package/src/client/routing/Router.tsx +8 -2
- package/src/client/routing/action.ts +122 -122
- package/src/client/routing/error-boundary.tsx +43 -43
- package/src/client/routing/hooks.ts +21 -6
- package/src/client/routing/loader.ts +325 -235
- package/src/client/routing/match.ts +47 -47
- package/src/client/routing/mount.tsx +54 -52
- package/src/client/routing/params-context.ts +10 -10
- package/src/client/routing/slot-context.ts +7 -7
- package/src/client/search/search.ts +189 -189
- package/src/client/search/use-page-search.ts +73 -73
- package/src/client/types.ts +73 -73
- package/src/compiler/config.ts +219 -182
- package/src/compiler/docs.ts +228 -228
- package/src/compiler/generate.ts +394 -394
- package/src/compiler/index.ts +64 -57
- package/src/compiler/pages.ts +70 -70
- package/src/compiler/plugin.ts +170 -2
- package/src/compiler/prerender.ts +156 -156
- package/src/compiler/seo.ts +397 -390
- package/src/compiler/ssg.ts +162 -126
- package/src/io/BinaryReader.ts +340 -340
- package/src/io/BinaryWriter.ts +385 -385
- package/src/io/FastMap.ts +127 -127
- package/src/io/index.ts +11 -11
- package/src/io/lengths.ts +14 -14
- package/src/io/types.ts +18 -18
- package/src/logger/index.ts +22 -22
- package/src/server/index.ts +10 -10
- package/src/server/main.ts +13 -13
- package/src/server/tsconfig.json +4 -4
- package/src/shared/index.ts +10 -10
- package/std/client/index.d.ts +15 -15
- package/std/client/package.json +3 -3
- package/test/assembly/example.spec.ts +7 -7
- package/test/channel.test.ts +21 -21
- package/test/dom/Link.test.tsx +47 -47
- package/test/dom/NavLink.test.tsx +37 -37
- package/test/dom/error-overlay.test.tsx +44 -44
- package/test/dom/loader.test.tsx +121 -121
- package/test/dom/navigation.test.ts +59 -59
- package/test/dom/revalidate.test.tsx +38 -38
- package/test/dom/route-head.test.tsx +78 -78
- package/test/dom/router-loading.test.tsx +44 -44
- package/test/dom/scroll.test.ts +56 -56
- package/test/dom/use-metadata.test.tsx +58 -58
- package/test/io.test.ts +93 -93
- package/test/navlink.test.ts +28 -28
- package/test/placeholder.test.ts +9 -9
- package/test/routes.test.ts +76 -76
- package/test/seo.test.ts +175 -164
- package/test/slot-layouts.test.ts +69 -69
- package/test/ssg.test.ts +36 -36
- package/test/update.test.ts +44 -44
- package/test/validate.test.ts +42 -42
- package/toil-routes.d.ts +7 -0
- package/toilconfig.json +30 -30
- package/tsconfig.backend.json +13 -13
- package/tsconfig.base.json +35 -35
- package/tsconfig.cli.json +13 -13
- package/tsconfig.client.json +14 -14
- package/tsconfig.compiler.json +13 -13
- package/tsconfig.io.json +12 -12
- package/tsconfig.json +22 -22
- package/tsconfig.logger.json +12 -12
- package/tsconfig.server.json +10 -10
- package/tsconfig.shared.json +12 -12
- package/vitest.config.ts +26 -26
- package/.idea/codeStyles/Project.xml +0 -54
- package/.idea/codeStyles/codeStyleConfig.xml +0 -5
- package/.idea/inspectionProfiles/Project_Default.xml +0 -6
- package/.idea/modules.xml +0 -8
- package/.idea/prettier.xml +0 -7
- package/.idea/toiljs.iml +0 -8
- package/.idea/vcs.xml +0 -6
- package/.toil/entry.tsx +0 -9
- package/.toil/index.html +0 -12
- package/.toil/routes.ts +0 -9
- package/build/cli/configure.d.ts +0 -16
- package/build/cli/configure.js +0 -272
- package/build/cli/create.d.ts +0 -16
- package/build/cli/create.js +0 -420
- package/build/cli/diagnostics.d.ts +0 -55
- package/build/cli/diagnostics.js +0 -333
- package/build/cli/doctor.d.ts +0 -6
- package/build/cli/doctor.js +0 -249
- package/build/cli/features.d.ts +0 -25
- package/build/cli/features.js +0 -107
- package/build/cli/index.d.ts +0 -2
- package/build/cli/proc.d.ts +0 -6
- package/build/cli/proc.js +0 -31
- package/build/cli/ui.d.ts +0 -9
- package/build/cli/ui.js +0 -75
- package/build/cli/update.d.ts +0 -7
- package/build/cli/update.js +0 -117
- package/build/cli/updates.d.ts +0 -10
- package/build/cli/updates.js +0 -45
- package/build/cli/validate.d.ts +0 -4
- package/build/cli/validate.js +0 -19
- package/build/client/Link.d.ts +0 -8
- package/build/client/Link.js +0 -44
- package/build/client/NavLink.d.ts +0 -14
- package/build/client/NavLink.js +0 -37
- package/build/client/Router.d.ts +0 -7
- package/build/client/Router.js +0 -55
- package/build/client/channel.d.ts +0 -23
- package/build/client/channel.js +0 -94
- package/build/client/error-boundary.d.ts +0 -16
- package/build/client/error-boundary.js +0 -19
- package/build/client/head.d.ts +0 -26
- package/build/client/head.js +0 -87
- package/build/client/hooks.d.ts +0 -17
- package/build/client/hooks.js +0 -48
- package/build/client/lazy.d.ts +0 -16
- package/build/client/lazy.js +0 -53
- package/build/client/match.d.ts +0 -2
- package/build/client/match.js +0 -32
- package/build/client/mount.d.ts +0 -2
- package/build/client/mount.js +0 -13
- package/build/client/navigation.d.ts +0 -13
- package/build/client/navigation.js +0 -97
- package/build/client/params-context.d.ts +0 -2
- package/build/client/params-context.js +0 -2
- package/build/client/prefetch.d.ts +0 -11
- package/build/client/prefetch.js +0 -100
- package/build/client/runtime.d.ts +0 -31
- package/build/client/runtime.js +0 -112
- package/build/client/scroll.d.ts +0 -8
- package/build/client/scroll.js +0 -36
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|