toiljs 0.0.10 → 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.
- package/README.md +315 -1
- package/assets/logo.svg +37 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +10 -4
- package/build/cli/create.js +60 -32
- 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 +35 -26
- 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/compiler/vite.js +7 -0
- package/build/io/.tsbuildinfo +1 -1
- package/examples/basic/client/components/Header.tsx +43 -38
- package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
- package/examples/basic/client/layout.tsx +4 -1
- package/examples/basic/client/public/index.html +18 -16
- package/examples/basic/client/routes/(legal)/privacy.tsx +18 -0
- package/examples/basic/client/routes/(legal)/terms.tsx +15 -0
- package/examples/basic/client/routes/about.tsx +21 -19
- package/examples/basic/client/routes/blog/[id].tsx +26 -12
- package/examples/basic/client/routes/features/actions.tsx +67 -0
- package/examples/basic/client/routes/features/error/error.tsx +16 -0
- package/examples/basic/client/routes/features/error/index.tsx +27 -0
- package/examples/basic/client/routes/features/head.tsx +38 -0
- package/examples/basic/client/routes/features/index.tsx +83 -0
- package/examples/basic/client/routes/features/realtime.tsx +34 -0
- package/examples/basic/client/routes/features/script.tsx +31 -0
- package/examples/basic/client/routes/features/seo.tsx +39 -0
- package/examples/basic/client/routes/features/template/b.tsx +14 -0
- package/examples/basic/client/routes/features/template/index.tsx +20 -0
- package/examples/basic/client/routes/features/template/template.tsx +16 -0
- package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
- package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
- package/examples/basic/client/routes/gallery/index.tsx +42 -0
- package/examples/basic/client/routes/gallery/layout.tsx +13 -0
- package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
- package/examples/basic/client/routes/get-started.tsx +157 -84
- package/examples/basic/client/routes/index.tsx +137 -87
- package/examples/basic/client/routes/loader-demo/index.tsx +59 -50
- 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/examples/basic/client/toil.tsx +2 -4
- package/package.json +3 -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 +378 -364
- 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 -130
- 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 +44 -44
- package/test/dom/loader.test.tsx +2 -2
- package/test/dom/revalidate.test.tsx +1 -1
- package/test/dom/route-head.test.tsx +35 -2
- 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 +164 -142
- package/test/slot-layouts.test.ts +69 -0
- package/test/update.test.ts +44 -0
package/test/fonts.test.ts
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { fontPreloadTags } from '../src/compiler/fonts';
|
|
4
|
-
|
|
5
|
-
describe('fontPreloadTags', () => {
|
|
6
|
-
it('builds a crossorigin preload link per font, skipping non-fonts', () => {
|
|
7
|
-
const tags = fontPreloadTags(['fonts/a-abc.woff2', 'assets/x-1.js', 'fonts/b.ttf'], '/');
|
|
8
|
-
expect(tags).toHaveLength(2);
|
|
9
|
-
expect(tags[0]).toEqual({
|
|
10
|
-
tag: 'link',
|
|
11
|
-
attrs: {
|
|
12
|
-
rel: 'preload',
|
|
13
|
-
as: 'font',
|
|
14
|
-
type: 'font/woff2',
|
|
15
|
-
href: '/fonts/a-abc.woff2',
|
|
16
|
-
crossorigin: '',
|
|
17
|
-
},
|
|
18
|
-
injectTo: 'head',
|
|
19
|
-
});
|
|
20
|
-
expect(tags[1].attrs?.type).toBe('font/ttf');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('respects a non-root base path', () => {
|
|
24
|
-
expect(fontPreloadTags(['fonts/a.woff2'], '/app/')[0].attrs?.href).toBe(
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { fontPreloadTags } from '../src/compiler/fonts';
|
|
4
|
+
|
|
5
|
+
describe('fontPreloadTags', () => {
|
|
6
|
+
it('builds a crossorigin preload link per font, skipping non-fonts', () => {
|
|
7
|
+
const tags = fontPreloadTags(['fonts/a-abc.woff2', 'assets/x-1.js', 'fonts/b.ttf'], '/');
|
|
8
|
+
expect(tags).toHaveLength(2);
|
|
9
|
+
expect(tags[0]).toEqual({
|
|
10
|
+
tag: 'link',
|
|
11
|
+
attrs: {
|
|
12
|
+
rel: 'preload',
|
|
13
|
+
as: 'font',
|
|
14
|
+
type: 'font/woff2',
|
|
15
|
+
href: '/fonts/a-abc.woff2',
|
|
16
|
+
crossorigin: '',
|
|
17
|
+
},
|
|
18
|
+
injectTo: 'head',
|
|
19
|
+
});
|
|
20
|
+
expect(tags[1].attrs?.type).toBe('font/ttf');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('respects a non-root base path', () => {
|
|
24
|
+
expect(fontPreloadTags(['fonts/a.woff2'], '/app/')[0].attrs?.href).toBe(
|
|
25
|
+
'/app/fonts/a.woff2',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/test/head.test.ts
CHANGED
|
@@ -1,35 +1,45 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { mergeHead } from '../src/client/head/head';
|
|
4
|
-
|
|
5
|
-
describe('mergeHead', () => {
|
|
6
|
-
it('takes the last title and applies a titleTemplate', () => {
|
|
7
|
-
expect(mergeHead([{ title: 'Home' }]).title).toBe('Home');
|
|
8
|
-
expect(mergeHead([{ title: 'A' }, { title: 'B' }]).title).toBe('B');
|
|
9
|
-
expect(mergeHead([{ titleTemplate: '%s · toiljs' }, { title: 'About' }]).title).toBe(
|
|
10
|
-
'About · toiljs',
|
|
11
|
-
);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('leaves title undefined when nothing sets it', () => {
|
|
15
|
-
expect(mergeHead([{ meta: [{ name: 'x', content: 'y' }] }]).title).toBeUndefined();
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('dedupes meta by name/property, last wins', () => {
|
|
19
|
-
const resolved = mergeHead([
|
|
20
|
-
{ meta: [{ name: 'description', content: 'old' }] },
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { mergeHead } from '../src/client/head/head';
|
|
4
|
+
|
|
5
|
+
describe('mergeHead', () => {
|
|
6
|
+
it('takes the last title and applies a titleTemplate', () => {
|
|
7
|
+
expect(mergeHead([{ title: 'Home' }]).title).toBe('Home');
|
|
8
|
+
expect(mergeHead([{ title: 'A' }, { title: 'B' }]).title).toBe('B');
|
|
9
|
+
expect(mergeHead([{ titleTemplate: '%s · toiljs' }, { title: 'About' }]).title).toBe(
|
|
10
|
+
'About · toiljs',
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('leaves title undefined when nothing sets it', () => {
|
|
15
|
+
expect(mergeHead([{ meta: [{ name: 'x', content: 'y' }] }]).title).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('dedupes meta by name/property, last wins', () => {
|
|
19
|
+
const resolved = mergeHead([
|
|
20
|
+
{ meta: [{ name: 'description', content: 'old' }] },
|
|
21
|
+
{
|
|
22
|
+
meta: [
|
|
23
|
+
{ name: 'description', content: 'new' },
|
|
24
|
+
{ property: 'og:title', content: 'T' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
expect(resolved.meta).toHaveLength(2);
|
|
29
|
+
expect(resolved.meta.find((m) => m.name === 'description')?.content).toBe('new');
|
|
30
|
+
expect(resolved.meta.find((m) => m.property === 'og:title')?.content).toBe('T');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('dedupes links by rel+href', () => {
|
|
34
|
+
const resolved = mergeHead([
|
|
35
|
+
{ link: [{ rel: 'icon', href: '/a.svg' }] },
|
|
36
|
+
{
|
|
37
|
+
link: [
|
|
38
|
+
{ rel: 'icon', href: '/a.svg' },
|
|
39
|
+
{ rel: 'canonical', href: '/x' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
expect(resolved.link).toHaveLength(2);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/test/metadata.test.ts
CHANGED
|
@@ -1,41 +1,42 @@
|
|
|
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) =>
|
|
22
|
-
|
|
23
|
-
expect(byName('
|
|
24
|
-
expect(byName('
|
|
25
|
-
expect(byName('
|
|
26
|
-
expect(
|
|
27
|
-
expect(byProp('og:
|
|
28
|
-
expect(byProp('og:
|
|
29
|
-
expect(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(head.
|
|
40
|
-
|
|
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) =>
|
|
22
|
+
head.meta?.find((m) => m.property === property)?.content;
|
|
23
|
+
expect(byName('description')).toBe('desc');
|
|
24
|
+
expect(byName('keywords')).toBe('a, b');
|
|
25
|
+
expect(byName('robots')).toBe('noindex');
|
|
26
|
+
expect(byName('theme-color')).toBe('#000');
|
|
27
|
+
expect(byProp('og:title')).toBe('OG');
|
|
28
|
+
expect(byProp('og:type')).toBe('website');
|
|
29
|
+
expect(byProp('og:image')).toBe('https://x.test/og.png');
|
|
30
|
+
expect(head.link?.find((l) => l.rel === 'canonical')?.href).toBe('https://x.test/about');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('passes through raw meta/link and omits unset fields', () => {
|
|
34
|
+
const head = resolveMetadata({
|
|
35
|
+
title: 'X',
|
|
36
|
+
meta: [{ name: 'author', content: 'me' }],
|
|
37
|
+
link: [{ rel: 'alternate', href: '/rss' }],
|
|
38
|
+
});
|
|
39
|
+
expect(head.meta).toEqual([{ name: 'author', content: 'me' }]);
|
|
40
|
+
expect(head.link).toEqual([{ rel: 'alternate', href: '/rss' }]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -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
|
+
});
|