toiljs 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +313 -1
- package/assets/logo.svg +37 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/create.js +2 -2
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/head/head.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +23 -6
- package/build/compiler/vite.js +7 -0
- package/examples/basic/client/components/Header.tsx +4 -1
- package/examples/basic/client/layout.tsx +4 -1
- package/examples/basic/client/public/index.html +1 -1
- package/examples/basic/client/routes/(legal)/privacy.tsx +19 -0
- package/examples/basic/client/routes/(legal)/terms.tsx +16 -0
- package/examples/basic/client/routes/about.tsx +8 -5
- package/examples/basic/client/routes/blog/[id].tsx +7 -1
- package/examples/basic/client/routes/features/actions.tsx +67 -0
- package/examples/basic/client/routes/features/error/error.tsx +16 -0
- package/examples/basic/client/routes/features/error/index.tsx +27 -0
- package/examples/basic/client/routes/features/head.tsx +38 -0
- package/examples/basic/client/routes/features/index.tsx +75 -0
- package/examples/basic/client/routes/features/realtime.tsx +32 -0
- package/examples/basic/client/routes/features/script.tsx +31 -0
- package/examples/basic/client/routes/features/seo.tsx +39 -0
- package/examples/basic/client/routes/features/template/b.tsx +14 -0
- package/examples/basic/client/routes/features/template/index.tsx +20 -0
- package/examples/basic/client/routes/features/template/template.tsx +18 -0
- package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
- package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
- package/examples/basic/client/routes/gallery/index.tsx +42 -0
- package/examples/basic/client/routes/gallery/layout.tsx +13 -0
- package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
- package/examples/basic/client/routes/index.tsx +11 -2
- package/examples/basic/client/routes/loader-demo/index.tsx +6 -4
- package/examples/basic/client/toil.tsx +2 -4
- package/package.json +3 -2
- package/src/cli/create.ts +2 -2
- package/src/client/head/head.ts +5 -3
- package/src/compiler/generate.ts +28 -6
- package/src/compiler/vite.ts +15 -0
- package/test/dom/route-head.test.tsx +52 -7
- package/test/slot-layouts.test.ts +69 -0
package/src/client/head/head.ts
CHANGED
|
@@ -70,8 +70,10 @@ 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
|
|
74
|
-
//
|
|
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.
|
|
75
77
|
let routeHead: HeadSpec | null = null;
|
|
76
78
|
|
|
77
79
|
function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
|
|
@@ -86,7 +88,7 @@ function apply(): void {
|
|
|
86
88
|
if (typeof document === 'undefined') return;
|
|
87
89
|
if (baseTitle === null) baseTitle = document.title;
|
|
88
90
|
|
|
89
|
-
const specs = [
|
|
91
|
+
const specs = [...order.map((id) => entries.get(id)), routeHead];
|
|
90
92
|
const resolved = mergeHead(specs.filter((s): s is HeadSpec => !!s));
|
|
91
93
|
|
|
92
94
|
document.title = resolved.title ?? baseTitle;
|
package/src/compiler/generate.ts
CHANGED
|
@@ -47,6 +47,8 @@ export const TOIL_ENV_DTS =
|
|
|
47
47
|
` type Metadata = import('toiljs/client').Metadata;\n` +
|
|
48
48
|
` type GenerateMetadata<T = unknown> = import('toiljs/client').GenerateMetadata<T>;\n` +
|
|
49
49
|
` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
|
|
50
|
+
` type Href = import('toiljs/client').Href;\n` +
|
|
51
|
+
` type RoutePath = import('toiljs/client').RoutePath;\n` +
|
|
50
52
|
`}\n` +
|
|
51
53
|
`declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
|
|
52
54
|
`declare const BinaryReader: typeof import('toiljs/io').BinaryReader;\n` +
|
|
@@ -124,10 +126,23 @@ function routePathUnion(routes: ScannedRoute[]): string {
|
|
|
124
126
|
* The `toil-routes.d.ts` contents: a module augmentation registering the project's route paths so
|
|
125
127
|
* `Link`/`navigate`/`useRouter` hrefs are type-checked. Regenerated each dev/build.
|
|
126
128
|
*/
|
|
127
|
-
function routesDts(routes: ScannedRoute[]): string {
|
|
129
|
+
function routesDts(cfg: ResolvedToilConfig, routes: ScannedRoute[]): string {
|
|
130
|
+
// Type-only namespace import of every route module (erased at build) so editors don't flag a
|
|
131
|
+
// route's `loader` / `metadata` / `generateMetadata` / `revalidate` / `default` exports as unused
|
|
132
|
+
// — the compiler consumes them via dynamic `import()`, which editors don't count as a reference.
|
|
133
|
+
const refs = routes.map((route, i) => {
|
|
134
|
+
let rel = path.relative(cfg.root, route.file).replace(/\\/g, '/').replace(/\.(tsx|jsx)$/, '');
|
|
135
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
136
|
+
return { name: `_toilRoute${String(i)}`, rel };
|
|
137
|
+
});
|
|
138
|
+
const imports = refs.map((m) => `import type * as ${m.name} from ${JSON.stringify(m.rel)};\n`).join('');
|
|
139
|
+
const referenced = refs.length
|
|
140
|
+
? `export type _ToilRouteModules = [${refs.map((m) => `typeof ${m.name}`).join(', ')}];\n`
|
|
141
|
+
: `export {};\n`;
|
|
128
142
|
return (
|
|
129
143
|
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
130
|
-
|
|
144
|
+
imports +
|
|
145
|
+
referenced +
|
|
131
146
|
`declare module 'toiljs/client' {\n` +
|
|
132
147
|
` interface Register {\n` +
|
|
133
148
|
` routePath: ${routePathUnion(routes)};\n` +
|
|
@@ -162,15 +177,22 @@ function findSpecialChain(
|
|
|
162
177
|
includeClientRoot: boolean,
|
|
163
178
|
): string[] {
|
|
164
179
|
const chain: string[] = [];
|
|
165
|
-
|
|
180
|
+
const relDir = path.dirname(path.relative(cfg.routesAbsDir, routeFile));
|
|
181
|
+
const segments = relDir === '.' ? [] : relDir.split(path.sep);
|
|
182
|
+
// A parallel-slot route (one under an `@slot` segment) is rendered INTO a parent layout's
|
|
183
|
+
// `<Slot>`. Its own layout/template chain must therefore start at the `@slot` directory, not the
|
|
184
|
+
// routes root: the parent segments' layouts already wrap the slot, so re-including them here
|
|
185
|
+
// would nest the slot inside itself and recurse. For non-slot routes this is the full chain.
|
|
186
|
+
const slotIdx = segments.findIndex((s) => s.startsWith('@'));
|
|
187
|
+
const startAt = slotIdx < 0 ? 0 : slotIdx + 1;
|
|
188
|
+
if (includeClientRoot && slotIdx < 0) {
|
|
166
189
|
const root = specialIn(cfg.clientAbsDir, base);
|
|
167
190
|
if (root) chain.push(root);
|
|
168
191
|
}
|
|
169
|
-
const relDir = path.dirname(path.relative(cfg.routesAbsDir, routeFile));
|
|
170
|
-
const segments = relDir === '.' ? [] : relDir.split(path.sep);
|
|
171
192
|
let dir = cfg.routesAbsDir;
|
|
172
193
|
for (let i = 0; i <= segments.length; i++) {
|
|
173
194
|
if (i > 0) dir = path.join(dir, segments[i - 1]);
|
|
195
|
+
if (i < startAt) continue;
|
|
174
196
|
const found = specialIn(dir, base);
|
|
175
197
|
if (found) chain.push(found);
|
|
176
198
|
}
|
|
@@ -268,7 +290,7 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
|
|
|
268
290
|
fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
|
|
269
291
|
|
|
270
292
|
fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), TOIL_ENV_DTS);
|
|
271
|
-
fs.writeFileSync(path.join(cfg.root, 'toil-routes.d.ts'), routesDts(routes));
|
|
293
|
+
fs.writeFileSync(path.join(cfg.root, 'toil-routes.d.ts'), routesDts(cfg, routes));
|
|
272
294
|
|
|
273
295
|
fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), buildHtml(cfg));
|
|
274
296
|
syncPublicAssets(cfg);
|
package/src/compiler/vite.ts
CHANGED
|
@@ -13,6 +13,20 @@ import { imageReportPlugin } from './image-report.js';
|
|
|
13
13
|
import { toilPlugin } from './plugin.js';
|
|
14
14
|
import { prerenderPlugin } from './prerender.js';
|
|
15
15
|
|
|
16
|
+
// `vite-plugin-node-polyfills` rewrites react/react-dom to import its `vite-plugin-node-polyfills/
|
|
17
|
+
// shims/*` modules. When a consumer links toiljs by symlink (`file:`/workspace), that package lives
|
|
18
|
+
// only in toiljs's own node_modules, so resolving those bare specifiers from the consumer root
|
|
19
|
+
// fails ("Failed to resolve import vite-plugin-node-polyfills/shims/process"). Alias them to
|
|
20
|
+
// absolute paths resolved from toiljs's location so the build works however toiljs was installed.
|
|
21
|
+
const polyfillPkgRoot = path.dirname(
|
|
22
|
+
path.dirname(createRequire(import.meta.url).resolve('vite-plugin-node-polyfills')),
|
|
23
|
+
);
|
|
24
|
+
const polyfillShimAliases: Record<string, string> = {
|
|
25
|
+
'vite-plugin-node-polyfills/shims/buffer': path.join(polyfillPkgRoot, 'shims/buffer/dist/index.js'),
|
|
26
|
+
'vite-plugin-node-polyfills/shims/global': path.join(polyfillPkgRoot, 'shims/global/dist/index.js'),
|
|
27
|
+
'vite-plugin-node-polyfills/shims/process': path.join(polyfillPkgRoot, 'shims/process/dist/index.js'),
|
|
28
|
+
};
|
|
29
|
+
|
|
16
30
|
/** Image extensions routed to `images/` in the build output. */
|
|
17
31
|
const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
|
|
18
32
|
/** Font extensions routed to `fonts/`. */
|
|
@@ -98,6 +112,7 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
|
|
|
98
112
|
alias: {
|
|
99
113
|
'toiljs/client': cfg.runtimePath,
|
|
100
114
|
'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
|
|
115
|
+
...polyfillShimAliases,
|
|
101
116
|
},
|
|
102
117
|
dedupe: ['react', 'react-dom'],
|
|
103
118
|
},
|
|
@@ -21,14 +21,59 @@ describe('route head (metadata baseline)', () => {
|
|
|
21
21
|
expect(desc()).toBe('about page');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it(
|
|
25
|
-
|
|
26
|
-
function
|
|
27
|
-
useHead({ title: '
|
|
24
|
+
it("wins over a layout's useHead/<Head> defaults for the keys it sets", () => {
|
|
25
|
+
// A layout default title + a route's metadata title: the route metadata should win.
|
|
26
|
+
function LayoutDefaults() {
|
|
27
|
+
useHead({ title: 'Site Default', meta: [{ name: 'description', content: 'site' }] });
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
|
-
render(<
|
|
31
|
-
|
|
32
|
-
expect(
|
|
30
|
+
render(<LayoutDefaults />);
|
|
31
|
+
setRouteHead(resolveMetadata({ title: 'useReducer', description: 'route desc' }));
|
|
32
|
+
expect(document.title).toBe('useReducer');
|
|
33
|
+
expect(desc()).toBe('route desc');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("applies a layout's titleTemplate to the route's title", () => {
|
|
37
|
+
function LayoutDefaults() {
|
|
38
|
+
useHead({ titleTemplate: '%s · toiljs' });
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
render(<LayoutDefaults />);
|
|
42
|
+
setRouteHead(resolveMetadata({ title: 'About' }));
|
|
43
|
+
expect(document.title).toBe('About · toiljs');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Regression for the "metadata title doesn't update" report: a real layout (title + template)
|
|
47
|
+
// plus a route's full `metadata` (the exact shape users write) must land on the route's title,
|
|
48
|
+
// wrapped by the layout template, with the route's og:title applied too.
|
|
49
|
+
it('applies a full route metadata over a layout title + template', () => {
|
|
50
|
+
function LayoutDefaults() {
|
|
51
|
+
useHead({ titleTemplate: '%s | ToilJS', title: 'ToilJS' });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
render(<LayoutDefaults />);
|
|
55
|
+
setRouteHead(
|
|
56
|
+
resolveMetadata({
|
|
57
|
+
title: 'useReducer | React Hooks',
|
|
58
|
+
description: 'Manage complex state with a reducer.',
|
|
59
|
+
openGraph: { title: 'useReducer | React Hooks', type: 'website' },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(document.title).toBe('useReducer | React Hooks | ToilJS');
|
|
63
|
+
expect(
|
|
64
|
+
document.head.querySelector('meta[property="og:title"]')?.getAttribute('content'),
|
|
65
|
+
).toBe('useReducer | React Hooks');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// A route can opt out of the layout's template by setting its own `titleTemplate: '%s'`, so the
|
|
69
|
+
// tab reads exactly the route title with no site suffix.
|
|
70
|
+
it("lets a route override the layout template with its own '%s'", () => {
|
|
71
|
+
function LayoutDefaults() {
|
|
72
|
+
useHead({ titleTemplate: '%s | ToilJS', title: 'ToilJS' });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
render(<LayoutDefaults />);
|
|
76
|
+
setRouteHead(resolveMetadata({ title: 'useReducer | React Hooks', titleTemplate: '%s' }));
|
|
77
|
+
expect(document.title).toBe('useReducer | React Hooks');
|
|
33
78
|
});
|
|
34
79
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { loadConfig } from '../src/compiler/config';
|
|
8
|
+
import { generate } from '../src/compiler/generate';
|
|
9
|
+
|
|
10
|
+
const roots: string[] = [];
|
|
11
|
+
function project(files: Record<string, string>): string {
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-gen-'));
|
|
13
|
+
roots.push(root);
|
|
14
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
15
|
+
const abs = path.join(root, rel);
|
|
16
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
17
|
+
fs.writeFileSync(abs, content);
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const COMP = `export default function C() { return null; }\n`;
|
|
26
|
+
const LAYOUT = `export default function L({ children }) { return children; }\n`;
|
|
27
|
+
const HTML = `<!doctype html><html><head></head><body><div id="root"></div></body></html>\n`;
|
|
28
|
+
|
|
29
|
+
describe('generate: parallel-slot layout chains', () => {
|
|
30
|
+
it('keeps the parent layout on the full-page route but drops it from the @slot route', async () => {
|
|
31
|
+
const root = project({
|
|
32
|
+
'client/public/index.html': HTML,
|
|
33
|
+
'client/routes/gallery/layout.tsx': LAYOUT,
|
|
34
|
+
'client/routes/gallery/index.tsx': COMP,
|
|
35
|
+
'client/routes/gallery/photo/[id].tsx': COMP,
|
|
36
|
+
'client/routes/gallery/@modal/(.)photo/[id].tsx': COMP,
|
|
37
|
+
});
|
|
38
|
+
const cfg = await loadConfig({ root });
|
|
39
|
+
generate(cfg);
|
|
40
|
+
const lines = fs.readFileSync(path.join(cfg.toilDir, 'routes.ts'), 'utf8').split('\n');
|
|
41
|
+
|
|
42
|
+
// The normal full-page route is wrapped by gallery/layout.
|
|
43
|
+
const mainLine = lines.find((l) => l.includes('photo/[id]') && !l.includes('@modal'));
|
|
44
|
+
expect(mainLine).toMatch(/gallery\/layout/);
|
|
45
|
+
|
|
46
|
+
// The intercepting @modal slot route is rendered INTO gallery/layout's <Slot>, so it must not
|
|
47
|
+
// re-include that layout (doing so recurses, the slot rendering itself forever).
|
|
48
|
+
const slotLine = lines.find((l) => l.includes('@modal'));
|
|
49
|
+
expect(slotLine).toContain('intercept: true');
|
|
50
|
+
expect(slotLine).toMatch(/layouts: \[\]/);
|
|
51
|
+
expect(slotLine).not.toMatch(/gallery\/layout/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('still applies a layout placed inside the @slot subtree', async () => {
|
|
55
|
+
const root = project({
|
|
56
|
+
'client/public/index.html': HTML,
|
|
57
|
+
'client/routes/gallery/layout.tsx': LAYOUT,
|
|
58
|
+
'client/routes/gallery/@modal/layout.tsx': LAYOUT,
|
|
59
|
+
'client/routes/gallery/@modal/(.)photo/[id].tsx': COMP,
|
|
60
|
+
});
|
|
61
|
+
const cfg = await loadConfig({ root });
|
|
62
|
+
generate(cfg);
|
|
63
|
+
const lines = fs.readFileSync(path.join(cfg.toilDir, 'routes.ts'), 'utf8').split('\n');
|
|
64
|
+
const slotLine = lines.find((l) => l.includes('@modal/(.)photo'));
|
|
65
|
+
// The slot's own layout (inside @modal) applies; the parent gallery layout does not.
|
|
66
|
+
expect(slotLine).toMatch(/@modal\/layout/);
|
|
67
|
+
expect(slotLine).not.toMatch(/gallery\/layout/);
|
|
68
|
+
});
|
|
69
|
+
});
|