jamdesk 1.1.149 → 1.1.151
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 +28 -0
- package/dist/__tests__/integration/fix-scan-integration.test.d.ts +2 -0
- package/dist/__tests__/integration/fix-scan-integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/fix-scan-integration.test.js +77 -0
- package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -0
- package/dist/__tests__/unit/ai-fix-client.test.d.ts +2 -0
- package/dist/__tests__/unit/ai-fix-client.test.d.ts.map +1 -0
- package/dist/__tests__/unit/ai-fix-client.test.js +64 -0
- package/dist/__tests__/unit/ai-fix-client.test.js.map +1 -0
- package/dist/__tests__/unit/docs-config.test.js +66 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/fix-ai-fallback.test.d.ts +2 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.js +82 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.js.map +1 -0
- package/dist/__tests__/unit/fix-engine-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/fix-engine-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix-engine-sync.test.js +16 -0
- package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -0
- package/dist/__tests__/unit/fix.test.d.ts +2 -0
- package/dist/__tests__/unit/fix.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix.test.js +61 -0
- package/dist/__tests__/unit/fix.test.js.map +1 -0
- package/dist/__tests__/unit/frontmatter-write.test.d.ts +2 -0
- package/dist/__tests__/unit/frontmatter-write.test.d.ts.map +1 -0
- package/dist/__tests__/unit/frontmatter-write.test.js +31 -0
- package/dist/__tests__/unit/frontmatter-write.test.js.map +1 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.js +16 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -0
- package/dist/__tests__/unit/missing-description-scan.test.d.ts +2 -0
- package/dist/__tests__/unit/missing-description-scan.test.d.ts.map +1 -0
- package/dist/__tests__/unit/missing-description-scan.test.js +50 -0
- package/dist/__tests__/unit/missing-description-scan.test.js.map +1 -0
- package/dist/commands/fix.d.ts +97 -0
- package/dist/commands/fix.d.ts.map +1 -0
- package/dist/commands/fix.js +348 -0
- package/dist/commands/fix.js.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-fix-client.d.ts +27 -0
- package/dist/lib/ai-fix-client.d.ts.map +1 -0
- package/dist/lib/ai-fix-client.js +45 -0
- package/dist/lib/ai-fix-client.js.map +1 -0
- package/dist/lib/deps.js +1 -1
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +59 -4
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/fix-engine.d.ts +89 -0
- package/dist/lib/fix-engine.d.ts.map +1 -0
- package/dist/lib/fix-engine.js +236 -0
- package/dist/lib/fix-engine.js.map +1 -0
- package/dist/lib/frontmatter-write.d.ts +4 -0
- package/dist/lib/frontmatter-write.d.ts.map +1 -0
- package/dist/lib/frontmatter-write.js +10 -0
- package/dist/lib/frontmatter-write.js.map +1 -0
- package/dist/lib/heading-extractor.d.ts +63 -0
- package/dist/lib/heading-extractor.d.ts.map +1 -0
- package/dist/lib/heading-extractor.js +171 -0
- package/dist/lib/heading-extractor.js.map +1 -0
- package/dist/lib/missing-description-scan.d.ts +7 -0
- package/dist/lib/missing-description-scan.d.ts.map +1 -0
- package/dist/lib/missing-description-scan.js +28 -0
- package/dist/lib/missing-description-scan.js.map +1 -0
- package/package.json +3 -3
- package/vendored/components/mdx/ApiEndpoint.tsx +1 -1
- package/vendored/components/mdx/OpenApiEndpoint.tsx +7 -2
- package/vendored/components/navigation/SocialFooter.tsx +9 -1
- package/vendored/lib/build/error-parser.ts +6 -3
- package/vendored/lib/firestore-helpers.ts +2 -3
- package/vendored/lib/fix-engine.ts +293 -0
- package/vendored/lib/health-checks.ts +2 -2
- package/vendored/lib/json-ld.ts +98 -6
- package/vendored/lib/render-doc-page.tsx +8 -2
- package/vendored/lib/search.ts +2 -2
- package/vendored/lib/seo.ts +29 -13
- package/vendored/lib/static-artifacts.ts +2 -2
- package/vendored/lib/ui-strings.ts +6 -0
- package/vendored/lib/validate-config.ts +63 -4
- package/vendored/scripts/build-search-index.cjs +2 -2
- package/vendored/shared/types.ts +1 -0
- package/vendored/workspace-package-lock.json +36 -36
|
@@ -191,7 +191,9 @@ export function parseErrorDetails(
|
|
|
191
191
|
'• "name": Your site name\n' +
|
|
192
192
|
'• "theme": One of "jam", "nebula", or "pulsar"\n' +
|
|
193
193
|
'• "colors": { "primary": "#hexcolor" }\n' +
|
|
194
|
-
'• "navigation":
|
|
194
|
+
'• "navigation": one of "tabs", "groups", "pages", "dropdowns", ' +
|
|
195
|
+
'"versions", "languages", or "products" ' +
|
|
196
|
+
'(e.g. { "groups": [...] })\n\n';
|
|
195
197
|
|
|
196
198
|
let suggestion = `${intro}\n\n`;
|
|
197
199
|
if (discoveryHint) {
|
|
@@ -249,10 +251,11 @@ export function parseErrorDetails(
|
|
|
249
251
|
`Error occurred while building: ${file}`,
|
|
250
252
|
suggestion:
|
|
251
253
|
'Check your docs.json configuration file. The "navigation" field ' +
|
|
252
|
-
'must be an object with "
|
|
254
|
+
'must be an object with one of "tabs", "groups", "pages", ' +
|
|
255
|
+
'"dropdowns", "versions", "languages", or "products".\n\n' +
|
|
253
256
|
'Example:\n' +
|
|
254
257
|
'"navigation": {\n' +
|
|
255
|
-
' "
|
|
258
|
+
' "groups": [{ "group": "Docs", "pages": [...] }]\n' +
|
|
256
259
|
'}\n\n' +
|
|
257
260
|
'See https://www.jamdesk.com/docs/navigation for details.',
|
|
258
261
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { getFirestore } from 'firebase-admin/firestore';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* True when a Firestore project claims this slug (the canonical slug lives at
|
|
@@ -10,8 +10,7 @@ import admin from 'firebase-admin';
|
|
|
10
10
|
* dashboard/firestore.indexes.json, so this query needs no new index.
|
|
11
11
|
*/
|
|
12
12
|
export async function isSlugActive(slug: string): Promise<boolean> {
|
|
13
|
-
const snap = await
|
|
14
|
-
.firestore()
|
|
13
|
+
const snap = await getFirestore()
|
|
15
14
|
.collectionGroup('github')
|
|
16
15
|
.where('slug', '==', slug)
|
|
17
16
|
.limit(1)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// builder/build-service/lib/fix-engine.ts
|
|
2
|
+
//
|
|
3
|
+
// Dependency-free deterministic doc-warning fix engine. Imports NOTHING so it
|
|
4
|
+
// byte-syncs into the CLI's src/lib (cli/src cannot import cli/vendored).
|
|
5
|
+
//
|
|
6
|
+
// The engine does NO slugging and NO I/O: the caller injects heading data via
|
|
7
|
+
// FixContext.headingsOf(), populated from the canonical extractHeadings()
|
|
8
|
+
// (heading-extractor.ts) whose ids ARE the real rehype-slug DOM ids. Re-slugging
|
|
9
|
+
// here is forbidden — markdown-only re-slugging diverges from real ids (dedup
|
|
10
|
+
// suffixes shift when <Update>/<Step> anchors share heading text) and was the
|
|
11
|
+
// original wrong-fix bug.
|
|
12
|
+
|
|
13
|
+
// ---- Types ----------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface Edit {
|
|
16
|
+
file: string;
|
|
17
|
+
/** 1-indexed source line the edit applies to (from the warning). */
|
|
18
|
+
line: number;
|
|
19
|
+
oldText: string;
|
|
20
|
+
newText: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FixResult {
|
|
24
|
+
status: 'fixed' | 'skipped';
|
|
25
|
+
edits: Edit[];
|
|
26
|
+
reason: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LocaleInfo {
|
|
30
|
+
codes: string[];
|
|
31
|
+
defaultCode: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FixWarning {
|
|
35
|
+
type: string;
|
|
36
|
+
file: string;
|
|
37
|
+
line: number | null;
|
|
38
|
+
link: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Structural subset of heading-extractor's HeadingInfo — extractHeadings()
|
|
43
|
+
* output is assignable to EngineHeading[] directly (no mapping). */
|
|
44
|
+
export interface EngineHeading {
|
|
45
|
+
id: string;
|
|
46
|
+
stepNumber?: number;
|
|
47
|
+
isUpdate?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FixContext {
|
|
51
|
+
currentPagePath: string;
|
|
52
|
+
locales: LocaleInfo;
|
|
53
|
+
/** Resolve a doc link href to a normalized page path, or null if it does not exist. */
|
|
54
|
+
resolveLinkPagePath(href: string): string | null;
|
|
55
|
+
/** Ordered headings for a page path (no extension) via extractHeadings(), or null. */
|
|
56
|
+
headingsOf(pagePath: string): EngineHeading[] | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type Fixer = (warning: FixWarning, ctx: FixContext) => FixResult;
|
|
60
|
+
|
|
61
|
+
/** A markdown heading entry (not a <Step> or <Update> component anchor). */
|
|
62
|
+
export function isMarkdownHeading(h: EngineHeading): boolean {
|
|
63
|
+
return h.stepNumber == null && !h.isUpdate;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True iff `candidate` is an exact heading id on `headings`. The canonical
|
|
67
|
+
* guard for any externally-sourced (e.g. AI) anchor suggestion — the engine
|
|
68
|
+
* itself uses the same `allIds.includes(...)` check inside fixBrokenLink's
|
|
69
|
+
* `emit`. Phase 2's AI tier MUST route every suggestion through this so a
|
|
70
|
+
* hallucinated fragment can never be written. Pure, dependency-free. */
|
|
71
|
+
export function isRealAnchor(candidate: string, headings: EngineHeading[]): boolean {
|
|
72
|
+
if (!candidate) return false;
|
|
73
|
+
return headings.some((h) => h.id === candidate);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Substring appended to the skip reason when the deterministic engine defers an
|
|
77
|
+
* anchor to the AI tier. Exported so the AI-tier callers match on this constant
|
|
78
|
+
* instead of a re-typed literal — producer and consumer cannot drift. */
|
|
79
|
+
export const NEEDS_AI_MARKER = '(needs AI)';
|
|
80
|
+
|
|
81
|
+
// ---- Edit application -----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
// A flagged href is always the target of a markdown link `](href)` or an
|
|
84
|
+
// `href="…"`/`href='…'` attribute, so in source it is bounded on the left by a
|
|
85
|
+
// delimiter — never by another URL/path character. A match whose preceding char
|
|
86
|
+
// IS a path character is the href appearing as a SUBSTRING inside a longer URL
|
|
87
|
+
// (e.g. `/setup#x` inside `/guide/setup#x`) and must be skipped, or the edit
|
|
88
|
+
// would corrupt an unrelated, valid link on the same line.
|
|
89
|
+
const URL_PATH_CHAR = /[A-Za-z0-9\-._~%/#?:@]/;
|
|
90
|
+
|
|
91
|
+
/** Index of the next occurrence of `href` at/after `from` that sits at a link
|
|
92
|
+
* boundary (not as a substring inside a longer path), or -1. */
|
|
93
|
+
function findHrefBoundary(line: string, href: string, from: number): number {
|
|
94
|
+
let pos = line.indexOf(href, from);
|
|
95
|
+
while (pos !== -1) {
|
|
96
|
+
if (pos === 0 || !URL_PATH_CHAR.test(line[pos - 1])) return pos;
|
|
97
|
+
pos = line.indexOf(href, pos + 1);
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Apply edits, replacing each edit's oldText with newText on its specific
|
|
104
|
+
* (1-indexed) source line. Line-scoped, so an identical href on another line
|
|
105
|
+
* (e.g. a code sample) is never touched. Within a line: oldText is matched at a
|
|
106
|
+
* link boundary (so a short href is never replaced inside a longer one),
|
|
107
|
+
* matching runs against the ORIGINAL line (so an edit whose newText contains its
|
|
108
|
+
* oldText is never re-matched), and edits apply right-to-left so earlier
|
|
109
|
+
* positions keep their indices. Identical oldTexts on one line consume
|
|
110
|
+
* successive occurrences. Throws if a line is out of range or an oldText is
|
|
111
|
+
* absent — a missing match means the source changed since the fix was derived,
|
|
112
|
+
* and a partial/wrong edit must never be written.
|
|
113
|
+
*/
|
|
114
|
+
export function applyEdits(content: string, edits: Edit[]): string {
|
|
115
|
+
if (edits.length === 0) return content;
|
|
116
|
+
const lines = content.split('\n');
|
|
117
|
+
|
|
118
|
+
const byLine = new Map<number, Edit[]>();
|
|
119
|
+
for (const edit of edits) {
|
|
120
|
+
const list = byLine.get(edit.line);
|
|
121
|
+
if (list) list.push(edit);
|
|
122
|
+
else byLine.set(edit.line, [edit]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const [line, lineEdits] of byLine) {
|
|
126
|
+
const idx = line - 1;
|
|
127
|
+
if (idx < 0 || idx >= lines.length) {
|
|
128
|
+
throw new Error(`applyEdits: line ${line} out of range (${lines.length} lines)`);
|
|
129
|
+
}
|
|
130
|
+
const src = lines[idx];
|
|
131
|
+
// Resolve every edit to a concrete [start,end) span on the ORIGINAL line.
|
|
132
|
+
const spans: Array<{ start: number; end: number; newText: string }> = [];
|
|
133
|
+
const nextFrom = new Map<string, number>();
|
|
134
|
+
for (const edit of lineEdits) {
|
|
135
|
+
const from = nextFrom.get(edit.oldText) ?? 0;
|
|
136
|
+
const start = findHrefBoundary(src, edit.oldText, from);
|
|
137
|
+
if (start === -1) {
|
|
138
|
+
throw new Error(`applyEdits: oldText not found on line ${line}: ${JSON.stringify(edit.oldText)}`);
|
|
139
|
+
}
|
|
140
|
+
const end = start + edit.oldText.length;
|
|
141
|
+
nextFrom.set(edit.oldText, end);
|
|
142
|
+
spans.push({ start, end, newText: edit.newText });
|
|
143
|
+
}
|
|
144
|
+
// Apply right-to-left so an earlier span's indices survive later edits.
|
|
145
|
+
spans.sort((a, b) => b.start - a.start);
|
|
146
|
+
let out = src;
|
|
147
|
+
for (const s of spans) out = out.slice(0, s.start) + s.newText + out.slice(s.end);
|
|
148
|
+
lines[idx] = out;
|
|
149
|
+
}
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- Distance / nearest-slug (ported from validate-links.cjs) -------------
|
|
154
|
+
|
|
155
|
+
const MAX_SUGGESTION_DISTANCE = 2;
|
|
156
|
+
|
|
157
|
+
export function levenshtein(a: string, b: string): number {
|
|
158
|
+
if (a === b) return 0;
|
|
159
|
+
if (a.length === 0) return b.length;
|
|
160
|
+
if (b.length === 0) return a.length;
|
|
161
|
+
let prev = new Array<number>(b.length + 1);
|
|
162
|
+
let curr = new Array<number>(b.length + 1);
|
|
163
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
164
|
+
for (let i = 0; i < a.length; i++) {
|
|
165
|
+
curr[0] = i + 1;
|
|
166
|
+
for (let j = 0; j < b.length; j++) {
|
|
167
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
168
|
+
curr[j + 1] = Math.min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost);
|
|
169
|
+
}
|
|
170
|
+
[prev, curr] = [curr, prev];
|
|
171
|
+
}
|
|
172
|
+
return prev[b.length];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function closestSlug(fragment: string, slugs: Iterable<string>): string | null {
|
|
176
|
+
if (!fragment) return null;
|
|
177
|
+
let best: string | null = null;
|
|
178
|
+
let bestDist = Infinity;
|
|
179
|
+
let secondDist = Infinity;
|
|
180
|
+
for (const slug of slugs) {
|
|
181
|
+
if (Math.abs(fragment.length - slug.length) > MAX_SUGGESTION_DISTANCE) continue;
|
|
182
|
+
const d = levenshtein(fragment, slug);
|
|
183
|
+
if (d < bestDist) {
|
|
184
|
+
secondDist = bestDist;
|
|
185
|
+
bestDist = d;
|
|
186
|
+
best = slug;
|
|
187
|
+
} else if (d < secondDist) {
|
|
188
|
+
secondDist = d;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return best !== null && bestDist >= 1 && bestDist <= MAX_SUGGESTION_DISTANCE && bestDist < secondDist
|
|
192
|
+
? best
|
|
193
|
+
: null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- Link + locale helpers ------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export function parseHref(href: string): { path: string | null; fragment: string | null } {
|
|
199
|
+
const i = href.indexOf('#');
|
|
200
|
+
if (i === -1) return { path: href, fragment: null };
|
|
201
|
+
return { path: i === 0 ? null : href.slice(0, i), fragment: href.slice(i + 1) || null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function normalizeLinkPath(linkPath: string, hostAtDocs: boolean): string {
|
|
205
|
+
let p = linkPath;
|
|
206
|
+
if (hostAtDocs && p.startsWith('/docs/')) p = p.slice('/docs/'.length);
|
|
207
|
+
if (p.startsWith('/')) p = p.slice(1);
|
|
208
|
+
return p.replace(/\.mdx$/, '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The default-locale twin of a localized page path, or null if already default. */
|
|
212
|
+
export function defaultTwinPath(pagePath: string, locales: LocaleInfo): string | null {
|
|
213
|
+
const segments = pagePath.split('/');
|
|
214
|
+
const first = segments[0];
|
|
215
|
+
if (first !== locales.defaultCode && locales.codes.includes(first)) {
|
|
216
|
+
return segments.slice(1).join('/');
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Broken-link fixer ----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function skip(reason: string): FixResult {
|
|
224
|
+
return { status: 'skipped', edits: [], reason };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const fixBrokenLink: Fixer = (warning, ctx) => {
|
|
228
|
+
const { path: linkPath, fragment } = parseHref(warning.link);
|
|
229
|
+
if (!fragment) return skip('not a fragment link — out of scope for phase 1');
|
|
230
|
+
if (!linkPath) return skip('same-page fragment — out of scope for phase 1');
|
|
231
|
+
if (warning.line == null) return skip('warning has no line — cannot place a safe edit');
|
|
232
|
+
|
|
233
|
+
const targetPage = ctx.resolveLinkPagePath(linkPath);
|
|
234
|
+
if (!targetPage) return skip('target page could not be resolved');
|
|
235
|
+
const targetHeadings = ctx.headingsOf(targetPage);
|
|
236
|
+
if (!targetHeadings) return skip('target page headings could not be read');
|
|
237
|
+
|
|
238
|
+
const allIds = targetHeadings.map((h) => h.id);
|
|
239
|
+
|
|
240
|
+
// (0) Re-derivation: may already be valid in current source.
|
|
241
|
+
if (allIds.includes(fragment)) return skip('already valid — fragment exists on target');
|
|
242
|
+
|
|
243
|
+
// Emit only a fragment that is a REAL anchor on the target (never point at a
|
|
244
|
+
// non-existent or wrong-kind id), reconstructing the href (no substring replace).
|
|
245
|
+
const emit = (candidate: string, reason: string): FixResult => {
|
|
246
|
+
if (!allIds.includes(candidate)) return skip('internal: candidate is not a real anchor — refusing');
|
|
247
|
+
return {
|
|
248
|
+
status: 'fixed',
|
|
249
|
+
edits: [{ file: warning.file, line: warning.line as number, oldText: warning.link, newText: linkPath + '#' + candidate }],
|
|
250
|
+
reason,
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// (1) Cross-locale positional map FIRST (high precision for localized targets).
|
|
255
|
+
// Ordinals over MARKDOWN headings only (so <Step>/<Update> can't misalign them),
|
|
256
|
+
// but the emitted id is the target heading's REAL id (full-set dedup).
|
|
257
|
+
const twinPage = defaultTwinPath(targetPage, ctx.locales);
|
|
258
|
+
if (twinPage) {
|
|
259
|
+
const twinHeadings = ctx.headingsOf(twinPage);
|
|
260
|
+
if (twinHeadings) {
|
|
261
|
+
const twinMd = twinHeadings.filter(isMarkdownHeading);
|
|
262
|
+
const targetMd = targetHeadings.filter(isMarkdownHeading);
|
|
263
|
+
const ordinal = twinMd.findIndex((h) => h.id === fragment);
|
|
264
|
+
if (ordinal !== -1 && twinMd.length === targetMd.length && targetMd[ordinal]) {
|
|
265
|
+
return emit(targetMd[ordinal].id, `cross-locale positional map: #${fragment} → #${targetMd[ordinal].id}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// (2) Near-miss fallback (typos within the same language).
|
|
271
|
+
const near = closestSlug(fragment, allIds);
|
|
272
|
+
if (near) return emit(near, `near-miss: #${fragment} → #${near}`);
|
|
273
|
+
|
|
274
|
+
return skip(`no confident deterministic fix — structures diverge or target is ambiguous ${NEEDS_AI_MARKER}`);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ---- Registry + engine entry point ----------------------------------------
|
|
278
|
+
|
|
279
|
+
/** Fixer registry keyed by BuildWarningType. Phase 1: broken_link only. */
|
|
280
|
+
export const FIXERS: Record<string, Fixer> = {
|
|
281
|
+
broken_link: fixBrokenLink,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Run the registered fixer for a warning. Deterministic-first is implemented
|
|
286
|
+
* inside each fixer (exact strategies, then 'skipped' for AI cases). The AI
|
|
287
|
+
* fallback tier is wired in Phase 2; until then a 'skipped' result is terminal.
|
|
288
|
+
*/
|
|
289
|
+
export function runFixer(warning: FixWarning, ctx: FixContext): FixResult {
|
|
290
|
+
const fixer = FIXERS[warning.type];
|
|
291
|
+
if (!fixer) return skip(`no fixer registered for type "${warning.type}"`);
|
|
292
|
+
return fixer(warning, ctx);
|
|
293
|
+
}
|
|
@@ -190,10 +190,10 @@ export async function checkFirestoreHealth(db: Firestore): Promise<FirestoreHeal
|
|
|
190
190
|
let writeLatencyMs: number | null = null;
|
|
191
191
|
try {
|
|
192
192
|
const writeStart = Date.now();
|
|
193
|
-
const
|
|
193
|
+
const { FieldValue } = await import('firebase-admin/firestore');
|
|
194
194
|
await Promise.race([
|
|
195
195
|
healthRef.set(
|
|
196
|
-
{ lastCheck:
|
|
196
|
+
{ lastCheck: FieldValue.serverTimestamp() },
|
|
197
197
|
{ merge: true }
|
|
198
198
|
),
|
|
199
199
|
new Promise((_, reject) =>
|
package/vendored/lib/json-ld.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JSON-LD Structured Data for Documentation Pages
|
|
3
3
|
*
|
|
4
|
-
* Generates WebSite + BreadcrumbList schemas for search engine rich results.
|
|
4
|
+
* Generates WebSite + Organization + BreadcrumbList schemas for search engine rich results.
|
|
5
5
|
* Rendered as <script type="application/ld+json"> in page.tsx.
|
|
6
6
|
*
|
|
7
7
|
* Breadcrumb path extraction is server-side only (pure functions, no hooks).
|
|
8
8
|
* Intentionally separate from Breadcrumb.tsx which is a 'use client' component.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { DocsConfig, NavigationPage, GroupConfig } from './docs-types';
|
|
12
|
-
import { normalizeNavPage } from './docs-types';
|
|
11
|
+
import type { DocsConfig, NavigationPage, GroupConfig, LanguageCode } from './docs-types';
|
|
12
|
+
import { normalizeNavPage, normalizeLogo } from './docs-types';
|
|
13
|
+
import { normalizeLanguageList, deriveChunkLocale } from './locale-helpers';
|
|
14
|
+
import { getUiStrings } from './ui-strings';
|
|
13
15
|
import type { FaqPair } from './extract-faq';
|
|
14
16
|
|
|
15
17
|
interface BreadcrumbEntry {
|
|
@@ -23,6 +25,10 @@ export interface JsonLdOptions {
|
|
|
23
25
|
pageTitle: string;
|
|
24
26
|
baseUrl: string;
|
|
25
27
|
faqPairs?: FaqPair[];
|
|
28
|
+
isRootAlias?: boolean;
|
|
29
|
+
ogImageUrl?: string;
|
|
30
|
+
dateModified?: string;
|
|
31
|
+
author?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export interface JsonLdGraph {
|
|
@@ -69,7 +75,14 @@ export function findBreadcrumbPath(
|
|
|
69
75
|
config: DocsConfig, targetSlug: string, baseUrl: string, pageTitle?: string
|
|
70
76
|
): BreadcrumbEntry[] {
|
|
71
77
|
const nav = config.navigation;
|
|
72
|
-
|
|
78
|
+
// Localize the root crumb label so a Spanish/French page's breadcrumb rich
|
|
79
|
+
// result reads "Inicio"/"Accueil", not "Home". deriveChunkLocale only treats
|
|
80
|
+
// the leading slug segment as a locale when it's a configured language (and
|
|
81
|
+
// falls back to the tenant's default language), so single-language sites are
|
|
82
|
+
// unaffected. The visual breadcrumb uses a house icon, so this is JSON-LD only.
|
|
83
|
+
const locale = deriveChunkLocale(targetSlug, normalizeLanguageList(nav?.languages));
|
|
84
|
+
const homeLabel = getUiStrings((locale ?? undefined) as LanguageCode | undefined).breadcrumbHome;
|
|
85
|
+
const home: BreadcrumbEntry = { name: homeLabel, url: baseUrl };
|
|
73
86
|
const pageEntry: BreadcrumbEntry = {
|
|
74
87
|
name: pageTitle || formatSlugAsTitle(targetSlug),
|
|
75
88
|
url: `${baseUrl}/${targetSlug}`,
|
|
@@ -142,25 +155,102 @@ export function findBreadcrumbPath(
|
|
|
142
155
|
);
|
|
143
156
|
}
|
|
144
157
|
|
|
158
|
+
// sameAs is for identity/disambiguation PROFILES, not the company homepage.
|
|
159
|
+
// `website` and `earth-americas` are both homepage-link social keys — excluded
|
|
160
|
+
// (along with any empty values) so the entity isn't muddied with a non-profile URL.
|
|
161
|
+
const NON_PROFILE_SOCIALS = new Set(['website', 'earth-americas']);
|
|
162
|
+
|
|
163
|
+
/** Absolute brand-logo URL for the Organization node, or undefined if no logo. */
|
|
164
|
+
function resolveLogoUrl(config: DocsConfig, baseUrl: string): string | undefined {
|
|
165
|
+
const norm = normalizeLogo(config.logo);
|
|
166
|
+
const path = norm?.light || norm?.dark;
|
|
167
|
+
if (!path) return undefined;
|
|
168
|
+
if (path.startsWith('http')) return path;
|
|
169
|
+
const rootUrl = baseUrl.replace(/\/docs$/, '');
|
|
170
|
+
return `${rootUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
145
173
|
/**
|
|
146
174
|
* Build JSON-LD structured data for a documentation page.
|
|
147
|
-
* Returns a schema.org @graph with WebSite + BreadcrumbList.
|
|
175
|
+
* Returns a schema.org @graph with WebSite + Organization + BreadcrumbList (+ optional FAQPage).
|
|
148
176
|
*/
|
|
149
177
|
export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
|
|
150
|
-
const {
|
|
178
|
+
const {
|
|
179
|
+
config, pagePath, pageTitle, baseUrl, faqPairs,
|
|
180
|
+
isRootAlias = false, ogImageUrl, dateModified, author: pageAuthor,
|
|
181
|
+
} = options;
|
|
151
182
|
const breadcrumbs = findBreadcrumbPath(config, pagePath, baseUrl, pageTitle);
|
|
183
|
+
const pageUrl = breadcrumbs.length ? breadcrumbs[breadcrumbs.length - 1].url : baseUrl;
|
|
184
|
+
|
|
185
|
+
const websiteId = `${baseUrl}#website`;
|
|
186
|
+
const orgId = `${baseUrl}#organization`;
|
|
187
|
+
const breadcrumbId = `${pageUrl}#breadcrumb`;
|
|
188
|
+
const faqId = `${pageUrl}#faq`;
|
|
189
|
+
|
|
190
|
+
const logoUrl = resolveLogoUrl(config, baseUrl);
|
|
191
|
+
const socials = config.footer?.socials
|
|
192
|
+
? Object.entries(config.footer.socials)
|
|
193
|
+
.filter(([key, url]) => Boolean(url) && !NON_PROFILE_SOCIALS.has(key))
|
|
194
|
+
.map(([, url]) => url as string)
|
|
195
|
+
: [];
|
|
196
|
+
|
|
197
|
+
const organization: Record<string, unknown> = {
|
|
198
|
+
'@type': 'Organization',
|
|
199
|
+
'@id': orgId,
|
|
200
|
+
name: config.name,
|
|
201
|
+
url: baseUrl,
|
|
202
|
+
...(logoUrl && { logo: { '@type': 'ImageObject', url: logoUrl } }),
|
|
203
|
+
...(socials.length && { sameAs: socials }),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const mainId = `${pageUrl}#${isRootAlias ? 'webpage' : 'article'}`;
|
|
207
|
+
const author = pageAuthor
|
|
208
|
+
? { '@type': 'Person', name: pageAuthor }
|
|
209
|
+
: { '@id': orgId };
|
|
210
|
+
|
|
211
|
+
const mainNode: Record<string, unknown> = isRootAlias
|
|
212
|
+
? {
|
|
213
|
+
'@type': 'WebPage',
|
|
214
|
+
'@id': mainId,
|
|
215
|
+
name: pageTitle,
|
|
216
|
+
url: pageUrl,
|
|
217
|
+
isPartOf: { '@id': websiteId },
|
|
218
|
+
// `breadcrumb` is a WebPage-only schema.org property, so it belongs here but
|
|
219
|
+
// NOT on the TechArticle branch below — the standalone BreadcrumbList node
|
|
220
|
+
// carries the trail for article pages (putting it on an Article fails validation).
|
|
221
|
+
breadcrumb: { '@id': breadcrumbId },
|
|
222
|
+
...(ogImageUrl && { image: ogImageUrl }),
|
|
223
|
+
...(dateModified && { dateModified }),
|
|
224
|
+
}
|
|
225
|
+
: {
|
|
226
|
+
'@type': 'TechArticle',
|
|
227
|
+
'@id': mainId,
|
|
228
|
+
headline: pageTitle,
|
|
229
|
+
url: pageUrl,
|
|
230
|
+
isPartOf: { '@id': websiteId },
|
|
231
|
+
mainEntityOfPage: pageUrl,
|
|
232
|
+
author,
|
|
233
|
+
publisher: { '@id': orgId },
|
|
234
|
+
...(ogImageUrl && { image: ogImageUrl }),
|
|
235
|
+
...(dateModified && { dateModified }),
|
|
236
|
+
};
|
|
152
237
|
|
|
153
238
|
return {
|
|
154
239
|
'@context': 'https://schema.org',
|
|
155
240
|
'@graph': [
|
|
156
241
|
{
|
|
157
242
|
'@type': 'WebSite',
|
|
243
|
+
'@id': websiteId,
|
|
158
244
|
name: config.name,
|
|
159
245
|
url: baseUrl,
|
|
160
246
|
...(config.description && { description: config.description }),
|
|
247
|
+
publisher: { '@id': orgId },
|
|
161
248
|
},
|
|
249
|
+
organization,
|
|
250
|
+
mainNode,
|
|
162
251
|
{
|
|
163
252
|
'@type': 'BreadcrumbList',
|
|
253
|
+
'@id': breadcrumbId,
|
|
164
254
|
itemListElement: breadcrumbs.map((item, i) => ({
|
|
165
255
|
'@type': 'ListItem',
|
|
166
256
|
position: i + 1,
|
|
@@ -171,6 +261,8 @@ export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
|
|
|
171
261
|
...(faqPairs && faqPairs.length
|
|
172
262
|
? [{
|
|
173
263
|
'@type': 'FAQPage',
|
|
264
|
+
'@id': faqId,
|
|
265
|
+
isPartOf: { '@id': websiteId },
|
|
174
266
|
mainEntity: faqPairs.map((p) => ({
|
|
175
267
|
'@type': 'Question', name: p.question,
|
|
176
268
|
acceptedAnswer: { '@type': 'Answer', text: p.answer },
|
|
@@ -61,7 +61,7 @@ import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
|
|
|
61
61
|
import { mdxSecurityOptions } from '@/lib/mdx-security-options';
|
|
62
62
|
import { getContentDir } from '@/lib/docs';
|
|
63
63
|
import type { DocsConfig } from '@/lib/docs-types';
|
|
64
|
-
import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
|
|
64
|
+
import { buildSeoMetadata, generateAutoDescription, buildSiteTitle, buildPageOgImageUrl } from '@/lib/seo';
|
|
65
65
|
import { buildJsonLd } from '@/lib/json-ld';
|
|
66
66
|
import { extractFaqPairs } from '@/lib/extract-faq';
|
|
67
67
|
import {
|
|
@@ -318,7 +318,8 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
318
318
|
const highlighterP = getHighlighter();
|
|
319
319
|
|
|
320
320
|
const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
|
|
321
|
-
const
|
|
321
|
+
const isRootAlias = needsSlugRewrite(normalizedSlug);
|
|
322
|
+
const slug = isRootAlias
|
|
322
323
|
? resolveSlug(normalizedSlug, await configP)
|
|
323
324
|
: normalizedSlug;
|
|
324
325
|
const pagePath = slug.join('/');
|
|
@@ -339,12 +340,17 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
339
340
|
|
|
340
341
|
const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
|
|
341
342
|
const faqPairs = extractFaqPairs(rawContent);
|
|
343
|
+
const ogImageUrl = buildPageOgImageUrl(config, data, baseUrl);
|
|
342
344
|
const jsonLd = buildJsonLd({
|
|
343
345
|
config,
|
|
344
346
|
pagePath,
|
|
345
347
|
pageTitle: data.title || pagePath,
|
|
346
348
|
baseUrl,
|
|
347
349
|
faqPairs,
|
|
350
|
+
isRootAlias,
|
|
351
|
+
ogImageUrl,
|
|
352
|
+
dateModified: typeof data.lastUpdated === 'string' ? data.lastUpdated : undefined,
|
|
353
|
+
author: typeof data.author === 'string' ? data.author : undefined,
|
|
348
354
|
});
|
|
349
355
|
const jsonLdScript = renderJsonLdScript(jsonLd);
|
|
350
356
|
|
package/vendored/lib/search.ts
CHANGED
|
@@ -23,8 +23,8 @@ let searchDocuments: SearchResult[] = [];
|
|
|
23
23
|
|
|
24
24
|
function stripMarkdown(text: string): string {
|
|
25
25
|
return text
|
|
26
|
-
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
27
|
-
.replace(/`[^`]
|
|
26
|
+
.replace(/```[\s\S]*?```/g, '') // Remove code blocks (too noisy to index)
|
|
27
|
+
.replace(/`([^`]+)`/g, '$1') // Inline code → keep text, drop backticks (config keys like starterQuestions stay searchable)
|
|
28
28
|
.replace(/#{1,6}\s/g, '') // Remove heading markers
|
|
29
29
|
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
|
|
30
30
|
.replace(/\*([^*]+)\*/g, '$1') // Remove italic
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -132,6 +132,34 @@ function buildOgImageUrl(
|
|
|
132
132
|
return `${rootUrl}${ogPath}?${params.toString()}`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Public OG-card URL builder. Mirrors the global+page metatag merge that
|
|
137
|
+
* buildSeoMetadata performs, so callers outside the metadata path (the JSON-LD
|
|
138
|
+
* layer) can set an article image to the exact same card the og:image points at.
|
|
139
|
+
*/
|
|
140
|
+
export function buildPageOgImageUrl(
|
|
141
|
+
config: DocsConfig,
|
|
142
|
+
frontmatter: PageFrontmatter,
|
|
143
|
+
baseUrl: string,
|
|
144
|
+
): string {
|
|
145
|
+
const globalMeta = config.seo?.metatags || {};
|
|
146
|
+
const pageMeta = normalizePageMetatags(frontmatter as Record<string, unknown>);
|
|
147
|
+
const metatags: Record<string, string> = { ...globalMeta, ...pageMeta };
|
|
148
|
+
const customImage = metatags['og:image']?.trim();
|
|
149
|
+
return buildOgImageUrl(
|
|
150
|
+
baseUrl,
|
|
151
|
+
metatags['og:title'] || frontmatter.title || 'Documentation',
|
|
152
|
+
config.name,
|
|
153
|
+
{
|
|
154
|
+
description: metatags['og:description'] || frontmatter.description,
|
|
155
|
+
section: frontmatter.section,
|
|
156
|
+
logo: config.logo,
|
|
157
|
+
favicon: config.favicon,
|
|
158
|
+
bg: customImage || undefined,
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* Coerce a raw frontmatter value into a meta-tag string.
|
|
137
165
|
* Mintlify allows numbers (`og:image:width: 1200`) and arrays
|
|
@@ -737,19 +765,7 @@ export function buildSeoMetadata(
|
|
|
737
765
|
// than a bare static image, so the emitted OG image is always the generated card.
|
|
738
766
|
// The card text follows og:title/og:description overrides so the image matches
|
|
739
767
|
// the link-card text built in buildOpenGraphMetadata.
|
|
740
|
-
const
|
|
741
|
-
const ogImageUrl = buildOgImageUrl(
|
|
742
|
-
baseUrl,
|
|
743
|
-
metatags['og:title'] || frontmatter.title || 'Documentation',
|
|
744
|
-
config.name,
|
|
745
|
-
{
|
|
746
|
-
description: metatags['og:description'] || frontmatter.description,
|
|
747
|
-
section: frontmatter.section,
|
|
748
|
-
logo: config.logo,
|
|
749
|
-
favicon: config.favicon,
|
|
750
|
-
bg: customImage || undefined,
|
|
751
|
-
},
|
|
752
|
-
);
|
|
768
|
+
const ogImageUrl = buildPageOgImageUrl(config, frontmatter, baseUrl);
|
|
753
769
|
metadata.openGraph = buildOpenGraphMetadata(metatags, {
|
|
754
770
|
title: frontmatter.title,
|
|
755
771
|
description: frontmatter.description,
|
|
@@ -1003,8 +1003,8 @@ function inferPageType(slug: string): string {
|
|
|
1003
1003
|
*/
|
|
1004
1004
|
export function stripMarkdown(text: string): string {
|
|
1005
1005
|
return text
|
|
1006
|
-
.replace(/```[\s\S]*?```/g, '') // code blocks
|
|
1007
|
-
.replace(/`[^`]
|
|
1006
|
+
.replace(/```[\s\S]*?```/g, '') // code blocks (dropped — too noisy to index)
|
|
1007
|
+
.replace(/`([^`]+)`/g, '$1') // inline code → keep text, drop backticks (config keys like starterQuestions stay searchable)
|
|
1008
1008
|
.replace(/#{1,6}\s/g, '') // headings
|
|
1009
1009
|
.replace(/\*\*([^*]+)\*\*/g, '$1') // bold
|
|
1010
1010
|
.replace(/\*([^*]+)\*/g, '$1') // italic
|
|
@@ -6,6 +6,8 @@ export interface UiStrings {
|
|
|
6
6
|
more: string;
|
|
7
7
|
toggleMenu: string;
|
|
8
8
|
selectLanguage: string;
|
|
9
|
+
/** Root crumb label in the JSON-LD BreadcrumbList (Google rich result). */
|
|
10
|
+
breadcrumbHome: string;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const EN: UiStrings = {
|
|
@@ -14,6 +16,7 @@ const EN: UiStrings = {
|
|
|
14
16
|
more: 'More',
|
|
15
17
|
toggleMenu: 'Toggle menu',
|
|
16
18
|
selectLanguage: 'Select language',
|
|
19
|
+
breadcrumbHome: 'Home',
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
const ZH: Partial<UiStrings> = {
|
|
@@ -22,6 +25,7 @@ const ZH: Partial<UiStrings> = {
|
|
|
22
25
|
more: '更多',
|
|
23
26
|
toggleMenu: '切换菜单',
|
|
24
27
|
selectLanguage: '选择语言',
|
|
28
|
+
breadcrumbHome: '首页',
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
|
|
@@ -31,6 +35,7 @@ const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
|
|
|
31
35
|
more: 'Plus',
|
|
32
36
|
toggleMenu: 'Basculer le menu',
|
|
33
37
|
selectLanguage: 'Choisir la langue',
|
|
38
|
+
breadcrumbHome: 'Accueil',
|
|
34
39
|
},
|
|
35
40
|
es: {
|
|
36
41
|
search: 'Buscar',
|
|
@@ -38,6 +43,7 @@ const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
|
|
|
38
43
|
more: 'Más',
|
|
39
44
|
toggleMenu: 'Alternar menú',
|
|
40
45
|
selectLanguage: 'Elegir idioma',
|
|
46
|
+
breadcrumbHome: 'Inicio',
|
|
41
47
|
},
|
|
42
48
|
zh: ZH,
|
|
43
49
|
cn: ZH,
|