toiljs 0.0.10 → 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 (39) 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/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/generate.js +9 -3
  7. package/build/compiler/vite.js +7 -0
  8. package/examples/basic/client/components/Header.tsx +4 -1
  9. package/examples/basic/client/layout.tsx +4 -1
  10. package/examples/basic/client/public/index.html +1 -1
  11. package/examples/basic/client/routes/(legal)/privacy.tsx +19 -0
  12. package/examples/basic/client/routes/(legal)/terms.tsx +16 -0
  13. package/examples/basic/client/routes/about.tsx +8 -5
  14. package/examples/basic/client/routes/blog/[id].tsx +7 -1
  15. package/examples/basic/client/routes/features/actions.tsx +67 -0
  16. package/examples/basic/client/routes/features/error/error.tsx +16 -0
  17. package/examples/basic/client/routes/features/error/index.tsx +27 -0
  18. package/examples/basic/client/routes/features/head.tsx +38 -0
  19. package/examples/basic/client/routes/features/index.tsx +75 -0
  20. package/examples/basic/client/routes/features/realtime.tsx +32 -0
  21. package/examples/basic/client/routes/features/script.tsx +31 -0
  22. package/examples/basic/client/routes/features/seo.tsx +39 -0
  23. package/examples/basic/client/routes/features/template/b.tsx +14 -0
  24. package/examples/basic/client/routes/features/template/index.tsx +20 -0
  25. package/examples/basic/client/routes/features/template/template.tsx +18 -0
  26. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
  27. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
  28. package/examples/basic/client/routes/gallery/index.tsx +42 -0
  29. package/examples/basic/client/routes/gallery/layout.tsx +13 -0
  30. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
  31. package/examples/basic/client/routes/index.tsx +11 -2
  32. package/examples/basic/client/routes/loader-demo/index.tsx +6 -4
  33. package/examples/basic/client/toil.tsx +2 -4
  34. package/package.json +3 -2
  35. package/src/cli/create.ts +2 -2
  36. package/src/compiler/generate.ts +12 -3
  37. package/src/compiler/vite.ts +15 -0
  38. package/test/dom/route-head.test.tsx +34 -0
  39. package/test/slot-layouts.test.ts +69 -0
@@ -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` +
@@ -175,15 +177,22 @@ function findSpecialChain(
175
177
  includeClientRoot: boolean,
176
178
  ): string[] {
177
179
  const chain: string[] = [];
178
- 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) {
179
189
  const root = specialIn(cfg.clientAbsDir, base);
180
190
  if (root) chain.push(root);
181
191
  }
182
- const relDir = path.dirname(path.relative(cfg.routesAbsDir, routeFile));
183
- const segments = relDir === '.' ? [] : relDir.split(path.sep);
184
192
  let dir = cfg.routesAbsDir;
185
193
  for (let i = 0; i <= segments.length; i++) {
186
194
  if (i > 0) dir = path.join(dir, segments[i - 1]);
195
+ if (i < startAt) continue;
187
196
  const found = specialIn(dir, base);
188
197
  if (found) chain.push(found);
189
198
  }
@@ -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
  },
@@ -42,4 +42,38 @@ describe('route head (metadata baseline)', () => {
42
42
  setRouteHead(resolveMetadata({ title: 'About' }));
43
43
  expect(document.title).toBe('About · toiljs');
44
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');
78
+ });
45
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
+ });