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.
Files changed (42) hide show
  1. package/README.md +313 -1
  2. package/assets/logo.svg +37 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/create.js +2 -2
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/head/head.js +1 -1
  7. package/build/compiler/.tsbuildinfo +1 -1
  8. package/build/compiler/generate.js +23 -6
  9. package/build/compiler/vite.js +7 -0
  10. package/examples/basic/client/components/Header.tsx +4 -1
  11. package/examples/basic/client/layout.tsx +4 -1
  12. package/examples/basic/client/public/index.html +1 -1
  13. package/examples/basic/client/routes/(legal)/privacy.tsx +19 -0
  14. package/examples/basic/client/routes/(legal)/terms.tsx +16 -0
  15. package/examples/basic/client/routes/about.tsx +8 -5
  16. package/examples/basic/client/routes/blog/[id].tsx +7 -1
  17. package/examples/basic/client/routes/features/actions.tsx +67 -0
  18. package/examples/basic/client/routes/features/error/error.tsx +16 -0
  19. package/examples/basic/client/routes/features/error/index.tsx +27 -0
  20. package/examples/basic/client/routes/features/head.tsx +38 -0
  21. package/examples/basic/client/routes/features/index.tsx +75 -0
  22. package/examples/basic/client/routes/features/realtime.tsx +32 -0
  23. package/examples/basic/client/routes/features/script.tsx +31 -0
  24. package/examples/basic/client/routes/features/seo.tsx +39 -0
  25. package/examples/basic/client/routes/features/template/b.tsx +14 -0
  26. package/examples/basic/client/routes/features/template/index.tsx +20 -0
  27. package/examples/basic/client/routes/features/template/template.tsx +18 -0
  28. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
  29. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
  30. package/examples/basic/client/routes/gallery/index.tsx +42 -0
  31. package/examples/basic/client/routes/gallery/layout.tsx +13 -0
  32. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
  33. package/examples/basic/client/routes/index.tsx +11 -2
  34. package/examples/basic/client/routes/loader-demo/index.tsx +6 -4
  35. package/examples/basic/client/toil.tsx +2 -4
  36. package/package.json +3 -2
  37. package/src/cli/create.ts +2 -2
  38. package/src/client/head/head.ts +5 -3
  39. package/src/compiler/generate.ts +28 -6
  40. package/src/compiler/vite.ts +15 -0
  41. package/test/dom/route-head.test.tsx +52 -7
  42. package/test/slot-layouts.test.ts +69 -0
@@ -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, the lowest-priority spec, so component `useHead`/`<Head>`
74
- // always compose on top of it. Set by the router via `setRouteHead` on each navigation.
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 = [routeHead, ...order.map((id) => entries.get(id))];
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;
@@ -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
- `export {};\n` +
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
- if (includeClientRoot) {
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);
@@ -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('is the lowest priority component useHead overrides it', () => {
25
- setRouteHead(resolveMetadata({ title: 'Base', description: 'base' }));
26
- function Page() {
27
- useHead({ title: 'Override', meta: [{ name: 'description', content: 'override' }] });
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(<Page />);
31
- expect(document.title).toBe('Override');
32
- expect(desc()).toBe('override');
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
+ });