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/compiler/routes.ts
CHANGED
|
@@ -5,6 +5,10 @@ import path from 'node:path';
|
|
|
5
5
|
export interface ScannedRoute {
|
|
6
6
|
readonly file: string;
|
|
7
7
|
readonly pattern: string;
|
|
8
|
+
/** Named parallel slot this route belongs to (from an `@slot` dir), or `undefined` for the main tree. */
|
|
9
|
+
readonly slot?: string;
|
|
10
|
+
/** True for an intercepting route (`(.)`/`(..)`/`(...)`), matched only on soft navigation. */
|
|
11
|
+
readonly intercept?: boolean;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
const ROUTE_EXT = /\.(tsx|jsx)$/;
|
|
@@ -20,7 +24,19 @@ const SPECIAL_FILE = /^(layout|template|loading|error|global-error|404|not-found
|
|
|
20
24
|
* docs/[...slug].tsx -> /docs/*slug (catch-all)
|
|
21
25
|
* docs/[[...slug]].tsx -> /docs/**slug (optional catch-all)
|
|
22
26
|
* (marketing)/about.tsx -> /about (route group: parens add no URL segment)
|
|
27
|
+
* @modal/photo/[id].tsx -> /photo/:id (parallel slot: `@slot` adds no URL segment)
|
|
23
28
|
*/
|
|
29
|
+
/** Converts a path segment's dynamic brackets to URL params (`[id]`→`:id`, `[...x]`→`*x`, `[[...x]]`→`**x`). */
|
|
30
|
+
function toUrlSegment(segment: string): string {
|
|
31
|
+
return segment
|
|
32
|
+
.replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
|
|
33
|
+
.replace(/^\[\.\.\.(.+)\]$/, '*$1')
|
|
34
|
+
.replace(/^\[(.+)\]$/, ':$1');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Interception markers: `(.)` same level, `(..)` up one, `(...)` from the routes root. */
|
|
38
|
+
const INTERCEPT_RE = /^\((\.{1,3})\)(.+)$/;
|
|
39
|
+
|
|
24
40
|
export function filePathToRoute(relPath: string): string {
|
|
25
41
|
const withoutExt = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '');
|
|
26
42
|
const segments = withoutExt.split('/').filter(Boolean);
|
|
@@ -28,17 +44,43 @@ export function filePathToRoute(relPath: string): string {
|
|
|
28
44
|
for (let i = 0; i < segments.length; i++) {
|
|
29
45
|
const segment = segments[i];
|
|
30
46
|
if (/^\(.+\)$/.test(segment)) continue;
|
|
47
|
+
if (/^@/.test(segment)) continue; // parallel-slot marker, contributes no URL segment
|
|
31
48
|
if (segment === 'index' && i === segments.length - 1) continue;
|
|
32
|
-
out.push(
|
|
33
|
-
segment
|
|
34
|
-
.replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
|
|
35
|
-
.replace(/^\[\.\.\.(.+)\]$/, '*$1')
|
|
36
|
-
.replace(/^\[(.+)\]$/, ':$1'),
|
|
37
|
-
);
|
|
49
|
+
out.push(toUrlSegment(segment));
|
|
38
50
|
}
|
|
39
51
|
return '/' + out.join('/');
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The URL an intercepting route targets, or `null` if the path has no `(.)`/`(..)`/`(...)` marker.
|
|
56
|
+
* The marker resolves the target relative to the route's position (ignoring `@slot`/`(group)`
|
|
57
|
+
* segments): `(.)` keeps the current level, `(..)` drops one, `(...)` resets to the root.
|
|
58
|
+
* @modal/(.)photo/[id].tsx -> /photo/:id
|
|
59
|
+
* feed/@modal/(..)photo/[id].tsx -> /photo/:id
|
|
60
|
+
*/
|
|
61
|
+
export function interceptTarget(relPath: string): string | null {
|
|
62
|
+
const segments = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '').split('/').filter(Boolean);
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
let marked = false;
|
|
65
|
+
for (let i = 0; i < segments.length; i++) {
|
|
66
|
+
const segment = segments[i];
|
|
67
|
+
if (/^@/.test(segment)) continue;
|
|
68
|
+
const marker = INTERCEPT_RE.exec(segment);
|
|
69
|
+
if (marker) {
|
|
70
|
+
marked = true;
|
|
71
|
+
const dots = marker[1].length;
|
|
72
|
+
if (dots === 2) out.pop(); // (..) up one level
|
|
73
|
+
else if (dots === 3) out.length = 0; // (...) from the routes root
|
|
74
|
+
out.push(toUrlSegment(marker[2]));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (/^\(.+\)$/.test(segment)) continue;
|
|
78
|
+
if (segment === 'index' && i === segments.length - 1) continue;
|
|
79
|
+
out.push(toUrlSegment(segment));
|
|
80
|
+
}
|
|
81
|
+
return marked ? '/' + out.join('/') : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
42
84
|
/**
|
|
43
85
|
* Ranks a pattern so more specific routes match first: static segments beat dynamic (`:x`),
|
|
44
86
|
* which beat catch-all (`*x`); deeper routes beat shallower ones.
|
|
@@ -53,6 +95,15 @@ function specificity(pattern: string): number {
|
|
|
53
95
|
return score;
|
|
54
96
|
}
|
|
55
97
|
|
|
98
|
+
/** The parallel-slot name for a route path (the first `@slot` segment), or `undefined`. */
|
|
99
|
+
function slotOf(relPath: string): string | undefined {
|
|
100
|
+
for (const segment of relPath.replace(/\\/g, '/').split('/')) {
|
|
101
|
+
const match = /^@(.+)$/.exec(segment);
|
|
102
|
+
if (match) return match[1];
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
/** Recursively scans `routesDir` for `.tsx`/`.jsx` files, returning routes sorted by specificity. */
|
|
57
108
|
export function scanRoutes(routesDir: string): ScannedRoute[] {
|
|
58
109
|
if (!fs.existsSync(routesDir)) return [];
|
|
@@ -63,9 +114,13 @@ export function scanRoutes(routesDir: string): ScannedRoute[] {
|
|
|
63
114
|
if (entry.isDirectory()) {
|
|
64
115
|
walk(full);
|
|
65
116
|
} else if (ROUTE_EXT.test(entry.name) && !SPECIAL_FILE.test(entry.name)) {
|
|
117
|
+
const rel = path.relative(routesDir, full);
|
|
118
|
+
const target = interceptTarget(rel);
|
|
66
119
|
found.push({
|
|
67
120
|
file: full,
|
|
68
|
-
pattern: filePathToRoute(
|
|
121
|
+
pattern: target ?? filePathToRoute(rel),
|
|
122
|
+
slot: slotOf(rel),
|
|
123
|
+
intercept: target !== null,
|
|
69
124
|
});
|
|
70
125
|
}
|
|
71
126
|
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { ScannedRoute } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build-time SEO for the (otherwise JS-only) SPA: bakes site-level metadata into the HTML `<head>`
|
|
5
|
+
* so JS-less crawlers and AI bots see real tags, and generates `robots.txt`, `sitemap.xml`, and
|
|
6
|
+
* `llms.txt`. All pure string builders here; `generate.ts` wires them into the build output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* OpenGraph defaults baked into the HTML, read by Facebook, Discord, Slack, LinkedIn, GitHub, and
|
|
11
|
+
* most link-preview crawlers. `image` should be an absolute URL (≥1200×630 for a large card).
|
|
12
|
+
*/
|
|
13
|
+
export interface SeoOpenGraph {
|
|
14
|
+
readonly title?: string;
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
/** `og:type`, e.g. `'website'` or `'article'`. */
|
|
17
|
+
readonly type?: string;
|
|
18
|
+
readonly siteName?: string;
|
|
19
|
+
/** `og:locale`, e.g. `'en_US'`. */
|
|
20
|
+
readonly locale?: string;
|
|
21
|
+
/** `og:image`, the preview image (absolute URL). */
|
|
22
|
+
readonly image?: string;
|
|
23
|
+
/** `og:image:alt`. */
|
|
24
|
+
readonly imageAlt?: string;
|
|
25
|
+
/** `og:image:width` in px (helps Facebook/LinkedIn render without a re-fetch). */
|
|
26
|
+
readonly imageWidth?: number;
|
|
27
|
+
/** `og:image:height` in px. */
|
|
28
|
+
readonly imageHeight?: number;
|
|
29
|
+
/** `og:image:type`, e.g. `'image/png'`. */
|
|
30
|
+
readonly imageType?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Twitter / X card. Unset fields fall back to the OpenGraph / top-level values. */
|
|
34
|
+
export interface SeoTwitter {
|
|
35
|
+
/** `'summary'` | `'summary_large_image'` | … Defaults by whether an image is present. */
|
|
36
|
+
readonly card?: string;
|
|
37
|
+
readonly site?: string;
|
|
38
|
+
readonly creator?: string;
|
|
39
|
+
readonly title?: string;
|
|
40
|
+
readonly description?: string;
|
|
41
|
+
readonly image?: string;
|
|
42
|
+
readonly imageAlt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A `robots.txt` group. */
|
|
46
|
+
export interface RobotsRule {
|
|
47
|
+
readonly userAgent?: string | readonly string[];
|
|
48
|
+
readonly allow?: readonly string[];
|
|
49
|
+
readonly disallow?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `robots.txt` configuration. */
|
|
53
|
+
export interface RobotsConfig {
|
|
54
|
+
readonly rules?: readonly RobotsRule[];
|
|
55
|
+
/** How to treat known AI crawlers (GPTBot, ClaudeBot, Google-Extended, …). Default `'allow'`. */
|
|
56
|
+
readonly ai?: 'allow' | 'disallow';
|
|
57
|
+
/** Explicit `Sitemap:` URL (defaults to `<url>/sitemap.xml` when `seo.url` is set). */
|
|
58
|
+
readonly sitemap?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A page listed in `llms.txt`. */
|
|
62
|
+
export interface LlmsPage {
|
|
63
|
+
readonly title: string;
|
|
64
|
+
readonly url: string;
|
|
65
|
+
readonly description?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `llms.txt` configuration (the AI-crawler guidance file). */
|
|
69
|
+
export interface LlmsConfig {
|
|
70
|
+
readonly title?: string;
|
|
71
|
+
readonly summary?: string;
|
|
72
|
+
/** Free-form instructions for AI/LLM crawlers. */
|
|
73
|
+
readonly instructions?: string;
|
|
74
|
+
/** Key pages; defaults to the site's static routes. */
|
|
75
|
+
readonly pages?: readonly LlmsPage[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build-time SEO configuration (under `client.seo`). */
|
|
79
|
+
export interface SeoConfig {
|
|
80
|
+
/** Absolute site base URL, e.g. `https://toil.dev`, required for `sitemap.xml` and canonical/OG URLs. */
|
|
81
|
+
readonly url?: string;
|
|
82
|
+
/** Default document title baked into the HTML. */
|
|
83
|
+
readonly title?: string;
|
|
84
|
+
/** Default meta description. */
|
|
85
|
+
readonly description?: string;
|
|
86
|
+
/** Default robots directive, e.g. `'index, follow'`. */
|
|
87
|
+
readonly robotsMeta?: string;
|
|
88
|
+
/** `<meta name="theme-color">`, also the accent color of Discord/Slack link embeds. */
|
|
89
|
+
readonly themeColor?: string;
|
|
90
|
+
readonly openGraph?: SeoOpenGraph;
|
|
91
|
+
readonly twitter?: SeoTwitter;
|
|
92
|
+
/** Facebook-specific tags (`fb:app_id`). OpenGraph above covers the rest of the FB card. */
|
|
93
|
+
readonly facebook?: { readonly appId?: string };
|
|
94
|
+
/** JSON-LD structured data injected as `<script type="application/ld+json">`. */
|
|
95
|
+
readonly jsonLd?: Record<string, unknown> | readonly Record<string, unknown>[];
|
|
96
|
+
/** Origins to `<link rel="preconnect">` (early connection hints). */
|
|
97
|
+
readonly preconnect?: readonly string[];
|
|
98
|
+
/** Origins to `<link rel="dns-prefetch">`. */
|
|
99
|
+
readonly dnsPrefetch?: readonly string[];
|
|
100
|
+
/** `robots.txt` generation; `false` to skip. */
|
|
101
|
+
readonly robots?: RobotsConfig | false;
|
|
102
|
+
/** `sitemap.xml` generation; defaults to on when `url` is set. */
|
|
103
|
+
readonly sitemap?: boolean;
|
|
104
|
+
/** `llms.txt` (AI guidance) generation; `false` to skip, `true`/object to configure. */
|
|
105
|
+
readonly llms?: LlmsConfig | boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Known AI / LLM crawler user-agents, for explicit allow/disallow in `robots.txt`. */
|
|
109
|
+
const AI_CRAWLERS: readonly string[] = [
|
|
110
|
+
'GPTBot',
|
|
111
|
+
'OAI-SearchBot',
|
|
112
|
+
'ChatGPT-User',
|
|
113
|
+
'ClaudeBot',
|
|
114
|
+
'Claude-Web',
|
|
115
|
+
'anthropic-ai',
|
|
116
|
+
'Google-Extended',
|
|
117
|
+
'PerplexityBot',
|
|
118
|
+
'CCBot',
|
|
119
|
+
'Applebot-Extended',
|
|
120
|
+
'Bytespider',
|
|
121
|
+
'Amazonbot',
|
|
122
|
+
'Meta-ExternalAgent',
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/** Escapes a value for use inside a double-quoted HTML attribute (prevents attribute-breakout XSS). */
|
|
126
|
+
function escapeAttr(value: string): string {
|
|
127
|
+
return value
|
|
128
|
+
.replace(/&/g, '&')
|
|
129
|
+
.replace(/"/g, '"')
|
|
130
|
+
.replace(/'/g, ''')
|
|
131
|
+
.replace(/</g, '<')
|
|
132
|
+
.replace(/>/g, '>');
|
|
133
|
+
}
|
|
134
|
+
/** Escapes a value for HTML text content (e.g. `<title>`, XML text). */
|
|
135
|
+
export function escapeHtml(value: string): string {
|
|
136
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
|
|
140
|
+
* which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
|
|
141
|
+
* so attacker-controlled data can't break out of the script element.
|
|
142
|
+
*/
|
|
143
|
+
function escapeJsonForScript(value: unknown): string {
|
|
144
|
+
return JSON.stringify(value)
|
|
145
|
+
.replace(/</g, '\\u003c')
|
|
146
|
+
.replace(/>/g, '\\u003e')
|
|
147
|
+
.replace(/&/g, '\\u0026');
|
|
148
|
+
}
|
|
149
|
+
function meta(attrs: Record<string, string | number | undefined>): string {
|
|
150
|
+
const pairs = Object.entries(attrs)
|
|
151
|
+
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
|
|
152
|
+
.map(([k, v]) => `${k}="${escapeAttr(String(v))}"`);
|
|
153
|
+
return ` <meta ${pairs.join(' ')} />`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Joins a base URL and a route path into a clean absolute URL. */
|
|
157
|
+
export function joinUrl(base: string, path: string): string {
|
|
158
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || base;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Static (parameter-free) route patterns, the ones that can be listed in a sitemap. */
|
|
162
|
+
function staticPaths(routes: readonly ScannedRoute[]): string[] {
|
|
163
|
+
return routes
|
|
164
|
+
.filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern))
|
|
165
|
+
.map((r) => r.pattern)
|
|
166
|
+
.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The site-level `<head>` fragment baked into the built HTML (title is handled separately). */
|
|
170
|
+
export function seoHeadTags(seo: SeoConfig): string {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
if (seo.description !== undefined) lines.push(meta({ name: 'description', content: seo.description }));
|
|
173
|
+
if (seo.robotsMeta !== undefined) lines.push(meta({ name: 'robots', content: seo.robotsMeta }));
|
|
174
|
+
if (seo.themeColor !== undefined) lines.push(meta({ name: 'theme-color', content: seo.themeColor }));
|
|
175
|
+
if (seo.url !== undefined) lines.push(` <link rel="canonical" href="${escapeAttr(seo.url)}" />`);
|
|
176
|
+
|
|
177
|
+
// OpenGraph (also drives Facebook, Discord, Slack, LinkedIn, GitHub previews).
|
|
178
|
+
const og = seo.openGraph;
|
|
179
|
+
const ogTitle = og?.title ?? seo.title;
|
|
180
|
+
const ogDesc = og?.description ?? seo.description;
|
|
181
|
+
if (ogTitle !== undefined) lines.push(meta({ property: 'og:title', content: ogTitle }));
|
|
182
|
+
if (ogDesc !== undefined) lines.push(meta({ property: 'og:description', content: ogDesc }));
|
|
183
|
+
lines.push(meta({ property: 'og:type', content: og?.type ?? 'website' }));
|
|
184
|
+
if (seo.url !== undefined) lines.push(meta({ property: 'og:url', content: seo.url }));
|
|
185
|
+
if (og?.siteName !== undefined) lines.push(meta({ property: 'og:site_name', content: og.siteName }));
|
|
186
|
+
if (og?.locale !== undefined) lines.push(meta({ property: 'og:locale', content: og.locale }));
|
|
187
|
+
if (og?.image !== undefined) {
|
|
188
|
+
lines.push(meta({ property: 'og:image', content: og.image }));
|
|
189
|
+
if (og.imageAlt !== undefined) lines.push(meta({ property: 'og:image:alt', content: og.imageAlt }));
|
|
190
|
+
if (og.imageType !== undefined) lines.push(meta({ property: 'og:image:type', content: og.imageType }));
|
|
191
|
+
if (og.imageWidth !== undefined) lines.push(meta({ property: 'og:image:width', content: og.imageWidth }));
|
|
192
|
+
if (og.imageHeight !== undefined) {
|
|
193
|
+
lines.push(meta({ property: 'og:image:height', content: og.imageHeight }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (seo.facebook?.appId !== undefined) {
|
|
197
|
+
lines.push(meta({ property: 'fb:app_id', content: seo.facebook.appId }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Twitter / X card. Unset fields fall back to OpenGraph / top-level values.
|
|
201
|
+
const tw = seo.twitter;
|
|
202
|
+
if (tw) {
|
|
203
|
+
const twImage = tw.image ?? og?.image;
|
|
204
|
+
const card = tw.card ?? (twImage !== undefined ? 'summary_large_image' : 'summary');
|
|
205
|
+
lines.push(meta({ name: 'twitter:card', content: card }));
|
|
206
|
+
if (tw.site !== undefined) lines.push(meta({ name: 'twitter:site', content: tw.site }));
|
|
207
|
+
if (tw.creator !== undefined) lines.push(meta({ name: 'twitter:creator', content: tw.creator }));
|
|
208
|
+
const twTitle = tw.title ?? ogTitle;
|
|
209
|
+
const twDesc = tw.description ?? ogDesc;
|
|
210
|
+
if (twTitle !== undefined) lines.push(meta({ name: 'twitter:title', content: twTitle }));
|
|
211
|
+
if (twDesc !== undefined) lines.push(meta({ name: 'twitter:description', content: twDesc }));
|
|
212
|
+
if (twImage !== undefined) lines.push(meta({ name: 'twitter:image', content: twImage }));
|
|
213
|
+
const twImageAlt = tw.imageAlt ?? og?.imageAlt;
|
|
214
|
+
if (twImageAlt !== undefined) lines.push(meta({ name: 'twitter:image:alt', content: twImageAlt }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const origin of seo.preconnect ?? []) {
|
|
218
|
+
lines.push(` <link rel="preconnect" href="${escapeAttr(origin)}" />`);
|
|
219
|
+
}
|
|
220
|
+
for (const origin of seo.dnsPrefetch ?? []) {
|
|
221
|
+
lines.push(` <link rel="dns-prefetch" href="${escapeAttr(origin)}" />`);
|
|
222
|
+
}
|
|
223
|
+
if (seo.jsonLd !== undefined) {
|
|
224
|
+
lines.push(` <script type="application/ld+json">${escapeJsonForScript(seo.jsonLd)}</script>`);
|
|
225
|
+
}
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The default document title to bake into the HTML, if any. */
|
|
230
|
+
export function seoTitle(seo: SeoConfig): string | undefined {
|
|
231
|
+
return seo.title;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Bakes the SEO `<head>` into an HTML document: replaces the existing `<title>` and `description`
|
|
236
|
+
* meta (so they aren't duplicated) and inserts the rest before `</head>`. Used for the shell and,
|
|
237
|
+
* per route, by the prerenderer.
|
|
238
|
+
*/
|
|
239
|
+
export function injectSeoHtml(html: string, seo: SeoConfig): string {
|
|
240
|
+
let out = html;
|
|
241
|
+
const title = seoTitle(seo);
|
|
242
|
+
if (title !== undefined) {
|
|
243
|
+
const tag = `<title>${escapeHtml(title)}</title>`;
|
|
244
|
+
out = /<title>[\s\S]*?<\/title>/i.test(out)
|
|
245
|
+
? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
|
|
246
|
+
: out.replace(/<\/head>/i, ` ${tag}\n </head>`);
|
|
247
|
+
}
|
|
248
|
+
if (seo.description !== undefined) {
|
|
249
|
+
out = out.replace(/[ \t]*<meta\s+name=["']description["'][^>]*>\s*\n?/i, '');
|
|
250
|
+
}
|
|
251
|
+
const tags = seoHeadTags(seo);
|
|
252
|
+
if (tags) {
|
|
253
|
+
out = out.includes('</head>') ? out.replace(/<\/head>/i, `${tags}\n </head>`) : `${tags}\n${out}`;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function asString(value: unknown): string | undefined {
|
|
259
|
+
return typeof value === 'string' ? value : undefined;
|
|
260
|
+
}
|
|
261
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
262
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Overlays a route's extracted `metadata` (title/description/openGraph/…) onto the site-wide
|
|
267
|
+
* {@link SeoConfig}, and points the canonical/`og:url` at the route's own URL. The result is what
|
|
268
|
+
* the prerenderer bakes into that route's HTML, per-file metadata winning over the site defaults.
|
|
269
|
+
*/
|
|
270
|
+
export function routeSeo(
|
|
271
|
+
seo: SeoConfig,
|
|
272
|
+
metadata: Record<string, unknown> | null,
|
|
273
|
+
pattern: string,
|
|
274
|
+
): SeoConfig {
|
|
275
|
+
const routeUrl = seo.url !== undefined ? joinUrl(seo.url, pattern) : undefined;
|
|
276
|
+
if (!metadata) return { ...seo, url: routeUrl };
|
|
277
|
+
const og = asRecord(metadata.openGraph);
|
|
278
|
+
return {
|
|
279
|
+
...seo,
|
|
280
|
+
url: asString(metadata.canonical) ?? routeUrl,
|
|
281
|
+
title: asString(metadata.title) ?? seo.title,
|
|
282
|
+
description: asString(metadata.description) ?? seo.description,
|
|
283
|
+
robotsMeta: asString(metadata.robots) ?? seo.robotsMeta,
|
|
284
|
+
themeColor: asString(metadata.themeColor) ?? seo.themeColor,
|
|
285
|
+
openGraph: {
|
|
286
|
+
...seo.openGraph,
|
|
287
|
+
title: asString(og.title) ?? asString(metadata.title) ?? seo.openGraph?.title,
|
|
288
|
+
description: asString(og.description) ?? asString(metadata.description) ?? seo.openGraph?.description,
|
|
289
|
+
type: asString(og.type) ?? seo.openGraph?.type,
|
|
290
|
+
image: asString(og.image) ?? seo.openGraph?.image,
|
|
291
|
+
imageAlt: asString(og.imageAlt) ?? seo.openGraph?.imageAlt,
|
|
292
|
+
siteName: asString(og.siteName) ?? seo.openGraph?.siteName,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** `robots.txt` contents. */
|
|
298
|
+
export function robotsTxt(seo: SeoConfig): string {
|
|
299
|
+
if (seo.robots === false) return '';
|
|
300
|
+
const cfg: RobotsConfig = seo.robots ?? {};
|
|
301
|
+
const blocks: string[] = [];
|
|
302
|
+
|
|
303
|
+
const rules = cfg.rules ?? [{ userAgent: '*', allow: ['/'] }];
|
|
304
|
+
for (const rule of rules) {
|
|
305
|
+
const agents = rule.userAgent === undefined ? ['*'] : [rule.userAgent].flat();
|
|
306
|
+
const lines = agents.map((a) => `User-agent: ${a}`);
|
|
307
|
+
for (const p of rule.allow ?? []) lines.push(`Allow: ${p}`);
|
|
308
|
+
for (const p of rule.disallow ?? []) lines.push(`Disallow: ${p}`);
|
|
309
|
+
blocks.push(lines.join('\n'));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const aiDirective = cfg.ai === 'disallow' ? 'Disallow: /' : 'Allow: /';
|
|
313
|
+
blocks.push(
|
|
314
|
+
['# AI / LLM crawlers', ...AI_CRAWLERS.map((a) => `User-agent: ${a}\n${aiDirective}`)].join('\n\n'),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const sitemap = cfg.sitemap ?? (seo.url !== undefined ? joinUrl(seo.url, 'sitemap.xml') : undefined);
|
|
318
|
+
if (sitemap !== undefined) blocks.push(`Sitemap: ${sitemap}`);
|
|
319
|
+
|
|
320
|
+
return blocks.join('\n\n') + '\n';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `sitemap.xml` from the site's static routes (requires `seo.url`); empty when no base URL. */
|
|
324
|
+
export function sitemapXml(seo: SeoConfig, routes: readonly ScannedRoute[]): string {
|
|
325
|
+
if (seo.url === undefined || seo.sitemap === false) return '';
|
|
326
|
+
const urls = staticPaths(routes)
|
|
327
|
+
.map((p) => ` <url><loc>${escapeHtml(joinUrl(seo.url ?? '', p))}</loc></url>`)
|
|
328
|
+
.join('\n');
|
|
329
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `llms.txt` (AI-crawler guidance) contents; empty when disabled. */
|
|
333
|
+
export function llmsTxt(seo: SeoConfig, routes: readonly ScannedRoute[]): string {
|
|
334
|
+
if (seo.llms === false) return '';
|
|
335
|
+
const cfg: LlmsConfig = seo.llms === true || seo.llms === undefined ? {} : seo.llms;
|
|
336
|
+
const title = cfg.title ?? seo.title ?? seo.url ?? 'Site';
|
|
337
|
+
const out: string[] = [`# ${title}`];
|
|
338
|
+
const summary = cfg.summary ?? seo.description;
|
|
339
|
+
if (summary !== undefined) out.push(`\n> ${summary}`);
|
|
340
|
+
if (cfg.instructions !== undefined) out.push(`\n${cfg.instructions}`);
|
|
341
|
+
|
|
342
|
+
const pages: readonly LlmsPage[] =
|
|
343
|
+
cfg.pages ??
|
|
344
|
+
(seo.url !== undefined
|
|
345
|
+
? staticPaths(routes).map(
|
|
346
|
+
(p): LlmsPage => ({ title: p === '/' ? 'Home' : p, url: joinUrl(seo.url ?? '', p) }),
|
|
347
|
+
)
|
|
348
|
+
: []);
|
|
349
|
+
if (pages.length) {
|
|
350
|
+
out.push('\n## Pages\n');
|
|
351
|
+
for (const page of pages) {
|
|
352
|
+
out.push(`- [${page.title}](${page.url})${page.description !== undefined ? `: ${page.description}` : ''}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out.join('\n') + '\n';
|
|
356
|
+
}
|
package/src/compiler/vite.ts
CHANGED
|
@@ -8,8 +8,10 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
|
|
8
8
|
import { mergeConfig, type InlineConfig, type PluginOption } from 'vite';
|
|
9
9
|
|
|
10
10
|
import { type ResolvedToilConfig } from './config.js';
|
|
11
|
+
import { fontPreloadPlugin } from './fonts.js';
|
|
11
12
|
import { imageReportPlugin } from './image-report.js';
|
|
12
13
|
import { toilPlugin } from './plugin.js';
|
|
14
|
+
import { prerenderPlugin } from './prerender.js';
|
|
13
15
|
|
|
14
16
|
/** Image extensions routed to `images/` in the build output. */
|
|
15
17
|
const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
|
|
@@ -37,8 +39,7 @@ async function tailwindPlugin(root: string): Promise<PluginOption | undefined> {
|
|
|
37
39
|
} catch {
|
|
38
40
|
return undefined;
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
const mod: { default?: () => PluginOption } = await import(pathToFileURL(resolved).href);
|
|
42
|
+
const mod = (await import(pathToFileURL(resolved).href)) as { default?: () => PluginOption };
|
|
42
43
|
return mod.default?.();
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -60,9 +61,9 @@ function manualChunks(id: string): string | undefined {
|
|
|
60
61
|
* `index.html` (built from the project's `public/index.html` template) emits at the output root
|
|
61
62
|
* with assets resolving correctly; static `public/` assets are mirrored to `.toil/public` and
|
|
62
63
|
* picked up via Vite's default publicDir. `fs.allow` opens the project (for `client/`) and the
|
|
63
|
-
* framework runtime. The opinionated default
|
|
64
|
+
* framework runtime. The opinionated default, Node polyfills
|
|
64
65
|
* (Buffer/global/process), React plugin, toil route plugin, typed asset folders, React chunk
|
|
65
|
-
* splitting and tuned build options
|
|
66
|
+
* splitting and tuned build options, is applied here; `toiljs/client` is aliased to the
|
|
66
67
|
* runtime, and the user's `client.vite` overrides deep-merge on top.
|
|
67
68
|
*/
|
|
68
69
|
export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineConfig> {
|
|
@@ -85,6 +86,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
|
|
|
85
86
|
})
|
|
86
87
|
: undefined,
|
|
87
88
|
cfg.images ? imageReportPlugin(cfg.root, cfg.toilDir) : undefined,
|
|
89
|
+
// Static per-route SEO prerender (build only): bakes each route's metadata into its HTML.
|
|
90
|
+
cfg.seo ? prerenderPlugin(cfg) : undefined,
|
|
91
|
+
// Preload bundled fonts (build only). Disabled by `client.fonts: false`.
|
|
92
|
+
cfg.fonts ? fontPreloadPlugin(cfg) : undefined,
|
|
88
93
|
nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
|
|
89
94
|
react(),
|
|
90
95
|
toilPlugin(cfg),
|
package/src/io/FastSet.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type FastRecord, type IndexKey, type PropertyExtendedKey } from './Fast
|
|
|
4
4
|
* The Set counterpart to {@link FastMap}: an insertion-ordered set backed by an array (for
|
|
5
5
|
* iteration/ordering) plus a record index (for O(1) membership), with bigint-key support.
|
|
6
6
|
*
|
|
7
|
-
* Authored to match FastMap's design
|
|
7
|
+
* Authored to match FastMap's design, the upstream package ships no `FastSet`.
|
|
8
8
|
*/
|
|
9
9
|
export class FastSet<T extends PropertyExtendedKey> implements Disposable {
|
|
10
10
|
protected _values: T[] = [];
|
package/src/io/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toiljs IO
|
|
2
|
+
* toiljs IO, native binary serialization + fast collections, exposed to the client both as
|
|
3
3
|
* `toiljs/io` imports and as ambient globals (see the generated `.toil/toil-env.d.ts`).
|
|
4
4
|
*/
|
|
5
5
|
export { BinaryWriter } from './BinaryWriter.js';
|
package/src/io/types.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* toilscript server (WASM) entry, compiled to WebAssembly by `toilscript`.
|
|
3
3
|
*
|
|
4
4
|
* Placeholder module: a trivial exported function that compiles with the toilscript std.
|
|
5
|
-
* Native decorators (e.g. `@main`) ship from toilscript directly
|
|
5
|
+
* Native decorators (e.g. `@main`) ship from toilscript directly, no transformer required.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export function add(a: i32, b: i32): i32 {
|
package/src/server/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server (WASM) entry point, compiled by the toilscript fork (`toilscript --target release`).
|
|
3
3
|
*
|
|
4
|
-
* `@main` is a toilscript-native decorator
|
|
4
|
+
* `@main` is a toilscript-native decorator, no import needed. It marks this
|
|
5
5
|
* function as the module entry; the compiler exports it as the WebAssembly
|
|
6
6
|
* export `main`.
|
|
7
7
|
*/
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DevErrorBoundary,
|
|
7
|
+
DevErrorOverlay,
|
|
8
|
+
initDevErrorOverlay,
|
|
9
|
+
} from '../../src/client/dev/error-overlay';
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
function Boom(): never {
|
|
14
|
+
throw new Error('render boom');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('dev error overlay', () => {
|
|
18
|
+
it('surfaces an uncaught render error', () => {
|
|
19
|
+
// React logs caught boundary errors to console.error — silence it for a clean test run.
|
|
20
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
21
|
+
const { getByRole } = render(
|
|
22
|
+
<>
|
|
23
|
+
<DevErrorBoundary>
|
|
24
|
+
<Boom />
|
|
25
|
+
</DevErrorBoundary>
|
|
26
|
+
<DevErrorOverlay />
|
|
27
|
+
</>,
|
|
28
|
+
);
|
|
29
|
+
expect(getByRole('alert').textContent).toContain('render boom');
|
|
30
|
+
spy.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('surfaces an unhandled window error and dismisses it', async () => {
|
|
34
|
+
initDevErrorOverlay();
|
|
35
|
+
const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
|
|
36
|
+
act(() => {
|
|
37
|
+
window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
|
|
38
|
+
});
|
|
39
|
+
const alert = await findByRole('alert');
|
|
40
|
+
expect(alert.textContent).toContain('async boom');
|
|
41
|
+
fireEvent.click(getByText('Dismiss'));
|
|
42
|
+
expect(queryByRole('alert')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { Router } from '../../src/client/routing/Router';
|
|
6
|
+
import { clearLoaderData, revalidate, useLoaderData } from '../../src/client/routing/loader';
|
|
7
|
+
import type { RouteDef } from '../../src/client/types';
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
clearLoaderData();
|
|
12
|
+
window.history.replaceState({}, '', '/');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('revalidate refetches', () => {
|
|
16
|
+
it('re-runs the loader and updates the rendered data', async () => {
|
|
17
|
+
let n = 0;
|
|
18
|
+
function Page(): React.ReactNode {
|
|
19
|
+
const value = useLoaderData<number>();
|
|
20
|
+
return <p>val:{String(value)}</p>;
|
|
21
|
+
}
|
|
22
|
+
const routes: RouteDef[] = [
|
|
23
|
+
{
|
|
24
|
+
pattern: '/',
|
|
25
|
+
load: () => Promise.resolve({ default: Page, loader: () => (n += 1) }),
|
|
26
|
+
// matches the example: this route has a loading.tsx (keyed boundary + transition).
|
|
27
|
+
loading: () => Promise.resolve({ default: () => <p>loading</p> }),
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
const { findByText } = render(<Router routes={routes} />);
|
|
31
|
+
await findByText('val:1');
|
|
32
|
+
|
|
33
|
+
act(() => {
|
|
34
|
+
revalidate();
|
|
35
|
+
});
|
|
36
|
+
await findByText('val:2');
|
|
37
|
+
});
|
|
38
|
+
});
|