toiljs 0.0.11 → 0.0.12
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/README.md +2 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +10 -4
- package/build/cli/create.js +58 -30
- package/build/cli/diagnostics.d.ts +55 -0
- package/build/cli/diagnostics.js +333 -0
- package/build/cli/doctor.d.ts +6 -0
- package/build/cli/doctor.js +249 -0
- package/build/cli/index.js +26 -0
- package/build/cli/proc.d.ts +5 -0
- package/build/cli/proc.js +20 -0
- package/build/cli/ui.d.ts +1 -0
- package/build/cli/ui.js +1 -0
- package/build/cli/update.d.ts +7 -0
- package/build/cli/update.js +117 -0
- package/build/cli/updates.d.ts +10 -0
- package/build/cli/updates.js +45 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/head/metadata.js +3 -1
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +2 -0
- package/build/client/navigation/navigation.js +1 -1
- package/build/client/routing/Router.js +2 -2
- package/build/client/search/search.d.ts +26 -0
- package/build/client/search/search.js +101 -0
- package/build/client/search/use-page-search.d.ts +8 -0
- package/build/client/search/use-page-search.js +21 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +26 -23
- package/build/compiler/index.d.ts +2 -0
- package/build/compiler/index.js +1 -0
- package/build/compiler/pages.d.ts +8 -0
- package/build/compiler/pages.js +37 -0
- package/build/compiler/plugin.js +3 -1
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +11 -5
- package/build/compiler/seo.js +10 -3
- package/build/io/.tsbuildinfo +1 -1
- package/examples/basic/client/components/Header.tsx +43 -41
- package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
- package/examples/basic/client/public/index.html +18 -16
- package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
- package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
- package/examples/basic/client/routes/about.tsx +21 -22
- package/examples/basic/client/routes/blog/[id].tsx +26 -18
- package/examples/basic/client/routes/features/actions.tsx +67 -67
- package/examples/basic/client/routes/features/error/index.tsx +27 -27
- package/examples/basic/client/routes/features/head.tsx +38 -38
- package/examples/basic/client/routes/features/index.tsx +83 -75
- package/examples/basic/client/routes/features/realtime.tsx +34 -32
- package/examples/basic/client/routes/features/script.tsx +31 -31
- package/examples/basic/client/routes/features/seo.tsx +39 -39
- package/examples/basic/client/routes/features/template/index.tsx +20 -20
- package/examples/basic/client/routes/features/template/template.tsx +16 -18
- package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
- package/examples/basic/client/routes/gallery/index.tsx +42 -42
- package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
- package/examples/basic/client/routes/get-started.tsx +157 -84
- package/examples/basic/client/routes/index.tsx +137 -96
- package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
- package/examples/basic/client/routes/search.tsx +61 -0
- package/examples/basic/client/routes/test.tsx +7 -8
- package/examples/basic/client/styles/main.css +624 -552
- package/package.json +2 -2
- package/presets/eslint.js +10 -3
- package/src/cli/configure.ts +363 -353
- package/src/cli/create.ts +563 -530
- package/src/cli/diagnostics.ts +421 -0
- package/src/cli/doctor.ts +318 -0
- package/src/cli/features.ts +166 -160
- package/src/cli/index.ts +242 -211
- package/src/cli/proc.ts +30 -0
- package/src/cli/ui.ts +111 -103
- package/src/cli/update.ts +150 -0
- package/src/cli/updates.ts +69 -0
- package/src/client/components/Image.tsx +91 -89
- package/src/client/dev/error-overlay.tsx +193 -197
- package/src/client/head/metadata.ts +94 -92
- package/src/client/index.ts +79 -64
- package/src/client/navigation/Link.tsx +94 -100
- package/src/client/navigation/navigation.ts +215 -218
- package/src/client/routing/Router.tsx +210 -193
- package/src/client/routing/hooks.ts +110 -114
- package/src/client/routing/lazy.ts +77 -81
- package/src/client/search/search.ts +189 -0
- package/src/client/search/use-page-search.ts +73 -0
- package/src/compiler/config.ts +173 -171
- package/src/compiler/fonts.ts +89 -87
- package/src/compiler/generate.ts +378 -373
- package/src/compiler/image-report.ts +88 -85
- package/src/compiler/index.ts +2 -0
- package/src/compiler/pages.ts +70 -0
- package/src/compiler/plugin.ts +51 -47
- package/src/compiler/prerender.ts +152 -130
- package/src/compiler/routes.ts +132 -131
- package/src/compiler/seo.ts +381 -356
- package/src/compiler/vite.ts +155 -145
- package/src/io/FastSet.ts +99 -96
- package/test/configure.test.ts +94 -90
- package/test/doctor.test.ts +140 -0
- package/test/dom/Image.test.tsx +73 -46
- package/test/dom/Script.test.tsx +48 -45
- package/test/dom/action.test.tsx +146 -129
- package/test/dom/error-overlay.test.tsx +44 -44
- package/test/dom/loader.test.tsx +2 -2
- package/test/dom/revalidate.test.tsx +1 -1
- package/test/dom/route-head.test.tsx +1 -2
- package/test/dom/slot.test.tsx +131 -109
- package/test/dom/view-transitions.test.tsx +53 -51
- package/test/features.test.ts +149 -142
- package/test/fonts.test.ts +28 -26
- package/test/head.test.ts +45 -35
- package/test/metadata.test.ts +42 -41
- package/test/pages.test.ts +105 -0
- package/test/prerender.test.ts +54 -46
- package/test/search.test.ts +114 -0
- package/test/seo.test.ts +164 -142
- package/test/update.test.ts +44 -0
|
@@ -1,81 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lazy-component resolution and caching. Each page/layout/not-found loader is wrapped in
|
|
3
|
-
* `React.lazy` exactly once and memoized, so re-renders reuse the same component (and React's
|
|
4
|
-
* Suspense cache) instead of re-creating it. Keyed by loader identity.
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import type {
|
|
9
|
-
LayoutComponentLoader,
|
|
10
|
-
LayoutLoader,
|
|
11
|
-
NotFoundLoader,
|
|
12
|
-
RouteErrorProps,
|
|
13
|
-
} from '../types.js';
|
|
14
|
-
|
|
15
|
-
type Loader<P> = () => Promise<{ default: ComponentType<P> }>;
|
|
16
|
-
|
|
17
|
-
/** Memoizes `lazy()` per loader identity in `cache`. */
|
|
18
|
-
function memoLazy<P>(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
notFoundLoader = loader;
|
|
79
|
-
}
|
|
80
|
-
return notFoundComponent;
|
|
81
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Lazy-component resolution and caching. Each page/layout/not-found loader is wrapped in
|
|
3
|
+
* `React.lazy` exactly once and memoized, so re-renders reuse the same component (and React's
|
|
4
|
+
* Suspense cache) instead of re-creating it. Keyed by loader identity.
|
|
5
|
+
*/
|
|
6
|
+
import { type ComponentType, lazy, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
LayoutComponentLoader,
|
|
10
|
+
LayoutLoader,
|
|
11
|
+
NotFoundLoader,
|
|
12
|
+
RouteErrorProps,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
|
|
15
|
+
type Loader<P> = () => Promise<{ default: ComponentType<P> }>;
|
|
16
|
+
|
|
17
|
+
/** Memoizes `lazy()` per loader identity in `cache`. */
|
|
18
|
+
function memoLazy<P>(cache: Map<Loader<P>, ComponentType<P>>, loader: Loader<P>): ComponentType<P> {
|
|
19
|
+
let component = cache.get(loader);
|
|
20
|
+
if (!component) {
|
|
21
|
+
component = lazy(loader);
|
|
22
|
+
cache.set(loader, component);
|
|
23
|
+
}
|
|
24
|
+
return component;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const loadingCache = new Map<Loader<object>, ComponentType<object>>();
|
|
28
|
+
/** Memoized lazy component for a route's `loading.tsx`. */
|
|
29
|
+
export function loadingComponent(loader: Loader<object>): ComponentType<object> {
|
|
30
|
+
return memoLazy(loadingCache, loader);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const errorCache = new Map<Loader<RouteErrorProps>, ComponentType<RouteErrorProps>>();
|
|
34
|
+
/** Memoized lazy component for a route's `error.tsx`. */
|
|
35
|
+
export function errorComponent(loader: Loader<RouteErrorProps>): ComponentType<RouteErrorProps> {
|
|
36
|
+
return memoLazy(errorCache, loader);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let layoutComponent: ComponentType<{ children?: ReactNode }> | null = null;
|
|
40
|
+
let layoutLoader: LayoutLoader = null;
|
|
41
|
+
|
|
42
|
+
/** Returns the memoized lazy root-layout component, rebuilding only if the loader identity changes. */
|
|
43
|
+
export function resolveLayout(
|
|
44
|
+
loader: NonNullable<LayoutLoader>,
|
|
45
|
+
): ComponentType<{ children?: ReactNode }> {
|
|
46
|
+
if (layoutLoader !== loader || !layoutComponent) {
|
|
47
|
+
layoutComponent = lazy(loader);
|
|
48
|
+
layoutLoader = loader;
|
|
49
|
+
}
|
|
50
|
+
return layoutComponent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nestedLayoutCache = new Map<LayoutComponentLoader, ComponentType<{ children?: ReactNode }>>();
|
|
54
|
+
|
|
55
|
+
/** Returns the memoized lazy component for a nested layout loader, keyed by loader identity. */
|
|
56
|
+
export function nestedLayout(
|
|
57
|
+
loader: LayoutComponentLoader,
|
|
58
|
+
): ComponentType<{ children?: ReactNode }> {
|
|
59
|
+
let component = nestedLayoutCache.get(loader);
|
|
60
|
+
if (!component) {
|
|
61
|
+
component = lazy(loader);
|
|
62
|
+
nestedLayoutCache.set(loader, component);
|
|
63
|
+
}
|
|
64
|
+
return component;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let notFoundComponent: ComponentType | null = null;
|
|
68
|
+
let notFoundLoader: NotFoundLoader = null;
|
|
69
|
+
|
|
70
|
+
/** Returns the memoized lazy not-found component, rebuilding only if the loader identity changes. */
|
|
71
|
+
export function resolveNotFound(loader: NonNullable<NotFoundLoader>): ComponentType {
|
|
72
|
+
if (notFoundLoader !== loader || !notFoundComponent) {
|
|
73
|
+
notFoundComponent = lazy(loader);
|
|
74
|
+
notFoundLoader = loader;
|
|
75
|
+
}
|
|
76
|
+
return notFoundComponent;
|
|
77
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|