jamdesk 1.1.149 → 1.1.150

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.
Files changed (85) hide show
  1. package/README.md +28 -0
  2. package/dist/__tests__/integration/fix-scan-integration.test.d.ts +2 -0
  3. package/dist/__tests__/integration/fix-scan-integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/integration/fix-scan-integration.test.js +77 -0
  5. package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -0
  6. package/dist/__tests__/unit/ai-fix-client.test.d.ts +2 -0
  7. package/dist/__tests__/unit/ai-fix-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/ai-fix-client.test.js +64 -0
  9. package/dist/__tests__/unit/ai-fix-client.test.js.map +1 -0
  10. package/dist/__tests__/unit/docs-config.test.js +66 -0
  11. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  12. package/dist/__tests__/unit/fix-ai-fallback.test.d.ts +2 -0
  13. package/dist/__tests__/unit/fix-ai-fallback.test.d.ts.map +1 -0
  14. package/dist/__tests__/unit/fix-ai-fallback.test.js +82 -0
  15. package/dist/__tests__/unit/fix-ai-fallback.test.js.map +1 -0
  16. package/dist/__tests__/unit/fix-engine-sync.test.d.ts +2 -0
  17. package/dist/__tests__/unit/fix-engine-sync.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/fix-engine-sync.test.js +16 -0
  19. package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -0
  20. package/dist/__tests__/unit/fix.test.d.ts +2 -0
  21. package/dist/__tests__/unit/fix.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/fix.test.js +61 -0
  23. package/dist/__tests__/unit/fix.test.js.map +1 -0
  24. package/dist/__tests__/unit/frontmatter-write.test.d.ts +2 -0
  25. package/dist/__tests__/unit/frontmatter-write.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/frontmatter-write.test.js +31 -0
  27. package/dist/__tests__/unit/frontmatter-write.test.js.map +1 -0
  28. package/dist/__tests__/unit/heading-extractor-sync.test.d.ts +2 -0
  29. package/dist/__tests__/unit/heading-extractor-sync.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/heading-extractor-sync.test.js +16 -0
  31. package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -0
  32. package/dist/__tests__/unit/missing-description-scan.test.d.ts +2 -0
  33. package/dist/__tests__/unit/missing-description-scan.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/missing-description-scan.test.js +36 -0
  35. package/dist/__tests__/unit/missing-description-scan.test.js.map +1 -0
  36. package/dist/commands/fix.d.ts +97 -0
  37. package/dist/commands/fix.d.ts.map +1 -0
  38. package/dist/commands/fix.js +331 -0
  39. package/dist/commands/fix.js.map +1 -0
  40. package/dist/index.js +19 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/lib/ai-fix-client.d.ts +27 -0
  43. package/dist/lib/ai-fix-client.d.ts.map +1 -0
  44. package/dist/lib/ai-fix-client.js +45 -0
  45. package/dist/lib/ai-fix-client.js.map +1 -0
  46. package/dist/lib/deps.js +1 -1
  47. package/dist/lib/deps.js.map +1 -1
  48. package/dist/lib/docs-config.d.ts +1 -1
  49. package/dist/lib/docs-config.d.ts.map +1 -1
  50. package/dist/lib/docs-config.js +59 -4
  51. package/dist/lib/docs-config.js.map +1 -1
  52. package/dist/lib/fix-engine.d.ts +89 -0
  53. package/dist/lib/fix-engine.d.ts.map +1 -0
  54. package/dist/lib/fix-engine.js +236 -0
  55. package/dist/lib/fix-engine.js.map +1 -0
  56. package/dist/lib/frontmatter-write.d.ts +4 -0
  57. package/dist/lib/frontmatter-write.d.ts.map +1 -0
  58. package/dist/lib/frontmatter-write.js +10 -0
  59. package/dist/lib/frontmatter-write.js.map +1 -0
  60. package/dist/lib/heading-extractor.d.ts +63 -0
  61. package/dist/lib/heading-extractor.d.ts.map +1 -0
  62. package/dist/lib/heading-extractor.js +171 -0
  63. package/dist/lib/heading-extractor.js.map +1 -0
  64. package/dist/lib/missing-description-scan.d.ts +7 -0
  65. package/dist/lib/missing-description-scan.d.ts.map +1 -0
  66. package/dist/lib/missing-description-scan.js +20 -0
  67. package/dist/lib/missing-description-scan.js.map +1 -0
  68. package/package.json +3 -3
  69. package/vendored/components/mdx/ApiEndpoint.tsx +1 -1
  70. package/vendored/components/mdx/OpenApiEndpoint.tsx +7 -2
  71. package/vendored/components/navigation/SocialFooter.tsx +9 -1
  72. package/vendored/lib/build/error-parser.ts +6 -3
  73. package/vendored/lib/firestore-helpers.ts +2 -3
  74. package/vendored/lib/fix-engine.ts +293 -0
  75. package/vendored/lib/health-checks.ts +2 -2
  76. package/vendored/lib/json-ld.ts +98 -6
  77. package/vendored/lib/render-doc-page.tsx +8 -2
  78. package/vendored/lib/search.ts +2 -2
  79. package/vendored/lib/seo.ts +29 -13
  80. package/vendored/lib/static-artifacts.ts +2 -2
  81. package/vendored/lib/ui-strings.ts +6 -0
  82. package/vendored/lib/validate-config.ts +63 -4
  83. package/vendored/scripts/build-search-index.cjs +2 -2
  84. package/vendored/shared/types.ts +1 -0
  85. 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": { "anchors": [...] } or { "groups": [...] }\n\n';
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 "anchors", "tabs", or "groups".\n\n' +
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
- ' "anchors": [{ "anchor": "Docs", "groups": [...] }]\n' +
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 admin from 'firebase-admin';
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 admin
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 admin = await import('firebase-admin');
193
+ const { FieldValue } = await import('firebase-admin/firestore');
194
194
  await Promise.race([
195
195
  healthRef.set(
196
- { lastCheck: admin.firestore.FieldValue.serverTimestamp() },
196
+ { lastCheck: FieldValue.serverTimestamp() },
197
197
  { merge: true }
198
198
  ),
199
199
  new Promise((_, reject) =>
@@ -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
- const home: BreadcrumbEntry = { name: 'Home', url: baseUrl };
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 { config, pagePath, pageTitle, baseUrl, faqPairs } = options;
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 slug = needsSlugRewrite(normalizedSlug)
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
 
@@ -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(/`[^`]+`/g, '') // Remove inline code
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
@@ -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 customImage = metatags['og:image']?.trim();
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(/`[^`]+`/g, '') // inline code
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,