jamdesk 1.1.148 → 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 (102) 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/__tests__/unit/seo-ai-schema.test.d.ts +2 -0
  37. package/dist/__tests__/unit/seo-ai-schema.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/seo-ai-schema.test.js +40 -0
  39. package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -0
  40. package/dist/commands/fix.d.ts +97 -0
  41. package/dist/commands/fix.d.ts.map +1 -0
  42. package/dist/commands/fix.js +331 -0
  43. package/dist/commands/fix.js.map +1 -0
  44. package/dist/index.js +19 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/ai-fix-client.d.ts +27 -0
  47. package/dist/lib/ai-fix-client.d.ts.map +1 -0
  48. package/dist/lib/ai-fix-client.js +45 -0
  49. package/dist/lib/ai-fix-client.js.map +1 -0
  50. package/dist/lib/deps.js +1 -1
  51. package/dist/lib/deps.js.map +1 -1
  52. package/dist/lib/docs-config.d.ts +1 -1
  53. package/dist/lib/docs-config.d.ts.map +1 -1
  54. package/dist/lib/docs-config.js +59 -4
  55. package/dist/lib/docs-config.js.map +1 -1
  56. package/dist/lib/fix-engine.d.ts +89 -0
  57. package/dist/lib/fix-engine.d.ts.map +1 -0
  58. package/dist/lib/fix-engine.js +236 -0
  59. package/dist/lib/fix-engine.js.map +1 -0
  60. package/dist/lib/frontmatter-write.d.ts +4 -0
  61. package/dist/lib/frontmatter-write.d.ts.map +1 -0
  62. package/dist/lib/frontmatter-write.js +10 -0
  63. package/dist/lib/frontmatter-write.js.map +1 -0
  64. package/dist/lib/heading-extractor.d.ts +63 -0
  65. package/dist/lib/heading-extractor.d.ts.map +1 -0
  66. package/dist/lib/heading-extractor.js +171 -0
  67. package/dist/lib/heading-extractor.js.map +1 -0
  68. package/dist/lib/missing-description-scan.d.ts +7 -0
  69. package/dist/lib/missing-description-scan.d.ts.map +1 -0
  70. package/dist/lib/missing-description-scan.js +20 -0
  71. package/dist/lib/missing-description-scan.js.map +1 -0
  72. package/package.json +3 -3
  73. package/vendored/components/mdx/Accordion.tsx +6 -1
  74. package/vendored/components/mdx/ApiEndpoint.tsx +1 -1
  75. package/vendored/components/mdx/MDXComponents.tsx +14 -1
  76. package/vendored/components/mdx/MermaidInner.tsx +2 -8
  77. package/vendored/components/mdx/OpenApiEndpoint.tsx +7 -2
  78. package/vendored/components/navigation/SocialFooter.tsx +9 -1
  79. package/vendored/lib/build/error-parser.ts +6 -3
  80. package/vendored/lib/docs-types.ts +1 -0
  81. package/vendored/lib/extract-faq.ts +54 -0
  82. package/vendored/lib/firestore-helpers.ts +2 -3
  83. package/vendored/lib/fix-engine.ts +293 -0
  84. package/vendored/lib/health-checks.ts +2 -2
  85. package/vendored/lib/json-ld.ts +113 -10
  86. package/vendored/lib/layout-helpers.tsx +14 -15
  87. package/vendored/lib/llms-flag.ts +5 -0
  88. package/vendored/lib/logo-warning.ts +35 -0
  89. package/vendored/lib/noindex-warning.ts +14 -0
  90. package/vendored/lib/r2-cleanup.ts +26 -0
  91. package/vendored/lib/render-doc-page.tsx +11 -2
  92. package/vendored/lib/search.ts +2 -2
  93. package/vendored/lib/seo.ts +49 -19
  94. package/vendored/lib/static-artifacts.ts +2 -2
  95. package/vendored/lib/ui-strings.ts +6 -0
  96. package/vendored/lib/validate-config.ts +63 -4
  97. package/vendored/lib/validate-page-frontmatter.ts +30 -0
  98. package/vendored/schema/docs-schema.json +10 -0
  99. package/vendored/scripts/build-search-index.cjs +2 -2
  100. package/vendored/shared/status-reporter.ts +27 -1
  101. package/vendored/shared/types.ts +1 -0
  102. package/vendored/workspace-package-lock.json +39 -39
