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.
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +166 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +95 -1
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +4 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/lib/language-filter.d.ts +31 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +14 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/package.json +1 -1
- package/vendored/components/theme/ThemeToggle.tsx +3 -1
- package/vendored/lib/email-templates/build-failure.tsx +1 -0
- package/vendored/lib/isr-build-executor.ts +4 -0
- package/vendored/lib/locale-fallback.ts +98 -0
- package/vendored/lib/regenerate-screenshot-handler.ts +3 -1
- package/vendored/lib/render-doc-page.tsx +117 -7
- package/vendored/lib/seo.ts +40 -5
- package/vendored/lib/ui-strings.ts +268 -23
- package/vendored/public/_jd/fonts/fontawesome/css/all.min.css +1 -1
- package/vendored/themes/base.css +28 -0
- 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
|
-
//
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
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 [
|
|
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
|
-
|
|
441
|
-
|
|
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">
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -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 (
|
|
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 =
|
|
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
|
|
773
|
-
|
|
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
|
|
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
|
|