jamdesk 1.1.185 → 1.1.187

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 (29) hide show
  1. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  2. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  4. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  5. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  6. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/language-filter.test.js +166 -0
  8. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  9. package/dist/__tests__/unit/vendored-sync.test.js +95 -1
  10. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  11. package/dist/commands/deploy.d.ts.map +1 -1
  12. package/dist/commands/deploy.js +4 -0
  13. package/dist/commands/deploy.js.map +1 -1
  14. package/dist/lib/language-filter.d.ts +31 -0
  15. package/dist/lib/language-filter.d.ts.map +1 -0
  16. package/dist/lib/language-filter.js +14 -0
  17. package/dist/lib/language-filter.js.map +1 -0
  18. package/package.json +1 -1
  19. package/vendored/components/theme/ThemeToggle.tsx +3 -1
  20. package/vendored/lib/email-templates/build-failure.tsx +1 -0
  21. package/vendored/lib/isr-build-executor.ts +4 -0
  22. package/vendored/lib/locale-fallback.ts +98 -0
  23. package/vendored/lib/regenerate-screenshot-handler.ts +3 -1
  24. package/vendored/lib/render-doc-page.tsx +117 -7
  25. package/vendored/lib/seo.ts +40 -5
  26. package/vendored/lib/ui-strings.ts +268 -23
  27. package/vendored/public/_jd/fonts/fontawesome/css/all.min.css +1 -1
  28. package/vendored/themes/base.css +28 -0
  29. package/vendored/workspace-package-lock.json +87 -3
