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.
Files changed (34) hide show
  1. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  2. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  3. package/dist/lib/deps.js +3 -3
  4. package/dist/lib/deps.js.map +1 -1
  5. package/package.json +5 -5
  6. package/vendored/app/layout.tsx +43 -4
  7. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  8. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  9. package/vendored/components/mdx/CodeGroup.tsx +161 -13
  10. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  11. package/vendored/components/navigation/Header.tsx +7 -0
  12. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  13. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  14. package/vendored/components/ui/CodePanel.tsx +88 -51
  15. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  16. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  17. package/vendored/lib/docs-types.ts +12 -0
  18. package/vendored/lib/language-cookie.ts +20 -0
  19. package/vendored/lib/language-matcher.ts +124 -0
  20. package/vendored/lib/language-utils.ts +103 -0
  21. package/vendored/lib/languages-artifact.ts +160 -0
  22. package/vendored/lib/layout-helpers.tsx +16 -3
  23. package/vendored/lib/middleware-helpers.ts +228 -1
  24. package/vendored/lib/page-timestamps.ts +36 -0
  25. package/vendored/lib/rehype-code-meta.ts +74 -9
  26. package/vendored/lib/render-doc-page.tsx +14 -3
  27. package/vendored/lib/revalidation-helpers.ts +3 -0
  28. package/vendored/lib/root-page-slug.ts +9 -4
  29. package/vendored/lib/shiki-transformers.ts +13 -0
  30. package/vendored/lib/static-artifacts.ts +70 -2
  31. package/vendored/lib/theme-preview-context.tsx +39 -0
  32. package/vendored/lib/theme-preview.ts +150 -0
  33. package/vendored/schema/docs-schema.json +21 -0
  34. 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
- <LayoutWrapper config={config} embed={embed}>
785
- {children}
786
- </LayoutWrapper>
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
- if (/^(showLineNumbers|\{[\d,\-\s]+\}|startLine=\d+)(\s+(showLineNumbers|\{[\d,\-\s]+\}|startLine=\d+))*$/.test(trimmed)) return true;
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
- // Only convert data-meta to data-title if:
264
- // 1. data-meta exists
265
- // 2. It doesn't look like a status code (no colon)
266
- // 3. It's not a language/tool label (which CodeGroup uses for tabs)
267
- // 4. It's not just code fence features (showLineNumbers, {1,3-5}, startLine=N)
268
- if (dataMeta && !dataMeta.includes(':') && !isLanguageLabel(dataMeta, dataLanguage) && !isCodeFenceFeature(dataMeta)) {
269
- node.properties['data-title'] = dataMeta;
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: typeof data.lastUpdated === 'string' ? data.lastUpdated : undefined,
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.lastUpdated} />}
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.lastUpdated} />}
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
  );