@@ -1,15 +1,18 @@
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';
15
+ import type { FaqPair } from './extract-faq';
13
16
 
14
17
  interface BreadcrumbEntry {
15
18
  name: string;
@@ -21,6 +24,11 @@ export interface JsonLdOptions {
21
24
  pagePath: string;
22
25
  pageTitle: string;
23
26
  baseUrl: string;
27
+ faqPairs?: FaqPair[];
28
+ isRootAlias?: boolean;
29
+ ogImageUrl?: string;
30
+ dateModified?: string;
31
+ author?: string;
24
32
  }
25
33
 
26
34
  export interface JsonLdGraph {
@@ -67,7 +75,14 @@ export function findBreadcrumbPath(
67
75
  config: DocsConfig, targetSlug: string, baseUrl: string, pageTitle?: string
68
76
  ): BreadcrumbEntry[] {
69
77
  const nav = config.navigation;
70
- 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 };
71
86
  const pageEntry: BreadcrumbEntry = {
72
87
  name: pageTitle || formatSlugAsTitle(targetSlug),
73
88
  url: `${baseUrl}/${targetSlug}`,
@@ -106,7 +121,7 @@ export function findBreadcrumbPath(
106
121
  // Search all navigation structures (mirrors Breadcrumb.tsx order)
107
122
  let trail: BreadcrumbEntry[] | null = null;
108
123
 
109
- if (!trail && nav.languages) {
124
+ if (nav && !trail && nav.languages) {
110
125
  for (const lang of nav.languages) {
111
126
  if (!lang.tabs) continue;
112
127
  for (const tab of lang.tabs) {
@@ -116,19 +131,19 @@ export function findBreadcrumbPath(
116
131
  if (trail) break;
117
132
  }
118
133
  }
119
- if (!trail && nav.anchors) {
134
+ if (nav && !trail && nav.anchors) {
120
135
  for (const anchor of nav.anchors) {
121
136
  if (anchor.groups) trail = searchGroups(anchor.groups);
122
137
  if (trail) break;
123
138
  }
124
139
  }
125
- if (!trail && nav.tabs) {
140
+ if (nav && !trail && nav.tabs) {
126
141
  for (const tab of nav.tabs) {
127
142
  if (tab.groups) trail = searchGroups(tab.groups);
128
143
  if (trail) break;
129
144
  }
130
145
  }
131
- if (!trail && nav.groups) {
146
+ if (nav && !trail && nav.groups) {
132
147
  trail = searchGroups(nav.groups);
133
148
  }
134
149
 
@@ -140,25 +155,102 @@ export function findBreadcrumbPath(
140
155
  );
141
156
  }
142
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
+
143
173
  /**
144
174
  * Build JSON-LD structured data for a documentation page.
145
- * Returns a schema.org @graph with WebSite + BreadcrumbList.
175
+ * Returns a schema.org @graph with WebSite + Organization + BreadcrumbList (+ optional FAQPage).
146
176
  */
147
177
  export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
148
- const { config, pagePath, pageTitle, baseUrl } = options;
178
+ const {
179
+ config, pagePath, pageTitle, baseUrl, faqPairs,
180
+ isRootAlias = false, ogImageUrl, dateModified, author: pageAuthor,
181
+ } = options;
149
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
+ };
150
237
 
151
238
  return {
152
239
  '@context': 'https://schema.org',
153
240
  '@graph': [
154
241
  {
155
242
  '@type': 'WebSite',
243
+ '@id': websiteId,
156
244
  name: config.name,
157
245
  url: baseUrl,
158
246
  ...(config.description && { description: config.description }),
247
+ publisher: { '@id': orgId },
159
248
  },
249
+ organization,
250
+ mainNode,
160
251
  {
161
252
  '@type': 'BreadcrumbList',
253
+ '@id': breadcrumbId,
162
254
  itemListElement: breadcrumbs.map((item, i) => ({
163
255
  '@type': 'ListItem',
164
256
  position: i + 1,
@@ -166,6 +258,17 @@ export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
166
258
  item: item.url,
167
259
  })),
168
260
  },
261
+ ...(faqPairs && faqPairs.length
262
+ ? [{
263
+ '@type': 'FAQPage',
264
+ '@id': faqId,
265
+ isPartOf: { '@id': websiteId },
266
+ mainEntity: faqPairs.map((p) => ({
267
+ '@type': 'Question', name: p.question,
268
+ acceptedAnswer: { '@type': 'Answer', text: p.answer },
269
+ })),
270
+ }]
271
+ : []),
169
272
  ],
170
273
  };
171
274
  }
@@ -3,7 +3,7 @@
3
3
  // or — in non-ISR dev/tests — from URL params; this module owns the
4
4
  // rendering once those have resolved.
5
5
  import { Inter, JetBrains_Mono } from 'next/font/google';
6
- import { preload, preinit } from 'react-dom';
6
+ import { preinit } from 'react-dom';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { ThemeProvider } from '@/components/theme/ThemeProvider';
@@ -454,22 +454,21 @@ export async function DocsChrome({
454
454
  const bannerNoFlash = !embed && !!bannerContent && config.banner?.dismissible === true && !!resolvedProjectSlug;
455
455
 
456
456
  // Font Awesome CSS uses preinit (not preload) so React 19 emits the
457
- // stylesheet link in <head> at SSR time. The previous approach used a
458
- // beforeInteractive Script that injected <link rel=stylesheet> at runtime —
459
- // that delayed @font-face parsing past the preloaded fonts' "few seconds"
460
- // grace window, producing the chrome console warning "preloaded but not
461
- // used within a few seconds." preinit closes the gap: the preloaded fonts
462
- // are matched to their @font-face declarations as soon as the stylesheet
463
- // parses, which now starts at HTML-parse time alongside the preloads.
457
+ // stylesheet link in <head> at SSR time. preinit (rather than a runtime
458
+ // beforeInteractive <Script>/<link> injection) is what gets the @font-face
459
+ // rules parsed early: the previous Script that ran document.createElement at
460
+ // parse time delayed @font-face parsing, so the actual woff2 fetch started
461
+ // late. preinit hoists the stylesheet at HTML-parse time instead.
464
462
  //
465
- // The font preloads stay on react-dom preload() so React 19 dedupes them
466
- // (JSX <link rel=preload> tags used to ship twice once from the JSX tree,
467
- // once from React's preload manager). The <noscript> fallback below uses
468
- // dangerouslySetInnerHTML for the same reason: a JSX <link rel=stylesheet>
469
- // inside noscript was being hoisted into <head> as a real stylesheet.
463
+ // The individual woff2 weights are deliberately NOT preloaded they load on
464
+ // demand when the stylesheet's @font-face rules are matched to the icons a
465
+ // page actually uses. The default docs icon weight is solid, so the previous
466
+ // unconditional fa-light-300 / fa-brands-400 preloads were almost never used
467
+ // and Chrome flagged them "preloaded but not used within a few seconds."
468
+ // The <noscript> fallback below uses dangerouslySetInnerHTML because a JSX
469
+ // <link rel=stylesheet> inside noscript was being hoisted into <head> as a
470
+ // real stylesheet by React 19.
470
471
  preinit(FA_CSS_HREF, { as: 'style' });
471
- preload('/_jd/fonts/fontawesome/webfonts/fa-light-300.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
472
- preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
473
472
 
474
473
  return (
475
474
  <html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
@@ -0,0 +1,5 @@
1
+ import type { DocsConfig } from './docs-types';
2
+
3
+ export function isLlmsTxtEnabled(config: DocsConfig): boolean {
4
+ return config.seo?.ai?.llmsTxt !== false;
5
+ }
@@ -0,0 +1,35 @@
1
+ import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
2
+ import type { Logo } from './docs-types.js';
3
+
4
+ const MAX_LOGO_BYTES = 100 * 1024;
5
+ const MAX_REASONABLE_WIDTH = 720; // 2x the LOGO_MAX_WIDTH target
6
+
7
+ /**
8
+ * Collect the distinct image refs from a logo config (string or {light,dark}),
9
+ * in light-then-dark order with empties dropped.
10
+ *
11
+ * Used to SNAPSHOT the originals before the convertToWebp pass can rewrite them:
12
+ * an object {light,dark} logo shares its nested object across the enhancedConfig
13
+ * shallow spread, so a later in-place ".webp" rewrite mutates docsConfig.logo too —
14
+ * destroying the ".png"/".jpg" refs the always-on downscale needs. Reading from a
15
+ * pre-convert snapshot makes the downscale work for object logos, not just strings.
16
+ */
17
+ export function collectLogoRefs(logo: Logo | undefined): string[] {
18
+ const refs: string[] = [];
19
+ if (typeof logo === 'string') {
20
+ refs.push(logo);
21
+ } else if (logo && typeof logo === 'object') {
22
+ if (logo.light) refs.push(logo.light);
23
+ if (logo.dark) refs.push(logo.dark);
24
+ }
25
+ return [...new Set(refs.filter(Boolean))];
26
+ }
27
+
28
+ export function buildLogoWarning(logo: { path: string; width: number; height: number; bytes: number }): BuildWarning | null {
29
+ if (logo.bytes <= MAX_LOGO_BYTES && logo.width <= MAX_REASONABLE_WIDTH) return null;
30
+ return {
31
+ type: 'logo_oversized',
32
+ file: logo.path,
33
+ message: `Logo is ${logo.width}×${logo.height} (${Math.round(logo.bytes / 1024)} KB). It's auto-downscaled, but uploading a logo ≤ ${MAX_REASONABLE_WIDTH}px wide / ≤ 100 KB avoids shipping a large source.`,
34
+ };
35
+ }
@@ -0,0 +1,14 @@
1
+ // NOTE: lib/*.ts import the VENDORED shared copy at build-service/shared via
2
+ // '../shared/...js' (see other lib files). '../../shared' would resolve outside the
3
+ // build-service package and break tsc + the prod build.
4
+ import type { BuildWarning } from '../shared/status-reporter.js';
5
+
6
+ export function buildNoindexWarning(noindex: boolean): BuildWarning | null {
7
+ if (!noindex) return null;
8
+ return {
9
+ type: 'site_noindex',
10
+ file: 'docs.json',
11
+ message:
12
+ 'This site is set to noindex: robots.txt is "Disallow: /" and the sitemap is empty, so it will not be indexed by any search engines. AI crawlers are still served via llms.txt (set seo.ai.llmsTxt: false to disable). Remove "noindex" from seo.metatags.robots to allow search indexing.',
13
+ };
14
+ }
@@ -442,6 +442,32 @@ export async function sweepStaleLocaleLlmsTxt(
442
442
  return staleKeys;
443
443
  }
444
444
 
445
+ /**
446
+ * Delete a project's AI-context files: the root `llms.txt` + `llms-full.txt` and
447
+ * every per-locale `<locale>/llms.txt`. Called on every build where `seo.ai.llmsTxt`
448
+ * is false so a previously published llms.txt stops serving after a customer opts
449
+ * out — the build gate stops REGENERATING these files but otherwise leaves the
450
+ * already-uploaded copies in place. The deletes are idempotent (DeleteObject is a
451
+ * no-op-success on a missing key), so re-running on later disabled-llms builds is
452
+ * harmless and self-healing. Per-locale files are discovered and deleted by
453
+ * sweepStaleLocaleLlmsTxt with an EMPTY active-locale set (every known-language
454
+ * locale then counts as removable). Note the returned array lists the keys
455
+ * TARGETED, not necessarily ones that existed. Non-fatal by contract: callers treat
456
+ * a throw as a warning — a stale llms.txt is a cosmetic orphan, never build-failing.
457
+ */
458
+ export async function deleteLlmsTxtArtifacts(projectSlug: string): Promise<string[]> {
459
+ const localeKeys = await sweepStaleLocaleLlmsTxt(projectSlug, new Set());
460
+ const client = getR2S3Client();
461
+ const { bucketName } = getR2Config();
462
+ const rootKeys = [`${projectSlug}/llms.txt`, `${projectSlug}/llms-full.txt`];
463
+ // Independent keys — delete concurrently (matches deleteRemovedR2Objects' Promise.all).
464
+ // A rejection propagates so the caller's non-fatal try/catch logs it; see doc above.
465
+ await Promise.all(
466
+ rootKeys.map((key) => client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }))),
467
+ );
468
+ return [...localeKeys, ...rootKeys];
469
+ }
470
+
445
471
  /**
446
472
  * Delete EVERY R2 object under the `{slug}/` prefix.
447
473
  *
@@ -61,8 +61,9 @@ 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
+ import { extractFaqPairs } from '@/lib/extract-faq';
66
67
  import {
67
68
  getContentLoader,
68
69
  isIsrMode,
@@ -317,7 +318,8 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
317
318
  const highlighterP = getHighlighter();
318
319
 
319
320
  const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
320
- const slug = needsSlugRewrite(normalizedSlug)
321
+ const isRootAlias = needsSlugRewrite(normalizedSlug);
322
+ const slug = isRootAlias
321
323
  ? resolveSlug(normalizedSlug, await configP)
322
324
  : normalizedSlug;
323
325
  const pagePath = slug.join('/');
@@ -337,11 +339,18 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
337
339
  const rawContent = parsed.content;
338
340
 
339
341
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
342
+ const faqPairs = extractFaqPairs(rawContent);
343
+ const ogImageUrl = buildPageOgImageUrl(config, data, baseUrl);
340
344
  const jsonLd = buildJsonLd({
341
345
  config,
342
346
  pagePath,
343
347
  pageTitle: data.title || pagePath,
344
348
  baseUrl,
349
+ faqPairs,
350
+ isRootAlias,
351
+ ogImageUrl,
352
+ dateModified: typeof data.lastUpdated === 'string' ? data.lastUpdated : undefined,
353
+ author: typeof data.author === 'string' ? data.author : undefined,
345
354
  });
346
355
  const jsonLdScript = renderJsonLdScript(jsonLd);
347
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
@@ -615,6 +643,18 @@ function derivePageLocale(pagePath: string, languages?: LanguageConfig[]): strin
615
643
  * the full hreflang cluster, so language discovery is preserved.
616
644
  * @returns Partial<Metadata> to spread into generateMetadata return
617
645
  */
646
+
647
+ // Next.js Metadata.robots can be a string OR an object. When we must ALSO emit
648
+ // googlebot (object-only), a raw string robots directive would be lost — convert
649
+ // it to object form first so the general directive survives the merge.
650
+ function parseRobotsString(robots: string): { index: boolean; follow: boolean } {
651
+ const tokens = robots.toLowerCase().split(',').map((t) => t.trim());
652
+ return {
653
+ index: !tokens.includes('noindex'),
654
+ follow: !tokens.includes('nofollow'),
655
+ };
656
+ }
657
+
618
658
  export function buildSeoMetadata(
619
659
  config: DocsConfig,
620
660
  frontmatter: PageFrontmatter,
@@ -653,12 +693,14 @@ export function buildSeoMetadata(
653
693
 
654
694
  // 3. Googlebot (separate from robots)
655
695
  if (metatags.googlebot) {
656
- // Merge googlebot with existing robots config
657
- const existingRobots = typeof metadata.robots === 'object' ? metadata.robots : {};
658
- metadata.robots = {
659
- ...existingRobots,
660
- googleBot: metatags.googlebot,
661
- };
696
+ // Merge googlebot with existing robots config. When metadata.robots is a string
697
+ // (set above from metatags.robots), coerce it to object form first so the general
698
+ // directive (e.g. noindex) is not silently dropped by the spread.
699
+ const base =
700
+ typeof metadata.robots === 'string'
701
+ ? parseRobotsString(metadata.robots)
702
+ : (metadata.robots ?? {});
703
+ metadata.robots = { ...base, googleBot: metatags.googlebot };
662
704
  }
663
705
 
664
706
  // 4. Google site verification
@@ -723,19 +765,7 @@ export function buildSeoMetadata(
723
765
  // than a bare static image, so the emitted OG image is always the generated card.
724
766
  // The card text follows og:title/og:description overrides so the image matches
725
767
  // the link-card text built in buildOpenGraphMetadata.
726
- const customImage = metatags['og:image']?.trim();
727
- const ogImageUrl = buildOgImageUrl(
728
- baseUrl,
729
- metatags['og:title'] || frontmatter.title || 'Documentation',
730
- config.name,
731
- {
732
- description: metatags['og:description'] || frontmatter.description,
733
- section: frontmatter.section,
734
- logo: config.logo,
735
- favicon: config.favicon,
736
- bg: customImage || undefined,
737
- },
738
- );
768
+ const ogImageUrl = buildPageOgImageUrl(config, frontmatter, baseUrl);
739
769
  metadata.openGraph = buildOpenGraphMetadata(metatags, {
740
770
  title: frontmatter.title,
741
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,
@@ -257,10 +257,65 @@ function deduplicateErrors(errors: ErrorObject[]): ErrorObject[] {
257
257
  });
258
258
  }
259
259
 
260
+ const NAV_BRANCH_KEYS = [
261
+ 'products', 'languages', 'versions', 'tabs',
262
+ 'dropdowns', 'groups', 'pages',
263
+ ] as const;
264
+
265
+ /**
266
+ * Collapse the navigation anyOf noise. When the user's navigation clearly uses
267
+ * one discriminator key (e.g. `tabs`), drop the sibling-branch
268
+ * `required`/`additionalProperties` errors AT `/navigation` and keep the deeper
269
+ * child errors (e.g. /navigation/tabs/0) that say what's actually wrong. No-op
270
+ * when navigation is absent or the intent is ambiguous (0 or >1 known keys).
271
+ *
272
+ * IMPORTANT: key the predicate on `instancePath` + `keyword` + `params`, NOT
273
+ * `schemaPath`. Ajv resolves the `$ref: "#/definitions/navigationSchema"`, so
274
+ * the nav-branch errors carry schemaPaths like `#/anyOf/0/required` — the
275
+ * string "navigationSchema" never appears in them. (Verified by compiling the
276
+ * real schema against a malformed-tabs config.) `instancePath` is the stable
277
+ * signal: every sibling-branch red herring sits exactly at `/navigation`,
278
+ * while the actionable child error sits at `/navigation/<key>/...`.
279
+ * Runs BEFORE `filterAnyOfNoise`, so schemaPaths are still `#/anyOf/N/...` here.
280
+ */
281
+ export function filterNavigationAnyOfNoise(
282
+ errors: ErrorObject[],
283
+ navigation: Record<string, unknown> | undefined,
284
+ ): ErrorObject[] {
285
+ if (!navigation) return errors;
286
+ const present = NAV_BRANCH_KEYS.filter((k) => k in navigation);
287
+ // Only collapse noise when the user's intent is unambiguous — exactly one
288
+ // discriminator key. Zero keys or multiple keys (itself invalid) → leave
289
+ // the raw errors so we never hide the real problem.
290
+ if (present.length !== 1) return errors;
291
+ const usedKey = present[0];
292
+
293
+ return errors.filter((err) => {
294
+ // Keep everything except the two red-herring classes AT /navigation.
295
+ // Child errors like /navigation/tabs/0 have a deeper instancePath -> kept.
296
+ if (err.instancePath !== '/navigation') return true;
297
+ // Drop sibling-branch "missing required <otherKey>" noise (e.g. products).
298
+ if (err.keyword === 'required') {
299
+ const missing =
300
+ (err.params as { missingProperty?: string }).missingProperty;
301
+ return !(missing && missing !== usedKey &&
302
+ (NAV_BRANCH_KEYS as readonly string[]).includes(missing));
303
+ }
304
+ // Drop "unknown property <usedKey>" flagged by non-matching sibling
305
+ // branches (e.g. additionalProperty "tabs").
306
+ if (err.keyword === 'additionalProperties') {
307
+ const extra =
308
+ (err.params as { additionalProperty?: string }).additionalProperty;
309
+ return extra !== usedKey;
310
+ }
311
+ return true;
312
+ });
313
+ }
314
+
260
315
  /**
261
316
  * Format validation errors into user-friendly messages
262
317
  */
263
- export function formatValidationErrors(errors: ErrorObject[] | null | undefined): string {
318
+ export function formatValidationErrors(errors: ErrorObject[] | null | undefined, navigation?: Record<string, unknown>): string {
264
319
  if (!errors || errors.length === 0) {
265
320
  return 'Unknown validation error';
266
321
  }
@@ -270,8 +325,9 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined)
270
325
  // Safe only because validateConfig pre-checks `config.theme` before Ajv,
271
326
  // so a fully-noise error list is unreachable in practice. A new caller
272
327
  // that skips the theme pre-check would break this invariant.
273
- const filteredErrors = filterAnyOfNoise(errors);
274
- const effectiveErrors = filteredErrors.length > 0 ? filteredErrors : errors;
328
+ const navFiltered = filterNavigationAnyOfNoise(errors, navigation);
329
+ const filteredErrors = filterAnyOfNoise(navFiltered);
330
+ const effectiveErrors = filteredErrors.length > 0 ? filteredErrors : navFiltered;
275
331
 
276
332
  const messages = effectiveErrors.slice(0, 3).map(err => {
277
333
  const path = err.instancePath || 'root';
@@ -548,7 +604,10 @@ export async function validateConfig(
548
604
  const valid = validate(config);
549
605
 
550
606
  if (!valid) {
551
- const errorMessage = formatValidationErrors(validate.errors);
607
+ const errorMessage = formatValidationErrors(
608
+ validate.errors,
609
+ config.navigation as Record<string, unknown> | undefined,
610
+ );
552
611
  return {
553
612
  valid: false,
554
613
  error: `Invalid docs.json: ${errorMessage}`,