toiljs 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2926 -191
- 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/head/metadata.d.ts +3 -1
- package/build/client/head/metadata.js +8 -0
- package/build/client/index.d.ts +4 -4
- package/build/client/index.js +2 -2
- package/build/client/navigation/navigation.d.ts +2 -0
- package/build/client/navigation/navigation.js +9 -1
- 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 +25 -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 +18 -0
- package/build/compiler/config.js +8 -0
- package/build/compiler/docs.js +16 -16
- package/build/compiler/generate.js +3 -0
- package/build/compiler/index.d.ts +2 -2
- package/build/compiler/index.js +3 -1
- package/build/compiler/plugin.js +156 -0
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +2 -1
- package/build/compiler/seo.d.ts +2 -2
- package/build/compiler/seo.js +8 -6
- package/build/compiler/ssg.d.ts +5 -0
- package/build/compiler/ssg.js +121 -0
- 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 -147
- 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 +19 -1
- package/src/client/index.ts +19 -9
- package/src/client/navigation/NavLink.tsx +86 -86
- package/src/client/navigation/navigation.ts +25 -5
- 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 -225
- 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 +47 -1
- package/src/compiler/docs.ts +228 -228
- package/src/compiler/generate.ts +394 -391
- package/src/compiler/index.ts +64 -54
- package/src/compiler/pages.ts +70 -70
- package/src/compiler/plugin.ts +170 -2
- package/src/compiler/prerender.ts +5 -1
- package/src/compiler/seo.ts +23 -7
- package/src/compiler/ssg.ts +162 -0
- 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 -0
- 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 -0
- 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
|
@@ -7,21 +7,33 @@
|
|
|
7
7
|
import { Component, type CSSProperties, type ErrorInfo, type ReactNode, useSyncExternalStore, } from 'react';
|
|
8
8
|
|
|
9
9
|
/** A captured dev error. */
|
|
10
|
-
interface DevError {
|
|
10
|
+
export interface DevError {
|
|
11
11
|
readonly error: Error;
|
|
12
12
|
readonly componentStack?: string;
|
|
13
13
|
/** Where it came from, a render boundary, a window `error`, or an unhandled rejection. */
|
|
14
14
|
readonly source: 'render' | 'window' | 'unhandledrejection';
|
|
15
|
+
/** Capture time (ms epoch). */
|
|
16
|
+
readonly time: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
let current: DevError | null = null;
|
|
18
20
|
const listeners = new Set<() => void>();
|
|
21
|
+
/**
|
|
22
|
+
* Bounded history of captured errors, for the dev toolbar's Errors tab. Reassigned to a new array
|
|
23
|
+
* on each change (never mutated in place) so `getErrorLog` is a stable useSyncExternalStore snapshot:
|
|
24
|
+
* the reference changes only when the log changes, so React re-renders on new errors but not in a loop.
|
|
25
|
+
*/
|
|
26
|
+
let errorLog: readonly DevError[] = [];
|
|
27
|
+
const MAX_LOG = 50;
|
|
19
28
|
|
|
20
29
|
function emit(): void {
|
|
21
30
|
for (const listener of listeners) listener();
|
|
22
31
|
}
|
|
23
32
|
function setDevError(next: DevError | null): void {
|
|
24
33
|
current = next;
|
|
34
|
+
if (next) {
|
|
35
|
+
errorLog = [...errorLog, next].slice(-MAX_LOG);
|
|
36
|
+
}
|
|
25
37
|
emit();
|
|
26
38
|
}
|
|
27
39
|
function subscribe(listener: () => void): () => void {
|
|
@@ -31,6 +43,13 @@ function subscribe(listener: () => void): () => void {
|
|
|
31
43
|
};
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
/** The captured-error history (most recent last). Subscribe via {@link subscribeErrors}. */
|
|
47
|
+
export function getErrorLog(): readonly DevError[] {
|
|
48
|
+
return errorLog;
|
|
49
|
+
}
|
|
50
|
+
/** Subscribes to error captures (fires whenever a new error is recorded or dismissed). */
|
|
51
|
+
export const subscribeErrors = subscribe;
|
|
52
|
+
|
|
34
53
|
/** True when running under Vite's dev server (replaced at build time; falsy in production). */
|
|
35
54
|
export function isDevMode(): boolean {
|
|
36
55
|
try {
|
|
@@ -46,12 +65,14 @@ export function initDevErrorOverlay(): void {
|
|
|
46
65
|
if (windowBound || typeof window === 'undefined') return;
|
|
47
66
|
windowBound = true;
|
|
48
67
|
window.addEventListener('error', (event) => {
|
|
49
|
-
if (event.error instanceof Error)
|
|
68
|
+
if (event.error instanceof Error) {
|
|
69
|
+
setDevError({ error: event.error, source: 'window', time: Date.now() });
|
|
70
|
+
}
|
|
50
71
|
});
|
|
51
72
|
window.addEventListener('unhandledrejection', (event) => {
|
|
52
73
|
const reason: unknown = event.reason;
|
|
53
74
|
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
54
|
-
setDevError({ error, source: 'unhandledrejection' });
|
|
75
|
+
setDevError({ error, source: 'unhandledrejection', time: Date.now() });
|
|
55
76
|
});
|
|
56
77
|
}
|
|
57
78
|
|
|
@@ -76,7 +97,12 @@ export class DevErrorBoundary extends Component<BoundaryProps, BoundaryState> {
|
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
79
|
-
setDevError({
|
|
100
|
+
setDevError({
|
|
101
|
+
error,
|
|
102
|
+
componentStack: info.componentStack ?? undefined,
|
|
103
|
+
source: 'render',
|
|
104
|
+
time: Date.now(),
|
|
105
|
+
});
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
public override componentDidMount(): void {
|
package/src/client/head/head.ts
CHANGED
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side document `<head>` management. `useHead` / `useTitle` / `<Head>` let any component
|
|
3
|
-
* (layout or page) set the title and `<meta>` / `<link>` tags; entries compose across the tree
|
|
4
|
-
* (later/deeper entries win per key) and are reverted when the component unmounts. Pure
|
|
5
|
-
* `mergeHead` resolves the active entries; the manager reconciles `document.head`.
|
|
6
|
-
*/
|
|
7
|
-
import { useEffect, useLayoutEffect } from 'react';
|
|
8
|
-
|
|
9
|
-
/** A `<meta>` tag. Use `name` or `property` (OpenGraph) as the dedup key; extra attrs pass through. */
|
|
10
|
-
export interface MetaTag {
|
|
11
|
-
readonly name?: string;
|
|
12
|
-
readonly property?: string;
|
|
13
|
-
readonly content: string;
|
|
14
|
-
readonly [attr: string]: string | undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** A `<link>` tag (deduped by `rel` + `href`); extra attrs pass through. */
|
|
18
|
-
export interface LinkTag {
|
|
19
|
-
readonly rel: string;
|
|
20
|
-
readonly href: string;
|
|
21
|
-
readonly [attr: string]: string | undefined;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** A head contribution from one component. */
|
|
25
|
-
export interface HeadSpec {
|
|
26
|
-
/** Document title. */
|
|
27
|
-
readonly title?: string;
|
|
28
|
-
/** Template applied to a child's title, `%s` = the title (e.g. `'%s · toiljs'`). */
|
|
29
|
-
readonly titleTemplate?: string;
|
|
30
|
-
readonly meta?: readonly MetaTag[];
|
|
31
|
-
readonly link?: readonly LinkTag[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The resolved head after merging all active specs. */
|
|
35
|
-
export interface ResolvedHead {
|
|
36
|
-
readonly title?: string;
|
|
37
|
-
readonly meta: MetaTag[];
|
|
38
|
-
readonly link: LinkTag[];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function metaKey(m: MetaTag): string {
|
|
42
|
-
if (m.name !== undefined) return `name:${m.name}`;
|
|
43
|
-
if (m.property !== undefined) return `property:${m.property}`;
|
|
44
|
-
return `meta:${JSON.stringify(m)}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Merges head specs in order: the last `title`/`titleTemplate` wins, `meta` dedupes by name/property
|
|
49
|
-
* and `link` by rel+href (last wins). A `titleTemplate` formats the resolved title via `%s`.
|
|
50
|
-
*/
|
|
51
|
-
export function mergeHead(specs: readonly HeadSpec[]): ResolvedHead {
|
|
52
|
-
let title: string | undefined;
|
|
53
|
-
let titleTemplate: string | undefined;
|
|
54
|
-
const meta = new Map<string, MetaTag>();
|
|
55
|
-
const link = new Map<string, LinkTag>();
|
|
56
|
-
for (const spec of specs) {
|
|
57
|
-
if (spec.title !== undefined) title = spec.title;
|
|
58
|
-
if (spec.titleTemplate !== undefined) titleTemplate = spec.titleTemplate;
|
|
59
|
-
for (const m of spec.meta ?? []) meta.set(metaKey(m), m);
|
|
60
|
-
for (const l of spec.link ?? []) link.set(`${l.rel}:${l.href}`, l);
|
|
61
|
-
}
|
|
62
|
-
const resolvedTitle =
|
|
63
|
-
title !== undefined && titleTemplate !== undefined
|
|
64
|
-
? titleTemplate.replace('%s', title)
|
|
65
|
-
: title;
|
|
66
|
-
return { title: resolvedTitle, meta: [...meta.values()], link: [...link.values()] };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const entries = new Map<number, HeadSpec>();
|
|
70
|
-
let order: number[] = [];
|
|
71
|
-
let seq = 0;
|
|
72
|
-
let baseTitle: string | null = null;
|
|
73
|
-
// The current route's resolved `metadata` export. Merged LAST (highest priority), so a route's
|
|
74
|
-
// metadata wins over a layout's `useHead`/`<Head>` defaults (e.g. a site-wide title/titleTemplate) for
|
|
75
|
-
// the keys it sets, while the layout still fills everything the route leaves unset. Set by the router
|
|
76
|
-
// via `setRouteHead` on each navigation.
|
|
77
|
-
let routeHead: HeadSpec | null = null;
|
|
78
|
-
|
|
79
|
-
function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
|
|
80
|
-
el.setAttribute('data-toil-head', '');
|
|
81
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
82
|
-
if (value !== undefined) el.setAttribute(key, value);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Reconciles `document.head` with the merged active specs. */
|
|
87
|
-
function apply(): void {
|
|
88
|
-
if (typeof document === 'undefined') return;
|
|
89
|
-
if (baseTitle === null) baseTitle = document.title;
|
|
90
|
-
|
|
91
|
-
const specs = [...order.map((id) => entries.get(id)), routeHead];
|
|
92
|
-
const resolved = mergeHead(specs.filter((s): s is HeadSpec => !!s));
|
|
93
|
-
|
|
94
|
-
document.title = resolved.title ?? baseTitle;
|
|
95
|
-
|
|
96
|
-
for (const stale of document.head.querySelectorAll('[data-toil-head]')) stale.remove();
|
|
97
|
-
for (const m of resolved.meta) {
|
|
98
|
-
const el = document.createElement('meta');
|
|
99
|
-
setAttrs(el, m);
|
|
100
|
-
document.head.appendChild(el);
|
|
101
|
-
}
|
|
102
|
-
for (const l of resolved.link) {
|
|
103
|
-
const el = document.createElement('link');
|
|
104
|
-
setAttrs(el, l);
|
|
105
|
-
document.head.appendChild(el);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function addHead(spec: HeadSpec): number {
|
|
110
|
-
const id = ++seq;
|
|
111
|
-
entries.set(id, spec);
|
|
112
|
-
order.push(id);
|
|
113
|
-
apply();
|
|
114
|
-
return id;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function removeHead(id: number): void {
|
|
118
|
-
entries.delete(id);
|
|
119
|
-
order = order.filter((x) => x !== id);
|
|
120
|
-
apply();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Applies a head contribution for the lifetime of the calling component: title, `<meta>`, `<link>`.
|
|
125
|
-
* Reverts on unmount. Compose freely, a root layout can set defaults a page overrides.
|
|
126
|
-
*/
|
|
127
|
-
export function useHead(spec: HeadSpec): void {
|
|
128
|
-
const json = JSON.stringify(spec);
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
const id = addHead(JSON.parse(json) as HeadSpec);
|
|
131
|
-
return () => {
|
|
132
|
-
removeHead(id);
|
|
133
|
-
};
|
|
134
|
-
}, [json]);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Sets `document.title` for the calling component's lifetime. */
|
|
138
|
-
export function useTitle(title: string): void {
|
|
139
|
-
useHead({ title });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Declarative form of {@link useHead}: `<Head title="…" meta={[…]} />`. Renders nothing. */
|
|
143
|
-
export function Head(props: HeadSpec): null {
|
|
144
|
-
useHead(props);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Sets the current route's baseline head (lowest priority). Pass `null` to clear it. */
|
|
149
|
-
export function setRouteHead(spec: HeadSpec | null): void {
|
|
150
|
-
routeHead = spec;
|
|
151
|
-
apply();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Applies a route's resolved `metadata` as the baseline head for the calling route's lifetime, and
|
|
156
|
-
* clears it on unmount. Used internally by the router; a layout-effect so the title updates before
|
|
157
|
-
* paint (no flicker).
|
|
158
|
-
*/
|
|
159
|
-
export function useRouteHead(spec: HeadSpec | undefined): void {
|
|
160
|
-
const json = spec ? JSON.stringify(spec) : '';
|
|
161
|
-
useLayoutEffect(() => {
|
|
162
|
-
setRouteHead(json ? (JSON.parse(json) as HeadSpec) : null);
|
|
163
|
-
return () => {
|
|
164
|
-
setRouteHead(null);
|
|
165
|
-
};
|
|
166
|
-
}, [json]);
|
|
167
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Client-side document `<head>` management. `useHead` / `useTitle` / `<Head>` let any component
|
|
3
|
+
* (layout or page) set the title and `<meta>` / `<link>` tags; entries compose across the tree
|
|
4
|
+
* (later/deeper entries win per key) and are reverted when the component unmounts. Pure
|
|
5
|
+
* `mergeHead` resolves the active entries; the manager reconciles `document.head`.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useLayoutEffect } from 'react';
|
|
8
|
+
|
|
9
|
+
/** A `<meta>` tag. Use `name` or `property` (OpenGraph) as the dedup key; extra attrs pass through. */
|
|
10
|
+
export interface MetaTag {
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly property?: string;
|
|
13
|
+
readonly content: string;
|
|
14
|
+
readonly [attr: string]: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A `<link>` tag (deduped by `rel` + `href`); extra attrs pass through. */
|
|
18
|
+
export interface LinkTag {
|
|
19
|
+
readonly rel: string;
|
|
20
|
+
readonly href: string;
|
|
21
|
+
readonly [attr: string]: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A head contribution from one component. */
|
|
25
|
+
export interface HeadSpec {
|
|
26
|
+
/** Document title. */
|
|
27
|
+
readonly title?: string;
|
|
28
|
+
/** Template applied to a child's title, `%s` = the title (e.g. `'%s · toiljs'`). */
|
|
29
|
+
readonly titleTemplate?: string;
|
|
30
|
+
readonly meta?: readonly MetaTag[];
|
|
31
|
+
readonly link?: readonly LinkTag[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The resolved head after merging all active specs. */
|
|
35
|
+
export interface ResolvedHead {
|
|
36
|
+
readonly title?: string;
|
|
37
|
+
readonly meta: MetaTag[];
|
|
38
|
+
readonly link: LinkTag[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function metaKey(m: MetaTag): string {
|
|
42
|
+
if (m.name !== undefined) return `name:${m.name}`;
|
|
43
|
+
if (m.property !== undefined) return `property:${m.property}`;
|
|
44
|
+
return `meta:${JSON.stringify(m)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Merges head specs in order: the last `title`/`titleTemplate` wins, `meta` dedupes by name/property
|
|
49
|
+
* and `link` by rel+href (last wins). A `titleTemplate` formats the resolved title via `%s`.
|
|
50
|
+
*/
|
|
51
|
+
export function mergeHead(specs: readonly HeadSpec[]): ResolvedHead {
|
|
52
|
+
let title: string | undefined;
|
|
53
|
+
let titleTemplate: string | undefined;
|
|
54
|
+
const meta = new Map<string, MetaTag>();
|
|
55
|
+
const link = new Map<string, LinkTag>();
|
|
56
|
+
for (const spec of specs) {
|
|
57
|
+
if (spec.title !== undefined) title = spec.title;
|
|
58
|
+
if (spec.titleTemplate !== undefined) titleTemplate = spec.titleTemplate;
|
|
59
|
+
for (const m of spec.meta ?? []) meta.set(metaKey(m), m);
|
|
60
|
+
for (const l of spec.link ?? []) link.set(`${l.rel}:${l.href}`, l);
|
|
61
|
+
}
|
|
62
|
+
const resolvedTitle =
|
|
63
|
+
title !== undefined && titleTemplate !== undefined
|
|
64
|
+
? titleTemplate.replace('%s', title)
|
|
65
|
+
: title;
|
|
66
|
+
return { title: resolvedTitle, meta: [...meta.values()], link: [...link.values()] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entries = new Map<number, HeadSpec>();
|
|
70
|
+
let order: number[] = [];
|
|
71
|
+
let seq = 0;
|
|
72
|
+
let baseTitle: string | null = null;
|
|
73
|
+
// The current route's resolved `metadata` export. Merged LAST (highest priority), so a route's
|
|
74
|
+
// metadata wins over a layout's `useHead`/`<Head>` defaults (e.g. a site-wide title/titleTemplate) for
|
|
75
|
+
// the keys it sets, while the layout still fills everything the route leaves unset. Set by the router
|
|
76
|
+
// via `setRouteHead` on each navigation.
|
|
77
|
+
let routeHead: HeadSpec | null = null;
|
|
78
|
+
|
|
79
|
+
function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
|
|
80
|
+
el.setAttribute('data-toil-head', '');
|
|
81
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
82
|
+
if (value !== undefined) el.setAttribute(key, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reconciles `document.head` with the merged active specs. */
|
|
87
|
+
function apply(): void {
|
|
88
|
+
if (typeof document === 'undefined') return;
|
|
89
|
+
if (baseTitle === null) baseTitle = document.title;
|
|
90
|
+
|
|
91
|
+
const specs = [...order.map((id) => entries.get(id)), routeHead];
|
|
92
|
+
const resolved = mergeHead(specs.filter((s): s is HeadSpec => !!s));
|
|
93
|
+
|
|
94
|
+
document.title = resolved.title ?? baseTitle;
|
|
95
|
+
|
|
96
|
+
for (const stale of document.head.querySelectorAll('[data-toil-head]')) stale.remove();
|
|
97
|
+
for (const m of resolved.meta) {
|
|
98
|
+
const el = document.createElement('meta');
|
|
99
|
+
setAttrs(el, m);
|
|
100
|
+
document.head.appendChild(el);
|
|
101
|
+
}
|
|
102
|
+
for (const l of resolved.link) {
|
|
103
|
+
const el = document.createElement('link');
|
|
104
|
+
setAttrs(el, l);
|
|
105
|
+
document.head.appendChild(el);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function addHead(spec: HeadSpec): number {
|
|
110
|
+
const id = ++seq;
|
|
111
|
+
entries.set(id, spec);
|
|
112
|
+
order.push(id);
|
|
113
|
+
apply();
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeHead(id: number): void {
|
|
118
|
+
entries.delete(id);
|
|
119
|
+
order = order.filter((x) => x !== id);
|
|
120
|
+
apply();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Applies a head contribution for the lifetime of the calling component: title, `<meta>`, `<link>`.
|
|
125
|
+
* Reverts on unmount. Compose freely, a root layout can set defaults a page overrides.
|
|
126
|
+
*/
|
|
127
|
+
export function useHead(spec: HeadSpec): void {
|
|
128
|
+
const json = JSON.stringify(spec);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const id = addHead(JSON.parse(json) as HeadSpec);
|
|
131
|
+
return () => {
|
|
132
|
+
removeHead(id);
|
|
133
|
+
};
|
|
134
|
+
}, [json]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Sets `document.title` for the calling component's lifetime. */
|
|
138
|
+
export function useTitle(title: string): void {
|
|
139
|
+
useHead({ title });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Declarative form of {@link useHead}: `<Head title="…" meta={[…]} />`. Renders nothing. */
|
|
143
|
+
export function Head(props: HeadSpec): null {
|
|
144
|
+
useHead(props);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Sets the current route's baseline head (lowest priority). Pass `null` to clear it. */
|
|
149
|
+
export function setRouteHead(spec: HeadSpec | null): void {
|
|
150
|
+
routeHead = spec;
|
|
151
|
+
apply();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Applies a route's resolved `metadata` as the baseline head for the calling route's lifetime, and
|
|
156
|
+
* clears it on unmount. Used internally by the router; a layout-effect so the title updates before
|
|
157
|
+
* paint (no flicker).
|
|
158
|
+
*/
|
|
159
|
+
export function useRouteHead(spec: HeadSpec | undefined): void {
|
|
160
|
+
const json = spec ? JSON.stringify(spec) : '';
|
|
161
|
+
useLayoutEffect(() => {
|
|
162
|
+
setRouteHead(json ? (JSON.parse(json) as HeadSpec) : null);
|
|
163
|
+
return () => {
|
|
164
|
+
setRouteHead(null);
|
|
165
|
+
};
|
|
166
|
+
}, [json]);
|
|
167
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* data); the compiler-driven loader resolves it to a {@link HeadSpec} that the router applies as the
|
|
5
5
|
* route's baseline head (component-level `useHead`/`<Head>` still compose on top and can override).
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { useHead, type HeadSpec, type LinkTag, type MetaTag } from './head.js';
|
|
8
8
|
import type { RouteParams } from '../routing/match.js';
|
|
9
9
|
|
|
10
10
|
/** OpenGraph fields, expanded to `og:*` meta tags. */
|
|
@@ -92,3 +92,21 @@ export function resolveMetadata(metadata: Metadata): HeadSpec {
|
|
|
92
92
|
|
|
93
93
|
return { title: metadata.title, titleTemplate: metadata.titleTemplate, meta, link };
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Applies a route-style {@link Metadata} object from inside any component for that component's
|
|
98
|
+
* lifetime, reverting on unmount. The runtime counterpart of a route's `metadata` export, for
|
|
99
|
+
* content that isn't itself a route file (a rendered article, a widget, ...). Composes through the
|
|
100
|
+
* head manager like {@link useHead}; a route's own `metadata` (applied last) still wins for keys it
|
|
101
|
+
* sets, so this fills in for routes that declare none. Resolved fresh each render, the head manager
|
|
102
|
+
* dedupes by value, so passing a computed object is fine.
|
|
103
|
+
*/
|
|
104
|
+
export function useMetadata(metadata: Metadata): void {
|
|
105
|
+
useHead(resolveMetadata(metadata));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Declarative form of {@link useMetadata}: `<Metadata title="…" openGraph={…} />`. Renders nothing. */
|
|
109
|
+
export function Metadata(props: Metadata): null {
|
|
110
|
+
useMetadata(props);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -14,7 +14,15 @@ export { Link } from './navigation/Link.js';
|
|
|
14
14
|
export type { LinkProps } from './navigation/Link.js';
|
|
15
15
|
export { NavLink, matchActive } from './navigation/NavLink.js';
|
|
16
16
|
export type { NavLinkProps, NavLinkState } from './navigation/NavLink.js';
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
navigate,
|
|
19
|
+
back,
|
|
20
|
+
forward,
|
|
21
|
+
refresh,
|
|
22
|
+
setViewTransitions,
|
|
23
|
+
setTransitions,
|
|
24
|
+
href,
|
|
25
|
+
} from './navigation/navigation.js';
|
|
18
26
|
export type { NavigateOptions } from './navigation/navigation.js';
|
|
19
27
|
export {
|
|
20
28
|
useParams,
|
|
@@ -27,7 +35,14 @@ export {
|
|
|
27
35
|
} from './routing/hooks.js';
|
|
28
36
|
export type { RouterInstance } from './routing/hooks.js';
|
|
29
37
|
export { useLoaderData, revalidate, invalidateLoaderData } from './routing/loader.js';
|
|
30
|
-
export type {
|
|
38
|
+
export type {
|
|
39
|
+
LoaderArgs,
|
|
40
|
+
LoaderFunction,
|
|
41
|
+
LoaderData,
|
|
42
|
+
Revalidate,
|
|
43
|
+
StaticParams,
|
|
44
|
+
GenerateStaticParams,
|
|
45
|
+
} from './routing/loader.js';
|
|
31
46
|
export { useAction } from './routing/action.js';
|
|
32
47
|
export type {
|
|
33
48
|
UseActionOptions,
|
|
@@ -52,13 +67,8 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
|
|
|
52
67
|
export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
|
|
53
68
|
export { useHead, useTitle, Head, mergeHead } from './head/head.js';
|
|
54
69
|
export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
|
|
55
|
-
export { resolveMetadata } from './head/metadata.js';
|
|
56
|
-
export type {
|
|
57
|
-
Metadata,
|
|
58
|
-
GenerateMetadata,
|
|
59
|
-
GenerateMetadataArgs,
|
|
60
|
-
OpenGraph,
|
|
61
|
-
} from './head/metadata.js';
|
|
70
|
+
export { resolveMetadata, useMetadata, Metadata } from './head/metadata.js';
|
|
71
|
+
export type { GenerateMetadata, GenerateMetadataArgs, OpenGraph } from './head/metadata.js';
|
|
62
72
|
export { searchPages, registerPages, getPages, pagePath } from './search/search.js';
|
|
63
73
|
export type {
|
|
64
74
|
PageMeta,
|
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
-
|
|
3
|
-
import { useLocation } from '../routing/hooks.js';
|
|
4
|
-
import { Link, type LinkProps } from './Link.js';
|
|
5
|
-
|
|
6
|
-
/** State passed to `NavLink`'s function-form `className` / `style` / `children`. */
|
|
7
|
-
export interface NavLinkState {
|
|
8
|
-
readonly isActive: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Props for {@link NavLink}: all {@link LinkProps}, but `className` / `style` / `children` may also
|
|
13
|
-
* be functions of the active state.
|
|
14
|
-
*/
|
|
15
|
-
export interface NavLinkProps extends Omit<LinkProps, 'className' | 'style' | 'children'> {
|
|
16
|
-
className?: string | ((state: NavLinkState) => string | undefined);
|
|
17
|
-
style?: CSSProperties | ((state: NavLinkState) => CSSProperties | undefined);
|
|
18
|
-
children?: ReactNode | ((state: NavLinkState) => ReactNode);
|
|
19
|
-
/** Match `href` exactly; without it, sub-paths are also active. Default `false`. */
|
|
20
|
-
end?: boolean;
|
|
21
|
-
/** Class added when active (used with a string `className`). Default `"active"`. */
|
|
22
|
-
activeClassName?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function normalizePath(p: string): string {
|
|
26
|
-
return p.length > 1 ? p.replace(/\/+$/, '') : p;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Whether a link to `linkPath` is active for `currentPath`. Exact when `end`; otherwise a parent
|
|
31
|
-
* path is active for its sub-paths (and `/` is active everywhere, matching React Router).
|
|
32
|
-
*/
|
|
33
|
-
export function matchActive(linkPath: string, currentPath: string, end: boolean): boolean {
|
|
34
|
-
const link = normalizePath(linkPath);
|
|
35
|
-
const current = normalizePath(currentPath);
|
|
36
|
-
if (current === link) return true;
|
|
37
|
-
if (end) return false;
|
|
38
|
-
if (link === '/') return true;
|
|
39
|
-
return current.startsWith(link + '/');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A {@link Link} that knows whether it points at the current location. Applies an active class
|
|
44
|
-
* (default `"active"`) and `aria-current="page"` when active; `className` / `style` / `children`
|
|
45
|
-
* may be functions of `{ isActive }`. Inherits Link's full anchor API and prefetching.
|
|
46
|
-
*/
|
|
47
|
-
export function NavLink(props: NavLinkProps): ReactNode {
|
|
48
|
-
const {
|
|
49
|
-
href,
|
|
50
|
-
className,
|
|
51
|
-
style,
|
|
52
|
-
children,
|
|
53
|
-
end = false,
|
|
54
|
-
activeClassName = 'active',
|
|
55
|
-
...rest
|
|
56
|
-
} = props;
|
|
57
|
-
const pathname = useLocation();
|
|
58
|
-
|
|
59
|
-
let linkPath = href;
|
|
60
|
-
try {
|
|
61
|
-
linkPath = new URL(href, window.location.href).pathname;
|
|
62
|
-
} catch {
|
|
63
|
-
linkPath = href;
|
|
64
|
-
}
|
|
65
|
-
const isActive = matchActive(linkPath, pathname, end);
|
|
66
|
-
const state: NavLinkState = { isActive };
|
|
67
|
-
|
|
68
|
-
const resolvedClassName =
|
|
69
|
-
typeof className === 'function'
|
|
70
|
-
? className(state)
|
|
71
|
-
: [className, isActive ? activeClassName : undefined].filter(Boolean).join(' ') ||
|
|
72
|
-
undefined;
|
|
73
|
-
const resolvedStyle = typeof style === 'function' ? style(state) : style;
|
|
74
|
-
const resolvedChildren = typeof children === 'function' ? children(state) : children;
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<Link
|
|
78
|
-
{...rest}
|
|
79
|
-
href={href}
|
|
80
|
-
className={resolvedClassName}
|
|
81
|
-
style={resolvedStyle}
|
|
82
|
-
aria-current={isActive ? 'page' : undefined}>
|
|
83
|
-
{resolvedChildren}
|
|
84
|
-
</Link>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useLocation } from '../routing/hooks.js';
|
|
4
|
+
import { Link, type LinkProps } from './Link.js';
|
|
5
|
+
|
|
6
|
+
/** State passed to `NavLink`'s function-form `className` / `style` / `children`. */
|
|
7
|
+
export interface NavLinkState {
|
|
8
|
+
readonly isActive: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for {@link NavLink}: all {@link LinkProps}, but `className` / `style` / `children` may also
|
|
13
|
+
* be functions of the active state.
|
|
14
|
+
*/
|
|
15
|
+
export interface NavLinkProps extends Omit<LinkProps, 'className' | 'style' | 'children'> {
|
|
16
|
+
className?: string | ((state: NavLinkState) => string | undefined);
|
|
17
|
+
style?: CSSProperties | ((state: NavLinkState) => CSSProperties | undefined);
|
|
18
|
+
children?: ReactNode | ((state: NavLinkState) => ReactNode);
|
|
19
|
+
/** Match `href` exactly; without it, sub-paths are also active. Default `false`. */
|
|
20
|
+
end?: boolean;
|
|
21
|
+
/** Class added when active (used with a string `className`). Default `"active"`. */
|
|
22
|
+
activeClassName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizePath(p: string): string {
|
|
26
|
+
return p.length > 1 ? p.replace(/\/+$/, '') : p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a link to `linkPath` is active for `currentPath`. Exact when `end`; otherwise a parent
|
|
31
|
+
* path is active for its sub-paths (and `/` is active everywhere, matching React Router).
|
|
32
|
+
*/
|
|
33
|
+
export function matchActive(linkPath: string, currentPath: string, end: boolean): boolean {
|
|
34
|
+
const link = normalizePath(linkPath);
|
|
35
|
+
const current = normalizePath(currentPath);
|
|
36
|
+
if (current === link) return true;
|
|
37
|
+
if (end) return false;
|
|
38
|
+
if (link === '/') return true;
|
|
39
|
+
return current.startsWith(link + '/');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A {@link Link} that knows whether it points at the current location. Applies an active class
|
|
44
|
+
* (default `"active"`) and `aria-current="page"` when active; `className` / `style` / `children`
|
|
45
|
+
* may be functions of `{ isActive }`. Inherits Link's full anchor API and prefetching.
|
|
46
|
+
*/
|
|
47
|
+
export function NavLink(props: NavLinkProps): ReactNode {
|
|
48
|
+
const {
|
|
49
|
+
href,
|
|
50
|
+
className,
|
|
51
|
+
style,
|
|
52
|
+
children,
|
|
53
|
+
end = false,
|
|
54
|
+
activeClassName = 'active',
|
|
55
|
+
...rest
|
|
56
|
+
} = props;
|
|
57
|
+
const pathname = useLocation();
|
|
58
|
+
|
|
59
|
+
let linkPath = href;
|
|
60
|
+
try {
|
|
61
|
+
linkPath = new URL(href, window.location.href).pathname;
|
|
62
|
+
} catch {
|
|
63
|
+
linkPath = href;
|
|
64
|
+
}
|
|
65
|
+
const isActive = matchActive(linkPath, pathname, end);
|
|
66
|
+
const state: NavLinkState = { isActive };
|
|
67
|
+
|
|
68
|
+
const resolvedClassName =
|
|
69
|
+
typeof className === 'function'
|
|
70
|
+
? className(state)
|
|
71
|
+
: [className, isActive ? activeClassName : undefined].filter(Boolean).join(' ') ||
|
|
72
|
+
undefined;
|
|
73
|
+
const resolvedStyle = typeof style === 'function' ? style(state) : style;
|
|
74
|
+
const resolvedChildren = typeof children === 'function' ? children(state) : children;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Link
|
|
78
|
+
{...rest}
|
|
79
|
+
href={href}
|
|
80
|
+
className={resolvedClassName}
|
|
81
|
+
style={resolvedStyle}
|
|
82
|
+
aria-current={isActive ? 'page' : undefined}>
|
|
83
|
+
{resolvedChildren}
|
|
84
|
+
</Link>
|
|
85
|
+
);
|
|
86
|
+
}
|