jamdesk 1.1.206 → 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/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/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/docs-types.ts +12 -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/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/schema/docs-schema.json +21 -0
- package/vendored/workspace-package-lock.json +207 -153
|
@@ -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 />
|
|
@@ -19,7 +19,7 @@ import { getDomainConfig, getDomainMapping, getDomainStatus, getProjectInactive
|
|
|
19
19
|
import { getForwardedHosts, isJamdeskDomain, normalizeForwardedHost, parseRedisConfig } from './domain-helpers';
|
|
20
20
|
import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
|
|
21
21
|
import { ASSET_PREFIX } from './docs-types';
|
|
22
|
-
import { extractLanguageFromPath } from './language-utils';
|
|
22
|
+
import { extractLanguageFromPath, negotiateLanguage } from './language-utils';
|
|
23
23
|
import { DEFAULT_DOCS_SUBPATH } from '../shared/docs-subpath';
|
|
24
24
|
import { NextResponse } from 'next/server';
|
|
25
25
|
import type { NextRequest } from 'next/server';
|
|
@@ -1315,3 +1315,230 @@ export async function customDomainOnlyBlock(args: {
|
|
|
1315
1315
|
},
|
|
1316
1316
|
});
|
|
1317
1317
|
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Is this the bare documentation root — the one path where auto-routing to the
|
|
1321
|
+
* visitor's language is safe?
|
|
1322
|
+
*
|
|
1323
|
+
* Exactly two shapes qualify: the site root `/`, and the hostAtDocs `/docs`
|
|
1324
|
+
* root (with or without a trailing slash). Nothing else. In particular an
|
|
1325
|
+
* explicit `/<lang>` root does **not** qualify, and that is the whole point:
|
|
1326
|
+
*
|
|
1327
|
+
* - `/fr` is a URL a human chose, pasted into a ticket, or reached from a
|
|
1328
|
+
* search result. Redirecting away from it means a shared French link opens
|
|
1329
|
+
* in English for any colleague whose browser does not ask for French —
|
|
1330
|
+
* including a French speaker running an English OS. The URL is a stronger
|
|
1331
|
+
* statement of intent than Accept-Language, so the URL wins.
|
|
1332
|
+
* - Bots are exempt from redirects, so a crawler indexes `/fr` happily while
|
|
1333
|
+
* every human who clicks the result gets bounced. That asymmetry is
|
|
1334
|
+
* invisible in testing and shows up as a ranking problem months later.
|
|
1335
|
+
* - It keeps this a pure string test with no language list, so proxy.ts can
|
|
1336
|
+
* reject every content path before paying for the Redis/R2 read. An earlier
|
|
1337
|
+
* draft admitted any bare `/<segment>`, which made `/quickstart` — an
|
|
1338
|
+
* ordinary page — pay that read on every request.
|
|
1339
|
+
*
|
|
1340
|
+
* At the call site `pathname` is INTERNAL (reassigned to `routing.rewrite` in
|
|
1341
|
+
* proxy.ts's getDocsPrefixRouting block), so a subpath-hosted tenant's root
|
|
1342
|
+
* arrives as `/docs`, not `/`.
|
|
1343
|
+
* Both shapes are handled here; dropping the `/docs` case silently disables the
|
|
1344
|
+
* feature for every subpath-mounted site.
|
|
1345
|
+
*/
|
|
1346
|
+
export function isLocaleRootPath(pathname: string): boolean {
|
|
1347
|
+
// The type says string, and the only call site (proxy.ts's `if (routing?.rewrite)`
|
|
1348
|
+
// guard, plus the always-string `request.nextUrl.pathname` fallback) guarantees
|
|
1349
|
+
// one — so this branch is unreachable today. It stays because edge middleware has
|
|
1350
|
+
// no error boundary: a throw here 500s every docs page, while returning false just
|
|
1351
|
+
// declines to redirect, the same fail-safe direction as every other path in this
|
|
1352
|
+
// feature. One line of insurance against a future caller that isn't as careful.
|
|
1353
|
+
if (typeof pathname !== 'string') return false;
|
|
1354
|
+
const trimmed = pathname.endsWith('/') && pathname.length > 1
|
|
1355
|
+
? pathname.slice(0, -1)
|
|
1356
|
+
: pathname;
|
|
1357
|
+
return trimmed === '' || trimmed === '/' || trimmed === '/docs';
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Case-insensitive substring match list for `isBotUserAgent`.
|
|
1362
|
+
*
|
|
1363
|
+
* NOT in the brief's own snippet — Step 3 there calls `BOT_UA_PATTERNS` without
|
|
1364
|
+
* ever defining it, and no such constant exists anywhere else in this repo
|
|
1365
|
+
* (verified by grep across build-service, cli, and dashboard/hosting). That
|
|
1366
|
+
* omission would not typecheck, so this list was authored here to make the
|
|
1367
|
+
* brief's own required test cases pass: the named crawlers/bots plus `curl`
|
|
1368
|
+
* from the test table, and `spider`/`crawler`/generic `bot` so a UA this list
|
|
1369
|
+
* doesn't yet name by product still fails safe (see the doc comment on
|
|
1370
|
+
* `isBotUserAgent` for why "fails safe" here means "gets flagged as a bot").
|
|
1371
|
+
* `facebookexternalhit` and `curl` are listed explicitly because neither
|
|
1372
|
+
* contains the substring "bot".
|
|
1373
|
+
*
|
|
1374
|
+
* Three more explicit entries beyond the brief's test table, none of them a
|
|
1375
|
+
* crash or a security issue — a missed bot just gets the same 307 a human
|
|
1376
|
+
* would. These are crawl-hygiene and measurement-accuracy fixes, cheap enough
|
|
1377
|
+
* to add on sight:
|
|
1378
|
+
* - `google-inspectiontool`: Search Console's URL inspection tool. Missed,
|
|
1379
|
+
* it sees a 307 to a translated locale instead of the canonical page —
|
|
1380
|
+
* actively misleading to someone debugging indexing on a docs product.
|
|
1381
|
+
* - `google-extended`: Google's AI-training crawler. Missed, it ingests a
|
|
1382
|
+
* translated variant instead of canonical.
|
|
1383
|
+
* - `chrome-lighthouse`: this repo's own CLAUDE.md requires a Lighthouse
|
|
1384
|
+
* mobile audit before builder-ISR and marketing deploys. Missed, an
|
|
1385
|
+
* audit measures a redirect hop instead of the target page and quietly
|
|
1386
|
+
* distorts our own pre-deploy gate. (Modern headless Chrome often drops
|
|
1387
|
+
* this token, which is exactly why it's low-risk to list: harmless when
|
|
1388
|
+
* absent, useful when present.)
|
|
1389
|
+
*/
|
|
1390
|
+
const BOT_UA_PATTERNS = [
|
|
1391
|
+
'bot',
|
|
1392
|
+
'spider',
|
|
1393
|
+
'crawler',
|
|
1394
|
+
'facebookexternalhit',
|
|
1395
|
+
'curl',
|
|
1396
|
+
'wget',
|
|
1397
|
+
'google-inspectiontool',
|
|
1398
|
+
'google-extended',
|
|
1399
|
+
'chrome-lighthouse',
|
|
1400
|
+
];
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Total predicate: every input, including `null`/`''`/garbage, returns a
|
|
1404
|
+
* boolean. This runs in edge middleware, which has no error boundary — a
|
|
1405
|
+
* throw here 500s the page (see constraints.md).
|
|
1406
|
+
*
|
|
1407
|
+
* A missing or empty User-Agent is treated as a bot. No real browser omits
|
|
1408
|
+
* the header, so the only visitors this can misclassify are non-browser
|
|
1409
|
+
* clients — and for R1.4 the safe failure mode is "do not auto-redirect",
|
|
1410
|
+
* not "guess a language for an unidentified client."
|
|
1411
|
+
*/
|
|
1412
|
+
export function isBotUserAgent(ua: string | null): boolean {
|
|
1413
|
+
if (!ua) return true;
|
|
1414
|
+
const lowered = ua.toLowerCase();
|
|
1415
|
+
return BOT_UA_PATTERNS.some((pattern) => lowered.includes(pattern));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
export interface LanguageRedirectInput {
|
|
1419
|
+
/** Request pathname, already normalized past any hostAtDocs 308. */
|
|
1420
|
+
pathname: string;
|
|
1421
|
+
/** Descriptor from getLanguageRouting(), or null when absent. */
|
|
1422
|
+
routing: { autoRedirect: boolean; defaultLanguage: string; languages: string[] } | null;
|
|
1423
|
+
acceptLanguage: string | null;
|
|
1424
|
+
userAgent: string | null;
|
|
1425
|
+
cookieLanguage: string | null;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Spellings of `JD_LANG_ROUTING_DISABLED` that mean "every tenant". `*` is the
|
|
1430
|
+
* documented one; the rest are here because an operator reaching for this at
|
|
1431
|
+
* 2am should not have to remember a magic string, and no project slug can
|
|
1432
|
+
* collide with any of them.
|
|
1433
|
+
*/
|
|
1434
|
+
const LANGUAGE_ROUTING_KILL_ALL = new Set(['*', 'all', 'true', '1', 'yes', 'on']);
|
|
1435
|
+
|
|
1436
|
+
/** Spellings that mean "nothing disabled", so `...=false` is not a slug. */
|
|
1437
|
+
const LANGUAGE_ROUTING_KILL_NONE = new Set(['0', 'false', 'no', 'off']);
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The language-routing kill switch: is auto-redirect turned off for this
|
|
1441
|
+
* project by platform configuration?
|
|
1442
|
+
*
|
|
1443
|
+
* Why this exists. `autoRedirect` is baked into `_languages.json` at BUILD
|
|
1444
|
+
* time, so flipping docs.json does nothing until that tenant rebuilds — and
|
|
1445
|
+
* the root CLAUDE.md forbids us triggering a customer rebuild without that
|
|
1446
|
+
* customer's explicit permission for that exact rebuild. Poisoning the Redis
|
|
1447
|
+
* key is not a rollback either: CACHE_TTL is 300s and R2 re-wins in five
|
|
1448
|
+
* minutes. Without this, the only lever for one misbehaving tenant was
|
|
1449
|
+
* rolling back the whole build-service deployment for all ~93.
|
|
1450
|
+
*
|
|
1451
|
+
* Format of the env var, read by proxy.ts:
|
|
1452
|
+
* unset / empty / `false` -> nothing disabled (the default; an unset var
|
|
1453
|
+
* must change nothing)
|
|
1454
|
+
* `*` -> disabled for every tenant
|
|
1455
|
+
* `acme,beta-docs` -> disabled for those project slugs only
|
|
1456
|
+
*
|
|
1457
|
+
* TOTAL and non-throwing by construction — String.split and Set.has cannot
|
|
1458
|
+
* raise, and a non-string argument returns false. That is not a style
|
|
1459
|
+
* preference: proxy.ts has no error boundary, so a throw on this line 500s
|
|
1460
|
+
* every docs page on every tenant. A value nobody can parse disables NOTHING
|
|
1461
|
+
* rather than everything, so a typo cannot take the feature out platform-wide.
|
|
1462
|
+
*
|
|
1463
|
+
* Trimmed and lowercased per entry — Vercel env values pick up trailing
|
|
1464
|
+
* newlines (see the ISR_MODE incident in CLAUDE.md) and slugs are lowercase.
|
|
1465
|
+
*/
|
|
1466
|
+
export function isLanguageRoutingDisabled(
|
|
1467
|
+
projectSlug: string | null | undefined,
|
|
1468
|
+
rawDenylist: string | null | undefined,
|
|
1469
|
+
): boolean {
|
|
1470
|
+
if (typeof rawDenylist !== 'string') return false;
|
|
1471
|
+
const entries = rawDenylist
|
|
1472
|
+
.split(',')
|
|
1473
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
1474
|
+
.filter((entry) => entry.length > 0 && !LANGUAGE_ROUTING_KILL_NONE.has(entry));
|
|
1475
|
+
if (entries.length === 0) return false;
|
|
1476
|
+
if (entries.some((entry) => LANGUAGE_ROUTING_KILL_ALL.has(entry))) return true;
|
|
1477
|
+
const slug = typeof projectSlug === 'string' ? projectSlug.trim().toLowerCase() : '';
|
|
1478
|
+
return slug.length > 0 && entries.includes(slug);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Decide where a language redirect should go, or null for "serve the page
|
|
1483
|
+
* as-is".
|
|
1484
|
+
*
|
|
1485
|
+
* Returns the **internal** (`/docs`-shaped) pathname plus the language that
|
|
1486
|
+
* was chosen. The caller needs both: the pathname must be mapped back to the
|
|
1487
|
+
* tenant's external prefix with toExternalPath(), and the language is the
|
|
1488
|
+
* cookie value — deriving it from the last path segment would write an empty
|
|
1489
|
+
* cookie whenever the destination is the default-language root `/`.
|
|
1490
|
+
*
|
|
1491
|
+
* `viaCookie` tells the caller WHY: true only when an existing cookie named
|
|
1492
|
+
* an offered language, false when the decision came from Accept-Language (or
|
|
1493
|
+
* a cookie was present but named a language the tenant no longer offers — a
|
|
1494
|
+
* stale cookie falls through to negotiation and must not be reported as the
|
|
1495
|
+
* reason). A caller logging "via" from `Boolean(cookieLanguage)` instead
|
|
1496
|
+
* would misattribute every stale-cookie redirect to the cookie.
|
|
1497
|
+
*
|
|
1498
|
+
* Pure and total: every bail-out returns null, so the caller can wire this in
|
|
1499
|
+
* without an error path. Precedence is cookie, then Accept-Language — an
|
|
1500
|
+
* explicit choice always beats a browser default (R1.3).
|
|
1501
|
+
*/
|
|
1502
|
+
export function decideLanguageRedirect(
|
|
1503
|
+
input: LanguageRedirectInput,
|
|
1504
|
+
): { pathname: string; language: string; viaCookie: boolean } | null {
|
|
1505
|
+
const { pathname, routing, acceptLanguage, userAgent, cookieLanguage } = input;
|
|
1506
|
+
|
|
1507
|
+
if (!routing?.autoRedirect) return null;
|
|
1508
|
+
if (isBotUserAgent(userAgent)) return null;
|
|
1509
|
+
// Bare docs root only. An explicit /<lang> root is the visitor's own stated
|
|
1510
|
+
// language and is never redirected away from — see isLocaleRootPath.
|
|
1511
|
+
if (!isLocaleRootPath(pathname)) return null;
|
|
1512
|
+
if (routing.languages.length < 2) return null;
|
|
1513
|
+
|
|
1514
|
+
const available = routing.languages;
|
|
1515
|
+
const defaultLang = routing.defaultLanguage.toLowerCase();
|
|
1516
|
+
|
|
1517
|
+
// A cookie naming a language the tenant has since dropped is stale, not a
|
|
1518
|
+
// preference — fall through to negotiation rather than 404 the visitor.
|
|
1519
|
+
const cookieMatch = cookieLanguage
|
|
1520
|
+
? available.find((code) => code.toLowerCase() === cookieLanguage.toLowerCase())
|
|
1521
|
+
: undefined;
|
|
1522
|
+
|
|
1523
|
+
// "No cookie match" and "nothing configured matched" are DIFFERENT answers
|
|
1524
|
+
// and must not collapse. negotiateLanguage returning null means R1.6's "do
|
|
1525
|
+
// nothing" — not "fall back to the default". An earlier draft wrote
|
|
1526
|
+
// `?? defaultLang` here, which turned every unmatched visitor into a
|
|
1527
|
+
// redirect to the default root.
|
|
1528
|
+
// negotiateLanguage already returns `string | null` (never undefined), so
|
|
1529
|
+
// wrapping it in another `?? null` added nothing — the single trailing one
|
|
1530
|
+
// normalizes the `undefined` that `?.toLowerCase()` produces when it's null.
|
|
1531
|
+
const negotiated = cookieMatch
|
|
1532
|
+
? cookieMatch.toLowerCase()
|
|
1533
|
+
: negotiateLanguage(acceptLanguage, available, defaultLang)?.toLowerCase() ?? null;
|
|
1534
|
+
if (!negotiated) return null;
|
|
1535
|
+
|
|
1536
|
+
// Already where they belong: the bare root IS the default-language root.
|
|
1537
|
+
if (negotiated === defaultLang) return null;
|
|
1538
|
+
|
|
1539
|
+
const trimmed = pathname.endsWith('/') && pathname.length > 1
|
|
1540
|
+
? pathname.slice(0, -1)
|
|
1541
|
+
: pathname;
|
|
1542
|
+
const base = trimmed === '/docs' ? '/docs' : '';
|
|
1543
|
+
return { pathname: `${base}/${negotiated}`, language: negotiated, viaCookie: !!cookieMatch };
|
|
1544
|
+
}
|
|
@@ -142,3 +142,39 @@ export function injectLastUpdated(content: string, date: string): string {
|
|
|
142
142
|
|
|
143
143
|
return `---\n${newInner}\n---\n${content.slice(match[0].length)}`;
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalize a frontmatter date value to `YYYY-MM-DD`, or undefined.
|
|
148
|
+
*
|
|
149
|
+
* YAML hands back three shapes for what an author typed as a date:
|
|
150
|
+
* an unquoted `2026-09-01` becomes a **Date**, a quoted one stays a string,
|
|
151
|
+
* and a bare `2026` becomes a number. Only the first two can mean a date, and
|
|
152
|
+
* a Date must be formatted in UTC — rendering it raw produces a
|
|
153
|
+
* timezone-shifted `Date.toString()` inside `<time dateTime>`, the same bug
|
|
154
|
+
* injectLastUpdated() quotes its own output to avoid.
|
|
155
|
+
*/
|
|
156
|
+
function normalizeDateValue(value: unknown): string | undefined {
|
|
157
|
+
if (value instanceof Date) {
|
|
158
|
+
return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== 'string') return undefined;
|
|
161
|
+
const trimmed = value.trim();
|
|
162
|
+
if (!trimmed) return undefined;
|
|
163
|
+
const parsed = new Date(trimmed);
|
|
164
|
+
if (Number.isNaN(parsed.getTime())) return undefined;
|
|
165
|
+
return parsed.toISOString().slice(0, 10);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the date shown as "Last updated on …".
|
|
170
|
+
*
|
|
171
|
+
* `lastUpdatedDate` is author-owned and wins; `lastUpdated` is build-owned (the
|
|
172
|
+
* git commit date injected by injectLastUpdated) and is the fallback. An
|
|
173
|
+
* unusable author value falls through to the git date rather than blanking the
|
|
174
|
+
* line — a typo should not silently remove a freshness signal.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveLastUpdated(
|
|
177
|
+
data: { lastUpdated?: unknown; lastUpdatedDate?: unknown },
|
|
178
|
+
): string | undefined {
|
|
179
|
+
return normalizeDateValue(data.lastUpdatedDate) ?? normalizeDateValue(data.lastUpdated);
|
|
180
|
+
}
|
|
@@ -16,6 +16,8 @@ export interface CodeMeta {
|
|
|
16
16
|
showLineNumbers?: boolean;
|
|
17
17
|
/** Starting line number (default: 1) */
|
|
18
18
|
startLine?: number;
|
|
19
|
+
/** Suppress the copy button on this block */
|
|
20
|
+
nocopy?: boolean;
|
|
19
21
|
/** Remaining meta string (status codes, etc.) */
|
|
20
22
|
meta?: string;
|
|
21
23
|
}
|
|
@@ -79,6 +81,25 @@ function parseShowLineNumbers(meta: string): boolean {
|
|
|
79
81
|
return /\bshowLineNumbers\b/.test(meta);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Parse the nocopy flag from a meta string.
|
|
86
|
+
* Word-bounded so `title="nocopyright.txt"` does not trigger it, and scanned
|
|
87
|
+
* with any quoted title stripped first so `title="My nocopy guide"` (the word
|
|
88
|
+
* appearing inside free-text title, not as the flag) does not trigger it either.
|
|
89
|
+
*/
|
|
90
|
+
function parseNocopy(meta: string): boolean {
|
|
91
|
+
return /\bnocopy\b/.test(meta.replace(/title=(["'])(?:(?!\1).)*\1/g, ''));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The `nocopy` flag token with its surrounding whitespace. Shared by cleanMeta
|
|
96
|
+
* (pre-Shiki) and rehypeRestoreDataTitle (post-Shiki) because they strip the
|
|
97
|
+
* same fence at two pipeline stages and the caption is only right if both
|
|
98
|
+
* agree. Safe to share as a /g literal: String#replace resets lastIndex before
|
|
99
|
+
* and after; only .test/.exec carry state between calls.
|
|
100
|
+
*/
|
|
101
|
+
const NOCOPY_TOKEN = /\s*\bnocopy\b\s*/g;
|
|
102
|
+
|
|
82
103
|
/**
|
|
83
104
|
* Parse startLine attribute from meta string
|
|
84
105
|
* Supports: startLine=10 or startLine="10"
|
|
@@ -94,6 +115,12 @@ function parseStartLine(meta: string): number | undefined {
|
|
|
94
115
|
|
|
95
116
|
/**
|
|
96
117
|
* Remove parsed attributes from meta string
|
|
118
|
+
*
|
|
119
|
+
* Accepted tradeoff: an UNQUOTED fallback title that happens to contain the
|
|
120
|
+
* word "nocopy" (e.g. ```txt My nocopy guide```) has it stripped here same
|
|
121
|
+
* as the real flag, so the caption renders "My guide" and the block also
|
|
122
|
+
* loses its copy button. Only a quoted `title="..."` is exempted (see
|
|
123
|
+
* parseNocopy above). The escape hatch is quoting the title.
|
|
97
124
|
*/
|
|
98
125
|
function cleanMeta(meta: string): string {
|
|
99
126
|
return meta
|
|
@@ -101,6 +128,7 @@ function cleanMeta(meta: string): string {
|
|
|
101
128
|
.replace(/\s*title=["'][^"']+["']\s*/g, ' ')
|
|
102
129
|
.replace(/\s*\{[^}]+\}\s*/g, ' ')
|
|
103
130
|
.replace(/\s*showLineNumbers\s*/g, ' ')
|
|
131
|
+
.replace(NOCOPY_TOKEN, ' ')
|
|
104
132
|
.replace(/\s*startLine=["']?\d+["']?\s*/g, ' ')
|
|
105
133
|
.trim();
|
|
106
134
|
}
|
|
@@ -122,6 +150,7 @@ export function parseCodeMeta(meta: string): CodeMeta {
|
|
|
122
150
|
highlightLines: parseHighlightLines(meta),
|
|
123
151
|
showLineNumbers: parseShowLineNumbers(meta),
|
|
124
152
|
startLine: parseStartLine(meta),
|
|
153
|
+
nocopy: parseNocopy(meta),
|
|
125
154
|
// Only include meta if we didn't use it as the title
|
|
126
155
|
meta: explicitTitle ? cleanedMeta || undefined : undefined,
|
|
127
156
|
};
|
|
@@ -248,8 +277,9 @@ function isCodeFenceFeature(meta: string): boolean {
|
|
|
248
277
|
if (/^showLineNumbers(\s|$)/.test(trimmed)) return true;
|
|
249
278
|
// startLine=N
|
|
250
279
|
if (/^startLine=/.test(trimmed)) return true;
|
|
251
|
-
// Multiple features combined (no actual title)
|
|
252
|
-
|
|
280
|
+
// Multiple features combined (no actual title) — also covers a lone
|
|
281
|
+
// `nocopy`, so a standalone `^nocopy(\s|$)` check above would be redundant.
|
|
282
|
+
if (/^(showLineNumbers|nocopy|\{[\d,\-\s]+\}|startLine=\d+)(\s+(showLineNumbers|nocopy|\{[\d,\-\s]+\}|startLine=\d+))*$/.test(trimmed)) return true;
|
|
253
283
|
return false;
|
|
254
284
|
}
|
|
255
285
|
|
|
@@ -260,13 +290,48 @@ export const rehypeRestoreDataTitle: Plugin<[], Root> = () => {
|
|
|
260
290
|
const dataMeta = node.properties['data-meta'] as string | undefined;
|
|
261
291
|
const dataLanguage = node.properties['data-language'] as string | undefined;
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
293
|
+
if (!dataMeta || dataMeta.includes(':')) return;
|
|
294
|
+
|
|
295
|
+
// An explicit `title="..."` always wins. Promote the PARSED value,
|
|
296
|
+
// never the raw `data-meta` string — using raw meta here shipped
|
|
297
|
+
// `title="/snippets/counter.tsx"` as the literal on-page caption
|
|
298
|
+
// instead of `/snippets/counter.tsx` (pre-existing since 7ed2dfe26).
|
|
299
|
+
const explicitTitle = parseTitle(dataMeta);
|
|
300
|
+
if (explicitTitle) {
|
|
301
|
+
node.properties['data-title'] = explicitTitle;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Strip nocopy BEFORE classifying, not after: `JavaScript nocopy`
|
|
306
|
+
// must still read as the "javascript" language label. Classifying
|
|
307
|
+
// the raw meta first would test "JavaScript nocopy" against
|
|
308
|
+
// isLanguageLabel/isCodeFenceFeature (neither matches the combined
|
|
309
|
+
// string), wrongly promoting it to a standalone title "JavaScript".
|
|
310
|
+
const cleaned = dataMeta.replace(NOCOPY_TOKEN, ' ').trim();
|
|
311
|
+
if (!cleaned) return;
|
|
312
|
+
|
|
313
|
+
if (isLanguageLabel(cleaned, dataLanguage)) {
|
|
314
|
+
// The author wrote a language/tool token as the fence meta
|
|
315
|
+
// (```bash npm). Deliberately NOT promoted to data-title — a
|
|
316
|
+
// standalone block must not caption itself "npm" — but inside a
|
|
317
|
+
// <CodeGroup> that token IS the tab label Mintlify documents, and
|
|
318
|
+
// it is the only thing distinguishing an npm tab from a yarn one.
|
|
319
|
+
// Dropping it made `npm`/`yarn` both fall through to the language,
|
|
320
|
+
// and lib/code-utils.ts maps bash -> "cURL": 75 groups in
|
|
321
|
+
// projects/ rendered two or three IDENTICAL tabs. Re-emitted here
|
|
322
|
+
// under its own name, so the only reader is CodeGroup's
|
|
323
|
+
// getTabLabel and no caption changes outside a group.
|
|
324
|
+
//
|
|
325
|
+
// Emitted post-Shiki on purpose: this plugin runs after
|
|
326
|
+
// rehypeShikiFromHighlighter, which replaces the <pre> wholesale,
|
|
327
|
+
// so a pre-Shiki attribute would be discarded (same reason
|
|
328
|
+
// data-nocopy is stamped from transformerLineFeatures().pre()).
|
|
329
|
+
node.properties['data-language-label'] = cleaned;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!isCodeFenceFeature(cleaned)) {
|
|
334
|
+
node.properties['data-title'] = cleaned;
|
|
270
335
|
}
|
|
271
336
|
}
|
|
272
337
|
});
|
|
@@ -22,6 +22,7 @@ import { PageColumns } from '@/components/layout/PageColumns';
|
|
|
22
22
|
import { EmbedLinkInterceptor } from '@/components/layout/EmbedLinkInterceptor';
|
|
23
23
|
import { PageNavigation } from '@/components/navigation/PageNavigation';
|
|
24
24
|
import { LastUpdated } from '@/components/navigation/LastUpdated';
|
|
25
|
+
import { resolveLastUpdated } from './page-timestamps';
|
|
25
26
|
import { SocialFooter } from '@/components/navigation/SocialFooter';
|
|
26
27
|
import { ApiPageWrapper } from '@/components/mdx/ApiPage';
|
|
27
28
|
import { OpenApiEndpoint } from '@/components/mdx/OpenApiEndpoint';
|
|
@@ -191,6 +192,16 @@ interface FrontmatterData {
|
|
|
191
192
|
// Injected at build time (YYYY-MM-DD) when metadata.timestamp is enabled —
|
|
192
193
|
// the date of the last git commit that touched this page. See LastUpdated.
|
|
193
194
|
lastUpdated?: string;
|
|
195
|
+
/**
|
|
196
|
+
* Author override; wins over the build-injected `lastUpdated`.
|
|
197
|
+
*
|
|
198
|
+
* Typed `string | Date` because that is what YAML actually yields: an
|
|
199
|
+
* unquoted `2026-09-01` parses to a Date, a quoted one stays a string.
|
|
200
|
+
* Declaring it `string` would compile but describe a shape the runtime
|
|
201
|
+
* never guarantees. `resolveLastUpdated` takes `unknown` and handles both,
|
|
202
|
+
* plus the number YAML produces for a bare `2026`.
|
|
203
|
+
*/
|
|
204
|
+
lastUpdatedDate?: string | Date;
|
|
194
205
|
[key: string]: unknown;
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -695,7 +706,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
695
706
|
faqPairs,
|
|
696
707
|
isRootAlias,
|
|
697
708
|
ogImageUrl,
|
|
698
|
-
dateModified:
|
|
709
|
+
dateModified: resolveLastUpdated(data),
|
|
699
710
|
author: typeof data.author === 'string' ? data.author : undefined,
|
|
700
711
|
});
|
|
701
712
|
const jsonLdScript = renderJsonLdScript(jsonLd);
|
|
@@ -1241,7 +1252,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1241
1252
|
</div>
|
|
1242
1253
|
|
|
1243
1254
|
{!embed && <PageNavigation currentSlug={slug.join('/')} config={config} />}
|
|
1244
|
-
{!embed && config.metadata?.timestamp && <LastUpdated date={data
|
|
1255
|
+
{!embed && config.metadata?.timestamp && <LastUpdated date={resolveLastUpdated(data)} />}
|
|
1245
1256
|
<SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
|
|
1246
1257
|
</article>,
|
|
1247
1258
|
)}
|
|
@@ -1303,7 +1314,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1303
1314
|
|
|
1304
1315
|
{!embed && <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />}
|
|
1305
1316
|
</div>
|
|
1306
|
-
{!embed && config.metadata?.timestamp && <LastUpdated date={data
|
|
1317
|
+
{!embed && config.metadata?.timestamp && <LastUpdated date={resolveLastUpdated(data)} />}
|
|
1307
1318
|
<SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
|
|
1308
1319
|
</article>,
|
|
1309
1320
|
);
|