toiljs 0.0.8 → 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.
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +5 -5
- package/build/cli/create.js +4 -4
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +55 -33
- package/build/client/routing/hooks.js +2 -6
- package/build/client/routing/loader.d.ts +2 -0
- package/build/client/routing/loader.js +9 -1
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +8 -0
- package/build/compiler/config.js +4 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +65 -32
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +5 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- package/examples/basic/client/global-error.tsx +1 -1
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
- package/package.json +1 -1
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +7 -7
- package/src/cli/create.ts +7 -7
- package/src/cli/features.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +2 -2
- package/src/client/components/Script.tsx +3 -3
- package/src/client/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +5 -1
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +121 -67
- package/src/client/routing/action.ts +4 -4
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +6 -25
- package/src/client/routing/loader.ts +20 -8
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +31 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +66 -31
- package/src/compiler/image-report.ts +1 -1
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +9 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/test/dom/error-overlay.test.tsx +44 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { setRouteHead, useHead } from '../../src/client/head/head';
|
|
5
|
+
import { resolveMetadata } from '../../src/client/head/metadata';
|
|
6
|
+
import { cleanup, render } from '@testing-library/react';
|
|
7
|
+
import { afterEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
cleanup();
|
|
11
|
+
setRouteHead(null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const desc = (): string | null =>
|
|
15
|
+
document.head.querySelector('meta[name="description"]')?.getAttribute('content') ?? null;
|
|
16
|
+
|
|
17
|
+
describe('route head (metadata baseline)', () => {
|
|
18
|
+
it('applies a resolved metadata head to the document', () => {
|
|
19
|
+
setRouteHead(resolveMetadata({ title: 'About', description: 'about page' }));
|
|
20
|
+
expect(document.title).toBe('About');
|
|
21
|
+
expect(desc()).toBe('about page');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('is the lowest priority — component useHead overrides it', () => {
|
|
25
|
+
setRouteHead(resolveMetadata({ title: 'Base', description: 'base' }));
|
|
26
|
+
function Page() {
|
|
27
|
+
useHead({ title: 'Override', meta: [{ name: 'description', content: 'override' }] });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
render(<Page />);
|
|
31
|
+
expect(document.title).toBe('Override');
|
|
32
|
+
expect(desc()).toBe('override');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Slot } from '../../src/client/components/Slot';
|
|
7
|
+
import { navigate } from '../../src/client/navigation/navigation';
|
|
8
|
+
import { Router } from '../../src/client/routing/Router';
|
|
9
|
+
import { clearLoaderData } from '../../src/client/routing/loader';
|
|
10
|
+
import { SlotContext } from '../../src/client/routing/slot-context';
|
|
11
|
+
import type { RouteDef } from '../../src/client/types';
|
|
12
|
+
|
|
13
|
+
const layoutWithModal = () =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
default: ({ children }: { children?: ReactNode }) => (
|
|
16
|
+
<div>
|
|
17
|
+
{children}
|
|
18
|
+
<Slot name="modal" />
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
clearLoaderData();
|
|
26
|
+
window.history.replaceState({}, '', '/');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Slot', () => {
|
|
30
|
+
it('renders the named slot element from context, or the fallback', () => {
|
|
31
|
+
const { getByText, queryByText } = render(
|
|
32
|
+
<SlotContext.Provider value={{ modal: <span>MODAL</span> }}>
|
|
33
|
+
<Slot name="modal" />
|
|
34
|
+
<Slot name="missing" fallback={<span>FB</span>} />
|
|
35
|
+
</SlotContext.Provider>,
|
|
36
|
+
);
|
|
37
|
+
expect(getByText('MODAL')).toBeTruthy();
|
|
38
|
+
expect(getByText('FB')).toBeTruthy();
|
|
39
|
+
expect(queryByText('missing')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Router parallel slots', () => {
|
|
44
|
+
const routes: RouteDef[] = [
|
|
45
|
+
{ pattern: '/', load: () => Promise.resolve({ default: () => <main>PAGE</main> }) },
|
|
46
|
+
];
|
|
47
|
+
// A layout that renders the page plus the "modal" slot.
|
|
48
|
+
const layout = () =>
|
|
49
|
+
Promise.resolve({
|
|
50
|
+
default: ({ children }: { children?: ReactNode }) => (
|
|
51
|
+
<div>
|
|
52
|
+
{children}
|
|
53
|
+
<Slot name="modal" />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
const slots: Record<string, RouteDef[]> = {
|
|
58
|
+
modal: [{ pattern: '/', load: () => Promise.resolve({ default: () => <aside>SLOT</aside> }) }],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it('renders the main route and a matching slot together', async () => {
|
|
62
|
+
const { findByText } = render(<Router routes={routes} layout={layout} slots={slots} />);
|
|
63
|
+
// Both the page and the parallel slot render for the same URL.
|
|
64
|
+
await findByText('PAGE');
|
|
65
|
+
await findByText('SLOT');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('intercepting routes', () => {
|
|
70
|
+
const routes: RouteDef[] = [
|
|
71
|
+
{ pattern: '/photo/:id', load: () => Promise.resolve({ default: () => <main>PHOTO PAGE</main> }) },
|
|
72
|
+
{ pattern: '/', load: () => Promise.resolve({ default: () => <main>FEED</main> }) },
|
|
73
|
+
];
|
|
74
|
+
const slots: Record<string, RouteDef[]> = {
|
|
75
|
+
modal: [
|
|
76
|
+
{
|
|
77
|
+
pattern: '/photo/:id',
|
|
78
|
+
intercept: true,
|
|
79
|
+
load: () => Promise.resolve({ default: () => <aside>PHOTO MODAL</aside> }),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// This test must run before any navigation so it observes a "hard" load (soft-nav state is false).
|
|
85
|
+
it('shows the full page on a hard load (no interception)', async () => {
|
|
86
|
+
window.history.replaceState({}, '', '/photo/1');
|
|
87
|
+
const { findByText, queryByText } = render(
|
|
88
|
+
<Router routes={routes} layout={layoutWithModal} slots={slots} />,
|
|
89
|
+
);
|
|
90
|
+
await findByText('PHOTO PAGE');
|
|
91
|
+
expect(queryByText('PHOTO MODAL')).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('intercepts on soft navigation: modal overlays, previous page stays', async () => {
|
|
95
|
+
window.history.replaceState({}, '', '/');
|
|
96
|
+
const { findByText, queryByText } = render(
|
|
97
|
+
<Router routes={routes} layout={layoutWithModal} slots={slots} />,
|
|
98
|
+
);
|
|
99
|
+
await findByText('FEED');
|
|
100
|
+
act(() => {
|
|
101
|
+
navigate('/photo/1');
|
|
102
|
+
});
|
|
103
|
+
// The intercepting slot route shows the modal…
|
|
104
|
+
await findByText('PHOTO MODAL');
|
|
105
|
+
// …while the main view keeps the previous page (the backdrop), not the full photo page.
|
|
106
|
+
expect(queryByText('FEED')).not.toBeNull();
|
|
107
|
+
expect(queryByText('PHOTO PAGE')).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { navigate, setViewTransitions } from '../../src/client/navigation/navigation';
|
|
5
|
+
|
|
6
|
+
interface VTDoc {
|
|
7
|
+
startViewTransition?: (cb: () => void) => unknown;
|
|
8
|
+
}
|
|
9
|
+
const doc = document as Document & VTDoc;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
setViewTransitions(false);
|
|
13
|
+
delete doc.startViewTransition;
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
window.history.replaceState({}, '', '/');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('view transitions', () => {
|
|
19
|
+
function stubReducedMotion(matches: boolean): void {
|
|
20
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches }) as unknown as typeof window.matchMedia;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('wraps navigation in startViewTransition when enabled and supported', () => {
|
|
24
|
+
const vt = vi.fn((cb: () => void) => {
|
|
25
|
+
cb();
|
|
26
|
+
});
|
|
27
|
+
doc.startViewTransition = vt;
|
|
28
|
+
stubReducedMotion(false);
|
|
29
|
+
setViewTransitions(true);
|
|
30
|
+
navigate('/a');
|
|
31
|
+
expect(vt).toHaveBeenCalledOnce();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('skips the view transition under prefers-reduced-motion', () => {
|
|
35
|
+
const vt = vi.fn();
|
|
36
|
+
doc.startViewTransition = vt;
|
|
37
|
+
stubReducedMotion(true);
|
|
38
|
+
setViewTransitions(true);
|
|
39
|
+
navigate('/b');
|
|
40
|
+
expect(vt).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not use view transitions when disabled', () => {
|
|
44
|
+
const vt = vi.fn();
|
|
45
|
+
doc.startViewTransition = vt;
|
|
46
|
+
stubReducedMotion(false);
|
|
47
|
+
setViewTransitions(false);
|
|
48
|
+
navigate('/c');
|
|
49
|
+
expect(vt).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,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('/app/fonts/a.woff2');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -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
|
+
});
|
package/test/routes.test.ts
CHANGED
|
@@ -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', () => {
|
package/test/seo.test.ts
ADDED
|
@@ -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 "b" <c>"');
|
|
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
|
+
});
|