toiljs 0.0.11 → 0.0.14
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 +3 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +10 -4
- package/build/cli/create.js +58 -30
- package/build/cli/diagnostics.d.ts +55 -0
- package/build/cli/diagnostics.js +333 -0
- package/build/cli/doctor.d.ts +6 -0
- package/build/cli/doctor.js +249 -0
- package/build/cli/index.js +26 -0
- package/build/cli/proc.d.ts +5 -0
- package/build/cli/proc.js +20 -0
- package/build/cli/ui.d.ts +1 -0
- package/build/cli/ui.js +1 -0
- package/build/cli/update.d.ts +7 -0
- package/build/cli/update.js +117 -0
- package/build/cli/updates.d.ts +10 -0
- package/build/cli/updates.js +45 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/head/metadata.js +3 -1
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +2 -0
- package/build/client/navigation/navigation.js +1 -1
- package/build/client/routing/Router.js +2 -2
- package/build/client/search/search.d.ts +26 -0
- package/build/client/search/search.js +101 -0
- package/build/client/search/use-page-search.d.ts +8 -0
- package/build/client/search/use-page-search.js +21 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +33 -24
- package/build/compiler/index.d.ts +2 -0
- package/build/compiler/index.js +1 -0
- package/build/compiler/pages.d.ts +8 -0
- package/build/compiler/pages.js +37 -0
- package/build/compiler/plugin.js +3 -1
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +11 -5
- package/build/compiler/seo.js +10 -3
- package/build/io/.tsbuildinfo +1 -1
- package/examples/basic/client/components/Header.tsx +43 -41
- package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
- package/examples/basic/client/public/index.html +18 -16
- package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
- package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
- package/examples/basic/client/routes/about.tsx +21 -22
- package/examples/basic/client/routes/blog/[id].tsx +26 -18
- package/examples/basic/client/routes/features/actions.tsx +67 -67
- package/examples/basic/client/routes/features/error/index.tsx +27 -27
- package/examples/basic/client/routes/features/head.tsx +38 -38
- package/examples/basic/client/routes/features/index.tsx +83 -75
- package/examples/basic/client/routes/features/realtime.tsx +34 -32
- package/examples/basic/client/routes/features/script.tsx +31 -31
- package/examples/basic/client/routes/features/seo.tsx +39 -39
- package/examples/basic/client/routes/features/template/index.tsx +20 -20
- package/examples/basic/client/routes/features/template/template.tsx +16 -18
- package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
- package/examples/basic/client/routes/gallery/index.tsx +42 -42
- package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
- package/examples/basic/client/routes/get-started.tsx +157 -84
- package/examples/basic/client/routes/index.tsx +137 -96
- package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
- package/examples/basic/client/routes/search.tsx +61 -0
- package/examples/basic/client/routes/test.tsx +7 -8
- package/examples/basic/client/styles/main.css +624 -552
- package/package.json +2 -2
- package/presets/eslint.js +10 -3
- package/src/cli/configure.ts +363 -353
- package/src/cli/create.ts +563 -530
- package/src/cli/diagnostics.ts +421 -0
- package/src/cli/doctor.ts +318 -0
- package/src/cli/features.ts +166 -160
- package/src/cli/index.ts +242 -211
- package/src/cli/proc.ts +30 -0
- package/src/cli/ui.ts +111 -103
- package/src/cli/update.ts +150 -0
- package/src/cli/updates.ts +69 -0
- package/src/client/components/Image.tsx +91 -89
- package/src/client/dev/error-overlay.tsx +193 -197
- package/src/client/head/metadata.ts +94 -92
- package/src/client/index.ts +79 -64
- package/src/client/navigation/Link.tsx +94 -100
- package/src/client/navigation/navigation.ts +215 -218
- package/src/client/routing/Router.tsx +210 -193
- package/src/client/routing/hooks.ts +110 -114
- package/src/client/routing/lazy.ts +77 -81
- package/src/client/search/search.ts +189 -0
- package/src/client/search/use-page-search.ts +73 -0
- package/src/compiler/config.ts +173 -171
- package/src/compiler/fonts.ts +89 -87
- package/src/compiler/generate.ts +45 -27
- package/src/compiler/image-report.ts +88 -85
- package/src/compiler/index.ts +2 -0
- package/src/compiler/pages.ts +70 -0
- package/src/compiler/plugin.ts +51 -47
- package/src/compiler/prerender.ts +152 -130
- package/src/compiler/routes.ts +132 -131
- package/src/compiler/seo.ts +381 -356
- package/src/compiler/vite.ts +155 -145
- package/src/io/FastSet.ts +99 -96
- package/test/configure.test.ts +94 -90
- package/test/doctor.test.ts +140 -0
- package/test/dom/Image.test.tsx +73 -46
- package/test/dom/Script.test.tsx +48 -45
- package/test/dom/action.test.tsx +146 -129
- package/test/dom/error-overlay.test.tsx +1 -1
- package/test/dom/loader.test.tsx +2 -2
- package/test/dom/revalidate.test.tsx +1 -1
- package/test/dom/route-head.test.tsx +1 -2
- package/test/dom/router-loading.test.tsx +1 -1
- package/test/dom/slot.test.tsx +131 -109
- package/test/dom/view-transitions.test.tsx +53 -51
- package/test/features.test.ts +149 -142
- package/test/fonts.test.ts +28 -26
- package/test/head.test.ts +45 -35
- package/test/metadata.test.ts +42 -41
- package/test/pages.test.ts +105 -0
- package/test/prerender.test.ts +54 -46
- package/test/search.test.ts +114 -0
- package/test/seo.test.ts +30 -8
- 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
|
+
});
|
package/test/prerender.test.ts
CHANGED
|
@@ -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(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,13 +1,20 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import type { ScannedRoute } from '../src/compiler/routes';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
injectSeoHtml,
|
|
6
|
+
llmsTxt,
|
|
7
|
+
robotsTxt,
|
|
8
|
+
routeSeo,
|
|
9
|
+
seoHeadTags,
|
|
10
|
+
sitemapXml,
|
|
11
|
+
} from '../src/compiler/seo';
|
|
5
12
|
|
|
6
13
|
const routes: ScannedRoute[] = [
|
|
7
14
|
{ file: 'a', pattern: '/' },
|
|
8
15
|
{ file: 'b', pattern: '/about' },
|
|
9
|
-
{ file: 'c', pattern: '/blog/:id' }, // dynamic
|
|
10
|
-
{ file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept
|
|
16
|
+
{ file: 'c', pattern: '/blog/:id' }, // dynamic, excluded from sitemap
|
|
17
|
+
{ file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept, excluded
|
|
11
18
|
];
|
|
12
19
|
|
|
13
20
|
describe('seoHeadTags', () => {
|
|
@@ -30,14 +37,21 @@ describe('seoHeadTags', () => {
|
|
|
30
37
|
});
|
|
31
38
|
|
|
32
39
|
it('escapes attribute values', () => {
|
|
33
|
-
expect(seoHeadTags({ description: 'a "b" <c>' })).toContain(
|
|
40
|
+
expect(seoHeadTags({ description: 'a "b" <c>' })).toContain(
|
|
41
|
+
'content="a "b" <c>"',
|
|
42
|
+
);
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
it('renders a full Twitter card + OG image dimensions + fb:app_id', () => {
|
|
37
46
|
const html = seoHeadTags({
|
|
38
47
|
title: 'Home',
|
|
39
48
|
description: 'd',
|
|
40
|
-
openGraph: {
|
|
49
|
+
openGraph: {
|
|
50
|
+
image: 'https://x.test/og.png',
|
|
51
|
+
imageAlt: 'alt',
|
|
52
|
+
imageWidth: 1200,
|
|
53
|
+
imageHeight: 630,
|
|
54
|
+
},
|
|
41
55
|
twitter: { site: '@x' },
|
|
42
56
|
facebook: { appId: '123' },
|
|
43
57
|
});
|
|
@@ -68,14 +82,22 @@ describe('routeSeo', () => {
|
|
|
68
82
|
|
|
69
83
|
it('falls back to the site defaults when a route has no metadata', () => {
|
|
70
84
|
const site = { url: 'https://x.test', title: 'Site' };
|
|
71
|
-
expect(routeSeo(site, null, '/x')).toMatchObject({
|
|
85
|
+
expect(routeSeo(site, null, '/x')).toMatchObject({
|
|
86
|
+
title: 'Site',
|
|
87
|
+
url: 'https://x.test/x',
|
|
88
|
+
});
|
|
72
89
|
});
|
|
73
90
|
});
|
|
74
91
|
|
|
75
92
|
describe('injectSeoHtml', () => {
|
|
76
93
|
it('replaces the title + description and inserts the rest before </head>', () => {
|
|
77
|
-
const shell =
|
|
78
|
-
|
|
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
|
+
});
|
|
79
101
|
expect(out).toContain('<title>New</title>');
|
|
80
102
|
expect(out).not.toContain('<title>old</title>');
|
|
81
103
|
expect(out.match(/name="description"/g)).toHaveLength(1);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildRows, classifyBump, parseNcuJson } from '../src/cli/updates';
|
|
4
|
+
|
|
5
|
+
describe('classifyBump', () => {
|
|
6
|
+
it('classifies major / minor / patch from ranges', () => {
|
|
7
|
+
expect(classifyBump('^19.2.6', '^20.0.0')).toBe('major');
|
|
8
|
+
expect(classifyBump('^19.2.6', '^19.3.0')).toBe('minor');
|
|
9
|
+
expect(classifyBump('^19.2.6', '^19.2.7')).toBe('patch');
|
|
10
|
+
expect(classifyBump('1.2.3', '1.2.3')).toBe('other');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('parseNcuJson', () => {
|
|
15
|
+
it('parses the jsonUpgraded object, tolerating surrounding noise', () => {
|
|
16
|
+
expect(parseNcuJson('{"react":"^19.3.0","eslint":"^10.4.1"}')).toEqual({
|
|
17
|
+
react: '^19.3.0',
|
|
18
|
+
eslint: '^10.4.1',
|
|
19
|
+
});
|
|
20
|
+
expect(parseNcuJson('npx noise\n{"a":"1.0.0"}\nbye')).toEqual({ a: '1.0.0' });
|
|
21
|
+
expect(parseNcuJson('{}')).toEqual({});
|
|
22
|
+
expect(parseNcuJson('not json')).toEqual({});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('buildRows', () => {
|
|
27
|
+
it('joins ncu output with current ranges and sorts major-first then by name', () => {
|
|
28
|
+
const rows = buildRows(
|
|
29
|
+
{ react: '^20.0.0', eslint: '^10.4.1', vite: '^8.0.15' },
|
|
30
|
+
{ react: '^19.2.6', eslint: '^10.2.0', vite: '^8.0.14' },
|
|
31
|
+
);
|
|
32
|
+
expect(rows.map((r) => `${r.name}:${r.bump}`)).toEqual([
|
|
33
|
+
'react:major',
|
|
34
|
+
'eslint:minor',
|
|
35
|
+
'vite:patch',
|
|
36
|
+
]);
|
|
37
|
+
expect(rows[0]).toMatchObject({ name: 'react', from: '^19.2.6', to: '^20.0.0' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks a package missing from current deps with a "?" source', () => {
|
|
41
|
+
const rows = buildRows({ foo: '^2.0.0' }, {});
|
|
42
|
+
expect(rows[0]).toMatchObject({ name: 'foo', from: '?', to: '^2.0.0', bump: 'major' });
|
|
43
|
+
});
|
|
44
|
+
});
|