jamdesk 1.1.134 → 1.1.136
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/package.json +1 -1
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +31 -18
- package/vendored/components/mdx/Widget.tsx +21 -2
- package/vendored/lib/agent-directive.tsx +101 -0
- package/vendored/lib/icon-utils.ts +1 -0
- package/vendored/lib/language-utils.ts +28 -1
- package/vendored/lib/layout-helpers.tsx +2 -0
- package/vendored/lib/r2-cleanup.ts +74 -0
- package/vendored/lib/seo.ts +4 -2
- package/vendored/lib/static-artifacts.ts +233 -11
- package/vendored/lib/static-file-route.ts +61 -0
- package/vendored/themes/pulsar/variables.css +6 -0
- package/vendored/workspace-package-lock.json +60 -60
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.136",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -17,6 +17,7 @@ import { siteHasOpenApiSpecs } from '@/lib/api-specs-bundle';
|
|
|
17
17
|
import { getContextualOptions } from '@/lib/contextual-defaults';
|
|
18
18
|
import { isIsrMode, getBaseUrlFromConfig } from '@/lib/page-isr-helpers';
|
|
19
19
|
import { apiSpecsMarkdownFooter } from '@/lib/api-specs-markdown-hint';
|
|
20
|
+
import { injectAgentDirective } from '@/lib/agent-directive';
|
|
20
21
|
import { logger } from '@/shared/logger';
|
|
21
22
|
import type { DocsConfig } from '@/lib/docs-types';
|
|
22
23
|
|
|
@@ -60,10 +61,31 @@ export async function GET(
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const filtered = filterVisibility(raw, 'agents');
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
64
|
+
|
|
65
|
+
// One config fetch feeds both the directive (hostAtDocs link prefix) and the
|
|
66
|
+
// api-specs hint. A failed fetch must never 500 an export: directive falls
|
|
67
|
+
// back to the subdomain-rooted /llms.txt and the hint keeps its own defaults.
|
|
68
|
+
let config: DocsConfig | null = null;
|
|
69
|
+
try {
|
|
70
|
+
config = await fetchDocsConfig(project);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logger.warn('[markdown-export] config fetch failed', {
|
|
73
|
+
project,
|
|
74
|
+
error: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// hostAtDocs rides the middleware-stamped header — the stored docs.json
|
|
79
|
+
// never carries it (runtime-only field, see DocsConfig), so reading it off
|
|
80
|
+
// `config` alone always resolved false in prod. The config check stays as a
|
|
81
|
+
// fallback for callers that bypassed the middleware rewrite.
|
|
82
|
+
const hostAtDocs =
|
|
83
|
+
request.headers.get('x-host-at-docs') === 'true' || !!config?.hostAtDocs;
|
|
84
|
+
const withDirective = injectAgentDirective(filtered, hostAtDocs);
|
|
85
|
+
// Arg order is load-bearing: `withDirective` is the agent-visible body the
|
|
86
|
+
// footer is appended to; `raw` is the unfiltered source the helper reads
|
|
87
|
+
// frontmatter from (visibility filtering never touches the leading YAML block).
|
|
88
|
+
const body = await withApiSpecsHint(withDirective, raw, project, config);
|
|
67
89
|
|
|
68
90
|
// Two paths reach this handler via proxy rewrite:
|
|
69
91
|
// (a) `.md` URL: unique CDN cache key, safe to share.
|
|
@@ -102,6 +124,7 @@ async function withApiSpecsHint(
|
|
|
102
124
|
markdown: string,
|
|
103
125
|
raw: string,
|
|
104
126
|
project: string,
|
|
127
|
+
config: DocsConfig | null,
|
|
105
128
|
): Promise<string> {
|
|
106
129
|
try {
|
|
107
130
|
if (!isIsrMode()) return markdown;
|
|
@@ -111,20 +134,10 @@ async function withApiSpecsHint(
|
|
|
111
134
|
const pageHasApi = !!data.api;
|
|
112
135
|
if (!(pageHasOpenApi || pageHasApi)) return markdown;
|
|
113
136
|
|
|
114
|
-
// Config drives the site-wide spec check, the
|
|
115
|
-
// hostAtDocs link prefix. A
|
|
116
|
-
// block a page-level `openapi:` page (which qualifies on
|
|
117
|
-
//
|
|
118
|
-
let config: DocsConfig | null = null;
|
|
119
|
-
try {
|
|
120
|
-
config = await fetchDocsConfig(project);
|
|
121
|
-
} catch (err) {
|
|
122
|
-
logger.warn('[api-specs-hint] config fetch failed', {
|
|
123
|
-
project,
|
|
124
|
-
error: err instanceof Error ? err.message : String(err),
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
137
|
+
// Config (fetched once in GET) drives the site-wide spec check, the
|
|
138
|
+
// menu-enabled check, and the hostAtDocs link prefix. A failed/missing
|
|
139
|
+
// config must not block a page-level `openapi:` page (which qualifies on
|
|
140
|
+
// its own): treat it as "no site specs" but keep the default-enabled menu.
|
|
128
141
|
const zipUrl = `${getBaseUrlFromConfig(project, !!config?.hostAtDocs)}/api-specs.zip`;
|
|
129
142
|
|
|
130
143
|
const footer = apiSpecsMarkdownFooter({
|
|
@@ -129,12 +129,12 @@ export function Widget({
|
|
|
129
129
|
|
|
130
130
|
if (trigger) return null;
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
const buttonEl = (
|
|
133
133
|
<button
|
|
134
134
|
type="button"
|
|
135
135
|
id={buttonId}
|
|
136
136
|
className={
|
|
137
|
-
'jd-widget-trigger
|
|
137
|
+
'jd-widget-trigger inline-flex cursor-pointer items-center gap-2 rounded-lg ' +
|
|
138
138
|
'border border-gray-200 bg-white px-4 py-2 text-sm font-medium ' +
|
|
139
139
|
'text-gray-900 shadow-sm transition hover:shadow-md ' +
|
|
140
140
|
'dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100' +
|
|
@@ -145,4 +145,23 @@ export function Widget({
|
|
|
145
145
|
{label}
|
|
146
146
|
</button>
|
|
147
147
|
);
|
|
148
|
+
|
|
149
|
+
// No slug → the trigger can't bind (see the effect above), which is the
|
|
150
|
+
// normal state in local `jamdesk dev`. Render a visible caption beside the
|
|
151
|
+
// button so a clicked-but-inert button doesn't read as broken — the console
|
|
152
|
+
// hint alone is invisible to anyone without devtools open. Never renders in
|
|
153
|
+
// the deployed build, where the slug is always set and the button works.
|
|
154
|
+
if (!resolvedBase) {
|
|
155
|
+
return (
|
|
156
|
+
<span className="jd-widget-preview my-4 flex flex-col items-start gap-1.5">
|
|
157
|
+
{buttonEl}
|
|
158
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
159
|
+
Live preview — opens on your deployed docs site, not in local{' '}
|
|
160
|
+
<code className="rounded bg-gray-100 px-1 py-0.5 dark:bg-gray-800">jamdesk dev</code>.
|
|
161
|
+
</span>
|
|
162
|
+
</span>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return <span className="jd-widget my-4 inline-flex">{buttonEl}</span>;
|
|
148
167
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// builder/build-service/lib/agent-directive.tsx
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Agent-facing llms.txt directive (agentdocsspec.com "llms-txt-directive-html"
|
|
6
|
+
* and "llms-txt-directive-md" checks).
|
|
7
|
+
*
|
|
8
|
+
* Most AI agents fetch HTML by default and have no signal that a markdown
|
|
9
|
+
* path exists. The HTML variant is visually hidden but present in the DOM
|
|
10
|
+
* near the top of <body>, so HTML→markdown conversion surfaces it first.
|
|
11
|
+
* aria-hidden keeps it out of the accessibility tree — screen readers must
|
|
12
|
+
* not announce it on every page.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Site-relative llms.txt path. Relative URLs resolve against the fetch origin. */
|
|
16
|
+
export function llmsTxtPath(hostAtDocs: boolean): string {
|
|
17
|
+
return hostAtDocs ? '/docs/llms.txt' : '/llms.txt';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Plain-text directive for the hidden HTML element — points agents at llms.txt and the .md exports. */
|
|
21
|
+
export function agentDirectiveText(hostAtDocs: boolean): string {
|
|
22
|
+
return (
|
|
23
|
+
`For AI agents: the documentation index for this site is at ${llmsTxtPath(hostAtDocs)}. ` +
|
|
24
|
+
'For a markdown version of any page, append .md to its URL or request it ' +
|
|
25
|
+
'with Accept: text/markdown.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Single-line blockquote — markdown pages put this directly after frontmatter. */
|
|
30
|
+
export function agentDirectiveMarkdown(hostAtDocs: boolean): string {
|
|
31
|
+
return (
|
|
32
|
+
`> **For AI agents:** the complete documentation index is at ` +
|
|
33
|
+
`[llms.txt](${llmsTxtPath(hostAtDocs)}). Append \`.md\` to any page URL ` +
|
|
34
|
+
'for its markdown version.'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Frontmatter must stay first — tools parse the leading YAML block. Anchored
|
|
40
|
+
* match only (a `---` thematic break mid-document is not frontmatter). The
|
|
41
|
+
* closing fence may sit at EOF with no trailing newline — R2 user content
|
|
42
|
+
* doesn't guarantee one (cf. frontmatter-utils.ts).
|
|
43
|
+
*/
|
|
44
|
+
const FRONTMATTER_RE = /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(\r?\n|$)/;
|
|
45
|
+
|
|
46
|
+
export function injectAgentDirective(markdown: string, hostAtDocs: boolean): string {
|
|
47
|
+
if (agentDirectiveDisabled()) return markdown;
|
|
48
|
+
const directive = agentDirectiveMarkdown(hostAtDocs);
|
|
49
|
+
const m = markdown.match(FRONTMATTER_RE);
|
|
50
|
+
if (m) {
|
|
51
|
+
const head = m[0];
|
|
52
|
+
const rest = markdown.slice(head.length).replace(/^\r?\n/, '');
|
|
53
|
+
return `${head}\n${directive}\n\n${rest}`;
|
|
54
|
+
}
|
|
55
|
+
return `${directive}\n\n${markdown}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Inline (not Tailwind) so it works on every theme and in vendored CLI dev. */
|
|
59
|
+
const SR_ONLY: React.CSSProperties = {
|
|
60
|
+
position: 'absolute',
|
|
61
|
+
width: 1,
|
|
62
|
+
height: 1,
|
|
63
|
+
padding: 0,
|
|
64
|
+
margin: -1,
|
|
65
|
+
overflow: 'hidden',
|
|
66
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
67
|
+
whiteSpace: 'nowrap',
|
|
68
|
+
border: 0,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Env kill switch — disable the directive fleet-wide without a code revert
|
|
73
|
+
* (precedent: EMBED_WIDGET_ENABLED, JD_PRUNE_CONTENT_DISABLED). Vercel env
|
|
74
|
+
* change + redeploy beats reverting three surfaces.
|
|
75
|
+
*/
|
|
76
|
+
export function agentDirectiveDisabled(): boolean {
|
|
77
|
+
// Lenient env-flag semantics, matching isCacheDisabled() (cached-redis.ts)
|
|
78
|
+
// and envFlag() (r2-cleanup.ts): Vercel env values can pick up trailing
|
|
79
|
+
// newlines, and operators set "true" as often as "1". Implemented locally —
|
|
80
|
+
// importing envFlag from r2-cleanup would pull @aws-sdk into a
|
|
81
|
+
// layout-rendered file.
|
|
82
|
+
const v = process.env.JD_AGENT_DIRECTIVE_DISABLED?.trim().toLowerCase();
|
|
83
|
+
if (!v) return false;
|
|
84
|
+
return v !== '0' && v !== 'false';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AgentDirective({ hostAtDocs }: { hostAtDocs: boolean }) {
|
|
88
|
+
if (agentDirectiveDisabled()) return null;
|
|
89
|
+
return (
|
|
90
|
+
// data-nosnippet keeps Google from surfacing the directive as a search
|
|
91
|
+
// snippet on description-less pages. The real <a> is what afdocs'
|
|
92
|
+
// LINK_PATTERN prefers and what DOM-walking agents can follow;
|
|
93
|
+
// tabIndex={-1} because an aria-hidden subtree must not be tabbable.
|
|
94
|
+
<div data-jd-agent-directive aria-hidden="true" data-nosnippet="" style={SR_ONLY}>
|
|
95
|
+
{agentDirectiveText(hostAtDocs)}{' '}
|
|
96
|
+
<a href={llmsTxtPath(hostAtDocs)} tabIndex={-1}>
|
|
97
|
+
llms.txt
|
|
98
|
+
</a>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -335,7 +335,9 @@ export function transformLanguagePath(
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
*
|
|
338
|
+
* Case-SENSITIVE membership test: only canonical-cased codes ('fr-CA', not
|
|
339
|
+
* 'fr-ca') return true. For URL segments or declared docs.json codes — which
|
|
340
|
+
* may carry any casing — use isKnownLanguageCode instead.
|
|
339
341
|
*/
|
|
340
342
|
export function isValidLanguageCode(code: string): code is LanguageCode {
|
|
341
343
|
return code in LANGUAGE_DISPLAY_NAMES;
|
|
@@ -358,6 +360,31 @@ const LANGUAGE_CODE_BY_LOWER: Map<string, LanguageCode> = (() => {
|
|
|
358
360
|
return m;
|
|
359
361
|
})();
|
|
360
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Case-insensitive membership test against the supported language-code list.
|
|
365
|
+
* Use this (not the case-sensitive isValidLanguageCode) for input that may
|
|
366
|
+
* carry declared or URL-sourced casing (e.g. 'fr-ca' declared in docs.json,
|
|
367
|
+
* '/fr-ca/' URL segment).
|
|
368
|
+
*/
|
|
369
|
+
export function isKnownLanguageCode(code: string): boolean {
|
|
370
|
+
return LANGUAGE_CODE_BY_LOWER.has(code.toLowerCase());
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Display name for a declared language code, tolerant of casing drift.
|
|
375
|
+
* Direct `LANGUAGE_DISPLAY_NAMES` hit first, then the lowered canonical map
|
|
376
|
+
* above (declared `'pt-br'` → canonical `'pt-BR'` → 'Português (BR)' —
|
|
377
|
+
* display-name keys are canonical-cased, so a strict keyed lookup would
|
|
378
|
+
* silently fall back to the bare code for lowercase region variants), else
|
|
379
|
+
* the bare code. Used by llms.txt cross-language links.
|
|
380
|
+
*/
|
|
381
|
+
export function languageDisplayName(code: string): string {
|
|
382
|
+
const direct = (LANGUAGE_DISPLAY_NAMES as Record<string, string>)[code];
|
|
383
|
+
if (direct) return direct;
|
|
384
|
+
const canonical = LANGUAGE_CODE_BY_LOWER.get(code.toLowerCase());
|
|
385
|
+
return canonical ? LANGUAGE_DISPLAY_NAMES[canonical] : code;
|
|
386
|
+
}
|
|
387
|
+
|
|
361
388
|
/**
|
|
362
389
|
* Extract language code from a pathname
|
|
363
390
|
*
|
|
@@ -25,6 +25,7 @@ import type { BackgroundConfig, DocsConfig, FontConfig, LanguageCode } from '@/l
|
|
|
25
25
|
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
27
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
|
+
import { AgentDirective } from './agent-directive';
|
|
28
29
|
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
29
30
|
import { toHreflang } from '@/lib/language-utils';
|
|
30
31
|
|
|
@@ -655,6 +656,7 @@ export async function DocsChrome({
|
|
|
655
656
|
)}
|
|
656
657
|
</head>
|
|
657
658
|
<body className={fontClassName} data-theme={themeName || 'jam'} data-decoration={decoration || undefined} suppressHydrationWarning>
|
|
659
|
+
{!embed && <AgentDirective hostAtDocs={!!config.hostAtDocs} />}
|
|
658
660
|
{config.integrations?.gtm?.tagId && (
|
|
659
661
|
<ConditionalGTM gtmId={config.integrations.gtm.tagId} />
|
|
660
662
|
)}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
ListObjectsV2Command,
|
|
13
13
|
} from '@aws-sdk/client-s3';
|
|
14
14
|
import { getR2S3Client, getR2Config } from './r2.js';
|
|
15
|
+
import { isKnownLanguageCode } from './language-utils.js';
|
|
15
16
|
import { logger } from '../shared/logger.js';
|
|
16
17
|
|
|
17
18
|
// AWS / R2 cap DeleteObjects to 1000 keys per request. Match ListObjectsV2's
|
|
@@ -315,6 +316,79 @@ export async function pruneRemovedContent(
|
|
|
315
316
|
return { deleted, failed, skipped: false };
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Given top-level CommonPrefixes from a delimiter='/' listing of `{slug}/`,
|
|
321
|
+
* return per-locale llms.txt keys whose locale is a KNOWN language code but
|
|
322
|
+
* is no longer declared — left behind when a project removes a language.
|
|
323
|
+
* Only ever targets our own `<locale>/llms.txt` artifact keys, so content
|
|
324
|
+
* dirs (content/, snippets/, openapi/, assets/) can never be touched.
|
|
325
|
+
*/
|
|
326
|
+
export function staleLocaleLlmsKeys(
|
|
327
|
+
commonPrefixes: string[],
|
|
328
|
+
projectSlug: string,
|
|
329
|
+
activeLocales: Set<string>,
|
|
330
|
+
): string[] {
|
|
331
|
+
const keys: string[] = [];
|
|
332
|
+
for (const prefix of commonPrefixes) {
|
|
333
|
+
if (!prefix.startsWith(`${projectSlug}/`)) continue;
|
|
334
|
+
const dir = prefix.slice(projectSlug.length + 1).replace(/\/$/, '');
|
|
335
|
+
if (!dir || dir.includes('/')) continue;
|
|
336
|
+
if (!isKnownLanguageCode(dir)) continue;
|
|
337
|
+
// Case-sensitive has() is correct here: activeLocales keys use declared
|
|
338
|
+
// casing, which matches the R2 paths this build just uploaded. A dir whose
|
|
339
|
+
// casing differs is either a renamed locale or a leftover from a prior
|
|
340
|
+
// casing change — both legitimately stale.
|
|
341
|
+
if (activeLocales.has(dir)) continue;
|
|
342
|
+
keys.push(`${projectSlug}/${dir}/llms.txt`);
|
|
343
|
+
}
|
|
344
|
+
return keys;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* List `{slug}/`'s top-level dirs and delete `<locale>/llms.txt` for any
|
|
349
|
+
* known-language dir no longer declared. Paginates the listing like
|
|
350
|
+
* deleteAllProjectR2Objects — a delimiter listing of a huge project could
|
|
351
|
+
* truncate, and an abandoned page would silently orphan its stale locales.
|
|
352
|
+
* Non-fatal by contract: callers treat a throw as a warning — a stale
|
|
353
|
+
* locale index is a cosmetic orphan.
|
|
354
|
+
*/
|
|
355
|
+
export async function sweepStaleLocaleLlmsTxt(
|
|
356
|
+
projectSlug: string,
|
|
357
|
+
activeLocales: Set<string>,
|
|
358
|
+
): Promise<string[]> {
|
|
359
|
+
const client = getR2S3Client();
|
|
360
|
+
const { bucketName } = getR2Config();
|
|
361
|
+
const prefixes: string[] = [];
|
|
362
|
+
let continuationToken: string | undefined;
|
|
363
|
+
|
|
364
|
+
do {
|
|
365
|
+
const listed: {
|
|
366
|
+
CommonPrefixes?: Array<{ Prefix?: string }>;
|
|
367
|
+
IsTruncated?: boolean;
|
|
368
|
+
NextContinuationToken?: string;
|
|
369
|
+
} = await client.send(
|
|
370
|
+
new ListObjectsV2Command({
|
|
371
|
+
Bucket: bucketName,
|
|
372
|
+
Prefix: `${projectSlug}/`,
|
|
373
|
+
Delimiter: '/',
|
|
374
|
+
ContinuationToken: continuationToken,
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
for (const p of listed.CommonPrefixes ?? []) {
|
|
378
|
+
if (typeof p.Prefix === 'string') prefixes.push(p.Prefix);
|
|
379
|
+
}
|
|
380
|
+
continuationToken = listed.IsTruncated ?
|
|
381
|
+
listed.NextContinuationToken :
|
|
382
|
+
undefined;
|
|
383
|
+
} while (continuationToken);
|
|
384
|
+
|
|
385
|
+
const staleKeys = staleLocaleLlmsKeys(prefixes, projectSlug, activeLocales);
|
|
386
|
+
for (const key of staleKeys) {
|
|
387
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
|
|
388
|
+
}
|
|
389
|
+
return staleKeys;
|
|
390
|
+
}
|
|
391
|
+
|
|
318
392
|
/**
|
|
319
393
|
* Delete EVERY R2 object under the `{slug}/` prefix.
|
|
320
394
|
*
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -710,13 +710,15 @@ export function buildSeoMetadata(
|
|
|
710
710
|
if (hasOgFamily || frontmatter.title) {
|
|
711
711
|
// A custom og:image becomes the card BACKGROUND (text renders on top) rather
|
|
712
712
|
// than a bare static image, so the emitted OG image is always the generated card.
|
|
713
|
+
// The card text follows og:title/og:description overrides so the image matches
|
|
714
|
+
// the link-card text built in buildOpenGraphMetadata.
|
|
713
715
|
const customImage = metatags['og:image']?.trim();
|
|
714
716
|
const ogImageUrl = buildOgImageUrl(
|
|
715
717
|
baseUrl,
|
|
716
|
-
frontmatter.title || 'Documentation',
|
|
718
|
+
metatags['og:title'] || frontmatter.title || 'Documentation',
|
|
717
719
|
config.name,
|
|
718
720
|
{
|
|
719
|
-
description: frontmatter.description,
|
|
721
|
+
description: metatags['og:description'] || frontmatter.description,
|
|
720
722
|
section: frontmatter.section,
|
|
721
723
|
logo: config.logo,
|
|
722
724
|
favicon: config.favicon,
|
|
@@ -10,6 +10,8 @@ import { RECURSE_KEYS } from './enhance-navigation.js';
|
|
|
10
10
|
import { filterVisibility } from './visibility-filter.js';
|
|
11
11
|
import {
|
|
12
12
|
buildLoweredLocaleSet,
|
|
13
|
+
languageDisplayName,
|
|
14
|
+
resolveLanguageWithFallback,
|
|
13
15
|
resolveLocaleFromPath,
|
|
14
16
|
resolveLocaleWithLoweredSet,
|
|
15
17
|
} from './language-utils.js';
|
|
@@ -170,6 +172,22 @@ export interface LlmsTxtOptions {
|
|
|
170
172
|
* handling across all artifacts.
|
|
171
173
|
*/
|
|
172
174
|
visibility?: VisibilityInputs;
|
|
175
|
+
/** Page path → section title (from extractNavigationSections). Nav order matters. */
|
|
176
|
+
sections?: Map<string, string>;
|
|
177
|
+
/** Cross-links to sibling-locale llms.txt files; rendered as the final H2 section. */
|
|
178
|
+
otherLanguages?: Array<{ label: string; url: string }>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* llmstxt.org reserves the bare "## Optional" heading as a skip-signal —
|
|
183
|
+
* agents wanting shorter context may drop that whole section. A customer
|
|
184
|
+
* tab/group literally named "Optional" must not inherit that semantics (its
|
|
185
|
+
* pages would be silently skipped) nor collide with our cross-language
|
|
186
|
+
* section, so nav sections render a disambiguated heading; only the language
|
|
187
|
+
* cross-link section emits the reserved bare name.
|
|
188
|
+
*/
|
|
189
|
+
function renderSectionName(section: string): string {
|
|
190
|
+
return section === 'Optional' ? 'Optional (docs)' : section;
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
/**
|
|
@@ -182,7 +200,10 @@ export interface LlmsTxtOptions {
|
|
|
182
200
|
* @returns Plain text string
|
|
183
201
|
*/
|
|
184
202
|
export function generateLlmsTxt(options: LlmsTxtOptions): string {
|
|
185
|
-
const {
|
|
203
|
+
const {
|
|
204
|
+
name, description, baseUrl, pages, hostAtDocs = false, visibility,
|
|
205
|
+
sections, otherLanguages,
|
|
206
|
+
} = options;
|
|
186
207
|
|
|
187
208
|
// NOTE: noindex is intentionally NOT respected here. AI agents fetch llms.txt
|
|
188
209
|
// directly and do not honor robots directives, so this file is generated
|
|
@@ -192,20 +213,158 @@ export function generateLlmsTxt(options: LlmsTxtOptions): string {
|
|
|
192
213
|
const urlPrefix = hostAtDocs ? '/docs' : '';
|
|
193
214
|
|
|
194
215
|
let output = `# ${name}\n\n`;
|
|
216
|
+
// Always emit the blockquote — llms-txt-valid requires H1 + blockquote +
|
|
217
|
+
// sections, and most projects never set a docs.json description.
|
|
218
|
+
output += `> ${description || `Documentation for ${name}.`}\n\n`;
|
|
219
|
+
|
|
220
|
+
const included = pages.filter((page) => isPageMetadataIncluded(page, visibility));
|
|
221
|
+
|
|
222
|
+
// Bucket pages by section. Section order = nav order (Map insertion order);
|
|
223
|
+
// pages keep nav order within a section via their index in the sections Map.
|
|
224
|
+
// Unsectioned pages land in a trailing "Documentation" bucket.
|
|
225
|
+
const FALLBACK_SECTION = 'Documentation';
|
|
226
|
+
const sectionOrder: string[] = sections ? [...new Set(sections.values())] : [];
|
|
227
|
+
const navIndex = new Map<string, number>(
|
|
228
|
+
sections ? [...sections.keys()].map((p, i) => [p, i] as const) : [],
|
|
229
|
+
);
|
|
230
|
+
const buckets = new Map<string, typeof included>();
|
|
231
|
+
for (const page of included) {
|
|
232
|
+
const section = sections?.get(page.path) ?? FALLBACK_SECTION;
|
|
233
|
+
if (!buckets.has(section)) buckets.set(section, []);
|
|
234
|
+
buckets.get(section)!.push(page);
|
|
235
|
+
}
|
|
236
|
+
const orderedSections = [
|
|
237
|
+
...sectionOrder.filter((s) => buckets.has(s)),
|
|
238
|
+
...(buckets.has(FALLBACK_SECTION) && !sectionOrder.includes(FALLBACK_SECTION)
|
|
239
|
+
? [FALLBACK_SECTION]
|
|
240
|
+
: []),
|
|
241
|
+
];
|
|
195
242
|
|
|
196
|
-
|
|
197
|
-
output +=
|
|
243
|
+
for (const section of orderedSections) {
|
|
244
|
+
output += `## ${renderSectionName(section)}\n\n`;
|
|
245
|
+
const bucket = buckets.get(section)!;
|
|
246
|
+
// `?? 0` (not Infinity): named buckets always carry nav indexes, so the
|
|
247
|
+
// fallback only fires in the Documentation bucket where EVERY page lacks
|
|
248
|
+
// one — all-zero comparisons let the stable sort keep page-array order.
|
|
249
|
+
// (`Infinity - Infinity` = NaN, a spec-undefined comparator result.)
|
|
250
|
+
bucket.sort((a, b) => (navIndex.get(a.path) ?? 0) - (navIndex.get(b.path) ?? 0));
|
|
251
|
+
for (const page of bucket) {
|
|
252
|
+
const url = `${baseUrl}${urlPrefix}/${page.path}.md`;
|
|
253
|
+
// Frontmatter strings can carry raw newlines — collapse them so a
|
|
254
|
+
// crafted title/description can't forge fake "## " section headings or
|
|
255
|
+
// split the entry across lines (llms.txt is line-oriented).
|
|
256
|
+
const safeTitle = page.title.replace(/[\r\n]+/g, ' ');
|
|
257
|
+
const desc = page.description ? `: ${page.description.replace(/[\r\n]+/g, ' ')}` : '';
|
|
258
|
+
output += `- [${safeTitle}](${url})${desc}\n`;
|
|
259
|
+
}
|
|
260
|
+
output += '\n';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (otherLanguages && otherLanguages.length > 0) {
|
|
264
|
+
// llmstxt.org reserves "## Optional" as the skip-signal section — agents
|
|
265
|
+
// wanting shorter context skip it, agents needing another language still
|
|
266
|
+
// see the links. Descriptive labels keep it human-readable.
|
|
267
|
+
output += `## Optional\n\n`;
|
|
268
|
+
for (const lang of otherLanguages) {
|
|
269
|
+
output += `- [${lang.label}](${lang.url}): ${lang.label} documentation index\n`;
|
|
270
|
+
}
|
|
271
|
+
output += '\n';
|
|
198
272
|
}
|
|
199
273
|
|
|
274
|
+
return output.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Options for generating the per-locale llms.txt file set.
|
|
279
|
+
*/
|
|
280
|
+
export interface LlmsTxtFilesOptions extends Omit<LlmsTxtOptions, 'sections' | 'otherLanguages'> {
|
|
281
|
+
/** docs.json navigation — source of the H2 section mapping. */
|
|
282
|
+
navigation?: NavigationConfig;
|
|
283
|
+
/** Declared languages (navigation.languages) — drives the locale split. */
|
|
284
|
+
languages?: LanguageConfig[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Render the root llms.txt (default locale) plus one file per declared,
|
|
289
|
+
* non-hidden, non-default language. Locale membership comes from the page
|
|
290
|
+
* path prefix (resolveLocaleFromPath, whitelisted against declared codes —
|
|
291
|
+
* same semantics as the search index), NOT from nav position. Cross-links
|
|
292
|
+
* between locale files render under the trailing "## Optional" section.
|
|
293
|
+
*/
|
|
294
|
+
export function generateLlmsTxtFiles(
|
|
295
|
+
options: LlmsTxtFilesOptions,
|
|
296
|
+
): { root: string; byLocale: Record<string, string> } {
|
|
297
|
+
const { navigation, languages, baseUrl, hostAtDocs = false, pages, ...rest } = options;
|
|
298
|
+
const urlPrefix = hostAtDocs ? '/docs' : '';
|
|
299
|
+
const sections = extractNavigationSections(navigation);
|
|
300
|
+
|
|
301
|
+
const declared = (languages ?? []).filter(
|
|
302
|
+
(l): l is LanguageConfig & { language: string } =>
|
|
303
|
+
typeof l.language === 'string' && !l.hidden,
|
|
304
|
+
);
|
|
305
|
+
// Dedupe case-insensitively, first declaration wins: a language declared
|
|
306
|
+
// twice in docs.json would otherwise render its cross-link twice in every
|
|
307
|
+
// "## Optional" section.
|
|
308
|
+
const codes = declared
|
|
309
|
+
.map((l) => l.language)
|
|
310
|
+
.filter(
|
|
311
|
+
(code, i, all) =>
|
|
312
|
+
all.findIndex((c) => c.toLowerCase() === code.toLowerCase()) === i,
|
|
313
|
+
);
|
|
314
|
+
const defaultCode = resolveLanguageWithFallback(null, languages);
|
|
315
|
+
|
|
316
|
+
if (codes.length === 0) {
|
|
317
|
+
const root = generateLlmsTxt({ ...rest, baseUrl, hostAtDocs, pages, sections });
|
|
318
|
+
return { root, byLocale: {} };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const byLocalePages = new Map<string, typeof pages>();
|
|
200
322
|
for (const page of pages) {
|
|
201
|
-
|
|
323
|
+
// resolveLocaleFromPath returns '' (NOT the default code) for unprefixed
|
|
324
|
+
// paths and for prefixes outside the whitelist — both belong to the root
|
|
325
|
+
// (default-locale) file. `|| defaultCode` is load-bearing: without it,
|
|
326
|
+
// every standard site's root llms.txt would render empty.
|
|
327
|
+
// `.toLowerCase()` is equally load-bearing: the resolver returns the
|
|
328
|
+
// CANONICAL casing (e.g. 'fr-CA' for a declared 'fr-ca'), so bucketing by
|
|
329
|
+
// its raw return and reading back by the declared code would silently
|
|
330
|
+
// drop those pages from every llms.txt file.
|
|
331
|
+
const locale = (resolveLocaleFromPath(page.path, codes) || defaultCode).toLowerCase();
|
|
332
|
+
if (!byLocalePages.has(locale)) byLocalePages.set(locale, []);
|
|
333
|
+
byLocalePages.get(locale)!.push(page);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const llmsUrl = (code: string): string =>
|
|
337
|
+
code === defaultCode
|
|
338
|
+
? `${baseUrl}${urlPrefix}/llms.txt`
|
|
339
|
+
: `${baseUrl}${urlPrefix}/${code}/llms.txt`;
|
|
340
|
+
// Declared-but-untranslated languages produce empty files nobody should be
|
|
341
|
+
// sent to — generate and cross-link only locales that actually have pages
|
|
342
|
+
// (the default locale always exists as the root file).
|
|
343
|
+
const activeCodes = codes.filter(
|
|
344
|
+
(c) => c === defaultCode || (byLocalePages.get(c.toLowerCase())?.length ?? 0) > 0,
|
|
345
|
+
);
|
|
346
|
+
const activeCrossLinks = (selfCode: string) =>
|
|
347
|
+
activeCodes
|
|
348
|
+
.filter((c) => c !== selfCode)
|
|
349
|
+
.map((c) => ({ label: languageDisplayName(c), url: llmsUrl(c) }));
|
|
350
|
+
|
|
351
|
+
const root = generateLlmsTxt({
|
|
352
|
+
...rest, baseUrl, hostAtDocs, sections,
|
|
353
|
+
pages: byLocalePages.get(defaultCode.toLowerCase()) ?? [],
|
|
354
|
+
otherLanguages: activeCrossLinks(defaultCode),
|
|
355
|
+
});
|
|
202
356
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
357
|
+
const byLocale: Record<string, string> = {};
|
|
358
|
+
for (const code of activeCodes) {
|
|
359
|
+
if (code === defaultCode) continue;
|
|
360
|
+
byLocale[code] = generateLlmsTxt({
|
|
361
|
+
...rest, baseUrl, hostAtDocs, sections,
|
|
362
|
+
pages: byLocalePages.get(code.toLowerCase()) ?? [],
|
|
363
|
+
otherLanguages: activeCrossLinks(code),
|
|
364
|
+
});
|
|
206
365
|
}
|
|
207
366
|
|
|
208
|
-
return
|
|
367
|
+
return { root, byLocale };
|
|
209
368
|
}
|
|
210
369
|
|
|
211
370
|
/**
|
|
@@ -332,6 +491,64 @@ export function extractNavigationPaths(
|
|
|
332
491
|
return paths;
|
|
333
492
|
}
|
|
334
493
|
|
|
494
|
+
/** Container keys whose string value names a section, checked in order. */
|
|
495
|
+
const SECTION_NAME_KEYS = ['tab', 'group', 'dropdown', 'product', 'anchor'] as const;
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Map every nav page path to its OUTERMOST named container (tab > top-level
|
|
499
|
+
* group > dropdown/product/legacy-anchor). Languages and versions are
|
|
500
|
+
* transparent: locale is derived from the page path, not the section, so
|
|
501
|
+
* "Guides" in the fr subtree and the en subtree share a section name.
|
|
502
|
+
* First mapping wins (a page referenced twice keeps its first home).
|
|
503
|
+
* Insertion order of values follows nav order — generateLlmsTxt relies on it.
|
|
504
|
+
*
|
|
505
|
+
* Traverses the same tree shapes as extractNavigationPaths above — keep the
|
|
506
|
+
* two walkers' pages-handling in sync.
|
|
507
|
+
*/
|
|
508
|
+
export function extractNavigationSections(
|
|
509
|
+
navigation: NavigationConfig | undefined,
|
|
510
|
+
): Map<string, string> {
|
|
511
|
+
const sections = new Map<string, string>();
|
|
512
|
+
if (!navigation) return sections;
|
|
513
|
+
|
|
514
|
+
function walk(node: Record<string, unknown>, inherited: string | null): void {
|
|
515
|
+
let section = inherited;
|
|
516
|
+
if (!section) {
|
|
517
|
+
for (const key of SECTION_NAME_KEYS) {
|
|
518
|
+
if (typeof node[key] === 'string') {
|
|
519
|
+
section = (node[key] as string).replace(/\s+/g, ' ').trim();
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (Array.isArray(node.pages)) {
|
|
526
|
+
for (const item of node.pages) {
|
|
527
|
+
if (typeof item === 'string') {
|
|
528
|
+
if (section && !sections.has(item)) sections.set(item, section);
|
|
529
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
530
|
+
const obj = item as Record<string, unknown>;
|
|
531
|
+
if (typeof obj.page === 'string' && section && !sections.has(obj.page)) {
|
|
532
|
+
sections.set(obj.page, section);
|
|
533
|
+
}
|
|
534
|
+
if ('group' in obj) walk(obj, section);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
for (const key of RECURSE_KEYS) {
|
|
540
|
+
if (Array.isArray(node[key])) {
|
|
541
|
+
for (const child of node[key] as Record<string, unknown>[]) {
|
|
542
|
+
walk(child, section);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
walk(navigation as Record<string, unknown>, null);
|
|
549
|
+
return sections;
|
|
550
|
+
}
|
|
551
|
+
|
|
335
552
|
/**
|
|
336
553
|
* Page info with content for llms-full.txt generation.
|
|
337
554
|
*/
|
|
@@ -447,6 +664,8 @@ export interface GenerateAllOptions {
|
|
|
447
664
|
llmsFullPages?: LlmsFullPageInfo[];
|
|
448
665
|
/** Language configurations (forwarded to sitemap for hreflang siblings) */
|
|
449
666
|
languages?: LanguageConfig[];
|
|
667
|
+
/** docs.json navigation tree (enhanced) — powers llms.txt H2 sections. */
|
|
668
|
+
navigation?: NavigationConfig;
|
|
450
669
|
/**
|
|
451
670
|
* Visibility inputs from lib/visibility.ts. When provided, all artifact
|
|
452
671
|
* generators use computePageVisibility for consistent hidden/orphan/searchable
|
|
@@ -463,6 +682,8 @@ export interface GenerateAllOptions {
|
|
|
463
682
|
export interface GeneratedArtifacts {
|
|
464
683
|
sitemap: string;
|
|
465
684
|
llmsTxt: string;
|
|
685
|
+
/** Non-default-locale llms.txt files, keyed by language code. */
|
|
686
|
+
llmsTxtByLocale: Record<string, string>;
|
|
466
687
|
llmsFullTxt: string;
|
|
467
688
|
robotsTxt: string;
|
|
468
689
|
rssFeed: string | null;
|
|
@@ -484,12 +705,13 @@ export interface GeneratedArtifacts {
|
|
|
484
705
|
export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArtifacts {
|
|
485
706
|
const {
|
|
486
707
|
baseUrl, name, description, pages, hostAtDocs, noindex, rssPages, llmsFullPages, languages,
|
|
487
|
-
visibility,
|
|
708
|
+
navigation, visibility,
|
|
488
709
|
} = options;
|
|
489
710
|
|
|
490
711
|
const sitemap = generateSitemap({ baseUrl, pages, hostAtDocs, noindex, languages, visibility });
|
|
491
|
-
const llmsTxt =
|
|
712
|
+
const { root: llmsTxt, byLocale: llmsTxtByLocale } = generateLlmsTxtFiles({
|
|
492
713
|
name, description, baseUrl, pages, hostAtDocs, noindex, visibility,
|
|
714
|
+
navigation, languages,
|
|
493
715
|
});
|
|
494
716
|
const llmsFullTxt = llmsFullPages
|
|
495
717
|
? generateLlmsFullTxt({ name, pages: llmsFullPages, noindex, visibility })
|
|
@@ -510,7 +732,7 @@ export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArti
|
|
|
510
732
|
|
|
511
733
|
const changelog = generateChangelog(updates);
|
|
512
734
|
|
|
513
|
-
return { sitemap, llmsTxt, llmsFullTxt, robotsTxt, rssFeed, changelog };
|
|
735
|
+
return { sitemap, llmsTxt, llmsTxtByLocale, llmsFullTxt, robotsTxt, rssFeed, changelog };
|
|
514
736
|
}
|
|
515
737
|
|
|
516
738
|
// =============================================================================
|
|
@@ -11,6 +11,7 @@ import path from 'path';
|
|
|
11
11
|
|
|
12
12
|
import { NextRequest, NextResponse } from 'next/server';
|
|
13
13
|
|
|
14
|
+
import { isKnownLanguageCode } from '@/lib/language-utils';
|
|
14
15
|
import { log } from '@/lib/logger';
|
|
15
16
|
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
16
17
|
import { fetchStaticFile } from '@/lib/r2-content';
|
|
@@ -22,6 +23,9 @@ export const STATIC_FILE_NAMES = [
|
|
|
22
23
|
] as const;
|
|
23
24
|
|
|
24
25
|
/** All CDN paths for static file routes — used by revalidation to purge CDN cache. */
|
|
26
|
+
// Per-locale llms.txt paths (/{locale}/llms.txt) are intentionally absent:
|
|
27
|
+
// locales are per-project, unenumerable here. They self-heal via s-maxage=3600,
|
|
28
|
+
// identical to llms.txt's behavior between explicit purges.
|
|
25
29
|
export const STATIC_REVALIDATION_PATHS = STATIC_FILE_NAMES.flatMap(
|
|
26
30
|
f => [`/${f}`, `/docs/${f}`]
|
|
27
31
|
);
|
|
@@ -129,6 +133,63 @@ export function createCorsStaticFileHandler(
|
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/**
|
|
137
|
+
* GET handler for per-locale llms.txt (`/{locale}/llms.txt`, `/docs/{locale}/llms.txt`).
|
|
138
|
+
*
|
|
139
|
+
* Locale is validated (case-insensitively) against the supported language-code
|
|
140
|
+
* list BEFORE any R2 I/O so arbitrary path segments (`/foo/llms.txt`) fast-404.
|
|
141
|
+
* Like llms.txt, locale files are intentionally NOT suppressed on
|
|
142
|
+
* `x-jd-noindex` — AI agents fetch them directly and don't honor robots
|
|
143
|
+
* semantics.
|
|
144
|
+
*/
|
|
145
|
+
export function createLocaleLlmsTxtHandler(): (
|
|
146
|
+
request: NextRequest,
|
|
147
|
+
ctx: { params: Promise<{ locale: string }> },
|
|
148
|
+
) => Promise<NextResponse> {
|
|
149
|
+
return async function GET(request, ctx): Promise<NextResponse> {
|
|
150
|
+
const { locale } = await ctx.params;
|
|
151
|
+
if (!isKnownLanguageCode(locale)) {
|
|
152
|
+
return new NextResponse('Not found', { status: 404 });
|
|
153
|
+
}
|
|
154
|
+
// Fetch with the segment verbatim: R2 keys are written with the DECLARED
|
|
155
|
+
// code, and every advertised URL is generated from that same code, so
|
|
156
|
+
// segment casing and key casing always agree for real links.
|
|
157
|
+
const filename = `${locale}/llms.txt`;
|
|
158
|
+
|
|
159
|
+
if (!isIsrMode()) {
|
|
160
|
+
const localPath = path.join(process.cwd(), 'public', filename);
|
|
161
|
+
if (fs.existsSync(localPath)) {
|
|
162
|
+
return new NextResponse(fs.readFileSync(localPath), {
|
|
163
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return new NextResponse('Not found', { status: 404 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const projectSlug = request.headers.get('x-project-slug');
|
|
170
|
+
if (!projectSlug) {
|
|
171
|
+
log('warn', 'Locale llms.txt request missing project slug');
|
|
172
|
+
return new NextResponse('Project not found', { status: 404 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const content = await fetchStaticFile(projectSlug, filename);
|
|
177
|
+
if (content === null) {
|
|
178
|
+
return new NextResponse('Not found', { status: 404 });
|
|
179
|
+
}
|
|
180
|
+
return new NextResponse(content, {
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
183
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
log('error', 'Error serving locale llms.txt', { projectSlug, locale, error: String(error) });
|
|
188
|
+
return new NextResponse('Error serving locale llms.txt', { status: 500 });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
132
193
|
/**
|
|
133
194
|
* Create a GET handler that serves a static file from R2.
|
|
134
195
|
*
|
|
@@ -764,6 +764,12 @@ body[data-theme="pulsar"] header [data-theme-toggle] {
|
|
|
764
764
|
body[data-theme="pulsar"] #content-scroll-container {
|
|
765
765
|
display: flex;
|
|
766
766
|
justify-content: center;
|
|
767
|
+
/* Without this, the default stretch pins the article's height to the
|
|
768
|
+
* visible container height; long content overflows the article box, the
|
|
769
|
+
* article's bottom padding lands mid-page, and the scroll area ends flush
|
|
770
|
+
* against the footer's last pixel ("Powered by Jamdesk" touching the
|
|
771
|
+
* viewport bottom). flex-start restores the article's natural height. */
|
|
772
|
+
align-items: flex-start;
|
|
767
773
|
/* Hide scrollbar but keep scrollable */
|
|
768
774
|
scrollbar-width: none; /* Firefox */
|
|
769
775
|
-ms-overflow-style: none; /* IE/Edge */
|
|
@@ -912,15 +912,15 @@
|
|
|
912
912
|
}
|
|
913
913
|
},
|
|
914
914
|
"node_modules/@next/env": {
|
|
915
|
-
"version": "16.2.
|
|
916
|
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.
|
|
917
|
-
"integrity": "sha512-
|
|
915
|
+
"version": "16.2.9",
|
|
916
|
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz",
|
|
917
|
+
"integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==",
|
|
918
918
|
"license": "MIT"
|
|
919
919
|
},
|
|
920
920
|
"node_modules/@next/mdx": {
|
|
921
|
-
"version": "16.2.
|
|
922
|
-
"resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.2.
|
|
923
|
-
"integrity": "sha512-
|
|
921
|
+
"version": "16.2.9",
|
|
922
|
+
"resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.2.9.tgz",
|
|
923
|
+
"integrity": "sha512-SdweShKGCuN639JjyFSMQ8uldo+I+254+HucpjwdbFfaWHqUNN6dnQ1Of6laahnFyo48CcfDXEc2OBCS/Wfngw==",
|
|
924
924
|
"license": "MIT",
|
|
925
925
|
"dependencies": {
|
|
926
926
|
"source-map": "^0.7.0"
|
|
@@ -939,9 +939,9 @@
|
|
|
939
939
|
}
|
|
940
940
|
},
|
|
941
941
|
"node_modules/@next/swc-darwin-arm64": {
|
|
942
|
-
"version": "16.2.
|
|
943
|
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.
|
|
944
|
-
"integrity": "sha512-
|
|
942
|
+
"version": "16.2.9",
|
|
943
|
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz",
|
|
944
|
+
"integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==",
|
|
945
945
|
"cpu": [
|
|
946
946
|
"arm64"
|
|
947
947
|
],
|
|
@@ -955,9 +955,9 @@
|
|
|
955
955
|
}
|
|
956
956
|
},
|
|
957
957
|
"node_modules/@next/swc-darwin-x64": {
|
|
958
|
-
"version": "16.2.
|
|
959
|
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.
|
|
960
|
-
"integrity": "sha512-
|
|
958
|
+
"version": "16.2.9",
|
|
959
|
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz",
|
|
960
|
+
"integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==",
|
|
961
961
|
"cpu": [
|
|
962
962
|
"x64"
|
|
963
963
|
],
|
|
@@ -971,9 +971,9 @@
|
|
|
971
971
|
}
|
|
972
972
|
},
|
|
973
973
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
|
974
|
-
"version": "16.2.
|
|
975
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.
|
|
976
|
-
"integrity": "sha512-
|
|
974
|
+
"version": "16.2.9",
|
|
975
|
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz",
|
|
976
|
+
"integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==",
|
|
977
977
|
"cpu": [
|
|
978
978
|
"arm64"
|
|
979
979
|
],
|
|
@@ -990,9 +990,9 @@
|
|
|
990
990
|
}
|
|
991
991
|
},
|
|
992
992
|
"node_modules/@next/swc-linux-arm64-musl": {
|
|
993
|
-
"version": "16.2.
|
|
994
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.
|
|
995
|
-
"integrity": "sha512-
|
|
993
|
+
"version": "16.2.9",
|
|
994
|
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz",
|
|
995
|
+
"integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==",
|
|
996
996
|
"cpu": [
|
|
997
997
|
"arm64"
|
|
998
998
|
],
|
|
@@ -1009,9 +1009,9 @@
|
|
|
1009
1009
|
}
|
|
1010
1010
|
},
|
|
1011
1011
|
"node_modules/@next/swc-linux-x64-gnu": {
|
|
1012
|
-
"version": "16.2.
|
|
1013
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.
|
|
1014
|
-
"integrity": "sha512-
|
|
1012
|
+
"version": "16.2.9",
|
|
1013
|
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz",
|
|
1014
|
+
"integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==",
|
|
1015
1015
|
"cpu": [
|
|
1016
1016
|
"x64"
|
|
1017
1017
|
],
|
|
@@ -1028,9 +1028,9 @@
|
|
|
1028
1028
|
}
|
|
1029
1029
|
},
|
|
1030
1030
|
"node_modules/@next/swc-linux-x64-musl": {
|
|
1031
|
-
"version": "16.2.
|
|
1032
|
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.
|
|
1033
|
-
"integrity": "sha512-
|
|
1031
|
+
"version": "16.2.9",
|
|
1032
|
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz",
|
|
1033
|
+
"integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==",
|
|
1034
1034
|
"cpu": [
|
|
1035
1035
|
"x64"
|
|
1036
1036
|
],
|
|
@@ -1047,9 +1047,9 @@
|
|
|
1047
1047
|
}
|
|
1048
1048
|
},
|
|
1049
1049
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
|
1050
|
-
"version": "16.2.
|
|
1051
|
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.
|
|
1052
|
-
"integrity": "sha512-
|
|
1050
|
+
"version": "16.2.9",
|
|
1051
|
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz",
|
|
1052
|
+
"integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==",
|
|
1053
1053
|
"cpu": [
|
|
1054
1054
|
"arm64"
|
|
1055
1055
|
],
|
|
@@ -1063,9 +1063,9 @@
|
|
|
1063
1063
|
}
|
|
1064
1064
|
},
|
|
1065
1065
|
"node_modules/@next/swc-win32-x64-msvc": {
|
|
1066
|
-
"version": "16.2.
|
|
1067
|
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.
|
|
1068
|
-
"integrity": "sha512-
|
|
1066
|
+
"version": "16.2.9",
|
|
1067
|
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz",
|
|
1068
|
+
"integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==",
|
|
1069
1069
|
"cpu": [
|
|
1070
1070
|
"x64"
|
|
1071
1071
|
],
|
|
@@ -1079,9 +1079,9 @@
|
|
|
1079
1079
|
}
|
|
1080
1080
|
},
|
|
1081
1081
|
"node_modules/@next/third-parties": {
|
|
1082
|
-
"version": "16.2.
|
|
1083
|
-
"resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-16.2.
|
|
1084
|
-
"integrity": "sha512-
|
|
1082
|
+
"version": "16.2.9",
|
|
1083
|
+
"resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-16.2.9.tgz",
|
|
1084
|
+
"integrity": "sha512-JZPGQRN8eQs2WMfBXOf6Zjrma+JIN0KyA24MvmIa3bUI4BwTaJ1UlxmYVc5ri6l30LQ8fPv6Kmx20447hCltrg==",
|
|
1085
1085
|
"license": "MIT",
|
|
1086
1086
|
"dependencies": {
|
|
1087
1087
|
"third-party-capital": "1.0.20"
|
|
@@ -2145,9 +2145,9 @@
|
|
|
2145
2145
|
}
|
|
2146
2146
|
},
|
|
2147
2147
|
"node_modules/baseline-browser-mapping": {
|
|
2148
|
-
"version": "2.10.
|
|
2149
|
-
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.
|
|
2150
|
-
"integrity": "sha512-
|
|
2148
|
+
"version": "2.10.35",
|
|
2149
|
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
|
|
2150
|
+
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
|
|
2151
2151
|
"license": "Apache-2.0",
|
|
2152
2152
|
"bin": {
|
|
2153
2153
|
"baseline-browser-mapping": "dist/cli.cjs"
|
|
@@ -2924,9 +2924,9 @@
|
|
|
2924
2924
|
}
|
|
2925
2925
|
},
|
|
2926
2926
|
"node_modules/dompurify": {
|
|
2927
|
-
"version": "3.4.
|
|
2928
|
-
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.
|
|
2929
|
-
"integrity": "sha512-
|
|
2927
|
+
"version": "3.4.9",
|
|
2928
|
+
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
|
|
2929
|
+
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
|
|
2930
2930
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
2931
2931
|
"optionalDependencies": {
|
|
2932
2932
|
"@types/trusted-types": "^2.0.7"
|
|
@@ -2939,9 +2939,9 @@
|
|
|
2939
2939
|
"license": "MIT"
|
|
2940
2940
|
},
|
|
2941
2941
|
"node_modules/electron-to-chromium": {
|
|
2942
|
-
"version": "1.5.
|
|
2943
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2944
|
-
"integrity": "sha512-
|
|
2942
|
+
"version": "1.5.371",
|
|
2943
|
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
|
2944
|
+
"integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==",
|
|
2945
2945
|
"license": "ISC"
|
|
2946
2946
|
},
|
|
2947
2947
|
"node_modules/enhanced-resolve": {
|
|
@@ -5306,12 +5306,12 @@
|
|
|
5306
5306
|
}
|
|
5307
5307
|
},
|
|
5308
5308
|
"node_modules/next": {
|
|
5309
|
-
"version": "16.2.
|
|
5310
|
-
"resolved": "https://registry.npmjs.org/next/-/next-16.2.
|
|
5311
|
-
"integrity": "sha512-
|
|
5309
|
+
"version": "16.2.9",
|
|
5310
|
+
"resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz",
|
|
5311
|
+
"integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==",
|
|
5312
5312
|
"license": "MIT",
|
|
5313
5313
|
"dependencies": {
|
|
5314
|
-
"@next/env": "16.2.
|
|
5314
|
+
"@next/env": "16.2.9",
|
|
5315
5315
|
"@swc/helpers": "0.5.15",
|
|
5316
5316
|
"baseline-browser-mapping": "^2.9.19",
|
|
5317
5317
|
"caniuse-lite": "^1.0.30001579",
|
|
@@ -5325,14 +5325,14 @@
|
|
|
5325
5325
|
"node": ">=20.9.0"
|
|
5326
5326
|
},
|
|
5327
5327
|
"optionalDependencies": {
|
|
5328
|
-
"@next/swc-darwin-arm64": "16.2.
|
|
5329
|
-
"@next/swc-darwin-x64": "16.2.
|
|
5330
|
-
"@next/swc-linux-arm64-gnu": "16.2.
|
|
5331
|
-
"@next/swc-linux-arm64-musl": "16.2.
|
|
5332
|
-
"@next/swc-linux-x64-gnu": "16.2.
|
|
5333
|
-
"@next/swc-linux-x64-musl": "16.2.
|
|
5334
|
-
"@next/swc-win32-arm64-msvc": "16.2.
|
|
5335
|
-
"@next/swc-win32-x64-msvc": "16.2.
|
|
5328
|
+
"@next/swc-darwin-arm64": "16.2.9",
|
|
5329
|
+
"@next/swc-darwin-x64": "16.2.9",
|
|
5330
|
+
"@next/swc-linux-arm64-gnu": "16.2.9",
|
|
5331
|
+
"@next/swc-linux-arm64-musl": "16.2.9",
|
|
5332
|
+
"@next/swc-linux-x64-gnu": "16.2.9",
|
|
5333
|
+
"@next/swc-linux-x64-musl": "16.2.9",
|
|
5334
|
+
"@next/swc-win32-arm64-msvc": "16.2.9",
|
|
5335
|
+
"@next/swc-win32-x64-msvc": "16.2.9",
|
|
5336
5336
|
"sharp": "^0.34.5"
|
|
5337
5337
|
},
|
|
5338
5338
|
"peerDependencies": {
|
|
@@ -6079,9 +6079,9 @@
|
|
|
6079
6079
|
}
|
|
6080
6080
|
},
|
|
6081
6081
|
"node_modules/semver": {
|
|
6082
|
-
"version": "7.8.
|
|
6083
|
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.
|
|
6084
|
-
"integrity": "sha512-
|
|
6082
|
+
"version": "7.8.4",
|
|
6083
|
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
|
6084
|
+
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
|
6085
6085
|
"license": "ISC",
|
|
6086
6086
|
"optional": true,
|
|
6087
6087
|
"bin": {
|
|
@@ -6323,9 +6323,9 @@
|
|
|
6323
6323
|
}
|
|
6324
6324
|
},
|
|
6325
6325
|
"node_modules/ts-dedent": {
|
|
6326
|
-
"version": "2.
|
|
6327
|
-
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.
|
|
6328
|
-
"integrity": "sha512-
|
|
6326
|
+
"version": "2.3.0",
|
|
6327
|
+
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
|
|
6328
|
+
"integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
|
|
6329
6329
|
"license": "MIT",
|
|
6330
6330
|
"engines": {
|
|
6331
6331
|
"node": ">=6.10"
|