jamdesk 1.1.205 → 1.1.207
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/turbopack-loader-config-drift.test.d.ts +20 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +9 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/dist/lib/deps.js.map +1 -1
- package/package.json +5 -5
- package/vendored/app/[[...slug]]/page.tsx +1 -102
- package/vendored/app/api/jd/auth/logout/route.ts +36 -4
- package/vendored/app/api/jd/unlock/route.ts +1 -4
- package/vendored/app/layout.tsx +43 -4
- package/vendored/components/CodeBlockCopyButton.tsx +7 -2
- package/vendored/components/layout/LayoutWrapper.tsx +7 -0
- package/vendored/components/mdx/CodeGroup.tsx +161 -13
- package/vendored/components/mdx/MDXComponents.tsx +93 -50
- package/vendored/components/navigation/Header.tsx +7 -0
- package/vendored/components/navigation/LanguageSelector.tsx +35 -0
- package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
- package/vendored/components/ui/CodePanel.tsx +88 -51
- package/vendored/components/ui/CodePanelModal.tsx +57 -36
- package/vendored/hooks/useWheelScrollChaining.ts +181 -0
- package/vendored/lib/auth-plane-write-warning.ts +84 -0
- package/vendored/lib/docs-types.ts +12 -0
- package/vendored/lib/jwt-key-build-warning.ts +38 -0
- package/vendored/lib/language-cookie.ts +20 -0
- package/vendored/lib/language-matcher.ts +124 -0
- package/vendored/lib/language-utils.ts +103 -0
- package/vendored/lib/languages-artifact.ts +160 -0
- package/vendored/lib/layout-helpers.tsx +16 -3
- package/vendored/lib/mdx-import-scan.ts +110 -0
- package/vendored/lib/middleware-helpers.ts +228 -1
- package/vendored/lib/page-timestamps.ts +36 -0
- package/vendored/lib/rehype-code-meta.ts +74 -9
- package/vendored/lib/render-doc-page.tsx +14 -3
- package/vendored/lib/revalidation-helpers.ts +3 -0
- package/vendored/lib/root-page-slug.ts +9 -4
- package/vendored/lib/shiki-transformers.ts +13 -0
- package/vendored/lib/static-artifacts.ts +70 -2
- package/vendored/lib/theme-preview-context.tsx +39 -0
- package/vendored/lib/theme-preview.ts +150 -0
- package/vendored/lib/unlock-audit.ts +10 -0
- package/vendored/next.config.js +32 -0
- package/vendored/schema/docs-schema.json +21 -0
- package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
- package/vendored/workspace-package-lock.json +231 -177
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language-preference cookie, in a module with NO imports.
|
|
3
|
+
*
|
|
4
|
+
* It lives alone because both sides need it and neither may reach the other:
|
|
5
|
+
* `middleware-helpers.ts` pulls in `next/server`, `@upstash/redis` (via
|
|
6
|
+
* ./redis and ./cached-redis) and the project resolver, while
|
|
7
|
+
* `language-utils.ts` is client code — the `'use client'` LanguageSelector
|
|
8
|
+
* imports it. Having language-utils import the constant from
|
|
9
|
+
* middleware-helpers would both close an import cycle (middleware-helpers
|
|
10
|
+
* already imports extractLanguageFromPath from language-utils, line 22) and
|
|
11
|
+
* drag the edge/Redis modules into the browser bundle. Neither `tsc --noEmit`
|
|
12
|
+
* nor vitest would catch that — only `next build` would, and this repo has
|
|
13
|
+
* already taken a production outage from exactly that class of bug.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Cookie carrying the visitor's chosen documentation language. */
|
|
17
|
+
export const LANGUAGE_COOKIE = 'jd-lang';
|
|
18
|
+
|
|
19
|
+
/** One year — this is a stated preference, not a session detail. */
|
|
20
|
+
export const LANGUAGE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language routing module.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a project's language-routing descriptor from `_languages.json` in R2,
|
|
5
|
+
* cached in Upstash Redis with a 5-minute TTL. Mirrors redirect-matcher.ts —
|
|
6
|
+
* same cache shape, same negative caching, same fail-open contract.
|
|
7
|
+
*
|
|
8
|
+
* Fail-open is not optional here: this runs inside proxy.ts, which has no error
|
|
9
|
+
* boundary. Any throw becomes a 500 on a page that would otherwise have served
|
|
10
|
+
* fine, so every path in this file returns null instead of raising.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { redis } from './redis';
|
|
14
|
+
import type { LanguagesFile } from './languages-artifact';
|
|
15
|
+
import { getFileBufferFromR2 } from './r2';
|
|
16
|
+
|
|
17
|
+
// Cache TTL in seconds (5 minutes) — same as redirects.
|
|
18
|
+
const CACHE_TTL = 300;
|
|
19
|
+
|
|
20
|
+
// Redis cache key prefix.
|
|
21
|
+
const CACHE_PREFIX = 'languages:';
|
|
22
|
+
|
|
23
|
+
/** Sentinel written on a miss so a project without the file stops hitting R2. */
|
|
24
|
+
const EMPTY: LanguagesFile = {
|
|
25
|
+
version: 1,
|
|
26
|
+
autoRedirect: false,
|
|
27
|
+
defaultLanguage: '',
|
|
28
|
+
languages: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Structural gate — a half-written or hand-edited file must not reach callers. */
|
|
32
|
+
function isValid(value: unknown): value is LanguagesFile {
|
|
33
|
+
if (!value || typeof value !== 'object') return false;
|
|
34
|
+
const file = value as Partial<LanguagesFile>;
|
|
35
|
+
return (
|
|
36
|
+
typeof file.autoRedirect === 'boolean' &&
|
|
37
|
+
typeof file.defaultLanguage === 'string' &&
|
|
38
|
+
Array.isArray(file.languages) &&
|
|
39
|
+
file.languages.every((code) => typeof code === 'string')
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clear cached language routing for a project (or all projects).
|
|
45
|
+
* Called during revalidation to pick up an updated `_languages.json` from R2.
|
|
46
|
+
*/
|
|
47
|
+
export async function clearLanguageCache(projectName?: string): Promise<void> {
|
|
48
|
+
if (!redis) return;
|
|
49
|
+
try {
|
|
50
|
+
if (projectName) {
|
|
51
|
+
await redis.del(`${CACHE_PREFIX}${projectName}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const keys: string[] = [];
|
|
55
|
+
let cursor = '0';
|
|
56
|
+
do {
|
|
57
|
+
const result: [string, string[]] = await redis.scan(cursor, {
|
|
58
|
+
match: `${CACHE_PREFIX}*`,
|
|
59
|
+
count: 100,
|
|
60
|
+
});
|
|
61
|
+
cursor = result[0];
|
|
62
|
+
keys.push(...result[1]);
|
|
63
|
+
} while (cursor !== '0');
|
|
64
|
+
if (keys.length > 0) {
|
|
65
|
+
const pipeline = redis.pipeline();
|
|
66
|
+
for (const key of keys) pipeline.del(key);
|
|
67
|
+
await pipeline.exec();
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Ignore cache clear errors — a stale entry expires in CACHE_TTL anyway.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch language routing for a project from R2 with Redis caching.
|
|
76
|
+
* Returns null when the project has no descriptor, or on any failure.
|
|
77
|
+
*/
|
|
78
|
+
export async function getLanguageRouting(projectName: string): Promise<LanguagesFile | null> {
|
|
79
|
+
const cacheKey = `${CACHE_PREFIX}${projectName}`;
|
|
80
|
+
|
|
81
|
+
if (redis) {
|
|
82
|
+
try {
|
|
83
|
+
const cached = await redis.get<LanguagesFile>(cacheKey);
|
|
84
|
+
if (isValid(cached)) {
|
|
85
|
+
return cached.languages.length > 0 ? cached : null;
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('[Languages] Redis cache read failed:', error);
|
|
89
|
+
// Continue to R2.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const buffer = await getFileBufferFromR2(projectName, '_languages.json');
|
|
95
|
+
if (!buffer) {
|
|
96
|
+
// Negative-cache the miss so a single-language project does not pay an R2
|
|
97
|
+
// round trip on every request for the next five minutes.
|
|
98
|
+
if (redis) {
|
|
99
|
+
try {
|
|
100
|
+
await redis.set(cacheKey, EMPTY, { ex: CACHE_TTL });
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore cache write errors.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsed: unknown = JSON.parse(buffer.toString('utf-8'));
|
|
109
|
+
if (!isValid(parsed)) return null;
|
|
110
|
+
|
|
111
|
+
if (redis) {
|
|
112
|
+
try {
|
|
113
|
+
await redis.set(cacheKey, parsed, { ex: CACHE_TTL });
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore cache write errors.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return parsed.languages.length > 0 ? parsed : null;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('[Languages] Failed to load _languages.json:', error);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { LanguageCode, LanguageConfig } from './docs-types';
|
|
8
8
|
import LANGUAGE_CODES_JSON from './language-codes.json';
|
|
9
|
+
import { LANGUAGE_COOKIE, LANGUAGE_COOKIE_MAX_AGE } from './language-cookie';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Canonical language code list, shared with the CJS build script via
|
|
@@ -472,6 +473,13 @@ function storageKey(projectSlug: string): string {
|
|
|
472
473
|
return `${LANGUAGE_STORAGE_KEY_PREFIX}${projectSlug}`;
|
|
473
474
|
}
|
|
474
475
|
|
|
476
|
+
// Mirrors proxy.ts's `secure: process.env.NODE_ENV === 'production'` exactly
|
|
477
|
+
// — gated rather than hardcoded so a write from this client code doesn't
|
|
478
|
+
// silently downgrade the attribute the server's redirect already set.
|
|
479
|
+
// `NODE_ENV` is inlined at build time by Next.js, so this is a build-time
|
|
480
|
+
// constant even though it reads like a runtime check.
|
|
481
|
+
const COOKIE_SECURE_ATTR = process.env.NODE_ENV === 'production' ? '; secure' : '';
|
|
482
|
+
|
|
475
483
|
/**
|
|
476
484
|
* Save language preference to localStorage, scoped to project slug.
|
|
477
485
|
* Handles private browsing mode gracefully.
|
|
@@ -484,6 +492,16 @@ export function saveLanguagePreference(code: LanguageCode, projectSlug: string):
|
|
|
484
492
|
// localStorage not available (private browsing, etc.)
|
|
485
493
|
// Fail silently
|
|
486
494
|
}
|
|
495
|
+
// Mirror the choice into the cookie the edge reads. Without this, a visitor
|
|
496
|
+
// who overrides the server's Accept-Language guess is redirected back to the
|
|
497
|
+
// language they just rejected on their next visit to a locale root.
|
|
498
|
+
try {
|
|
499
|
+
document.cookie =
|
|
500
|
+
`${LANGUAGE_COOKIE}=${encodeURIComponent(code)}; path=/; max-age=${LANGUAGE_COOKIE_MAX_AGE}; samesite=lax${COOKIE_SECURE_ATTR}`;
|
|
501
|
+
} catch {
|
|
502
|
+
// Cookies blocked (private mode, strict settings) — localStorage still
|
|
503
|
+
// carries the preference for this browser, so degrade quietly.
|
|
504
|
+
}
|
|
487
505
|
}
|
|
488
506
|
|
|
489
507
|
/**
|
|
@@ -521,6 +539,11 @@ export function clearLanguagePreference(projectSlug: string): void {
|
|
|
521
539
|
} catch {
|
|
522
540
|
// localStorage not available
|
|
523
541
|
}
|
|
542
|
+
try {
|
|
543
|
+
document.cookie = `${LANGUAGE_COOKIE}=; path=/; max-age=0; samesite=lax${COOKIE_SECURE_ATTR}`;
|
|
544
|
+
} catch {
|
|
545
|
+
// Ignore — see saveLanguagePreference.
|
|
546
|
+
}
|
|
524
547
|
}
|
|
525
548
|
|
|
526
549
|
/**
|
|
@@ -545,3 +568,83 @@ export function resolveLanguageWithFallback(
|
|
|
545
568
|
}
|
|
546
569
|
return 'en';
|
|
547
570
|
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Cap on Accept-Language segments we will parse. A header is attacker-
|
|
574
|
+
* controlled; parsing is O(segments x available) and middleware runs on every
|
|
575
|
+
* request, so we stop reading after a sane number of entries rather than let a
|
|
576
|
+
* crafted header burn edge CPU. Real browsers send fewer than 10.
|
|
577
|
+
*/
|
|
578
|
+
const MAX_ACCEPT_LANGUAGE_SEGMENTS = 20;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Pick the best available language for an Accept-Language header.
|
|
582
|
+
*
|
|
583
|
+
* Returns `null` — meaning "do nothing" — when the header is absent, unusable,
|
|
584
|
+
* matches nothing available, or resolves to the site's default language. A null
|
|
585
|
+
* return is always safe: the caller simply serves the page it was going to
|
|
586
|
+
* serve. This function never throws; middleware has no error boundary, so a
|
|
587
|
+
* throw here would 500 the page.
|
|
588
|
+
*
|
|
589
|
+
* Matching is case-insensitive and two-pass: an exact tag match (`zh-Hans`)
|
|
590
|
+
* beats a base-language match (`zh`), so a site offering both gets the precise
|
|
591
|
+
* one.
|
|
592
|
+
*/
|
|
593
|
+
export function negotiateLanguage(
|
|
594
|
+
header: string | null,
|
|
595
|
+
available: string[],
|
|
596
|
+
defaultLang: string,
|
|
597
|
+
): string | null {
|
|
598
|
+
if (!header || available.length === 0) return null;
|
|
599
|
+
|
|
600
|
+
// Map lowercased code -> original casing, so we return the code exactly as
|
|
601
|
+
// the tenant configured it (callers build URLs from this).
|
|
602
|
+
const availableByLower = new Map<string, string>();
|
|
603
|
+
for (const code of available) {
|
|
604
|
+
availableByLower.set(code.toLowerCase(), code);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const parsed: Array<{ tag: string; q: number }> = [];
|
|
608
|
+
const segments = header.split(',', MAX_ACCEPT_LANGUAGE_SEGMENTS);
|
|
609
|
+
|
|
610
|
+
for (const segment of segments) {
|
|
611
|
+
const [rawTag, ...params] = segment.trim().split(';');
|
|
612
|
+
const tag = rawTag.trim().toLowerCase();
|
|
613
|
+
// '*' means "anything" — it expresses no preference, so we decline to guess.
|
|
614
|
+
if (!tag || tag === '*') continue;
|
|
615
|
+
if (!BCP47_LANGUAGE_RE.test(tag)) continue;
|
|
616
|
+
|
|
617
|
+
let q = 1;
|
|
618
|
+
for (const param of params) {
|
|
619
|
+
const match = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(param);
|
|
620
|
+
if (!match) continue;
|
|
621
|
+
const value = Number.parseFloat(match[1]);
|
|
622
|
+
// A malformed q (NaN) is ignored, leaving the default q=1 — RFC 9110
|
|
623
|
+
// treats an unparseable parameter as absent rather than fatal.
|
|
624
|
+
if (Number.isFinite(value)) q = value;
|
|
625
|
+
}
|
|
626
|
+
if (q <= 0) continue;
|
|
627
|
+
parsed.push({ tag, q });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (parsed.length === 0) return null;
|
|
631
|
+
|
|
632
|
+
// Stable sort by descending q: equal-q tags keep header order, which is the
|
|
633
|
+
// client's own stated preference order.
|
|
634
|
+
parsed.sort((a, b) => b.q - a.q);
|
|
635
|
+
|
|
636
|
+
const defaultLower = defaultLang.toLowerCase();
|
|
637
|
+
|
|
638
|
+
for (const { tag } of parsed) {
|
|
639
|
+
// Pass 1: exact tag ('zh-hans' -> 'zh-Hans').
|
|
640
|
+
const exact = availableByLower.get(tag);
|
|
641
|
+
if (exact) return exact.toLowerCase() === defaultLower ? null : exact;
|
|
642
|
+
|
|
643
|
+
// Pass 2: base language ('fr-ca' -> 'fr').
|
|
644
|
+
const base = tag.split('-')[0];
|
|
645
|
+
const baseMatch = availableByLower.get(base);
|
|
646
|
+
if (baseMatch) return baseMatch.toLowerCase() === defaultLower ? null : baseMatch;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `_languages.json` descriptor shape, plus the pure builder for it — kept
|
|
3
|
+
* free of `./redis` and `./r2`.
|
|
4
|
+
*
|
|
5
|
+
* Two very different callers need it. `lib/language-matcher.ts` reads the file
|
|
6
|
+
* at the edge and therefore imports `./redis` (Upstash) and `./r2`. `build.ts`
|
|
7
|
+
* runs on Cloud Run and only ever *builds* the descriptor — a pure transform
|
|
8
|
+
* that must not pull an edge Redis client into the build image. Keeping the
|
|
9
|
+
* type and the builder here is what keeps those two apart: this module imports
|
|
10
|
+
* the `DocsConfig` type (erased at compile time) plus two import-light pure
|
|
11
|
+
* helpers — `./locale-helpers` and `./root-page-slug`, whose own chain
|
|
12
|
+
* (find-first-nav-page, language-utils, page-isr-helpers, shared/docs-subpath)
|
|
13
|
+
* is plain data and string work with no React, Next or network in it. The edge
|
|
14
|
+
* side imports THIS module `import type`-only, so none of that reaches the
|
|
15
|
+
* middleware bundle.
|
|
16
|
+
*
|
|
17
|
+
* `languages` and `defaultLanguage` are lowercased at build time by
|
|
18
|
+
* normalizeLanguageList(), so comparisons at the edge need no normalization.
|
|
19
|
+
*/
|
|
20
|
+
import { normalizeLanguageList } from './locale-helpers';
|
|
21
|
+
import { findFirstPage } from './root-page-slug';
|
|
22
|
+
import type { DocsConfig } from './docs-types';
|
|
23
|
+
|
|
24
|
+
export interface LanguagesFile {
|
|
25
|
+
version: number;
|
|
26
|
+
generatedAt?: string;
|
|
27
|
+
/** Mirrors docs.json `localization.autoRedirect`. */
|
|
28
|
+
autoRedirect: boolean;
|
|
29
|
+
defaultLanguage: string;
|
|
30
|
+
languages: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The descriptor that means "this project does not language-route", written
|
|
35
|
+
* whenever fewer than two languages are routable.
|
|
36
|
+
*
|
|
37
|
+
* Byte-identical in shape to language-matcher's EMPTY sentinel, deliberately:
|
|
38
|
+
* both `getLanguageRouting` return sites gate on `languages.length > 0` and
|
|
39
|
+
* hand the caller `null`, so this file reads at the edge exactly like no file
|
|
40
|
+
* at all.
|
|
41
|
+
*
|
|
42
|
+
* `autoRedirect` is hardcoded false rather than mirrored from docs.json. It is
|
|
43
|
+
* not a report of the tenant's setting here — it is this descriptor's own
|
|
44
|
+
* meaning, and `{autoRedirect: true, languages: []}` would be a file that
|
|
45
|
+
* contradicts itself for anyone reading it out of R2 during an incident.
|
|
46
|
+
*/
|
|
47
|
+
function noRoutingDescriptor(): LanguagesFile {
|
|
48
|
+
return {
|
|
49
|
+
version: 1,
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
autoRedirect: false,
|
|
52
|
+
defaultLanguage: '',
|
|
53
|
+
languages: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** docs.json nav paths and on-disk page paths, reduced to one comparable form. */
|
|
58
|
+
function normalizePagePath(value: string): string {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/\\/g, '/')
|
|
61
|
+
.replace(/^\/+/, '')
|
|
62
|
+
.replace(/\.mdx?$/i, '')
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the `_languages.json` descriptor for a project. ALWAYS returns one.
|
|
68
|
+
*
|
|
69
|
+
* Never null, and that is the whole contract. R2ContentUploader exposes no
|
|
70
|
+
* delete (lib/r2.ts:279-294), so "the file is absent" is not a state a build
|
|
71
|
+
* can reach for a project that has ever written one — every state has to be
|
|
72
|
+
* expressed by WRITING the truth over the top. An earlier version returned
|
|
73
|
+
* null both for the opted-out switch (correctly reasoned about in a comment)
|
|
74
|
+
* and for "fewer than two languages" (not), which meant a tenant who pulled a
|
|
75
|
+
* bad translation the documented way — `hidden: true` on the second language —
|
|
76
|
+
* skipped the write entirely and left `{autoRedirect: true, languages:
|
|
77
|
+
* ['en','fr']}` in R2 forever, 307-ing French visitors into the language just
|
|
78
|
+
* hidden, or into an `/fr` the build no longer produces. Silently: the success
|
|
79
|
+
* log sat inside the same `if`, so a skipped write looked exactly like a
|
|
80
|
+
* project that never had the feature. Single-language projects now write a
|
|
81
|
+
* no-routing descriptor too — ~93 small objects, one per build, which is the
|
|
82
|
+
* price of the absent state not existing.
|
|
83
|
+
*
|
|
84
|
+
* Two filters decide what may be a redirect DESTINATION:
|
|
85
|
+
*
|
|
86
|
+
* 1. Eligibility, from docs.json. `hidden: true` is the documented way to
|
|
87
|
+
* stage a translation that is not ready, and auto-redirecting a first-time
|
|
88
|
+
* visitor into it is the maximum possible surfacing of a language its
|
|
89
|
+
* author deliberately hid. `href` points at an external site with no local
|
|
90
|
+
* `/<code>` tree here, so redirecting there is a 307 into a 404.
|
|
91
|
+
*
|
|
92
|
+
* 2. Routability, from what this build actually PRODUCED. Config alone never
|
|
93
|
+
* proves a destination exists: `{"language": "de"}` added before anything
|
|
94
|
+
* is translated used to cost a broken entry in the language switcher — one
|
|
95
|
+
* click — and would now 307 every German-preferring visitor off the
|
|
96
|
+
* homepage into a 404. A non-default language is routable only when the
|
|
97
|
+
* page `/<code>` really renders is (a) inside that language's own tree and
|
|
98
|
+
* (b) among this build's pages.
|
|
99
|
+
*
|
|
100
|
+
* Both clauses are load-bearing. `findFirstPage` falls through to the WHOLE
|
|
101
|
+
* nav for a language block with no pages of its own, so (b) alone would
|
|
102
|
+
* happily route `/de` at an English page — English served under a German
|
|
103
|
+
* URL with `jd-lang=de` pinned, which is worse than not redirecting. And
|
|
104
|
+
* (a) alone would route a language whose tree exists but whose front door
|
|
105
|
+
* was never translated. The boundary this draws is deliberate: the test is
|
|
106
|
+
* the visitor's ENTRY POINT, not page count. One translated page is enough
|
|
107
|
+
* if it is the one `/<code>` lands on; two hundred are not enough if it is
|
|
108
|
+
* missing. Resolution goes through findFirstPage — the same module the HTML
|
|
109
|
+
* renderer and the markdown export both use — so this check cannot drift
|
|
110
|
+
* from what the URL actually serves; it is called with the lowercased code
|
|
111
|
+
* because that is the segment decideLanguageRedirect emits.
|
|
112
|
+
*
|
|
113
|
+
* The default language is exempt from (2). It is never a redirect destination
|
|
114
|
+
* — decideLanguageRedirect returns null as soon as the negotiated language is
|
|
115
|
+
* the default — but it has to stay in `languages` for negotiation to see it
|
|
116
|
+
* and for `defaultLanguage` to remain a member of its own list.
|
|
117
|
+
*
|
|
118
|
+
* @param builtPagePaths page paths this build produced, e.g. pageInfos[].path.
|
|
119
|
+
* Required, not defaulted: an empty list disables routing for every
|
|
120
|
+
* non-default language, and that must be a caller's decision rather than
|
|
121
|
+
* something a forgotten argument does quietly.
|
|
122
|
+
*/
|
|
123
|
+
export function buildLanguagesArtifact(
|
|
124
|
+
docsConfig: DocsConfig,
|
|
125
|
+
builtPagePaths: readonly string[],
|
|
126
|
+
): LanguagesFile {
|
|
127
|
+
const nav = docsConfig.navigation as
|
|
128
|
+
| { languages?: Array<{ language: string; default?: boolean; hidden?: boolean; href?: string }> }
|
|
129
|
+
| undefined;
|
|
130
|
+
|
|
131
|
+
// Applied HERE and not inside normalizeLanguageList(). That helper has
|
|
132
|
+
// another caller — deriveChunkLocale, via embedding-chunker.ts and
|
|
133
|
+
// json-ld.ts — which answers "what locale does this file belong to" for
|
|
134
|
+
// translation chunking and localized JSON-LD. A hidden or href-only entry is
|
|
135
|
+
// still a real locale for that question; it is only ineligible as an
|
|
136
|
+
// autoRedirect destination. Filtering inside normalizeLanguageList would
|
|
137
|
+
// have silently changed which pages get translated or how chunks are
|
|
138
|
+
// locale-tagged — a much bigger blast radius than this feature.
|
|
139
|
+
const eligible = (nav?.languages ?? []).filter((l) => l.hidden !== true && !l.href);
|
|
140
|
+
const entries = normalizeLanguageList(eligible);
|
|
141
|
+
|
|
142
|
+
const built = new Set(builtPagePaths.map(normalizePagePath));
|
|
143
|
+
const routable = entries.filter((entry) => {
|
|
144
|
+
if (entry.isDefault) return true;
|
|
145
|
+
const landing = normalizePagePath(findFirstPage(docsConfig, entry.code));
|
|
146
|
+
return landing.startsWith(`${entry.code}/`) && built.has(landing);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (routable.length < 2) return noRoutingDescriptor();
|
|
150
|
+
|
|
151
|
+
const defaultEntry = routable.find((e) => e.isDefault) ?? routable[0];
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
version: 1,
|
|
155
|
+
generatedAt: new Date().toISOString(),
|
|
156
|
+
autoRedirect: docsConfig.localization?.autoRedirect === true,
|
|
157
|
+
defaultLanguage: defaultEntry.code,
|
|
158
|
+
languages: routable.map((e) => e.code),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { BackgroundConfig, DocsConfig, FontConfig, LanguageCode } from '@/lib/docs-types';
|
|
25
25
|
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
|
+
import { ThemePreviewProvider, type ThemePreviewValue } from '@/lib/theme-preview-context';
|
|
27
28
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
29
|
import { isConsentGatingEnabled, buildGatedScripts, getCmpScriptSrcs } from '@/lib/consent-gating';
|
|
29
30
|
import { ConsentGate } from '@/components/ConsentGate';
|
|
@@ -410,6 +411,7 @@ interface DocsChromeProps {
|
|
|
410
411
|
children: React.ReactNode;
|
|
411
412
|
embed?: boolean;
|
|
412
413
|
embedTheme?: 'light' | 'dark' | 'auto';
|
|
414
|
+
themePreview?: ThemePreviewValue;
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
/**
|
|
@@ -430,6 +432,7 @@ export async function DocsChrome({
|
|
|
430
432
|
children,
|
|
431
433
|
embed,
|
|
432
434
|
embedTheme,
|
|
435
|
+
themePreview,
|
|
433
436
|
}: DocsChromeProps): Promise<React.ReactElement> {
|
|
434
437
|
// Lowercase to match docs-config canonical case — `data-theme="nebula"` CSS
|
|
435
438
|
// selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
|
|
@@ -781,9 +784,19 @@ export async function DocsChrome({
|
|
|
781
784
|
>
|
|
782
785
|
<LinkPrefixProvider prefix={linkPrefix}>
|
|
783
786
|
<ProjectSlugProvider slug={resolvedProjectSlug || ''}>
|
|
784
|
-
<
|
|
785
|
-
{
|
|
786
|
-
|
|
787
|
+
<ThemePreviewProvider
|
|
788
|
+
value={
|
|
789
|
+
themePreview ?? {
|
|
790
|
+
enabled: false,
|
|
791
|
+
activeTheme: themeName ?? 'jam',
|
|
792
|
+
defaultTheme: themeName ?? 'jam',
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
>
|
|
796
|
+
<LayoutWrapper config={config} embed={embed}>
|
|
797
|
+
{children}
|
|
798
|
+
</LayoutWrapper>
|
|
799
|
+
</ThemePreviewProvider>
|
|
787
800
|
</ProjectSlugProvider>
|
|
788
801
|
</LinkPrefixProvider>
|
|
789
802
|
<CodeBlockCopyButton />
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Extracted from app/[[...slug]]/page.tsx: Next.js's route/page typegen
|
|
2
|
+
// requires that a page.tsx export ONLY the known page-convention symbols
|
|
3
|
+
// (default, generateStaticParams, generateMetadata, dynamic, ...) — any
|
|
4
|
+
// other export fails `tsc`'s check of .next/types/app/**/page.ts with
|
|
5
|
+
// TS2344 ("does not satisfy the constraint '{ [x: string]: never }'").
|
|
6
|
+
// These helpers are used internally by generateStaticParams but also need
|
|
7
|
+
// to be unit-testable, so they live here instead and are imported (not
|
|
8
|
+
// re-exported) by page.tsx.
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { getContentDir } from '@/lib/docs';
|
|
12
|
+
import { readFileSync as readMdxFile } from '@/lib/fs-readfile';
|
|
13
|
+
|
|
14
|
+
// Mirror of the CLI's detector regex in cli/src/lib/relative-mdx-imports.ts.
|
|
15
|
+
// Duplicated intentionally — build-service uses bundler module resolution
|
|
16
|
+
// and shouldn't reach into cli/src/. Keep the regex itself in sync with
|
|
17
|
+
// that file. Two intentional simplifications vs the CLI version:
|
|
18
|
+
// - no `g` flag (boolean test, not iteration)
|
|
19
|
+
// - fence stripping replaces matches with empty string instead of
|
|
20
|
+
// blank lines of equal count — line numbers don't matter here, only
|
|
21
|
+
// a yes/no skip decision (the CLI preserves them for warning text).
|
|
22
|
+
const PARENT_RELATIVE_MDX_IMPORT_RE =
|
|
23
|
+
/^[ \t]*import\s+(?:type\s+)?[\w$*{}, \n\r]+\s+from\s+["']\.{1,2}\/[^"']+\.mdx["']\s*;?/m;
|
|
24
|
+
|
|
25
|
+
const FENCED_CODE_BLOCK_RE =
|
|
26
|
+
/^( *)(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1\2\s*$/gm;
|
|
27
|
+
|
|
28
|
+
// Cache for pageHasRelativeMdxImport, keyed by absolute file path,
|
|
29
|
+
// invalidated when the file's mtime changes. `generateStaticParams` runs
|
|
30
|
+
// on every nav in `jamdesk dev`, and the regex-test reads the FULL file
|
|
31
|
+
// (per fix 7205c17c). For dodo (~200 MDX × 70-94 KB) that's 14-19 MB of
|
|
32
|
+
// disk I/O per nav. Cache hit reduces it to a directory walk + statSync.
|
|
33
|
+
//
|
|
34
|
+
// In production ISR mode generateStaticParams returns [] before any of
|
|
35
|
+
// this runs (see isIsrMode early-return), so the cache is dev-only in
|
|
36
|
+
// practice. The cache is unbounded — entries for deleted files persist
|
|
37
|
+
// until process exit. For a dev-only ~200-file working set this is
|
|
38
|
+
// trivial (a few KB). If this pattern is reused in a long-running ISR
|
|
39
|
+
// context in the future, add LRU eviction or rebuild-on-walk.
|
|
40
|
+
const mdxImportCache = new Map<string, { mtimeMs: number; hasImport: boolean }>();
|
|
41
|
+
|
|
42
|
+
export function pageHasRelativeMdxImport(filePath: string, mtimeMs?: number): boolean {
|
|
43
|
+
const cached = mdxImportCache.get(filePath);
|
|
44
|
+
if (cached && mtimeMs !== undefined && cached.mtimeMs === mtimeMs) {
|
|
45
|
+
return cached.hasImport;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let hasImport: boolean;
|
|
49
|
+
try {
|
|
50
|
+
// Read the full file — MDX imports can appear at any top-level position
|
|
51
|
+
// (after a long prose intro or table of contents), and a slice was
|
|
52
|
+
// missing real imports past byte ~8192 in 70-94 KB customer pages.
|
|
53
|
+
const content = readMdxFile(filePath);
|
|
54
|
+
// Strip fenced code blocks so documentation examples (e.g.
|
|
55
|
+
// ```mdx\nimport X from "../snippets/foo.mdx";\n```) don't false-trigger.
|
|
56
|
+
hasImport = PARENT_RELATIVE_MDX_IMPORT_RE.test(content.replace(FENCED_CODE_BLOCK_RE, ''));
|
|
57
|
+
} catch {
|
|
58
|
+
hasImport = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (mtimeMs !== undefined) {
|
|
62
|
+
mdxImportCache.set(filePath, { mtimeMs, hasImport });
|
|
63
|
+
}
|
|
64
|
+
return hasImport;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Test-only: clear the per-file MDX-import cache between test cases. */
|
|
68
|
+
export function _resetMdxImportCacheForTest(): void {
|
|
69
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
70
|
+
mdxImportCache.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface CollectedPaths {
|
|
74
|
+
supported: string[];
|
|
75
|
+
skipped: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getAllDocPaths(): CollectedPaths {
|
|
79
|
+
const contentDir = getContentDir();
|
|
80
|
+
const supported: string[] = [];
|
|
81
|
+
const skipped: string[] = [];
|
|
82
|
+
|
|
83
|
+
function traverseDir(dir: string, basePath: string = '') {
|
|
84
|
+
if (!fs.existsSync(dir)) return;
|
|
85
|
+
const files = fs.readdirSync(dir);
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
if (file.startsWith('.')) continue;
|
|
88
|
+
const filePath = path.join(dir, file);
|
|
89
|
+
let stat;
|
|
90
|
+
try {
|
|
91
|
+
stat = fs.statSync(filePath);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (stat.isDirectory()) {
|
|
96
|
+
traverseDir(filePath, path.join(basePath, file));
|
|
97
|
+
} else if (file.endsWith('.mdx')) {
|
|
98
|
+
const slug = path.join(basePath, file.replace(/\.mdx$/, ''));
|
|
99
|
+
if (pageHasRelativeMdxImport(filePath, stat.mtimeMs)) {
|
|
100
|
+
skipped.push(slug);
|
|
101
|
+
} else {
|
|
102
|
+
supported.push(slug);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
traverseDir(contentDir);
|
|
109
|
+
return { supported, skipped };
|
|
110
|
+
}
|