toiljs 0.0.11 → 0.0.12

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 (119) hide show
  1. package/README.md +2 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +10 -4
  4. package/build/cli/create.js +58 -30
  5. package/build/cli/diagnostics.d.ts +55 -0
  6. package/build/cli/diagnostics.js +333 -0
  7. package/build/cli/doctor.d.ts +6 -0
  8. package/build/cli/doctor.js +249 -0
  9. package/build/cli/index.js +26 -0
  10. package/build/cli/proc.d.ts +5 -0
  11. package/build/cli/proc.js +20 -0
  12. package/build/cli/ui.d.ts +1 -0
  13. package/build/cli/ui.js +1 -0
  14. package/build/cli/update.d.ts +7 -0
  15. package/build/cli/update.js +117 -0
  16. package/build/cli/updates.d.ts +10 -0
  17. package/build/cli/updates.js +45 -0
  18. package/build/client/.tsbuildinfo +1 -1
  19. package/build/client/dev/error-overlay.js +1 -1
  20. package/build/client/head/metadata.js +3 -1
  21. package/build/client/index.d.ts +5 -1
  22. package/build/client/index.js +2 -0
  23. package/build/client/navigation/navigation.js +1 -1
  24. package/build/client/routing/Router.js +2 -2
  25. package/build/client/search/search.d.ts +26 -0
  26. package/build/client/search/search.js +101 -0
  27. package/build/client/search/use-page-search.d.ts +8 -0
  28. package/build/client/search/use-page-search.js +21 -0
  29. package/build/compiler/.tsbuildinfo +1 -1
  30. package/build/compiler/generate.js +26 -23
  31. package/build/compiler/index.d.ts +2 -0
  32. package/build/compiler/index.js +1 -0
  33. package/build/compiler/pages.d.ts +8 -0
  34. package/build/compiler/pages.js +37 -0
  35. package/build/compiler/plugin.js +3 -1
  36. package/build/compiler/prerender.d.ts +1 -0
  37. package/build/compiler/prerender.js +11 -5
  38. package/build/compiler/seo.js +10 -3
  39. package/build/io/.tsbuildinfo +1 -1
  40. package/examples/basic/client/components/Header.tsx +43 -41
  41. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  42. package/examples/basic/client/public/index.html +18 -16
  43. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
  44. package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
  45. package/examples/basic/client/routes/about.tsx +21 -22
  46. package/examples/basic/client/routes/blog/[id].tsx +26 -18
  47. package/examples/basic/client/routes/features/actions.tsx +67 -67
  48. package/examples/basic/client/routes/features/error/index.tsx +27 -27
  49. package/examples/basic/client/routes/features/head.tsx +38 -38
  50. package/examples/basic/client/routes/features/index.tsx +83 -75
  51. package/examples/basic/client/routes/features/realtime.tsx +34 -32
  52. package/examples/basic/client/routes/features/script.tsx +31 -31
  53. package/examples/basic/client/routes/features/seo.tsx +39 -39
  54. package/examples/basic/client/routes/features/template/index.tsx +20 -20
  55. package/examples/basic/client/routes/features/template/template.tsx +16 -18
  56. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
  57. package/examples/basic/client/routes/gallery/index.tsx +42 -42
  58. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
  59. package/examples/basic/client/routes/get-started.tsx +157 -84
  60. package/examples/basic/client/routes/index.tsx +137 -96
  61. package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
  62. package/examples/basic/client/routes/search.tsx +61 -0
  63. package/examples/basic/client/routes/test.tsx +7 -8
  64. package/examples/basic/client/styles/main.css +624 -552
  65. package/package.json +2 -2
  66. package/presets/eslint.js +10 -3
  67. package/src/cli/configure.ts +363 -353
  68. package/src/cli/create.ts +563 -530
  69. package/src/cli/diagnostics.ts +421 -0
  70. package/src/cli/doctor.ts +318 -0
  71. package/src/cli/features.ts +166 -160
  72. package/src/cli/index.ts +242 -211
  73. package/src/cli/proc.ts +30 -0
  74. package/src/cli/ui.ts +111 -103
  75. package/src/cli/update.ts +150 -0
  76. package/src/cli/updates.ts +69 -0
  77. package/src/client/components/Image.tsx +91 -89
  78. package/src/client/dev/error-overlay.tsx +193 -197
  79. package/src/client/head/metadata.ts +94 -92
  80. package/src/client/index.ts +79 -64
  81. package/src/client/navigation/Link.tsx +94 -100
  82. package/src/client/navigation/navigation.ts +215 -218
  83. package/src/client/routing/Router.tsx +210 -193
  84. package/src/client/routing/hooks.ts +110 -114
  85. package/src/client/routing/lazy.ts +77 -81
  86. package/src/client/search/search.ts +189 -0
  87. package/src/client/search/use-page-search.ts +73 -0
  88. package/src/compiler/config.ts +173 -171
  89. package/src/compiler/fonts.ts +89 -87
  90. package/src/compiler/generate.ts +378 -373
  91. package/src/compiler/image-report.ts +88 -85
  92. package/src/compiler/index.ts +2 -0
  93. package/src/compiler/pages.ts +70 -0
  94. package/src/compiler/plugin.ts +51 -47
  95. package/src/compiler/prerender.ts +152 -130
  96. package/src/compiler/routes.ts +132 -131
  97. package/src/compiler/seo.ts +381 -356
  98. package/src/compiler/vite.ts +155 -145
  99. package/src/io/FastSet.ts +99 -96
  100. package/test/configure.test.ts +94 -90
  101. package/test/doctor.test.ts +140 -0
  102. package/test/dom/Image.test.tsx +73 -46
  103. package/test/dom/Script.test.tsx +48 -45
  104. package/test/dom/action.test.tsx +146 -129
  105. package/test/dom/error-overlay.test.tsx +44 -44
  106. package/test/dom/loader.test.tsx +2 -2
  107. package/test/dom/revalidate.test.tsx +1 -1
  108. package/test/dom/route-head.test.tsx +1 -2
  109. package/test/dom/slot.test.tsx +131 -109
  110. package/test/dom/view-transitions.test.tsx +53 -51
  111. package/test/features.test.ts +149 -142
  112. package/test/fonts.test.ts +28 -26
  113. package/test/head.test.ts +45 -35
  114. package/test/metadata.test.ts +42 -41
  115. package/test/pages.test.ts +105 -0
  116. package/test/prerender.test.ts +54 -46
  117. package/test/search.test.ts +114 -0
  118. package/test/seo.test.ts +164 -142
  119. package/test/update.test.ts +44 -0
