toiljs 0.0.8 → 0.0.9
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/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +5 -5
- package/build/cli/create.js +4 -4
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +55 -33
- package/build/client/routing/hooks.js +2 -6
- package/build/client/routing/loader.d.ts +2 -0
- package/build/client/routing/loader.js +9 -1
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +8 -0
- package/build/compiler/config.js +4 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +65 -32
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +5 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- package/examples/basic/client/global-error.tsx +1 -1
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
- package/package.json +1 -1
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +7 -7
- package/src/cli/create.ts +7 -7
- package/src/cli/features.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +2 -2
- package/src/client/components/Script.tsx +3 -3
- package/src/client/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +5 -1
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +121 -67
- package/src/client/routing/action.ts +4 -4
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +6 -25
- package/src/client/routing/loader.ts +20 -8
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +31 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +66 -31
- package/src/compiler/image-report.ts +1 -1
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +9 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/test/dom/error-overlay.test.tsx +44 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
package/src/cli/configure.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `toiljs configure
|
|
2
|
+
* `toiljs configure`, toggle a project's client styling features (CSS preprocessor + Tailwind) on
|
|
3
3
|
* an existing app. Detects the current setup, prompts for the desired one, then rewrites the
|
|
4
4
|
* stylesheet(s) + the `client/toil.tsx` imports, edits `package.json`, and syncs node_modules with
|
|
5
5
|
* the project's package manager (so removed features are fully cleaned, not just disabled).
|
|
@@ -173,7 +173,7 @@ async function applyStyleFiles(
|
|
|
173
173
|
const newPath = path.join(clientDir, styleEntry(to.preprocessor));
|
|
174
174
|
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
|
175
175
|
// Rename whatever main stylesheet actually exists (preserving its content), not an assumed
|
|
176
|
-
// name
|
|
176
|
+
// name, so we never blow away the user's styles when the on-disk extension differs.
|
|
177
177
|
const existing = await findMainStylesheet(clientDir);
|
|
178
178
|
if (existing && path.resolve(existing) !== path.resolve(newPath)) {
|
|
179
179
|
await fs.rename(existing, newPath);
|
|
@@ -265,7 +265,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
|
|
|
265
265
|
try {
|
|
266
266
|
pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as PackageJson;
|
|
267
267
|
} catch {
|
|
268
|
-
cancel(`No package.json in ${pc.cyan(root)}
|
|
268
|
+
cancel(`No package.json in ${pc.cyan(root)}, run this inside a toiljs project.`);
|
|
269
269
|
process.exit(1);
|
|
270
270
|
}
|
|
271
271
|
|
|
@@ -310,7 +310,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
|
|
|
310
310
|
target.preprocessor !== current.preprocessor || target.tailwind !== current.tailwind;
|
|
311
311
|
const imagesChanged = targetImages !== currentImages;
|
|
312
312
|
if (!styleChanged && !imagesChanged) {
|
|
313
|
-
outro('No changes
|
|
313
|
+
outro('No changes, your setup is already up to date.');
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
316
316
|
|
|
@@ -320,7 +320,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
|
|
|
320
320
|
let imagesWarning = '';
|
|
321
321
|
if (imagesChanged && !(await writeImagesFlag(root, targetImages))) {
|
|
322
322
|
imagesWarning = pc.yellow(
|
|
323
|
-
' Could not edit toil.config automatically
|
|
323
|
+
' Could not edit toil.config automatically, set `client.images` by hand.',
|
|
324
324
|
);
|
|
325
325
|
}
|
|
326
326
|
s.stop('Updated project files');
|
|
@@ -336,7 +336,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
|
|
|
336
336
|
await run(pm, ['install'], root);
|
|
337
337
|
i.stop('Dependencies synced');
|
|
338
338
|
} catch {
|
|
339
|
-
i.stop(pc.yellow(`Could not run \`${pm} install
|
|
339
|
+
i.stop(pc.yellow(`Could not run \`${pm} install\`, run it yourself to finish`));
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
}
|
|
@@ -349,5 +349,5 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
|
|
|
349
349
|
.filter(Boolean)
|
|
350
350
|
.join('\n');
|
|
351
351
|
note(summary, 'Updated');
|
|
352
|
-
outro(`Reconfigured
|
|
352
|
+
outro(`Reconfigured, restart \`${accent('toiljs dev')}\` to pick up the changes.`);
|
|
353
353
|
}
|
package/src/cli/create.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `toiljs create
|
|
2
|
+
* `toiljs create`, an interactive project scaffolder (Clack-powered) that wires a new
|
|
3
3
|
* app to the enforced toiljs presets (tsconfig / eslint / prettier) and file-based routing.
|
|
4
4
|
* Supports a non-interactive path via flags (`--yes`, `--template`, …) for scripting/CI.
|
|
5
5
|
*/
|
|
@@ -161,7 +161,7 @@ function scaffold(
|
|
|
161
161
|
JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
|
|
162
162
|
'toil-env.d.ts': TOIL_ENV_DTS,
|
|
163
163
|
// Stub typed-routes augmentation (RoutePath = string until the first dev/build regenerates it).
|
|
164
|
-
'toil-routes.d.ts': '// AUTO-GENERATED by toil
|
|
164
|
+
'toil-routes.d.ts': '// AUTO-GENERATED by toil, do not edit.\nexport {};\n',
|
|
165
165
|
'toilconfig.json':
|
|
166
166
|
JSON.stringify(
|
|
167
167
|
{
|
|
@@ -312,7 +312,7 @@ export default function Layout({ children }: { children?: ReactNode }) {
|
|
|
312
312
|
|
|
313
313
|
/**
|
|
314
314
|
* Absolute path to the `app` starter client UI. There is a single source: `examples/basic/client`
|
|
315
|
-
* (shipped in the package)
|
|
315
|
+
* (shipped in the package), the runnable example IS the create template, so there's nothing to
|
|
316
316
|
* keep in sync.
|
|
317
317
|
*/
|
|
318
318
|
function appClientDir(): string {
|
|
@@ -331,7 +331,7 @@ function appClientDir(): string {
|
|
|
331
331
|
* preprocessor's extension, adds the Tailwind entry, and rewrites `toil.tsx`'s style imports.
|
|
332
332
|
*/
|
|
333
333
|
async function applyStyling(clientDir: string, features: StyleFeatures): Promise<void> {
|
|
334
|
-
// Plain CSS without Tailwind is exactly what the template ships
|
|
334
|
+
// Plain CSS without Tailwind is exactly what the template ships, leave it byte-for-byte.
|
|
335
335
|
if (features.preprocessor === 'css' && !features.tailwind) return;
|
|
336
336
|
const entry = styleEntry(features.preprocessor);
|
|
337
337
|
if (entry !== 'styles/main.css') {
|
|
@@ -406,7 +406,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
|
406
406
|
let template: Template = opts.template ?? 'app';
|
|
407
407
|
if (!opts.template && !opts.yes) {
|
|
408
408
|
const templateOptions: TemplateOption[] = [
|
|
409
|
-
{ value: 'app', label: 'App', hint: 'the full ToilJS starter
|
|
409
|
+
{ value: 'app', label: 'App', hint: 'the full ToilJS starter, landing page, layout, styles, demo routes' },
|
|
410
410
|
{ value: 'minimal', label: 'Minimal', hint: 'just a layout and a home route' },
|
|
411
411
|
];
|
|
412
412
|
const choice = await select({ message: 'Which template?', options: templateOptions, initialValue: 'app' });
|
|
@@ -514,7 +514,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
|
514
514
|
await run(pm, ['install'], targetDir);
|
|
515
515
|
i.stop('Installed dependencies');
|
|
516
516
|
} catch {
|
|
517
|
-
i.stop(pc.yellow(`Could not install with ${pm}
|
|
517
|
+
i.stop(pc.yellow(`Could not install with ${pm}, run it yourself later`));
|
|
518
518
|
install = false;
|
|
519
519
|
}
|
|
520
520
|
}
|
|
@@ -526,5 +526,5 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
|
526
526
|
steps.push(`${accent('npm run build')} ${dim('build for production')}`);
|
|
527
527
|
note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
|
|
528
528
|
|
|
529
|
-
outro(`Created ${accent(path.basename(name))}
|
|
529
|
+
outro(`Created ${accent(path.basename(name))}, happy building! ${dim('· v' + version())}`);
|
|
530
530
|
}
|
package/src/cli/features.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure description of toiljs's optional client styling features
|
|
2
|
+
* Pure description of toiljs's optional client styling features, a CSS preprocessor and Tailwind ,
|
|
3
3
|
* shared by `create` (scaffold) and `configure` (toggle on existing projects). Dependency-light
|
|
4
4
|
* (no node IO) so it can be unit-tested; the file writes and package-manager calls live in the
|
|
5
5
|
* commands. Preprocessor and Tailwind are independent: Tailwind lives in its own `.css` entry so
|
|
@@ -128,7 +128,7 @@ export function defaultConfigSource(images: boolean): string {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* Sets the `client.images` flag in a `toil.config` source, returning the updated source
|
|
131
|
+
* Sets the `client.images` flag in a `toil.config` source, returning the updated source, or `null`
|
|
132
132
|
* if the file's shape isn't recognized (the caller should then fall back to a manual note). Handles
|
|
133
133
|
* an existing `images:` value, an existing `client: {` block, or a bare `defineConfig({ … })`.
|
|
134
134
|
*/
|
package/src/cli/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* toiljs CLI. Routes `create` / `dev` / `build` and wraps them in the toiljs brand banner.
|
|
4
4
|
* The compiler stays presentation-free (imported via the package's own `toiljs/compiler`
|
|
5
|
-
* export); the epic bits
|
|
5
|
+
* export); the epic bits, banner, the Clack scaffolding wizard, live here.
|
|
6
6
|
*/
|
|
7
7
|
import { build, dev, start } from 'toiljs/compiler';
|
|
8
8
|
|
package/src/cli/ui.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function success(s: string): string {
|
|
|
54
54
|
return rgb(ACCENT, s);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/** Error accent (red
|
|
57
|
+
/** Error accent (red, kept outside the brand palette since errors should read as errors). */
|
|
58
58
|
export const danger = pc.red;
|
|
59
59
|
|
|
60
60
|
function lerp(a: number, b: number, t: number): number {
|
package/src/cli/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure input validation for `toiljs create
|
|
2
|
+
* Pure input validation for `toiljs create`, kept dependency-light (only node:path) so it can be
|
|
3
3
|
* unit-tested without pulling in the rest of the CLI.
|
|
4
4
|
*/
|
|
5
5
|
import path from 'node:path';
|
|
@@ -16,7 +16,7 @@ export interface FormProps {
|
|
|
16
16
|
resetOnSuccess?: boolean;
|
|
17
17
|
className?: string;
|
|
18
18
|
/**
|
|
19
|
-
* Form contents. Pass a render function to receive live submit state
|
|
19
|
+
* Form contents. Pass a render function to receive live submit state, e.g. to disable the
|
|
20
20
|
* button while pending: `{({ pending }) => <button disabled={pending}>Save</button>}`.
|
|
21
21
|
*/
|
|
22
22
|
children?: ReactNode | ((state: ActionState<void>) => ReactNode);
|
|
@@ -24,7 +24,7 @@ export interface FormProps {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* A `<form>` that runs an {@link useAction} on submit (no page reload) and revalidates loader data
|
|
27
|
-
* on success
|
|
27
|
+
* on success, the write half of the loader/action data loop. Tracks pending/error state, which a
|
|
28
28
|
* render-function child can read.
|
|
29
29
|
*/
|
|
30
30
|
export function Form({
|
|
@@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ComponentPropsWithRef, type ReactNod
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
|
|
5
|
-
* `src` and `alt` are required (`alt` is enforced for accessibility
|
|
5
|
+
* `src` and `alt` are required (`alt` is enforced for accessibility, pass `alt=""` for decorative
|
|
6
6
|
* images). `width`/`height` (or `fill`) reserve space to prevent layout shift.
|
|
7
7
|
*/
|
|
8
8
|
export interface ImageProps
|
|
@@ -35,7 +35,7 @@ export interface ImageProps
|
|
|
35
35
|
/**
|
|
36
36
|
* A drop-in `<img>` replacement that prevents layout shift and lazy-loads by default. It reserves
|
|
37
37
|
* space from `width`/`height` (or fills its container with `fill`), decodes async, lazy-loads unless
|
|
38
|
-
* `priority`, and can fade in from a `blur` placeholder. This is a client-only component
|
|
38
|
+
* `priority`, and can fade in from a `blur` placeholder. This is a client-only component, there is
|
|
39
39
|
* no server-side resizing; pass an already-optimized `src` (Vite hashes imported assets for you).
|
|
40
40
|
*/
|
|
41
41
|
export function Image(props: ImageProps): ReactNode {
|
|
@@ -2,9 +2,9 @@ import { useEffect, type ReactNode } from 'react';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* When a {@link Script} is injected, relative to the app becoming interactive:
|
|
5
|
-
* - `afterInteractive` (default)
|
|
6
|
-
* - `lazyOnload
|
|
7
|
-
* - `beforeInteractive
|
|
5
|
+
* - `afterInteractive` (default), on mount, once the app is running. Good for analytics, widgets.
|
|
6
|
+
* - `lazyOnload`, deferred until the browser is idle (after `window.load`). For low-priority scripts.
|
|
7
|
+
* - `beforeInteractive`, as early as possible. In a client-only SPA there is no SSR, so this still
|
|
8
8
|
* runs after hydration, but synchronously on first mount with high fetch priority.
|
|
9
9
|
*/
|
|
10
10
|
export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useContext, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { SlotContext } from '../routing/slot-context.js';
|
|
4
|
+
|
|
5
|
+
/** Props for {@link Slot}. */
|
|
6
|
+
export interface SlotProps {
|
|
7
|
+
/** The parallel-slot name, the `@name` directory under `routes/` (without the `@`). */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Rendered when the slot has no match for the current URL. Default `null`. */
|
|
10
|
+
fallback?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the parallel-route slot named `name` for the current URL. Place it in a layout or page to
|
|
15
|
+
* show an `@name` route tree alongside the main content (e.g. a persistent sidebar, or a modal that
|
|
16
|
+
* an intercepting route fills). Renders `fallback` (default nothing) when no slot route matches.
|
|
17
|
+
*/
|
|
18
|
+
export function Slot({ name, fallback = null }: SlotProps): ReactNode {
|
|
19
|
+
const slots = useContext(SlotContext);
|
|
20
|
+
return slots[name] ?? fallback;
|
|
21
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development-only error overlay. In dev, surfaces errors that would otherwise leave a blank page or
|
|
3
|
+
* live only in the console: uncaught render errors (incl. those thrown by a loader during render),
|
|
4
|
+
* plus `window` `error` / `unhandledrejection` events. Shows the message, stack, and (for render
|
|
5
|
+
* errors) the React component stack, with Dismiss / Reload. Inert in production builds.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
Component,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
type ErrorInfo,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react';
|
|
14
|
+
|
|
15
|
+
/** A captured dev error. */
|
|
16
|
+
interface DevError {
|
|
17
|
+
readonly error: Error;
|
|
18
|
+
readonly componentStack?: string;
|
|
19
|
+
/** Where it came from, a render boundary, a window `error`, or an unhandled rejection. */
|
|
20
|
+
readonly source: 'render' | 'window' | 'unhandledrejection';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let current: DevError | null = null;
|
|
24
|
+
const listeners = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
function emit(): void {
|
|
27
|
+
for (const listener of listeners) listener();
|
|
28
|
+
}
|
|
29
|
+
function setDevError(next: DevError | null): void {
|
|
30
|
+
current = next;
|
|
31
|
+
emit();
|
|
32
|
+
}
|
|
33
|
+
function subscribe(listener: () => void): () => void {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
return () => {
|
|
36
|
+
listeners.delete(listener);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when running under Vite's dev server (replaced at build time; falsy in production). */
|
|
41
|
+
export function isDevMode(): boolean {
|
|
42
|
+
try {
|
|
43
|
+
return Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let windowBound = false;
|
|
50
|
+
/** Wires `window` error / unhandledrejection into the overlay (idempotent; dev only). */
|
|
51
|
+
export function initDevErrorOverlay(): void {
|
|
52
|
+
if (windowBound || typeof window === 'undefined') return;
|
|
53
|
+
windowBound = true;
|
|
54
|
+
window.addEventListener('error', (event) => {
|
|
55
|
+
if (event.error instanceof Error) setDevError({ error: event.error, source: 'window' });
|
|
56
|
+
});
|
|
57
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
58
|
+
const reason: unknown = event.reason;
|
|
59
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
60
|
+
setDevError({ error, source: 'unhandledrejection' });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface BoundaryProps {
|
|
65
|
+
readonly children: ReactNode;
|
|
66
|
+
}
|
|
67
|
+
interface BoundaryState {
|
|
68
|
+
readonly crashed: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Catches render errors in its subtree and reports them to the overlay. While crashed it renders
|
|
73
|
+
* nothing (the subtree threw); it recovers when the overlay is dismissed. Class component because
|
|
74
|
+
* React error boundaries have no hook equivalent.
|
|
75
|
+
*/
|
|
76
|
+
export class DevErrorBoundary extends Component<BoundaryProps, BoundaryState> {
|
|
77
|
+
public state: BoundaryState = { crashed: false };
|
|
78
|
+
private unsubscribe: (() => void) | undefined;
|
|
79
|
+
|
|
80
|
+
public static getDerivedStateFromError(): BoundaryState {
|
|
81
|
+
return { crashed: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
85
|
+
setDevError({ error, componentStack: info.componentStack ?? undefined, source: 'render' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public override componentDidMount(): void {
|
|
89
|
+
// Recover (re-render children) once the error is dismissed from the overlay.
|
|
90
|
+
this.unsubscribe = subscribe(() => {
|
|
91
|
+
if (current === null && this.state.crashed) this.setState({ crashed: false });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public override componentWillUnmount(): void {
|
|
96
|
+
this.unsubscribe?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public override render(): ReactNode {
|
|
100
|
+
return this.state.crashed ? null : this.props.children;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const overlayStyle: CSSProperties = {
|
|
105
|
+
position: 'fixed',
|
|
106
|
+
inset: 0,
|
|
107
|
+
zIndex: 2147483647,
|
|
108
|
+
background: 'rgba(8, 8, 12, 0.88)',
|
|
109
|
+
color: '#f5f6fa',
|
|
110
|
+
font: '13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
111
|
+
padding: '2rem',
|
|
112
|
+
overflow: 'auto',
|
|
113
|
+
display: 'flex',
|
|
114
|
+
justifyContent: 'center',
|
|
115
|
+
alignItems: 'flex-start',
|
|
116
|
+
};
|
|
117
|
+
const panelStyle: CSSProperties = {
|
|
118
|
+
maxWidth: 900,
|
|
119
|
+
width: '100%',
|
|
120
|
+
background: '#15151c',
|
|
121
|
+
border: '1px solid #ef4444',
|
|
122
|
+
borderRadius: 10,
|
|
123
|
+
padding: '1.25rem 1.5rem',
|
|
124
|
+
boxShadow: '0 12px 48px rgba(0,0,0,0.5)',
|
|
125
|
+
};
|
|
126
|
+
const titleStyle: CSSProperties = {
|
|
127
|
+
margin: 0,
|
|
128
|
+
color: '#ff6b6b',
|
|
129
|
+
fontSize: '1rem',
|
|
130
|
+
fontWeight: 700,
|
|
131
|
+
wordBreak: 'break-word',
|
|
132
|
+
};
|
|
133
|
+
const preStyle: CSSProperties = {
|
|
134
|
+
whiteSpace: 'pre-wrap',
|
|
135
|
+
wordBreak: 'break-word',
|
|
136
|
+
margin: '0.75rem 0 0',
|
|
137
|
+
color: '#c8cee0',
|
|
138
|
+
};
|
|
139
|
+
const buttonStyle: CSSProperties = {
|
|
140
|
+
font: 'inherit',
|
|
141
|
+
color: '#f5f6fa',
|
|
142
|
+
background: '#2a2a36',
|
|
143
|
+
border: '1px solid #3a3a48',
|
|
144
|
+
borderRadius: 6,
|
|
145
|
+
padding: '0.4em 1em',
|
|
146
|
+
cursor: 'pointer',
|
|
147
|
+
marginRight: '0.5rem',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const SOURCE_LABEL: Record<DevError['source'], string> = {
|
|
151
|
+
render: 'Render error',
|
|
152
|
+
window: 'Uncaught error',
|
|
153
|
+
unhandledrejection: 'Unhandled promise rejection',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Renders the overlay when a dev error is captured. Mount once at the app root (dev only). */
|
|
157
|
+
export function DevErrorOverlay(): ReactNode {
|
|
158
|
+
const devError = useSyncExternalStore(
|
|
159
|
+
subscribe,
|
|
160
|
+
() => current,
|
|
161
|
+
() => null,
|
|
162
|
+
);
|
|
163
|
+
if (!devError) return null;
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
style={overlayStyle}
|
|
167
|
+
role="alert">
|
|
168
|
+
<div style={panelStyle}>
|
|
169
|
+
<p style={titleStyle}>
|
|
170
|
+
{SOURCE_LABEL[devError.source]}, {devError.error.name}: {devError.error.message}
|
|
171
|
+
</p>
|
|
172
|
+
{devError.error.stack !== undefined && <pre style={preStyle}>{devError.error.stack}</pre>}
|
|
173
|
+
{devError.componentStack !== undefined && (
|
|
174
|
+
<pre style={{ ...preStyle, color: '#8b9ab4' }}>{devError.componentStack}</pre>
|
|
175
|
+
)}
|
|
176
|
+
<div style={{ marginTop: '1.25rem' }}>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
style={buttonStyle}
|
|
180
|
+
onClick={() => {
|
|
181
|
+
setDevError(null);
|
|
182
|
+
}}>
|
|
183
|
+
Dismiss
|
|
184
|
+
</button>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
style={buttonStyle}
|
|
188
|
+
onClick={() => {
|
|
189
|
+
window.location.reload();
|
|
190
|
+
}}>
|
|
191
|
+
Reload
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
package/src/client/head/head.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* (later/deeper entries win per key) and are reverted when the component unmounts. Pure
|
|
5
5
|
* `mergeHead` resolves the active entries; the manager reconciles `document.head`.
|
|
6
6
|
*/
|
|
7
|
-
import { useEffect } from 'react';
|
|
7
|
+
import { useEffect, useLayoutEffect } from 'react';
|
|
8
8
|
|
|
9
9
|
/** A `<meta>` tag. Use `name` or `property` (OpenGraph) as the dedup key; extra attrs pass through. */
|
|
10
10
|
export interface MetaTag {
|
|
@@ -70,6 +70,9 @@ const entries = new Map<number, HeadSpec>();
|
|
|
70
70
|
let order: number[] = [];
|
|
71
71
|
let seq = 0;
|
|
72
72
|
let baseTitle: string | null = null;
|
|
73
|
+
// The current route's resolved metadata, the lowest-priority spec, so component `useHead`/`<Head>`
|
|
74
|
+
// always compose on top of it. Set by the router via `setRouteHead` on each navigation.
|
|
75
|
+
let routeHead: HeadSpec | null = null;
|
|
73
76
|
|
|
74
77
|
function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
|
|
75
78
|
el.setAttribute('data-toil-head', '');
|
|
@@ -83,7 +86,8 @@ function apply(): void {
|
|
|
83
86
|
if (typeof document === 'undefined') return;
|
|
84
87
|
if (baseTitle === null) baseTitle = document.title;
|
|
85
88
|
|
|
86
|
-
const
|
|
89
|
+
const specs = [routeHead, ...order.map((id) => entries.get(id))];
|
|
90
|
+
const resolved = mergeHead(specs.filter((s): s is HeadSpec => !!s));
|
|
87
91
|
|
|
88
92
|
document.title = resolved.title ?? baseTitle;
|
|
89
93
|
|
|
@@ -116,7 +120,7 @@ function removeHead(id: number): void {
|
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
122
|
* Applies a head contribution for the lifetime of the calling component: title, `<meta>`, `<link>`.
|
|
119
|
-
* Reverts on unmount. Compose freely
|
|
123
|
+
* Reverts on unmount. Compose freely, a root layout can set defaults a page overrides.
|
|
120
124
|
*/
|
|
121
125
|
export function useHead(spec: HeadSpec): void {
|
|
122
126
|
const json = JSON.stringify(spec);
|
|
@@ -138,3 +142,24 @@ export function Head(props: HeadSpec): null {
|
|
|
138
142
|
useHead(props);
|
|
139
143
|
return null;
|
|
140
144
|
}
|
|
145
|
+
|
|
146
|
+
/** Sets the current route's baseline head (lowest priority). Pass `null` to clear it. */
|
|
147
|
+
export function setRouteHead(spec: HeadSpec | null): void {
|
|
148
|
+
routeHead = spec;
|
|
149
|
+
apply();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Applies a route's resolved `metadata` as the baseline head for the calling route's lifetime, and
|
|
154
|
+
* clears it on unmount. Used internally by the router; a layout-effect so the title updates before
|
|
155
|
+
* paint (no flicker).
|
|
156
|
+
*/
|
|
157
|
+
export function useRouteHead(spec: HeadSpec | undefined): void {
|
|
158
|
+
const json = spec ? JSON.stringify(spec) : '';
|
|
159
|
+
useLayoutEffect(() => {
|
|
160
|
+
setRouteHead(json ? (JSON.parse(json) as HeadSpec) : null);
|
|
161
|
+
return () => {
|
|
162
|
+
setRouteHead(null);
|
|
163
|
+
};
|
|
164
|
+
}, [json]);
|
|
165
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route metadata, the declarative SEO counterpart to `useHead`/`<Head>`. A route file may
|
|
3
|
+
* `export const metadata` (static) or `export const generateMetadata` (dynamic, using its loader
|
|
4
|
+
* data); the compiler-driven loader resolves it to a {@link HeadSpec} that the router applies as the
|
|
5
|
+
* route's baseline head (component-level `useHead`/`<Head>` still compose on top and can override).
|
|
6
|
+
*/
|
|
7
|
+
import type { HeadSpec, LinkTag, MetaTag } from './head.js';
|
|
8
|
+
import type { RouteParams } from '../routing/match.js';
|
|
9
|
+
|
|
10
|
+
/** OpenGraph fields, expanded to `og:*` meta tags. */
|
|
11
|
+
export interface OpenGraph {
|
|
12
|
+
readonly title?: string;
|
|
13
|
+
readonly description?: string;
|
|
14
|
+
readonly type?: string;
|
|
15
|
+
readonly url?: string;
|
|
16
|
+
readonly image?: string;
|
|
17
|
+
readonly siteName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A route's metadata. Convenience fields expand to the right `<meta>`/`<link>` tags. */
|
|
21
|
+
export interface Metadata {
|
|
22
|
+
/** Document title. */
|
|
23
|
+
readonly title?: string;
|
|
24
|
+
/** Template applied to the title (`%s` = the title), e.g. `'%s · toiljs'`. */
|
|
25
|
+
readonly titleTemplate?: string;
|
|
26
|
+
/** `<meta name="description">`. */
|
|
27
|
+
readonly description?: string;
|
|
28
|
+
/** `<meta name="keywords">`, joined with `, ` if an array. */
|
|
29
|
+
readonly keywords?: string | readonly string[];
|
|
30
|
+
/** `<link rel="canonical">`. */
|
|
31
|
+
readonly canonical?: string;
|
|
32
|
+
/** `<meta name="robots">`, e.g. `'noindex, nofollow'`. */
|
|
33
|
+
readonly robots?: string;
|
|
34
|
+
/** `<meta name="theme-color">`. */
|
|
35
|
+
readonly themeColor?: string;
|
|
36
|
+
/** OpenGraph (`og:*`) tags. */
|
|
37
|
+
readonly openGraph?: OpenGraph;
|
|
38
|
+
/** Escape hatch: extra raw `<meta>` tags. */
|
|
39
|
+
readonly meta?: readonly MetaTag[];
|
|
40
|
+
/** Escape hatch: extra raw `<link>` tags. */
|
|
41
|
+
readonly link?: readonly LinkTag[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Arguments passed to {@link GenerateMetadata}: route params, query, and the loader's data. */
|
|
45
|
+
export interface GenerateMetadataArgs<T = unknown> {
|
|
46
|
+
readonly params: RouteParams;
|
|
47
|
+
readonly searchParams: URLSearchParams;
|
|
48
|
+
readonly data: T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A route's `export const generateMetadata`, dynamic metadata derived from params/query/loader data. */
|
|
52
|
+
export type GenerateMetadata<T = unknown> = (
|
|
53
|
+
args: GenerateMetadataArgs<T>,
|
|
54
|
+
) => Metadata | Promise<Metadata>;
|
|
55
|
+
|
|
56
|
+
/** Expands a {@link Metadata} into a {@link HeadSpec} (title + concrete meta/link tags). */
|
|
57
|
+
export function resolveMetadata(metadata: Metadata): HeadSpec {
|
|
58
|
+
const meta: MetaTag[] = [];
|
|
59
|
+
if (metadata.description !== undefined) {
|
|
60
|
+
meta.push({ name: 'description', content: metadata.description });
|
|
61
|
+
}
|
|
62
|
+
if (metadata.keywords !== undefined) {
|
|
63
|
+
const content =
|
|
64
|
+
typeof metadata.keywords === 'string' ? metadata.keywords : metadata.keywords.join(', ');
|
|
65
|
+
meta.push({ name: 'keywords', content });
|
|
66
|
+
}
|
|
67
|
+
if (metadata.robots !== undefined) meta.push({ name: 'robots', content: metadata.robots });
|
|
68
|
+
if (metadata.themeColor !== undefined) {
|
|
69
|
+
meta.push({ name: 'theme-color', content: metadata.themeColor });
|
|
70
|
+
}
|
|
71
|
+
const og = metadata.openGraph;
|
|
72
|
+
if (og) {
|
|
73
|
+
const pairs: readonly [string, string | undefined][] = [
|
|
74
|
+
['og:title', og.title],
|
|
75
|
+
['og:description', og.description],
|
|
76
|
+
['og:type', og.type],
|
|
77
|
+
['og:url', og.url],
|
|
78
|
+
['og:image', og.image],
|
|
79
|
+
['og:site_name', og.siteName],
|
|
80
|
+
];
|
|
81
|
+
for (const [property, content] of pairs) {
|
|
82
|
+
if (content !== undefined) meta.push({ property, content });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (metadata.meta) meta.push(...metadata.meta);
|
|
86
|
+
|
|
87
|
+
const link: LinkTag[] = [];
|
|
88
|
+
if (metadata.canonical !== undefined) link.push({ rel: 'canonical', href: metadata.canonical });
|
|
89
|
+
if (metadata.link) link.push(...metadata.link);
|
|
90
|
+
|
|
91
|
+
return { title: metadata.title, titleTemplate: metadata.titleTemplate, meta, link };
|
|
92
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ 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 { navigate, back, forward, refresh } from './navigation/navigation.js';
|
|
17
|
+
export { navigate, back, forward, refresh, setViewTransitions } from './navigation/navigation.js';
|
|
18
18
|
export type { NavigateOptions } from './navigation/navigation.js';
|
|
19
19
|
export {
|
|
20
20
|
useParams,
|
|
@@ -52,9 +52,13 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
|
|
|
52
52
|
export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
|
|
53
53
|
export { useHead, useTitle, Head, mergeHead } from './head/head.js';
|
|
54
54
|
export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
|
|
55
|
+
export { resolveMetadata } from './head/metadata.js';
|
|
56
|
+
export type { Metadata, GenerateMetadata, GenerateMetadataArgs, OpenGraph } from './head/metadata.js';
|
|
55
57
|
export { Image } from './components/Image.js';
|
|
56
58
|
export type { ImageProps } from './components/Image.js';
|
|
57
59
|
export { Script } from './components/Script.js';
|
|
58
60
|
export type { ScriptProps, ScriptStrategy } from './components/Script.js';
|
|
59
61
|
export { Form } from './components/Form.js';
|
|
60
62
|
export type { FormProps } from './components/Form.js';
|
|
63
|
+
export { Slot } from './components/Slot.js';
|
|
64
|
+
export type { SlotProps } from './components/Slot.js';
|
|
@@ -37,7 +37,7 @@ function isExternalHref(href: string): boolean {
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Client-side navigation link. Forwards all anchor attributes to the underlying `<a>`, and
|
|
40
|
-
* prefetches the target route's chunk on hover/focus. Intercepts only plain same-origin clicks
|
|
40
|
+
* prefetches the target route's chunk on hover/focus. Intercepts only plain same-origin clicks ,
|
|
41
41
|
* modified clicks, `target=_blank`, `download`, in-page `#hash`, and external URLs fall through to
|
|
42
42
|
* native browser behavior.
|
|
43
43
|
*/
|