jamdesk 1.0.19 → 1.0.21
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/dist/__tests__/unit/openapi.test.js +12 -0
- package/dist/__tests__/unit/openapi.test.js.map +1 -1
- package/dist/commands/openapi-check.d.ts.map +1 -1
- package/dist/commands/openapi-check.js +7 -13
- package/dist/commands/openapi-check.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/deps.js +1 -1
- package/dist/lib/openapi/errors.d.ts.map +1 -1
- package/dist/lib/openapi/errors.js +17 -0
- package/dist/lib/openapi/errors.js.map +1 -1
- package/dist/lib/openapi/validator.d.ts +2 -2
- package/dist/lib/openapi/validator.d.ts.map +1 -1
- package/dist/lib/openapi/validator.js +52 -15
- package/dist/lib/openapi/validator.js.map +1 -1
- package/package.json +2 -2
- package/vendored/app/[[...slug]]/page.tsx +105 -28
- package/vendored/app/api/ev/route.ts +4 -1
- package/vendored/app/api/indexnow/[key]/route.ts +34 -0
- package/vendored/app/api/search-ev/route.ts +69 -0
- package/vendored/app/layout.tsx +70 -12
- package/vendored/components/chat/ChatInput.tsx +1 -1
- package/vendored/components/chat/ChatPanel.tsx +60 -11
- package/vendored/components/mdx/Update.tsx +7 -7
- package/vendored/components/navigation/Sidebar.tsx +2 -10
- package/vendored/components/navigation/TableOfContents.tsx +48 -35
- package/vendored/components/search/SearchModal.tsx +9 -5
- package/vendored/components/ui/CodePanelModal.tsx +5 -14
- package/vendored/hooks/useBodyScrollLock.ts +37 -0
- package/vendored/lib/analytics-client.ts +17 -7
- package/vendored/lib/docs-types.ts +3 -2
- package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
- package/vendored/lib/extract-highlights.ts +2 -0
- package/vendored/lib/heading-extractor.ts +2 -2
- package/vendored/lib/indexnow.ts +77 -0
- package/vendored/lib/json-ld.ts +171 -0
- package/vendored/lib/middleware-helpers.ts +2 -0
- package/vendored/lib/openapi/errors.ts +21 -1
- package/vendored/lib/openapi/validator.ts +70 -23
- package/vendored/lib/route-helpers.ts +7 -2
- package/vendored/lib/search-client.ts +81 -36
- package/vendored/lib/seo.ts +77 -0
- package/vendored/lib/static-artifacts.ts +204 -5
- package/vendored/lib/static-file-route.ts +10 -5
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/schema/docs-schema.json +130 -8
- package/vendored/scripts/validate-links.cjs +1 -1
|
@@ -11,8 +11,7 @@ export interface SearchResult {
|
|
|
11
11
|
type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
let db: Orama<{
|
|
14
|
+
type OramaDb = Orama<{
|
|
16
15
|
id: 'string';
|
|
17
16
|
title: 'string';
|
|
18
17
|
description: 'string';
|
|
@@ -20,47 +19,91 @@ let db: Orama<{
|
|
|
20
19
|
slug: 'string';
|
|
21
20
|
section: 'string';
|
|
22
21
|
type: 'string';
|
|
23
|
-
}
|
|
22
|
+
}>;
|
|
24
23
|
|
|
24
|
+
// Orama database instance
|
|
25
|
+
let db: OramaDb | null = null;
|
|
26
|
+
// Fingerprint of the data that is currently indexed (committed) or null if not yet built
|
|
27
|
+
let committedFingerprint: string | null = null;
|
|
28
|
+
// Fingerprint of the data currently being built (in-flight), to deduplicate concurrent calls
|
|
29
|
+
let buildingFingerprint: string | null = null;
|
|
25
30
|
let initPromise: Promise<void> | null = null;
|
|
31
|
+
// ETag of the last fetched search-data.json and the parsed data it produced.
|
|
32
|
+
// Lets the modal skip response.json() when the CDN returns the same ETag.
|
|
33
|
+
let lastEtag = '';
|
|
34
|
+
let lastParsedData: SearchResult[] | null = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cheap fingerprint: count + first/last IDs + a sample of content lengths.
|
|
38
|
+
* Detects new/removed pages AND content edits (which change content length).
|
|
39
|
+
*/
|
|
40
|
+
function fingerprint(data: SearchResult[]): string {
|
|
41
|
+
if (data.length === 0) return '0';
|
|
42
|
+
const first = data[0].id;
|
|
43
|
+
const last = data[data.length - 1].id;
|
|
44
|
+
const step = Math.max(1, Math.floor(data.length / 8));
|
|
45
|
+
let contentSig = '';
|
|
46
|
+
// Sample up to 8 evenly-spaced items' content lengths to detect edits
|
|
47
|
+
for (let i = 0; i < data.length; i += step) {
|
|
48
|
+
contentSig += data[i].content.length + ',';
|
|
49
|
+
}
|
|
50
|
+
return `${data.length}:${first}:${last}:${contentSig}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildIndex(data: SearchResult[], etag: string): Promise<void> {
|
|
54
|
+
db = await create({
|
|
55
|
+
schema: {
|
|
56
|
+
id: 'string',
|
|
57
|
+
title: 'string',
|
|
58
|
+
description: 'string',
|
|
59
|
+
content: 'string',
|
|
60
|
+
slug: 'string',
|
|
61
|
+
section: 'string',
|
|
62
|
+
type: 'string',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
26
65
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
schema: {
|
|
37
|
-
id: 'string',
|
|
38
|
-
title: 'string',
|
|
39
|
-
description: 'string',
|
|
40
|
-
content: 'string',
|
|
41
|
-
slug: 'string',
|
|
42
|
-
section: 'string',
|
|
43
|
-
type: 'string',
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Normalize data - ensure all fields are strings
|
|
48
|
-
const normalizedData = data.map(item => ({
|
|
49
|
-
id: item.id,
|
|
50
|
-
title: item.title,
|
|
51
|
-
description: item.description || '',
|
|
52
|
-
content: item.content,
|
|
53
|
-
slug: item.slug,
|
|
54
|
-
section: item.section || '',
|
|
55
|
-
type: item.type || 'guide',
|
|
56
|
-
}));
|
|
57
|
-
|
|
58
|
-
await insertMultiple(db, normalizedData);
|
|
59
|
-
})();
|
|
66
|
+
const normalizedData = data.map(item => ({
|
|
67
|
+
id: item.id,
|
|
68
|
+
title: item.title,
|
|
69
|
+
description: item.description || '',
|
|
70
|
+
content: item.content,
|
|
71
|
+
slug: item.slug,
|
|
72
|
+
section: item.section || '',
|
|
73
|
+
type: item.type || 'guide',
|
|
74
|
+
}));
|
|
60
75
|
|
|
76
|
+
await insertMultiple(db, normalizedData);
|
|
77
|
+
committedFingerprint = buildingFingerprint;
|
|
78
|
+
lastParsedData = data;
|
|
79
|
+
lastEtag = etag;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function initializeSearch(data: SearchResult[], etag = ''): Promise<void> {
|
|
83
|
+
const fp = fingerprint(data);
|
|
84
|
+
|
|
85
|
+
// Skip rebuild if the committed index already matches
|
|
86
|
+
if (fp === committedFingerprint) return;
|
|
87
|
+
|
|
88
|
+
// If a build with this exact data is already in flight, wait on it
|
|
89
|
+
if (initPromise && fp === buildingFingerprint) return initPromise;
|
|
90
|
+
|
|
91
|
+
// New data available (or first init) — rebuild the index
|
|
92
|
+
buildingFingerprint = fp;
|
|
93
|
+
initPromise = buildIndex(data, etag);
|
|
61
94
|
return initPromise;
|
|
62
95
|
}
|
|
63
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Returns the previously parsed search data if the given ETag matches the
|
|
99
|
+
* last successful fetch, so the modal can skip response.json() on unchanged data.
|
|
100
|
+
* Returns null when the ETag is empty, unknown, or has changed.
|
|
101
|
+
*/
|
|
102
|
+
export function getLastData(etag: string | null): SearchResult[] | null {
|
|
103
|
+
if (!etag || etag !== lastEtag || !lastParsedData) return null;
|
|
104
|
+
return lastParsedData;
|
|
105
|
+
}
|
|
106
|
+
|
|
64
107
|
export async function search(query: string, limit = 10): Promise<SearchResult[]> {
|
|
65
108
|
if (!db) {
|
|
66
109
|
console.warn('Search database not initialized');
|
|
@@ -86,6 +129,8 @@ export async function search(query: string, limit = 10): Promise<SearchResult[]>
|
|
|
86
129
|
return results.hits.map(hit => hit.document as unknown as SearchResult);
|
|
87
130
|
}
|
|
88
131
|
|
|
132
|
+
/** @internal Used by tests only */
|
|
89
133
|
export function isInitialized(): boolean {
|
|
90
134
|
return db !== null;
|
|
91
135
|
}
|
|
136
|
+
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -11,6 +11,18 @@ import type { Metadata } from 'next';
|
|
|
11
11
|
import type { DocsConfig, Logo, LogoConfig, Favicon, FaviconConfig, LanguageConfig, LanguageCode } from './docs-types';
|
|
12
12
|
import { transformLanguagePath, extractLanguageFromPath, isValidLanguageCode } from './language-utils';
|
|
13
13
|
|
|
14
|
+
const HAS_DOCS_SUFFIX = /\b(?:Documentation|Docs)\s*$/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a display title for the site, appending "Documentation" only when
|
|
18
|
+
* config.name doesn't already end with a docs-related word.
|
|
19
|
+
*/
|
|
20
|
+
export function buildSiteTitle(configName: string): string {
|
|
21
|
+
return HAS_DOCS_SUFFIX.test(configName)
|
|
22
|
+
? configName
|
|
23
|
+
: `${configName} Documentation`;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Build the OG image URL for a page using the proxy's /api/og endpoint.
|
|
16
28
|
* Returns undefined if og:image is explicitly set in metatags.
|
|
@@ -288,3 +300,68 @@ export function buildSeoMetadata(
|
|
|
288
300
|
|
|
289
301
|
return metadata;
|
|
290
302
|
}
|
|
303
|
+
|
|
304
|
+
const MAX_DESCRIPTION_LENGTH = 155;
|
|
305
|
+
|
|
306
|
+
/** Patterns that indicate a paragraph is not prose (headings, components, images, comments, etc). */
|
|
307
|
+
const NON_PROSE_PATTERNS = [
|
|
308
|
+
/^#{1,6}\s/, // headings
|
|
309
|
+
/^<[A-Z]/, // MDX components
|
|
310
|
+
/^!\[/, // images
|
|
311
|
+
/^<!--/, // HTML comments
|
|
312
|
+
/^>/, // blockquotes
|
|
313
|
+
/^[-*_]{3,}\s*$/, // horizontal rules (---, ***, ___)
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
function isNonProseParagraph(text: string): boolean {
|
|
317
|
+
return NON_PROSE_PATTERNS.some((pattern) => pattern.test(text));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Strip inline markdown formatting, preserving text content. */
|
|
321
|
+
function stripInlineMarkdown(text: string): string {
|
|
322
|
+
return text
|
|
323
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // inline images
|
|
324
|
+
.replace(/`[^`]+`/g, '') // inline code
|
|
325
|
+
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
|
326
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // bold (non-greedy to handle nested *italic*)
|
|
327
|
+
.replace(/\*([^*]+)\*/g, '$1') // italic
|
|
328
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links (keep text)
|
|
329
|
+
.replace(/<[^>]+>/g, '') // HTML tags
|
|
330
|
+
.replace(/\n/g, ' ') // join multi-line paragraph
|
|
331
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
332
|
+
.trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract first prose paragraph from MDX content for auto-description.
|
|
337
|
+
* Skips non-prose blocks (headings, components, code), strips markdown
|
|
338
|
+
* formatting, and truncates to 155 chars on a word boundary.
|
|
339
|
+
*/
|
|
340
|
+
export function generateAutoDescription(rawContent: string): string {
|
|
341
|
+
if (!rawContent?.trim()) return '';
|
|
342
|
+
|
|
343
|
+
// Strip frontmatter, import/export statements, and code blocks
|
|
344
|
+
const content = rawContent
|
|
345
|
+
.replace(/^---[\s\S]*?---\n*/m, '')
|
|
346
|
+
.replace(/^(import|export)\s+.*$/gm, '')
|
|
347
|
+
.replace(/```[\s\S]*?```/g, '');
|
|
348
|
+
|
|
349
|
+
const paragraphs = content.split(/\n\s*\n/);
|
|
350
|
+
|
|
351
|
+
for (const para of paragraphs) {
|
|
352
|
+
const trimmed = para.trim();
|
|
353
|
+
if (!trimmed || isNonProseParagraph(trimmed)) continue;
|
|
354
|
+
|
|
355
|
+
const stripped = stripInlineMarkdown(trimmed);
|
|
356
|
+
if (!stripped) continue;
|
|
357
|
+
|
|
358
|
+
if (stripped.length <= MAX_DESCRIPTION_LENGTH) return stripped;
|
|
359
|
+
|
|
360
|
+
// Truncate on word boundary
|
|
361
|
+
const truncated = stripped.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
362
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
363
|
+
return truncated.slice(0, lastSpace > 0 ? lastSpace : MAX_DESCRIPTION_LENGTH) + '...';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return '';
|
|
367
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static Artifact Generator for ISR Mode
|
|
3
3
|
*
|
|
4
|
-
* Generates sitemap.xml, llms.txt,
|
|
5
|
-
* These are uploaded to R2 alongside the MDX content.
|
|
4
|
+
* Generates sitemap.xml, llms.txt, robots.txt, feed.xml, and search-data.json
|
|
5
|
+
* for ISR projects. These are uploaded to R2 alongside the MDX content.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -178,6 +178,7 @@ export interface RawPageInfo {
|
|
|
178
178
|
noindex?: boolean;
|
|
179
179
|
hidden?: boolean;
|
|
180
180
|
lastModified?: string;
|
|
181
|
+
rss?: boolean;
|
|
181
182
|
seo?: {
|
|
182
183
|
noindex?: boolean;
|
|
183
184
|
};
|
|
@@ -223,6 +224,8 @@ export interface GenerateAllOptions {
|
|
|
223
224
|
hostAtDocs?: boolean;
|
|
224
225
|
/** Block all crawlers (seo.metatags.robots: "noindex") */
|
|
225
226
|
noindex?: boolean;
|
|
227
|
+
/** Pages with rss: true frontmatter (for RSS feed generation) */
|
|
228
|
+
rssPages?: RssPageInfo[];
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
/**
|
|
@@ -232,6 +235,7 @@ export interface GeneratedArtifacts {
|
|
|
232
235
|
sitemap: string;
|
|
233
236
|
llmsTxt: string;
|
|
234
237
|
robotsTxt: string;
|
|
238
|
+
rssFeed: string | null;
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
/**
|
|
@@ -241,15 +245,210 @@ export interface GeneratedArtifacts {
|
|
|
241
245
|
* @returns Object with all generated artifacts
|
|
242
246
|
*/
|
|
243
247
|
export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArtifacts {
|
|
244
|
-
const {
|
|
248
|
+
const {
|
|
249
|
+
baseUrl, name, description, pages, hostAtDocs, noindex, rssPages,
|
|
250
|
+
} = options;
|
|
245
251
|
|
|
246
252
|
const sitemap = generateSitemap({ baseUrl, pages, hostAtDocs, noindex });
|
|
247
|
-
const llmsTxt = generateLlmsTxt({
|
|
253
|
+
const llmsTxt = generateLlmsTxt({
|
|
254
|
+
name, description, baseUrl, pages, hostAtDocs, noindex,
|
|
255
|
+
});
|
|
248
256
|
const robotsTxt = generateRobotsTxt({ baseUrl, hostAtDocs, noindex });
|
|
249
257
|
|
|
250
|
-
|
|
258
|
+
// Generate RSS feed if any pages have rss: true
|
|
259
|
+
let rssFeed: string | null = null;
|
|
260
|
+
if (rssPages && rssPages.length > 0) {
|
|
261
|
+
const updates = extractUpdatesFromPages(rssPages);
|
|
262
|
+
if (updates.length > 0) {
|
|
263
|
+
rssFeed = generateRssFeed({
|
|
264
|
+
name, description, baseUrl, hostAtDocs, updates,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { sitemap, llmsTxt, robotsTxt, rssFeed };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// =============================================================================
|
|
273
|
+
// RSS FEED GENERATION
|
|
274
|
+
// =============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A single update entry extracted from an MDX page.
|
|
278
|
+
*/
|
|
279
|
+
export interface UpdateEntry {
|
|
280
|
+
label: string;
|
|
281
|
+
description?: string;
|
|
282
|
+
tags?: string[];
|
|
283
|
+
date?: string;
|
|
284
|
+
content: string;
|
|
285
|
+
pagePath: string;
|
|
286
|
+
pageTitle: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Options for generating an RSS feed.
|
|
291
|
+
*/
|
|
292
|
+
export interface RssFeedOptions {
|
|
293
|
+
name: string;
|
|
294
|
+
description?: string;
|
|
295
|
+
baseUrl: string;
|
|
296
|
+
hostAtDocs?: boolean;
|
|
297
|
+
updates: UpdateEntry[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Raw page info with content for RSS extraction.
|
|
302
|
+
*/
|
|
303
|
+
export interface RssPageInfo {
|
|
304
|
+
path: string;
|
|
305
|
+
content: string;
|
|
306
|
+
frontmatter: {
|
|
307
|
+
title?: string;
|
|
308
|
+
rss?: boolean;
|
|
309
|
+
};
|
|
251
310
|
}
|
|
252
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Escape XML special characters.
|
|
314
|
+
*/
|
|
315
|
+
function escapeXml(text: string): string {
|
|
316
|
+
return text
|
|
317
|
+
.replace(/&/g, '&')
|
|
318
|
+
.replace(/</g, '<')
|
|
319
|
+
.replace(/>/g, '>')
|
|
320
|
+
.replace(/"/g, '"')
|
|
321
|
+
.replace(/'/g, ''');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Generate a URL-friendly slug from a label string.
|
|
326
|
+
* Same algorithm as generateUpdateId in Update.tsx.
|
|
327
|
+
*/
|
|
328
|
+
function labelToSlug(label: string): string {
|
|
329
|
+
return label
|
|
330
|
+
.toLowerCase()
|
|
331
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
332
|
+
.replace(/^-+|-+$/g, '');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract Update component entries from MDX pages with rss: true.
|
|
337
|
+
*/
|
|
338
|
+
export function extractUpdatesFromPages(pages: RssPageInfo[]): UpdateEntry[] {
|
|
339
|
+
const updates: UpdateEntry[] = [];
|
|
340
|
+
|
|
341
|
+
for (const page of pages) {
|
|
342
|
+
if (!page.frontmatter.rss) continue;
|
|
343
|
+
|
|
344
|
+
const pagePath = page.path.replace(/\.mdx?$/, '');
|
|
345
|
+
const pageTitle = page.frontmatter.title || pagePath;
|
|
346
|
+
|
|
347
|
+
// Match <Update ...props>content</Update>
|
|
348
|
+
// Quoted strings come first in alternation so > inside quotes is consumed correctly
|
|
349
|
+
const updateRegex = /<Update\s+((?:"[^"]*"|'[^']*'|[^>])*?)>([\s\S]*?)<\/Update>/g;
|
|
350
|
+
let match;
|
|
351
|
+
|
|
352
|
+
while ((match = updateRegex.exec(page.content)) !== null) {
|
|
353
|
+
const attrsStr = match[1];
|
|
354
|
+
const content = match[2].trim();
|
|
355
|
+
|
|
356
|
+
// Parse label
|
|
357
|
+
const labelMatch = attrsStr.match(/label=["']([^"']+)["']/);
|
|
358
|
+
if (!labelMatch) continue; // Skip updates without label
|
|
359
|
+
|
|
360
|
+
const label = labelMatch[1];
|
|
361
|
+
|
|
362
|
+
// Parse description
|
|
363
|
+
const descMatch = attrsStr.match(/description=["']([^"']+)["']/);
|
|
364
|
+
const description = descMatch ? descMatch[1] : undefined;
|
|
365
|
+
|
|
366
|
+
// Parse date
|
|
367
|
+
const dateMatch = attrsStr.match(/date=["']([^"']+)["']/);
|
|
368
|
+
const date = dateMatch ? dateMatch[1] : undefined;
|
|
369
|
+
|
|
370
|
+
// Parse tags - handles tags={["feature", "breaking"]}
|
|
371
|
+
let tags: string[] | undefined;
|
|
372
|
+
const tagsMatch = attrsStr.match(/tags=\{(\[[\s\S]*?\])}/);
|
|
373
|
+
if (tagsMatch) {
|
|
374
|
+
try {
|
|
375
|
+
tags = JSON.parse(tagsMatch[1].replace(/'/g, '"'));
|
|
376
|
+
} catch {
|
|
377
|
+
// Ignore malformed tags
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
updates.push({
|
|
382
|
+
label, description, tags, date, content, pagePath, pageTitle,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return updates;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Generate RSS 2.0 XML feed from update entries.
|
|
392
|
+
*/
|
|
393
|
+
export function generateRssFeed(options: RssFeedOptions): string {
|
|
394
|
+
const {
|
|
395
|
+
name, description, baseUrl, hostAtDocs = false, updates,
|
|
396
|
+
} = options;
|
|
397
|
+
|
|
398
|
+
const urlPrefix = hostAtDocs ? '/docs' : '';
|
|
399
|
+
const feedUrl = `${baseUrl}${urlPrefix}/feed.xml`;
|
|
400
|
+
const siteLink = `${baseUrl}${urlPrefix}`;
|
|
401
|
+
const channelTitle = `${escapeXml(name)} Changelog`;
|
|
402
|
+
const channelDesc = description
|
|
403
|
+
? escapeXml(description)
|
|
404
|
+
: `Updates and changelog for ${escapeXml(name)}`;
|
|
405
|
+
|
|
406
|
+
const items = updates.map(update => {
|
|
407
|
+
const slug = labelToSlug(update.label);
|
|
408
|
+
const link = `${baseUrl}${urlPrefix}/${update.pagePath}#${slug}`;
|
|
409
|
+
const title = update.description
|
|
410
|
+
? `${escapeXml(update.label)} \u2014 ${escapeXml(update.description)}`
|
|
411
|
+
: escapeXml(update.label);
|
|
412
|
+
|
|
413
|
+
// Strip markdown and truncate content for description
|
|
414
|
+
let desc = stripMarkdown(update.content);
|
|
415
|
+
if (desc.length > 500) {
|
|
416
|
+
desc = desc.substring(0, 497) + '...';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let pubDateLine = '';
|
|
420
|
+
if (update.date) {
|
|
421
|
+
const d = new Date(update.date + 'T00:00:00Z');
|
|
422
|
+
if (!isNaN(d.getTime())) {
|
|
423
|
+
pubDateLine =
|
|
424
|
+
` <pubDate>${d.toUTCString()}</pubDate>\n`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return ` <item>
|
|
429
|
+
<title>${title}</title>
|
|
430
|
+
<link>${link}</link>
|
|
431
|
+
<guid isPermaLink="true">${link}</guid>
|
|
432
|
+
<description>${escapeXml(desc)}</description>
|
|
433
|
+
${pubDateLine} </item>`;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
437
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
438
|
+
<channel>
|
|
439
|
+
<title>${channelTitle}</title>
|
|
440
|
+
<link>${siteLink}</link>
|
|
441
|
+
<description>${channelDesc}</description>
|
|
442
|
+
<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />
|
|
443
|
+
${items.join('\n')}
|
|
444
|
+
</channel>
|
|
445
|
+
</rss>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// =============================================================================
|
|
449
|
+
// SEARCH INDEX
|
|
450
|
+
// =============================================================================
|
|
451
|
+
|
|
253
452
|
/**
|
|
254
453
|
* Search document entry.
|
|
255
454
|
*/
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared handler for static file routes (sitemap.xml, robots.txt, llms.txt, search-data.json).
|
|
2
|
+
* Shared handler for static file routes (sitemap.xml, robots.txt, llms.txt, search-data.json, feed.xml).
|
|
3
3
|
*
|
|
4
4
|
* All static file routes follow the same pattern: check ISR mode, extract project slug,
|
|
5
5
|
* fetch from R2, and return with appropriate headers. This helper eliminates the
|
|
6
|
-
* duplication across
|
|
6
|
+
* duplication across 10 route files (5 at root + 5 at /docs).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { NextRequest, NextResponse } from 'next/server';
|
|
@@ -31,12 +31,17 @@ function getContentType(filename: string): string {
|
|
|
31
31
|
*
|
|
32
32
|
* @param filename - The R2 filename (e.g., 'sitemap.xml', 'search-data.json')
|
|
33
33
|
* @param label - Human-readable label for log messages (e.g., 'Sitemap', 'Search data')
|
|
34
|
+
* @param contentTypeOverride - Override the inferred content type (e.g., 'application/rss+xml')
|
|
35
|
+
* @param cacheControlOverride - Override the default Cache-Control header
|
|
34
36
|
*/
|
|
35
37
|
export function createStaticFileHandler(
|
|
36
38
|
filename: string,
|
|
37
|
-
label: string
|
|
39
|
+
label: string,
|
|
40
|
+
contentTypeOverride?: string,
|
|
41
|
+
cacheControlOverride?: string
|
|
38
42
|
): (request: NextRequest) => Promise<NextResponse> {
|
|
39
|
-
const contentType = getContentType(filename);
|
|
43
|
+
const contentType = contentTypeOverride || getContentType(filename);
|
|
44
|
+
const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=86400';
|
|
40
45
|
|
|
41
46
|
return async function GET(request: NextRequest): Promise<NextResponse> {
|
|
42
47
|
if (!isIsrMode()) {
|
|
@@ -61,7 +66,7 @@ export function createStaticFileHandler(
|
|
|
61
66
|
return new NextResponse(content, {
|
|
62
67
|
headers: {
|
|
63
68
|
'Content-Type': contentType,
|
|
64
|
-
'Cache-Control':
|
|
69
|
+
'Cache-Control': cacheControl,
|
|
65
70
|
},
|
|
66
71
|
});
|
|
67
72
|
} catch (error) {
|