@@ -0,0 +1,105 @@
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 { buildPageIndex, pagesModuleSource } from '../src/compiler/pages';
8
+ import type { ScannedRoute } from '../src/compiler/routes';
9
+
10
+ const dirs: string[] = [];
11
+ /** Writes a throwaway routes dir with the given `{ relPath: source }` files, returns its path. */
12
+ function tmpRoutes(files: Record<string, string>): string {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-pages-'));
14
+ dirs.push(dir);
15
+ for (const [rel, src] of Object.entries(files)) {
16
+ const full = path.join(dir, rel);
17
+ fs.mkdirSync(path.dirname(full), { recursive: true });
18
+ fs.writeFileSync(full, src);
19
+ }
20
+ return dir;
21
+ }
22
+ function route(
23
+ dir: string,
24
+ file: string,
25
+ pattern: string,
26
+ extra: Partial<ScannedRoute> = {},
27
+ ): ScannedRoute {
28
+ return { file: path.join(dir, file), pattern, ...extra };
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('buildPageIndex', () => {
36
+ it('extracts each page metadata and flags dynamic routes', () => {
37
+ const dir = tmpRoutes({
38
+ 'index.tsx': `export const metadata = { title: 'Home' };\nexport default () => null;\n`,
39
+ 'blog/[id].tsx': `export const metadata = { title: 'Post' };\nexport default () => null;\n`,
40
+ });
41
+ const pages = buildPageIndex(process.cwd(), [
42
+ route(dir, 'index.tsx', '/'),
43
+ route(dir, 'blog/[id].tsx', '/blog/:id'),
44
+ ]);
45
+ expect(pages).toEqual([
46
+ { path: '/', dynamic: false, metadata: { title: 'Home' } },
47
+ { path: '/blog/:id', dynamic: true, metadata: { title: 'Post' } },
48
+ ]);
49
+ });
50
+
51
+ it('merges searchHints over static metadata (hints win) for dynamic discoverability', () => {
52
+ const dir = tmpRoutes({
53
+ 'blog/[id].tsx':
54
+ `export const searchHints = { title: 'Blog', keywords: ['posts', 'articles'] };\n` +
55
+ `export const generateMetadata = ({ params }) => ({ title: params.id });\n` +
56
+ `export default () => null;\n`,
57
+ });
58
+ const [page] = buildPageIndex(process.cwd(), [route(dir, 'blog/[id].tsx', '/blog/:id')]);
59
+ expect(page).toEqual({
60
+ path: '/blog/:id',
61
+ dynamic: true,
62
+ metadata: { title: 'Blog', keywords: ['posts', 'articles'] },
63
+ });
64
+ });
65
+
66
+ it('uses an empty metadata object when a route declares none', () => {
67
+ const dir = tmpRoutes({ 'about.tsx': `export default () => null;\n` });
68
+ const pages = buildPageIndex(process.cwd(), [route(dir, 'about.tsx', '/about')]);
69
+ expect(pages).toEqual([{ path: '/about', dynamic: false, metadata: {} }]);
70
+ });
71
+
72
+ it('excludes slots and intercepting routes, and dedupes patterns', () => {
73
+ const dir = tmpRoutes({
74
+ 'index.tsx': `export const metadata = { title: 'Home' };\nexport default () => null;\n`,
75
+ '@modal/photo.tsx': `export default () => null;\n`,
76
+ '(.)photo.tsx': `export default () => null;\n`,
77
+ });
78
+ const pages = buildPageIndex(process.cwd(), [
79
+ route(dir, 'index.tsx', '/'),
80
+ route(dir, '@modal/photo.tsx', '/photo', { slot: 'modal' }),
81
+ route(dir, '(.)photo.tsx', '/photo', { intercept: true }),
82
+ ]);
83
+ expect(pages.map((p) => p.path)).toEqual(['/']);
84
+ });
85
+
86
+ it('sorts the index by path for deterministic output', () => {
87
+ const dir = tmpRoutes({
88
+ 'zed.tsx': `export default () => null;\n`,
89
+ 'about.tsx': `export default () => null;\n`,
90
+ });
91
+ const pages = buildPageIndex(process.cwd(), [
92
+ route(dir, 'zed.tsx', '/zed'),
93
+ route(dir, 'about.tsx', '/about'),
94
+ ]);
95
+ expect(pages.map((p) => p.path)).toEqual(['/about', '/zed']);
96
+ });
97
+ });
98
+
99
+ describe('pagesModuleSource', () => {
100
+ it('emits a typed pages export with JSON-serialized entries', () => {
101
+ const src = pagesModuleSource([{ path: '/', dynamic: false, metadata: { title: 'Home' } }]);
102
+ expect(src).toContain('export const pages: PageMeta[] = [');
103
+ expect(src).toContain('{"path":"/","dynamic":false,"metadata":{"title":"Home"}}');
104
+ });
105
+ });
@@ -1,46 +1,54 @@
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
+ 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(
13
+ os.tmpdir(),
14
+ `toil-prerender-${String(written.length)}-${process.pid}.tsx`,
15
+ );
16
+ fs.writeFileSync(file, source);
17
+ written.push(file);
18
+ return file;
19
+ }
20
+ afterEach(() => {
21
+ for (const f of written.splice(0)) fs.rmSync(f, { force: true });
22
+ });
23
+
24
+ describe('extractStaticMetadata', () => {
25
+ it('extracts a static metadata object literal (nested objects/arrays)', () => {
26
+ const file = tmp(
27
+ `export const metadata = { title: 'X', keywords: ['a', 'b'], openGraph: { type: 'website' } };\n` +
28
+ `export default function P() { return null; }\n`,
29
+ );
30
+ expect(extractStaticMetadata(ts, file)).toEqual({
31
+ title: 'X',
32
+ keywords: ['a', 'b'],
33
+ openGraph: { type: 'website' },
34
+ });
35
+ });
36
+
37
+ it('returns null when there is no static metadata export', () => {
38
+ expect(
39
+ extractStaticMetadata(ts, tmp(`export default function P() { return null; }\n`)),
40
+ ).toBeNull();
41
+ // generateMetadata (a function) is not a static object literal → not extracted.
42
+ expect(
43
+ extractStaticMetadata(
44
+ ts,
45
+ tmp(`export const generateMetadata = () => ({ title: 'X' });\n`),
46
+ ),
47
+ ).toBeNull();
48
+ });
49
+
50
+ it('skips computed/non-literal properties but keeps the static ones', () => {
51
+ const file = tmp(`const x = foo();\nexport const metadata = { title: 'X', dyn: x };\n`);
52
+ expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
53
+ });
54
+ });
@@ -0,0 +1,114 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ getPages,
5
+ type PageMeta,
6
+ pagePath,
7
+ registerPages,
8
+ searchPages,
9
+ } from '../src/client/search/search';
10
+
11
+ const PAGES: PageMeta[] = [
12
+ {
13
+ path: '/',
14
+ dynamic: false,
15
+ metadata: { title: 'Home', description: 'Welcome to the toil demo site' },
16
+ },
17
+ {
18
+ path: '/about',
19
+ dynamic: false,
20
+ metadata: { title: 'About', description: 'Who we are', keywords: ['team', 'company'] },
21
+ },
22
+ {
23
+ path: '/blog',
24
+ dynamic: false,
25
+ metadata: {
26
+ title: 'Blog',
27
+ description: 'Articles and updates',
28
+ openGraph: { title: 'The Blog', siteName: 'toil' },
29
+ },
30
+ },
31
+ { path: '/blog/:id', dynamic: true, metadata: { title: 'Blog post' } },
32
+ { path: '/get-started', dynamic: false, metadata: {} },
33
+ ];
34
+
35
+ beforeEach(() => {
36
+ registerPages(PAGES);
37
+ });
38
+
39
+ describe('searchPages', () => {
40
+ it('returns no results for an empty / whitespace query', () => {
41
+ expect(searchPages('')).toEqual([]);
42
+ expect(searchPages(' ')).toEqual([]);
43
+ });
44
+
45
+ it('ranks a title match above a description-only match', () => {
46
+ const results = searchPages('about');
47
+ expect(results[0].page.path).toBe('/about');
48
+ // Two pages mention "about"? Only /about does here; the home page does not.
49
+ expect(results.map((r) => r.page.path)).toContain('/about');
50
+ expect(results[0].matches).toContain('title');
51
+ });
52
+
53
+ it('matches keywords', () => {
54
+ const results = searchPages('team');
55
+ expect(results).toHaveLength(1);
56
+ expect(results[0].page.path).toBe('/about');
57
+ expect(results[0].matches).toContain('keywords');
58
+ });
59
+
60
+ it('matches OpenGraph fields', () => {
61
+ const results = searchPages('blog', { fields: ['openGraph'] });
62
+ expect(results.map((r) => r.page.path)).toEqual(['/blog']);
63
+ expect(results[0].matches).toEqual(['openGraph']);
64
+ });
65
+
66
+ it('makes slugs word-searchable via the path field', () => {
67
+ const results = searchPages('started');
68
+ expect(results.map((r) => r.page.path)).toContain('/get-started');
69
+ });
70
+
71
+ it('excludes dynamic routes unless includeDynamic is set', () => {
72
+ expect(searchPages('post').map((r) => r.page.path)).not.toContain('/blog/:id');
73
+ expect(searchPages('post', { includeDynamic: true }).map((r) => r.page.path)).toContain(
74
+ '/blog/:id',
75
+ );
76
+ });
77
+
78
+ it('uses AND semantics: every term must match', () => {
79
+ expect(searchPages('blog updates').map((r) => r.page.path)).toEqual(['/blog']);
80
+ expect(searchPages('blog nonexistentword')).toEqual([]);
81
+ });
82
+
83
+ it('honors the limit option', () => {
84
+ // "toil" appears in the home description and the blog's OpenGraph siteName.
85
+ const all = searchPages('toil');
86
+ expect(all.length).toBeGreaterThan(1);
87
+ expect(searchPages('toil', { limit: 1 })).toHaveLength(1);
88
+ });
89
+
90
+ it('restricts matching to the requested fields', () => {
91
+ // "welcome" only lives in the home description; excluding it yields nothing.
92
+ expect(searchPages('welcome', { fields: ['title', 'keywords'] })).toEqual([]);
93
+ expect(searchPages('welcome', { fields: ['description'] }).map((r) => r.page.path)).toEqual(
94
+ ['/'],
95
+ );
96
+ });
97
+
98
+ it('is case-insensitive', () => {
99
+ expect(searchPages('ABOUT')[0].page.path).toBe('/about');
100
+ });
101
+ });
102
+
103
+ describe('registry helpers', () => {
104
+ it('getPages returns the registered index', () => {
105
+ expect(getPages()).toBe(PAGES);
106
+ });
107
+
108
+ it('pagePath normalizes results, pages, and raw strings', () => {
109
+ const result = searchPages('about')[0];
110
+ expect(pagePath(result)).toBe('/about');
111
+ expect(pagePath(result.page)).toBe('/about');
112
+ expect(pagePath('/raw')).toBe('/raw');
113
+ });
114
+ });
package/test/seo.test.ts CHANGED
@@ -1,142 +1,164 @@
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
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { ScannedRoute } from '../src/compiler/routes';
4
+ import {
5
+ injectSeoHtml,
6
+ llmsTxt,
7
+ robotsTxt,
8
+ routeSeo,
9
+ seoHeadTags,
10
+ sitemapXml,
11
+ } from '../src/compiler/seo';
12
+
13
+ const routes: ScannedRoute[] = [
14
+ { file: 'a', pattern: '/' },
15
+ { file: 'b', pattern: '/about' },
16
+ { file: 'c', pattern: '/blog/:id' }, // dynamic — excluded from sitemap
17
+ { file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept — excluded
18
+ ];
19
+
20
+ describe('seoHeadTags', () => {
21
+ it('bakes description, OG, canonical, preconnect, and JSON-LD', () => {
22
+ const html = seoHeadTags({
23
+ url: 'https://x.test',
24
+ title: 'Home',
25
+ description: 'desc',
26
+ openGraph: { type: 'website', image: 'https://x.test/og.png' },
27
+ preconnect: ['https://cdn.test'],
28
+ jsonLd: { '@context': 'https://schema.org', '@type': 'WebSite' },
29
+ });
30
+ expect(html).toContain('<meta name="description" content="desc" />');
31
+ expect(html).toContain('<meta property="og:title" content="Home" />');
32
+ expect(html).toContain('<meta property="og:image" content="https://x.test/og.png" />');
33
+ expect(html).toContain('<link rel="canonical" href="https://x.test" />');
34
+ expect(html).toContain('<link rel="preconnect" href="https://cdn.test" />');
35
+ expect(html).toContain('application/ld+json');
36
+ expect(html).toContain('"@type":"WebSite"');
37
+ });
38
+
39
+ it('escapes attribute values', () => {
40
+ expect(seoHeadTags({ description: 'a "b" <c>' })).toContain(
41
+ 'content="a &quot;b&quot; &lt;c&gt;"',
42
+ );
43
+ });
44
+
45
+ it('renders a full Twitter card + OG image dimensions + fb:app_id', () => {
46
+ const html = seoHeadTags({
47
+ title: 'Home',
48
+ description: 'd',
49
+ openGraph: {
50
+ image: 'https://x.test/og.png',
51
+ imageAlt: 'alt',
52
+ imageWidth: 1200,
53
+ imageHeight: 630,
54
+ },
55
+ twitter: { site: '@x' },
56
+ facebook: { appId: '123' },
57
+ });
58
+ expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />');
59
+ expect(html).toContain('<meta name="twitter:site" content="@x" />');
60
+ expect(html).toContain('<meta name="twitter:title" content="Home" />');
61
+ expect(html).toContain('<meta name="twitter:image" content="https://x.test/og.png" />');
62
+ expect(html).toContain('<meta property="og:image:width" content="1200" />');
63
+ expect(html).toContain('<meta property="og:image:alt" content="alt" />');
64
+ expect(html).toContain('<meta property="fb:app_id" content="123" />');
65
+ });
66
+
67
+ it('neutralizes </script> in JSON-LD (no script breakout)', () => {
68
+ const html = seoHeadTags({ jsonLd: { x: '</script><img src=x onerror=alert(1)>' } });
69
+ expect(html).not.toContain('</script><img');
70
+ expect(html).toContain('\\u003c/script');
71
+ });
72
+ });
73
+
74
+ describe('routeSeo', () => {
75
+ it("overlays a route's metadata over the site defaults and points URLs at the route", () => {
76
+ const site = { url: 'https://x.test', title: 'Site', description: 'site desc' };
77
+ const out = routeSeo(site, { title: 'About', description: 'about desc' }, '/about');
78
+ expect(out.title).toBe('About');
79
+ expect(out.description).toBe('about desc');
80
+ expect(out.url).toBe('https://x.test/about');
81
+ });
82
+
83
+ it('falls back to the site defaults when a route has no metadata', () => {
84
+ const site = { url: 'https://x.test', title: 'Site' };
85
+ expect(routeSeo(site, null, '/x')).toMatchObject({
86
+ title: 'Site',
87
+ url: 'https://x.test/x',
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('injectSeoHtml', () => {
93
+ it('replaces the title + description and inserts the rest before </head>', () => {
94
+ const shell =
95
+ '<!doctype html><html><head><title>old</title><meta name="description" content="" /></head><body></body></html>';
96
+ const out = injectSeoHtml(shell, {
97
+ title: 'New',
98
+ description: 'fresh',
99
+ url: 'https://x.test',
100
+ });
101
+ expect(out).toContain('<title>New</title>');
102
+ expect(out).not.toContain('<title>old</title>');
103
+ expect(out.match(/name="description"/g)).toHaveLength(1);
104
+ expect(out).toContain('content="fresh"');
105
+ expect(out).toContain('<link rel="canonical" href="https://x.test" />');
106
+ });
107
+ });
108
+
109
+ describe('robotsTxt', () => {
110
+ it('allows all + lists AI crawlers + links the sitemap by default', () => {
111
+ const txt = robotsTxt({ url: 'https://x.test' });
112
+ expect(txt).toContain('User-agent: *');
113
+ expect(txt).toContain('Allow: /');
114
+ expect(txt).toContain('User-agent: GPTBot');
115
+ expect(txt).toContain('User-agent: ClaudeBot');
116
+ expect(txt).toContain('Sitemap: https://x.test/sitemap.xml');
117
+ });
118
+
119
+ it('disallows AI crawlers when ai: "disallow"', () => {
120
+ const txt = robotsTxt({ url: 'https://x.test', robots: { ai: 'disallow' } });
121
+ expect(txt).toMatch(/User-agent: GPTBot\nDisallow: \//);
122
+ });
123
+
124
+ it('is empty when robots: false', () => {
125
+ expect(robotsTxt({ robots: false })).toBe('');
126
+ });
127
+ });
128
+
129
+ describe('sitemapXml', () => {
130
+ it('lists only static routes, absolute', () => {
131
+ const xml = sitemapXml({ url: 'https://x.test' }, routes);
132
+ expect(xml).toContain('<loc>https://x.test</loc>');
133
+ expect(xml).toContain('<loc>https://x.test/about</loc>');
134
+ expect(xml).not.toContain(':id');
135
+ expect(xml).not.toContain('/photo');
136
+ });
137
+
138
+ it('is empty without a base url', () => {
139
+ expect(sitemapXml({}, routes)).toBe('');
140
+ });
141
+ });
142
+
143
+ describe('llmsTxt', () => {
144
+ it('renders title, summary, instructions, and pages', () => {
145
+ const txt = llmsTxt(
146
+ {
147
+ url: 'https://x.test',
148
+ title: 'My Site',
149
+ description: 'a site',
150
+ llms: { instructions: 'Be nice.' },
151
+ },
152
+ routes,
153
+ );
154
+ expect(txt).toContain('# My Site');
155
+ expect(txt).toContain('> a site');
156
+ expect(txt).toContain('Be nice.');
157
+ expect(txt).toContain('[Home](https://x.test)');
158
+ expect(txt).toContain('[/about](https://x.test/about)');
159
+ });
160
+
161
+ it('is empty when llms: false', () => {
162
+ expect(llmsTxt({ llms: false }, routes)).toBe('');
163
+ });
164
+ });