toiljs 0.0.12 → 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 +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +7 -1
- package/examples/basic/client/routes/search.tsx +61 -61
- package/package.json +1 -1
- package/src/client/search/search.ts +189 -189
- package/src/client/search/use-page-search.ts +73 -73
- package/src/compiler/generate.ts +391 -378
- package/src/compiler/pages.ts +2 -2
- package/test/dom/error-overlay.test.tsx +44 -44
- package/test/dom/router-loading.test.tsx +1 -1
- package/test/seo.test.ts +164 -164
package/src/compiler/pages.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface PageIndexEntry {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Loads the project's TypeScript synchronously (so {@link buildPageIndex} can run inside the sync
|
|
25
|
-
* `generate()`), or `null` if it isn't installed
|
|
25
|
+
* `generate()`), or `null` if it isn't installed, in which case pages are indexed by path only.
|
|
26
26
|
*/
|
|
27
27
|
function loadTypeScriptSync(root: string): Ts | null {
|
|
28
28
|
try {
|
|
@@ -41,7 +41,7 @@ function isDynamic(pattern: string): boolean {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Builds the searchable page index from the scanned routes: every main-tree page (slots and
|
|
44
|
-
* intercepting routes are excluded
|
|
44
|
+
* intercepting routes are excluded, they don't own a distinct URL) paired with its statically
|
|
45
45
|
* extracted `metadata`. A route may also `export const searchHints` (a static `title`/`description`/
|
|
46
46
|
* `keywords` object) to feed the index even when its real metadata is dynamic (`generateMetadata`);
|
|
47
47
|
* hints are merged over the static `metadata`, winning ties. Reads each route file once.
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
DevErrorBoundary,
|
|
7
|
-
DevErrorOverlay,
|
|
8
|
-
initDevErrorOverlay,
|
|
9
|
-
} from '../../src/client/dev/error-overlay';
|
|
10
|
-
|
|
11
|
-
afterEach(cleanup);
|
|
12
|
-
|
|
13
|
-
function Boom(): never {
|
|
14
|
-
throw new Error('render boom');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('dev error overlay', () => {
|
|
18
|
-
it('surfaces an uncaught render error', () => {
|
|
19
|
-
// React logs caught boundary errors to console.error
|
|
20
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
21
|
-
const { getByRole } = render(
|
|
22
|
-
<>
|
|
23
|
-
<DevErrorBoundary>
|
|
24
|
-
<Boom />
|
|
25
|
-
</DevErrorBoundary>
|
|
26
|
-
<DevErrorOverlay />
|
|
27
|
-
</>,
|
|
28
|
-
);
|
|
29
|
-
expect(getByRole('alert').textContent).toContain('render boom');
|
|
30
|
-
spy.mockRestore();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('surfaces an unhandled window error and dismisses it', async () => {
|
|
34
|
-
initDevErrorOverlay();
|
|
35
|
-
const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
|
|
36
|
-
act(() => {
|
|
37
|
-
window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
|
|
38
|
-
});
|
|
39
|
-
const alert = await findByRole('alert');
|
|
40
|
-
expect(alert.textContent).toContain('async boom');
|
|
41
|
-
fireEvent.click(getByText('Dismiss'));
|
|
42
|
-
expect(queryByRole('alert')).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
});
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DevErrorBoundary,
|
|
7
|
+
DevErrorOverlay,
|
|
8
|
+
initDevErrorOverlay,
|
|
9
|
+
} from '../../src/client/dev/error-overlay';
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
function Boom(): never {
|
|
14
|
+
throw new Error('render boom');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('dev error overlay', () => {
|
|
18
|
+
it('surfaces an uncaught render error', () => {
|
|
19
|
+
// React logs caught boundary errors to console.error, silence it for a clean test run.
|
|
20
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
21
|
+
const { getByRole } = render(
|
|
22
|
+
<>
|
|
23
|
+
<DevErrorBoundary>
|
|
24
|
+
<Boom />
|
|
25
|
+
</DevErrorBoundary>
|
|
26
|
+
<DevErrorOverlay />
|
|
27
|
+
</>,
|
|
28
|
+
);
|
|
29
|
+
expect(getByRole('alert').textContent).toContain('render boom');
|
|
30
|
+
spy.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('surfaces an unhandled window error and dismisses it', async () => {
|
|
34
|
+
initDevErrorOverlay();
|
|
35
|
+
const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
|
|
36
|
+
act(() => {
|
|
37
|
+
window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
|
|
38
|
+
});
|
|
39
|
+
const alert = await findByRole('alert');
|
|
40
|
+
expect(alert.textContent).toContain('async boom');
|
|
41
|
+
fireEvent.click(getByText('Dismiss'));
|
|
42
|
+
expect(queryByRole('alert')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -17,7 +17,7 @@ const routes: RouteDef[] = [
|
|
|
17
17
|
load: () => Promise.resolve({ default: () => <div>HOME</div> }),
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
// Page chunk never resolves, so the route stays suspended
|
|
20
|
+
// Page chunk never resolves, so the route stays suspended, exercising the fallback path.
|
|
21
21
|
pattern: '/slow',
|
|
22
22
|
load: () => new Promise<{ default: () => null }>(() => undefined),
|
|
23
23
|
loading: () => Promise.resolve({ default: () => <div>LOADING</div> }),
|
package/test/seo.test.ts
CHANGED
|
@@ -1,164 +1,164 @@
|
|
|
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
|
|
17
|
-
{ file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept
|
|
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 "b" <c>"',
|
|
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
|
-
});
|
|
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 "b" <c>"',
|
|
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
|
+
});
|