toiljs 0.0.7 → 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.
Files changed (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. package/examples/basic/client/template.tsx +0 -7
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveMetadata } from '../src/client/head/metadata';
4
+
5
+ describe('resolveMetadata', () => {
6
+ it('expands convenience fields into meta/link tags', () => {
7
+ const head = resolveMetadata({
8
+ title: 'About',
9
+ titleTemplate: '%s · toiljs',
10
+ description: 'desc',
11
+ keywords: ['a', 'b'],
12
+ robots: 'noindex',
13
+ themeColor: '#000',
14
+ canonical: 'https://x.test/about',
15
+ openGraph: { title: 'OG', type: 'website', image: 'https://x.test/og.png' },
16
+ });
17
+
18
+ expect(head.title).toBe('About');
19
+ expect(head.titleTemplate).toBe('%s · toiljs');
20
+ const byName = (name: string) => head.meta?.find((m) => m.name === name)?.content;
21
+ const byProp = (property: string) => head.meta?.find((m) => m.property === property)?.content;
22
+ expect(byName('description')).toBe('desc');
23
+ expect(byName('keywords')).toBe('a, b');
24
+ expect(byName('robots')).toBe('noindex');
25
+ expect(byName('theme-color')).toBe('#000');
26
+ expect(byProp('og:title')).toBe('OG');
27
+ expect(byProp('og:type')).toBe('website');
28
+ expect(byProp('og:image')).toBe('https://x.test/og.png');
29
+ expect(head.link?.find((l) => l.rel === 'canonical')?.href).toBe('https://x.test/about');
30
+ });
31
+
32
+ it('passes through raw meta/link and omits unset fields', () => {
33
+ const head = resolveMetadata({
34
+ title: 'X',
35
+ meta: [{ name: 'author', content: 'me' }],
36
+ link: [{ rel: 'alternate', href: '/rss' }],
37
+ });
38
+ expect(head.meta).toEqual([{ name: 'author', content: 'me' }]);
39
+ expect(head.link).toEqual([{ rel: 'alternate', href: '/rss' }]);
40
+ });
41
+ });
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import ts from 'typescript';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { extractStaticMetadata } from '../src/compiler/prerender';
9
+
10
+ const written: string[] = [];
11
+ function tmp(source: string): string {
12
+ const file = path.join(os.tmpdir(), `toil-prerender-${String(written.length)}-${process.pid}.tsx`);
13
+ fs.writeFileSync(file, source);
14
+ written.push(file);
15
+ return file;
16
+ }
17
+ afterEach(() => {
18
+ for (const f of written.splice(0)) fs.rmSync(f, { force: true });
19
+ });
20
+
21
+ describe('extractStaticMetadata', () => {
22
+ it('extracts a static metadata object literal (nested objects/arrays)', () => {
23
+ const file = tmp(
24
+ `export const metadata = { title: 'X', keywords: ['a', 'b'], openGraph: { type: 'website' } };\n` +
25
+ `export default function P() { return null; }\n`,
26
+ );
27
+ expect(extractStaticMetadata(ts, file)).toEqual({
28
+ title: 'X',
29
+ keywords: ['a', 'b'],
30
+ openGraph: { type: 'website' },
31
+ });
32
+ });
33
+
34
+ it('returns null when there is no static metadata export', () => {
35
+ expect(extractStaticMetadata(ts, tmp(`export default function P() { return null; }\n`))).toBeNull();
36
+ // generateMetadata (a function) is not a static object literal → not extracted.
37
+ expect(
38
+ extractStaticMetadata(ts, tmp(`export const generateMetadata = () => ({ title: 'X' });\n`)),
39
+ ).toBeNull();
40
+ });
41
+
42
+ it('skips computed/non-literal properties but keeps the static ones', () => {
43
+ const file = tmp(`const x = foo();\nexport const metadata = { title: 'X', dyn: x };\n`);
44
+ expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
45
+ });
46
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import { matchRoute } from '../src/client/routing/match';
4
- import { filePathToRoute } from '../src/compiler/routes';
4
+ import { filePathToRoute, interceptTarget } from '../src/compiler/routes';
5
5
 
6
6
  describe('filePathToRoute', () => {
7
7
  it('maps index, static, nested, and dynamic files to patterns', () => {
@@ -20,6 +20,25 @@ describe('filePathToRoute', () => {
20
20
  expect(filePathToRoute('(shop)/index.tsx')).toBe('/');
21
21
  expect(filePathToRoute('(a)/(b)/deep.tsx')).toBe('/deep');
22
22
  });
23
+
24
+ it('strips parallel-slot (@slot) segments from the URL', () => {
25
+ expect(filePathToRoute('@modal/photo/[id].tsx')).toBe('/photo/:id');
26
+ expect(filePathToRoute('@sidebar/index.tsx')).toBe('/');
27
+ expect(filePathToRoute('dashboard/@chart/views.tsx')).toBe('/dashboard/views');
28
+ });
29
+ });
30
+
31
+ describe('interceptTarget', () => {
32
+ it('resolves (.)/(..)/(...) marker targets', () => {
33
+ expect(interceptTarget('@modal/(.)photo/[id].tsx')).toBe('/photo/:id');
34
+ expect(interceptTarget('feed/@modal/(..)photo/[id].tsx')).toBe('/photo/:id');
35
+ expect(interceptTarget('a/b/@m/(...)login.tsx')).toBe('/login');
36
+ });
37
+
38
+ it('returns null for routes with no interception marker', () => {
39
+ expect(interceptTarget('photo/[id].tsx')).toBeNull();
40
+ expect(interceptTarget('@modal/settings.tsx')).toBeNull();
41
+ });
23
42
  });
24
43
 
25
44
  describe('matchRoute', () => {
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { ScannedRoute } from '../src/compiler/routes';
4
+ import { injectSeoHtml, llmsTxt, robotsTxt, routeSeo, seoHeadTags, sitemapXml } from '../src/compiler/seo';
5
+
6
+ const routes: ScannedRoute[] = [
7
+ { file: 'a', pattern: '/' },
8
+ { file: 'b', pattern: '/about' },
9
+ { file: 'c', pattern: '/blog/:id' }, // dynamic — excluded from sitemap
10
+ { file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept — excluded
11
+ ];
12
+
13
+ describe('seoHeadTags', () => {
14
+ it('bakes description, OG, canonical, preconnect, and JSON-LD', () => {
15
+ const html = seoHeadTags({
16
+ url: 'https://x.test',
17
+ title: 'Home',
18
+ description: 'desc',
19
+ openGraph: { type: 'website', image: 'https://x.test/og.png' },
20
+ preconnect: ['https://cdn.test'],
21
+ jsonLd: { '@context': 'https://schema.org', '@type': 'WebSite' },
22
+ });
23
+ expect(html).toContain('<meta name="description" content="desc" />');
24
+ expect(html).toContain('<meta property="og:title" content="Home" />');
25
+ expect(html).toContain('<meta property="og:image" content="https://x.test/og.png" />');
26
+ expect(html).toContain('<link rel="canonical" href="https://x.test" />');
27
+ expect(html).toContain('<link rel="preconnect" href="https://cdn.test" />');
28
+ expect(html).toContain('application/ld+json');
29
+ expect(html).toContain('"@type":"WebSite"');
30
+ });
31
+
32
+ it('escapes attribute values', () => {
33
+ expect(seoHeadTags({ description: 'a "b" <c>' })).toContain('content="a &quot;b&quot; &lt;c&gt;"');
34
+ });
35
+
36
+ it('renders a full Twitter card + OG image dimensions + fb:app_id', () => {
37
+ const html = seoHeadTags({
38
+ title: 'Home',
39
+ description: 'd',
40
+ openGraph: { image: 'https://x.test/og.png', imageAlt: 'alt', imageWidth: 1200, imageHeight: 630 },
41
+ twitter: { site: '@x' },
42
+ facebook: { appId: '123' },
43
+ });
44
+ expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />');
45
+ expect(html).toContain('<meta name="twitter:site" content="@x" />');
46
+ expect(html).toContain('<meta name="twitter:title" content="Home" />');
47
+ expect(html).toContain('<meta name="twitter:image" content="https://x.test/og.png" />');
48
+ expect(html).toContain('<meta property="og:image:width" content="1200" />');
49
+ expect(html).toContain('<meta property="og:image:alt" content="alt" />');
50
+ expect(html).toContain('<meta property="fb:app_id" content="123" />');
51
+ });
52
+
53
+ it('neutralizes </script> in JSON-LD (no script breakout)', () => {
54
+ const html = seoHeadTags({ jsonLd: { x: '</script><img src=x onerror=alert(1)>' } });
55
+ expect(html).not.toContain('</script><img');
56
+ expect(html).toContain('\\u003c/script');
57
+ });
58
+ });
59
+
60
+ describe('routeSeo', () => {
61
+ it("overlays a route's metadata over the site defaults and points URLs at the route", () => {
62
+ const site = { url: 'https://x.test', title: 'Site', description: 'site desc' };
63
+ const out = routeSeo(site, { title: 'About', description: 'about desc' }, '/about');
64
+ expect(out.title).toBe('About');
65
+ expect(out.description).toBe('about desc');
66
+ expect(out.url).toBe('https://x.test/about');
67
+ });
68
+
69
+ it('falls back to the site defaults when a route has no metadata', () => {
70
+ const site = { url: 'https://x.test', title: 'Site' };
71
+ expect(routeSeo(site, null, '/x')).toMatchObject({ title: 'Site', url: 'https://x.test/x' });
72
+ });
73
+ });
74
+
75
+ describe('injectSeoHtml', () => {
76
+ it('replaces the title + description and inserts the rest before </head>', () => {
77
+ const shell = '<!doctype html><html><head><title>old</title><meta name="description" content="" /></head><body></body></html>';
78
+ const out = injectSeoHtml(shell, { title: 'New', description: 'fresh', url: 'https://x.test' });
79
+ expect(out).toContain('<title>New</title>');
80
+ expect(out).not.toContain('<title>old</title>');
81
+ expect(out.match(/name="description"/g)).toHaveLength(1);
82
+ expect(out).toContain('content="fresh"');
83
+ expect(out).toContain('<link rel="canonical" href="https://x.test" />');
84
+ });
85
+ });
86
+
87
+ describe('robotsTxt', () => {
88
+ it('allows all + lists AI crawlers + links the sitemap by default', () => {
89
+ const txt = robotsTxt({ url: 'https://x.test' });
90
+ expect(txt).toContain('User-agent: *');
91
+ expect(txt).toContain('Allow: /');
92
+ expect(txt).toContain('User-agent: GPTBot');
93
+ expect(txt).toContain('User-agent: ClaudeBot');
94
+ expect(txt).toContain('Sitemap: https://x.test/sitemap.xml');
95
+ });
96
+
97
+ it('disallows AI crawlers when ai: "disallow"', () => {
98
+ const txt = robotsTxt({ url: 'https://x.test', robots: { ai: 'disallow' } });
99
+ expect(txt).toMatch(/User-agent: GPTBot\nDisallow: \//);
100
+ });
101
+
102
+ it('is empty when robots: false', () => {
103
+ expect(robotsTxt({ robots: false })).toBe('');
104
+ });
105
+ });
106
+
107
+ describe('sitemapXml', () => {
108
+ it('lists only static routes, absolute', () => {
109
+ const xml = sitemapXml({ url: 'https://x.test' }, routes);
110
+ expect(xml).toContain('<loc>https://x.test</loc>');
111
+ expect(xml).toContain('<loc>https://x.test/about</loc>');
112
+ expect(xml).not.toContain(':id');
113
+ expect(xml).not.toContain('/photo');
114
+ });
115
+
116
+ it('is empty without a base url', () => {
117
+ expect(sitemapXml({}, routes)).toBe('');
118
+ });
119
+ });
120
+
121
+ describe('llmsTxt', () => {
122
+ it('renders title, summary, instructions, and pages', () => {
123
+ const txt = llmsTxt(
124
+ {
125
+ url: 'https://x.test',
126
+ title: 'My Site',
127
+ description: 'a site',
128
+ llms: { instructions: 'Be nice.' },
129
+ },
130
+ routes,
131
+ );
132
+ expect(txt).toContain('# My Site');
133
+ expect(txt).toContain('> a site');
134
+ expect(txt).toContain('Be nice.');
135
+ expect(txt).toContain('[Home](https://x.test)');
136
+ expect(txt).toContain('[/about](https://x.test/about)');
137
+ });
138
+
139
+ it('is empty when llms: false', () => {
140
+ expect(llmsTxt({ llms: false }, routes)).toBe('');
141
+ });
142
+ });
@@ -1,7 +0,0 @@
1
- import { type ReactNode } from 'react';
2
-
3
- // Like `layout.tsx`, but re-mounted on every navigation (keyed by pathname) instead of persisting.
4
- // Use it for per-navigation effects — enter animations, resetting state, replaying transitions.
5
- export default function Template({ children }: { children?: ReactNode }) {
6
- return <div className="route-transition">{children}</div>;
7
- }