@@ -25,7 +25,9 @@ export function ThemeToggle() {
25
25
  const iconClass = 'text-[12px]';
26
26
  // Fixed-size icon slot shared by the pre-mount placeholder and the mounted
27
27
  // buttons. Without it the control changes width when the FontAwesome icon font
28
- // swaps in (font-display: swap), reflowing the header nav horizontally (CLS).
28
+ // loads in, reflowing the header nav horizontally (CLS). The bundle now uses
29
+ // font-display: block, so the pre-load glyph is invisible rather than tofu —
30
+ // but the slot is still what pins the width, so it stays load-bearing.
29
31
  // overflow-hidden caps a wide fallback glyph.
30
32
  const iconSlot = 'inline-flex items-center justify-center w-4 h-4 overflow-hidden';
31
33
 
@@ -104,6 +104,7 @@ function formatPhase(phase?: string): string {
104
104
  r2_upload: 'CDN Upload',
105
105
  embeddings: 'AI Search Indexing',
106
106
  vercel_purge: 'Cache Refresh',
107
+ translate: 'Queueing Translations',
107
108
  cleanup: 'Cleanup',
108
109
  };
109
110
  return phaseLabels[phase] || phase;
@@ -571,6 +571,10 @@ export const ISR_PHASES = {
571
571
  embeddings: { label: 'Indexing AI search + chat...', weight: 5 },
572
572
  vercel_purge: { label: 'Refreshing cache...', weight: 10 },
573
573
  cleanup: { label: 'Cleaning up...', weight: 5 },
574
+ // Terminal and non-fatal: enqueues background translation work. Weight 0 —
575
+ // it does no user-visible work, and the dashboard's progress bar must not
576
+ // stall on it.
577
+ translate: { label: 'Queueing translations...', weight: 0 },
574
578
  } as const;
575
579
 
576
580
  export type IsrPhase = keyof typeof ISR_PHASES;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * English fallback for untranslated locale pages.
3
+ *
4
+ * The AI translation pipeline writes locale MDX (`es/quickstart.mdx`,
5
+ * `fr/guides/deploy.mdx`, …) into the customer's repo asynchronously, AFTER
6
+ * their English push has already built and published. Between those two
7
+ * moments — minutes normally, much longer on a first backfill — a locale URL
8
+ * already has a navigation entry (nav is emitted for every configured
9
+ * language, not just the finished pages) but no file behind it. Without this
10
+ * module every such URL 404s.
11
+ *
12
+ * Both Next entry points over a docs URL consume these helpers:
13
+ * - `renderDocPage` → serves the English body plus an inline notice
14
+ * - `buildDocMetadata` → canonicalizes to the English page, `noindex, follow`
15
+ * They must agree; see the wiring in `lib/render-doc-page.tsx`.
16
+ */
17
+ import { resolveLocaleFromPath } from './language-utils';
18
+
19
+ export interface LocaleFallback {
20
+ /** The locale prefix, in the casing the project declared in docs.json. */
21
+ locale: string;
22
+ /** The same page without the locale prefix, e.g. `guides/deploy`. */
23
+ englishPath: string;
24
+ }
25
+
26
+ /**
27
+ * Split a declared locale prefix off a page path.
28
+ *
29
+ * `declaredLocales` is the project's **non-default** locales — the default
30
+ * language's pages live at the root, so its prefix is never a content path (see
31
+ * `fallbackLocales` in `render-doc-page.tsx`).
32
+ *
33
+ * Returns null — meaning "not a locale-prefixed page, leave the 404 alone" —
34
+ * for a bare page, a locale root with no page, a directory that merely looks
35
+ * like a language code (`ai/`, `id/`), and any locale not in that list.
36
+ *
37
+ * The canonical-code test is delegated to `resolveLocaleFromPath` (itself gated
38
+ * on the canonical `LANGUAGE_CODES` list) rather than reimplemented here,
39
+ * because `renderDocPage` calls that same helper to pick localized component
40
+ * chrome. Two independent notions of "declared locale prefix" would drift into
41
+ * chrome localizing on a page this fallback does not fire for, or the reverse —
42
+ * a bug class nobody would trace back to here.
43
+ *
44
+ * The locale is matched case-insensitively (`pt-br/a` resolves against a
45
+ * configured `pt-BR`) but returned in the DECLARED casing, so callers can key
46
+ * straight back into `navigation.languages`.
47
+ */
48
+ export function parseLocalePath(
49
+ path: string,
50
+ declaredLocales: string[],
51
+ ): LocaleFallback | null {
52
+ const trimmed = String(path ?? '').replace(/^\/+/, '');
53
+
54
+ // Gate 1 (unchanged, delegated): is this a canonical language code the
55
+ // project declared? Keeps the yes/no answer aligned with the chrome resolver.
56
+ if (!resolveLocaleFromPath(`/${trimmed}`, declaredLocales)) return null;
57
+
58
+ // Gate 2 (authoritative): the segment we DROP must be the segment that
59
+ // MATCHED. `resolveLocaleFromPath` reaches `extractLanguageFromPath`, which
60
+ // strips a leading `/docs/` because it is written for URL pathnames — but
61
+ // this function is given a CONTENT path (a hostAtDocs tenant's `/docs` prefix
62
+ // is already gone, removed upstream by `normalizeSlugForContent`). Mirroring
63
+ // that strip would drop a real path component and serve `/quickstart`'s body
64
+ // at `/docs/es/quickstart`. So re-match the first segment here: that makes
65
+ // this function strictly narrower than the chrome resolver, never wider.
66
+ const segments = trimmed.split('/').filter(Boolean);
67
+ const first = segments[0]?.toLowerCase();
68
+ const declared = declaredLocales.find((l) => l.toLowerCase() === first);
69
+ if (!declared) return null;
70
+
71
+ const englishPath = segments.slice(1).join('/');
72
+ if (!englishPath) return null;
73
+
74
+ return { locale: declared, englishPath };
75
+ }
76
+
77
+ /**
78
+ * SEO for a locale URL that is currently serving the English body.
79
+ *
80
+ * `noindex` keeps the untranslated duplicate out of the index (it is the same
81
+ * text as the English page, under a second URL), while the canonical points at
82
+ * the English original. `follow`, not `nofollow`: the page's links are real, so
83
+ * link equity should still flow through it.
84
+ *
85
+ * @param englishPath - Page path without the locale prefix (`guides/deploy`).
86
+ * @param siteUrl - Base URL for the site, with or without a trailing slash.
87
+ */
88
+ export function fallbackSeo(
89
+ englishPath: string,
90
+ siteUrl: string,
91
+ ): { canonical: string; robots: 'noindex, follow' } {
92
+ const base = siteUrl.replace(/\/+$/, '');
93
+ const clean = englishPath.replace(/^\/+|\/+$/g, '');
94
+ return {
95
+ canonical: clean ? `${base}/${clean}` : base,
96
+ robots: 'noindex, follow',
97
+ };
98
+ }
@@ -25,7 +25,9 @@ const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
25
25
  */
26
26
  export async function handleRegenerateScreenshot(req: Request, res: Response): Promise<void> {
27
27
  const buildSecret = req.headers['x-build-service-secret'] as string | undefined;
28
- const expectedSecret = process.env.BUILD_SERVICE_SECRET;
28
+ // Trimmed: an untrimmed compare can only reject the legitimate caller, since
29
+ // header values carry no surrounding whitespace on the wire. See server.ts.
30
+ const expectedSecret = process.env.BUILD_SERVICE_SECRET?.trim();
29
31
  if (!expectedSecret) {
30
32
  console.log(JSON.stringify({severity: 'ERROR', message: 'BUILD_SERVICE_SECRET not configured - rejecting /regenerate-screenshot'}));
31
33
  res.status(500).json({error: 'Server misconfiguration'});
@@ -88,6 +88,7 @@ import {
88
88
  import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
89
89
  import { findOperation, deriveOperationDescription, collectLocalSpecPaths } from '@/lib/openapi/operation-description';
90
90
  import { extractLanguageFromPath, isValidLanguageCode, resolveLocaleFromPath } from '@/lib/language-utils';
91
+ import { parseLocalePath } from '@/lib/locale-fallback';
91
92
  import { DocsLocaleProvider } from '@/components/mdx/docs-locale-context';
92
93
  import { findFirstNavPage } from '@/lib/find-first-nav-page';
93
94
  import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
@@ -212,6 +213,64 @@ function findFirstPage(config: DocsConfig, lang?: string): string {
212
213
  return result ? result.replace(/^\//, '') : 'introduction';
213
214
  }
214
215
 
216
+ /**
217
+ * Every locale this project declared in docs.json — the whitelist the localized
218
+ * component chrome (`resolveLocaleFromPath`) gates on. Empty for a project with
219
+ * translations off.
220
+ */
221
+ function declaredLocales(config: DocsConfig): string[] {
222
+ return config.navigation?.languages?.map((l) => l.language) ?? [];
223
+ }
224
+
225
+ /**
226
+ * The subset of those that actually live under a URL prefix — the whitelist the
227
+ * untranslated-page fallback gates on.
228
+ *
229
+ * The default language's pages are at the ROOT (`transformLanguagePath` drops
230
+ * the prefix when `toLang === defaultLang`), so `<default>/…` is never a real
231
+ * content path. Letting the fallback claim it would put "this page hasn't been
232
+ * translated yet" on the page that IS the default-language page — and on a
233
+ * project whose default is not English, serve that language's body under a
234
+ * "showing the English version" notice.
235
+ *
236
+ * The default is resolved the way the rest of the codebase does it
237
+ * (`buildHreflangAlternates`, `resolveLanguageWithFallback`): explicit
238
+ * `default: true`, else the first entry. Excluding a locale here can only ever
239
+ * mean "404 instead of English fallback" — the fallback is consulted solely
240
+ * when the requested file is already missing — so erring narrow is safe.
241
+ *
242
+ * Excluding the default's prefix closes only HALF the hazard the paragraph
243
+ * above names, because the fallback's DESTINATION is "strip the prefix and
244
+ * serve the root page" — and the root page is the DEFAULT language's page, not
245
+ * English. Those coincide on an English-default project and diverge on every
246
+ * other one, where `/en/quickstart` would 200 with the Spanish body under the
247
+ * hardcoded "Showing the English version" notice, canonical → the Spanish root.
248
+ * So the whole fallback is inert unless the default language IS English.
249
+ *
250
+ * Inert rather than reworded, for three reasons:
251
+ * - This module is English-by-contract end to end (`LocaleFallback.englishPath`,
252
+ * `fallbackSeo`'s doc, the notice copy, the `[translation-fallback] serving
253
+ * English body` log line). Naming the actual language served means
254
+ * generalising all of it — for a population the feature never reaches.
255
+ * - `lib/translation/nav.ts` (`unwrapLanguagesNav`) THROWS on a non-English
256
+ * default, so the AI pipeline that creates the untranslated window can never
257
+ * produce this state. Same exact `!== 'en'` test, deliberately: the set where
258
+ * the fallback is inert is exactly the set the pipeline refuses.
259
+ * - It restores the pre-feature 404 on precisely the configurations where no
260
+ * correct answer exists, and changes nothing for English-default projects.
261
+ *
262
+ * The `'en'` is a literal, not `SOURCE_LANGUAGE`: that constant lives under
263
+ * `lib/translation/`, which is server-only and excluded from CLI vendoring
264
+ * (`vendor.js` EXCLUDED_SUBTREES), and this file IS vendored.
265
+ */
266
+ function fallbackLocales(config: DocsConfig): string[] {
267
+ const languages = config.navigation?.languages;
268
+ if (!languages || languages.length === 0) return [];
269
+ const defaultLang = languages.find((l) => l.default)?.language ?? languages[0].language;
270
+ if (defaultLang !== 'en') return [];
271
+ return languages.filter((l) => l.language !== defaultLang).map((l) => l.language);
272
+ }
273
+
215
274
  function needsSlugRewrite(slug: string[]): boolean {
216
275
  return slug.length === 0 || (slug.length === 1 && isValidLanguageCode(slug[0]));
217
276
  }
@@ -296,12 +355,34 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
296
355
  : normalizedSlug;
297
356
  const pagePath = slug.join('/');
298
357
 
299
- const [fileContents, config] = await Promise.all([
358
+ const [pageContents, config] = await Promise.all([
300
359
  loader.getContent(pagePath).catch(() => null),
301
360
  configP,
302
361
  ]);
303
362
 
363
+ // Untranslated locale page → serve the English page's metadata for it (see
364
+ // lib/locale-fallback). This block MUST stay in lockstep with the identical
365
+ // one in renderDocPage: they are two Next entry points over ONE URL, and a
366
+ // fallback known to only one of them either serves the English body under an
367
+ // indexable locale canonical (the duplicate-content problem the noindex
368
+ // exists to prevent, on every untranslated page) or claims `noindex, follow`
369
+ // on a page that 404s.
370
+ //
371
+ // The two halves read R2 independently, so a translation landing between them
372
+ // is served with the metadata of the state its own read saw — self-healing on
373
+ // the next request, and inherent to two reads. Note this changed the severity
374
+ // of a transient R2 miss on a translated page: it used to cost a `<title>`,
375
+ // it now costs a `noindex` for one request.
376
+ const localeFallback = pageContents
377
+ ? null
378
+ : parseLocalePath(pagePath, fallbackLocales(config));
379
+ const fileContents = pageContents ?? (localeFallback
380
+ ? await loader.getContent(localeFallback.englishPath).catch(() => null)
381
+ : null);
382
+
304
383
  if (!fileContents) {
384
+ // Includes the case where the locale prefix matched but the English page
385
+ // is missing too — nothing to fall back to.
305
386
  return { title: 'Not Found' };
306
387
  }
307
388
 
@@ -352,7 +433,10 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
352
433
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
353
434
  const languages = config.navigation?.languages;
354
435
 
355
- const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages, isRootAlias, linkPrefix);
436
+ const seoMetadata = buildSeoMetadata(
437
+ config, data, pagePath, baseUrl, languages, isRootAlias, linkPrefix,
438
+ localeFallback?.englishPath,
439
+ );
356
440
 
357
441
  const titleValue = data.title
358
442
  ? (data.title === config.name ? { absolute: data.title } : data.title)
@@ -419,15 +503,33 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
419
503
  : normalizedSlug;
420
504
  const pagePath = slug.join('/');
421
505
  const currentLang = extractLanguageFromPath(`/${pagePath}`);
422
- const [fileContents, config, highlighter] = await Promise.all([
506
+ const [pageContents, config, highlighter] = await Promise.all([
423
507
  loader.getContent(pagePath).catch(() => null),
424
508
  configP,
425
509
  highlighterP,
426
510
  ]);
427
511
 
512
+ // Untranslated locale page → serve the English body with a notice instead of
513
+ // 404ing. Mirror of the block in buildDocMetadata — see the comment there for
514
+ // why both entry points must reach the same verdict on the same URL.
515
+ const localeFallback = pageContents
516
+ ? null
517
+ : parseLocalePath(pagePath, fallbackLocales(config));
518
+ const fileContents = pageContents ?? (localeFallback
519
+ ? await loader.getContent(localeFallback.englishPath).catch(() => null)
520
+ : null);
521
+
428
522
  if (!fileContents) {
523
+ // Includes the case where the locale prefix matched but the English page
524
+ // is missing too — nothing to fall back to.
429
525
  notFound();
430
526
  }
527
+ if (localeFallback) {
528
+ logger.info('[translation-fallback] serving English body for untranslated locale page', {
529
+ locale: localeFallback.locale,
530
+ pagePath,
531
+ });
532
+ }
431
533
 
432
534
  const parsed = parseFrontmatter(fileContents);
433
535
  const data = parsed.data as FrontmatterData;
@@ -436,10 +538,16 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
436
538
  // Whitelisted page locale for localized component chrome (Prompt action
437
539
  // labels). Unlike `currentLang` above, this only counts a path prefix that
438
540
  // is actually declared in navigation.languages — '' otherwise.
439
- const docsLocale = resolveLocaleFromPath(
440
- `/${pagePath}`,
441
- config.navigation?.languages?.map((l) => l.language) ?? [],
442
- );
541
+ const docsLocale = resolveLocaleFromPath(`/${pagePath}`, declaredLocales(config));
542
+
543
+ // Plain server-rendered element — no client component, no dependency. On the
544
+ // fallback the chrome above stays localized (docsLocale is the same declared
545
+ // prefix parseLocalePath matched); only the body is English.
546
+ const translationNotice = localeFallback ? (
547
+ <div role="status" className="jd-translation-pending">
548
+ This page hasn’t been translated yet. Showing the English version.
549
+ </div>
550
+ ) : null;
443
551
 
444
552
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
445
553
  const faqPairs = extractFaqPairs(rawContent);
@@ -810,6 +918,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
810
918
  {wrap(
811
919
  <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
812
920
  <Breadcrumb slug={slug} config={config} hidden={embed} />
921
+ {translationNotice}
813
922
 
814
923
  {data.title && (
815
924
  <header className="mb-4 sm:mb-6">
@@ -929,6 +1038,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
929
1038
  const articleContent = wrap(
930
1039
  <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
931
1040
  <Breadcrumb slug={slug} config={config} hidden={embed} />
1041
+ {translationNotice}
932
1042
 
933
1043
  {data.title && (
934
1044
  <header className="mb-6 sm:mb-10">
@@ -12,6 +12,7 @@ import type { Metadata } from 'next';
12
12
  import type { DocsConfig, Logo, LogoConfig, Favicon, LanguageConfig, LanguageCode } from './docs-types';
13
13
  import { normalizeLogo } from './docs-types';
14
14
  import { findHreflangAliasCollisions, transformLanguagePath, toHreflang, resolveLocaleFromPath } from './language-utils';
15
+ import { fallbackSeo } from './locale-fallback';
15
16
  import { logger } from '../shared/logger';
16
17
 
17
18
  // Dedupe per-process: same docs.json shape produces the same collision
@@ -674,6 +675,16 @@ function derivePageLocale(pagePath: string, languages?: LanguageConfig[]): strin
674
675
  * `/_jd/og` vs direct `/api/og`) and to strip the prefix off baseUrl for
675
676
  * metadataBase/rootUrl. Falls back to the legacy `baseUrl.endsWith('/docs')`
676
677
  * sniff when omitted, so callers that predate this param are unaffected.
678
+ * @param englishFallbackPath - Set ONLY when this URL is an untranslated locale
679
+ * variant serving the English page's body (`parseLocalePath` matched and the
680
+ * locale MDX is not in R2 yet — see lib/locale-fallback). Its value is the
681
+ * English page path. The variant then canonicalizes to the English page,
682
+ * emits `noindex, follow`, and drops hreflang: advertising an alternate for a
683
+ * URL that is serving English is the same "translation that isn't there"
684
+ * defect buildHreflangAlternates exists to prevent, and pairing hreflang with
685
+ * a cross-URL canonical is the Semrush "Conflicting hreflang and
686
+ * rel=canonical" finding. The canonical target emits the full cluster, so
687
+ * language discovery is preserved — same reasoning as isRootAlias.
677
688
  * @returns Partial<Metadata> to spread into generateMetadata return
678
689
  */
679
690
 
@@ -696,12 +707,19 @@ export function buildSeoMetadata(
696
707
  languages?: LanguageConfig[],
697
708
  isRootAlias = false,
698
709
  docsPrefix?: string,
710
+ englishFallbackPath?: string,
699
711
  ): Partial<Metadata> {
700
712
  const globalMeta = config.seo?.metatags || {};
701
713
  const pageMeta = normalizePageMetatags(frontmatter as Record<string, unknown>);
702
714
  // Merge: page (flat-wins, already resolved inside normalizePageMetatags) over global
703
715
  const metatags: Record<string, string> = { ...globalMeta, ...pageMeta };
704
716
  const metadata: Partial<Metadata> = {};
717
+ // Non-null only for the untranslated-locale variant — see englishFallbackPath.
718
+ // An empty string is treated as "no fallback": it is unreachable today
719
+ // (parseLocalePath returns null on an empty englishPath) but would otherwise
720
+ // canonicalize the page to the bare site root and noindex it.
721
+ const fallbackPath = englishFallbackPath || null;
722
+ const fallback = fallbackPath ? fallbackSeo(fallbackPath, baseUrl) : null;
705
723
 
706
724
  // 1. Generator - always add
707
725
  metadata.generator = 'Jamdesk';
@@ -716,7 +734,11 @@ export function buildSeoMetadata(
716
734
  const hiddenImpliesNoindex = frontmatter.hidden === true && !projectIndexesHidden;
717
735
  const effectiveNoindex = explicitPageNoindex ?? (hiddenImpliesNoindex ? true : undefined);
718
736
 
719
- if (effectiveNoindex === true) {
737
+ if (fallback) {
738
+ // The untranslated variant duplicates the English body under a second URL,
739
+ // so it stays out of the index no matter what the page or project declares.
740
+ metadata.robots = fallback.robots;
741
+ } else if (effectiveNoindex === true) {
720
742
  // noindex does NOT imply nofollow - use follow: true
721
743
  metadata.robots = { index: false, follow: true };
722
744
  } else if (effectiveNoindex !== false && metatags.robots) {
@@ -755,6 +777,12 @@ export function buildSeoMetadata(
755
777
  // 7. Canonical
756
778
  const cleanPath = pagePath.replace(/^\/+|\/+$/g, '');
757
779
  const pageUrl = cleanPath ? `${baseUrl}/${cleanPath}` : baseUrl;
780
+ // Every canonical form below is computed from the ENGLISH path on the
781
+ // fallback variant — the served body is the English page's, so claiming the
782
+ // locale URL as canonical is exactly the duplicate-content problem to avoid.
783
+ const canonicalPath = fallbackPath
784
+ ? fallbackPath.replace(/^\/+|\/+$/g, '')
785
+ : cleanPath;
758
786
  let canonical: string;
759
787
  if (pageMeta.canonical) {
760
788
  // Per-page canonical: use exactly as authored.
@@ -763,14 +791,17 @@ export function buildSeoMetadata(
763
791
  // Global canonical is a BASE — append the page path so every page does not
764
792
  // report the same URL (which would de-index the whole site). Matches Mintlify.
765
793
  const base = globalMeta.canonical.replace(/\/+$/, '');
766
- canonical = cleanPath ? `${base}/${cleanPath}` : base;
794
+ canonical = canonicalPath ? `${base}/${canonicalPath}` : base;
795
+ } else if (fallback) {
796
+ canonical = fallback.canonical;
767
797
  } else {
768
798
  canonical = pageUrl;
769
799
  }
770
800
 
771
801
  // 7b. Hreflang alternates for multi-language support. Suppressed on root-alias
772
- // pages — see the isRootAlias param doc above for why.
773
- const hreflangLanguages = isRootAlias
802
+ // pages and on the untranslated-locale fallback — see the isRootAlias and
803
+ // englishFallbackPath param docs above for why.
804
+ const hreflangLanguages = isRootAlias || fallback
774
805
  ? undefined
775
806
  : buildHreflangAlternates(baseUrl, pagePath, languages || []);
776
807
 
@@ -806,7 +837,11 @@ export function buildSeoMetadata(
806
837
  url: pageUrl,
807
838
  siteName: config.name,
808
839
  ogImageUrl,
809
- locale: derivePageLocale(pagePath, languages),
840
+ // og:locale describes the language of the CONTENT, so on the fallback it
841
+ // must follow the English body, not the locale URL. (og:url deliberately
842
+ // still points at the locale URL — that is the page's real address, and
843
+ // it is not an indexing directive.)
844
+ locale: derivePageLocale(fallbackPath ?? pagePath, languages),
810
845
  });
811
846
  }
812
847