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.
Files changed (47) hide show
  1. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts +20 -0
  2. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
  4. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
  5. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  6. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  7. package/dist/lib/deps.js +3 -3
  8. package/dist/lib/deps.js.map +1 -1
  9. package/package.json +5 -5
  10. package/vendored/app/[[...slug]]/page.tsx +1 -102
  11. package/vendored/app/api/jd/auth/logout/route.ts +36 -4
  12. package/vendored/app/api/jd/unlock/route.ts +1 -4
  13. package/vendored/app/layout.tsx +43 -4
  14. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  15. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  16. package/vendored/components/mdx/CodeGroup.tsx +161 -13
  17. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  18. package/vendored/components/navigation/Header.tsx +7 -0
  19. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  20. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  21. package/vendored/components/ui/CodePanel.tsx +88 -51
  22. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  23. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  24. package/vendored/lib/auth-plane-write-warning.ts +84 -0
  25. package/vendored/lib/docs-types.ts +12 -0
  26. package/vendored/lib/jwt-key-build-warning.ts +38 -0
  27. package/vendored/lib/language-cookie.ts +20 -0
  28. package/vendored/lib/language-matcher.ts +124 -0
  29. package/vendored/lib/language-utils.ts +103 -0
  30. package/vendored/lib/languages-artifact.ts +160 -0
  31. package/vendored/lib/layout-helpers.tsx +16 -3
  32. package/vendored/lib/mdx-import-scan.ts +110 -0
  33. package/vendored/lib/middleware-helpers.ts +228 -1
  34. package/vendored/lib/page-timestamps.ts +36 -0
  35. package/vendored/lib/rehype-code-meta.ts +74 -9
  36. package/vendored/lib/render-doc-page.tsx +14 -3
  37. package/vendored/lib/revalidation-helpers.ts +3 -0
  38. package/vendored/lib/root-page-slug.ts +9 -4
  39. package/vendored/lib/shiki-transformers.ts +13 -0
  40. package/vendored/lib/static-artifacts.ts +70 -2
  41. package/vendored/lib/theme-preview-context.tsx +39 -0
  42. package/vendored/lib/theme-preview.ts +150 -0
  43. package/vendored/lib/unlock-audit.ts +10 -0
  44. package/vendored/next.config.js +32 -0
  45. package/vendored/schema/docs-schema.json +21 -0
  46. package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
  47. package/vendored/workspace-package-lock.json +231 -177
@@ -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
  );
@@ -13,6 +13,7 @@ import { clearProjectCache as clearMcpProjectCache, clearCache as clearMcpCache
13
13
  import { clearNavigationCache } from './navigation-resolver';
14
14
  import { getStaticRevalidationPaths } from './static-file-route';
15
15
  import { clearRedirectCache } from './redirect-matcher';
16
+ import { clearLanguageCache } from './language-matcher';
16
17
  import { clearProjectDeployIdCache } from './project-deploy-id';
17
18
  import { mdxCacheTag, projectCacheTag } from './cache-tags';
18
19
 
@@ -158,6 +159,7 @@ export async function executeRevalidation(
158
159
  clearOpenApiCache(project);
159
160
  clearMcpProjectCache(project);
160
161
  await clearRedirectCache(project);
162
+ await clearLanguageCache(project);
161
163
  clearNavigationCache(); // Navigation cache keys include project name, clear all for simplicity
162
164
  revalidated.push(`cache:${project}`);
163
165
  request.tags = [projectCacheTag(project)];
@@ -216,6 +218,7 @@ export async function executeRevalidation(
216
218
  clearNavigationCache();
217
219
  clearProjectDeployIdCache();
218
220
  await clearRedirectCache();
221
+ await clearLanguageCache();
219
222
  if (revalidatePath) {
220
223
  revalidatePath('/', 'layout');
221
224
  // Also purge CDN cache for static file routes (sitemap, robots, llms.txt, etc.)
@@ -17,10 +17,15 @@
17
17
  * resolving to the first nav page. Parity with HTML is the contract here — the
18
18
  * helper is shared, so the two surfaces move together.
19
19
  */
20
- import type { DocsConfig } from '@/lib/docs-types';
21
- import { findFirstNavPage } from '@/lib/find-first-nav-page';
22
- import { isValidLanguageCode } from '@/lib/language-utils';
23
- import { pathToSlug } from '@/lib/page-isr-helpers';
20
+ // Relative, not `@/lib/...`: this module is now reachable from build.ts (via
21
+ // languages-artifact.ts) and therefore compiled by tsconfig.node.json for the
22
+ // Cloud Run image, which does not carry the `@` path alias. Its whole
23
+ // neighbourhood — locale-helpers, language-utils, page-isr-helpers — imports
24
+ // relatively for the same reason. Caught by __tests__/build.cjs-interop.test.ts.
25
+ import type { DocsConfig } from './docs-types';
26
+ import { findFirstNavPage } from './find-first-nav-page';
27
+ import { isValidLanguageCode } from './language-utils';
28
+ import { pathToSlug } from './page-isr-helpers';
24
29
 
25
30
  /**
26
31
  * The page path proxy.ts rewrites the site root to (`/`, `/index.md`, and the
@@ -32,6 +32,7 @@ interface ParsedMeta {
32
32
  showLineNumbers: boolean;
33
33
  startLine: number;
34
34
  title: string | undefined;
35
+ nocopy: boolean;
35
36
  }
36
37
 
37
38
  const metaCache = new Map<string, ParsedMeta>();
@@ -50,6 +51,11 @@ function getParsedMeta(meta: string): ParsedMeta {
50
51
  showLineNumbers: /\bshowLineNumbers\b/.test(meta),
51
52
  startLine: parseStartLineImpl(meta),
52
53
  title: parseTitleImpl(meta),
54
+ // Word-boundary so `nocopyright.txt` is unaffected. Scanned on the meta
55
+ // with any quoted title removed first: a fence written
56
+ // `title="My nocopy guide"` must not set the flag, and must not have its
57
+ // caption mangled by the strip in rehypeRestoreDataTitle.
58
+ nocopy: /\bnocopy\b/.test(meta.replace(/title=(["'])(?:(?!\1).)*\1/g, '')),
53
59
  };
54
60
 
55
61
  // Prevent unbounded cache growth
@@ -318,6 +324,13 @@ export function transformerLineFeatures(): ShikiTransformer {
318
324
  if (parsed.highlightLines.length > 0) {
319
325
  node.properties['data-highlight-lines'] = JSON.stringify(parsed.highlightLines);
320
326
  }
327
+
328
+ // Suppress the copy button. Must be emitted HERE, not from
329
+ // rehypeCodeMeta: Shiki replaces the <pre>, so a pre-Shiki attribute is
330
+ // discarded, and CodeBlockCopyButton scans the post-Shiki `pre.shiki`.
331
+ if (parsed.nocopy) {
332
+ node.properties['data-nocopy'] = 'true';
333
+ }
321
334
  },
322
335
  line(node, line) {
323
336
  const meta = this.options.meta?.__raw || '';
@@ -19,6 +19,7 @@ import { buildHreflangAlternates } from './seo.js';
19
19
  import { computePageVisibility, type VisibilityInputs } from './visibility.js';
20
20
  import { logger } from '../shared/logger.js';
21
21
  import { frontmatterText } from './frontmatter-utils.js';
22
+ import { resolveLastUpdated } from './page-timestamps';
22
23
 
23
24
  /**
24
25
  * Page metadata for artifact generation.
@@ -447,6 +448,11 @@ export interface RawPageInfo {
447
448
  noindex?: boolean;
448
449
  hidden?: boolean;
449
450
  lastModified?: string;
451
+ // Author override for the sitemap's <lastmod>, resolved via
452
+ // resolveLastUpdated (page-timestamps.ts). YAML yields a Date for an
453
+ // unquoted YYYY-MM-DD and a string for a quoted one — see FrontmatterData
454
+ // in render-doc-page.tsx for the same shape.
455
+ lastUpdatedDate?: string | Date;
450
456
  rss?: boolean;
451
457
  seo?: {
452
458
  noindex?: boolean;
@@ -482,7 +488,16 @@ export function extractPageMetadata(
482
488
  description: frontmatterText(rawDescription) || undefined,
483
489
  noindex: page.frontmatter.noindex || page.frontmatter.seo?.noindex,
484
490
  hidden: page.frontmatter.hidden,
485
- lastModified: page.frontmatter.lastModified,
491
+ // An explicit lastUpdatedDate is the author stating when this page was
492
+ // last meaningfully changed. The page body and the JSON-LD dateModified
493
+ // already honour it; the sitemap must agree or we publish two different
494
+ // answers to the same question. Deliberately NOT falling back to the
495
+ // build-injected `lastUpdated` here: that would change <lastmod> from the
496
+ // build date to the commit date for every page on every tenant, which is
497
+ // a platform-wide SEO change this plan has no mandate for.
498
+ lastModified:
499
+ resolveLastUpdated({ lastUpdatedDate: page.frontmatter.lastUpdatedDate }) ??
500
+ page.frontmatter.lastModified,
486
501
  };
487
502
  });
488
503
  }
@@ -766,6 +781,57 @@ export interface GeneratedArtifacts {
766
781
  changelog: string;
767
782
  }
768
783
 
784
+ /**
785
+ * Keep the root feed.xml / changelog.json to the DEFAULT locale's pages.
786
+ *
787
+ * Every translated copy of a changelog page carries the same <Update> entries,
788
+ * so without this each update appeared once per language: jamdesk-docs shipped
789
+ * 61 changelog entries for 9 real updates, RSS subscribers got each update
790
+ * seven times, and the English widget's "latest" rendered a Chinese label
791
+ * (generateChangelog breaks date ties on label slug, and '2026 \u5e74 9 \u6708'
792
+ * slugs to '2026-9', which sorts ahead of 'september-2026').
793
+ *
794
+ * Mirrors the bucketing generateLlmsTxtFiles already does for the root
795
+ * llms.txt; see there for why `|| defaultCode` and `.toLowerCase()` are both
796
+ * load-bearing. There are no per-locale feed.xml/changelog.json routes, so
797
+ * translated updates are dropped here rather than rehomed.
798
+ */
799
+ function selectDefaultLocaleRssPages(
800
+ rssPages: RssPageInfo[],
801
+ languages: LanguageConfig[] | undefined,
802
+ ): RssPageInfo[] {
803
+ // Unlike generateLlmsTxtFiles, hidden languages are KEPT here. There `codes`
804
+ // also drives cross-links, which a hidden language must not appear in; here
805
+ // it is purely a list of path prefixes that mean "this is a translation",
806
+ // and hiding a language from the picker does not stop it being one. Dropping
807
+ // hidden locales would let their updates duplicate into the root feed — the
808
+ // exact bug this function exists to fix.
809
+ const codes = (languages ?? [])
810
+ .filter(
811
+ (l): l is LanguageConfig & { language: string } => typeof l.language === 'string',
812
+ )
813
+ .map((l) => l.language);
814
+ // Must come BEFORE defaultCode: resolveLanguageWithFallback returns
815
+ // configLanguages[0].language verbatim, which is customer JSON and need not
816
+ // be a string (the type guard above concedes that). Calling .toLowerCase()
817
+ // on `languages: [{}]` or `[{language: 123}]` throws and kills the whole
818
+ // build. generateLlmsTxtFiles is safe for the same reason — its own empty
819
+ // check fires before it lowercases. No dedupe is needed though: `codes` is
820
+ // only a whitelist for resolveLocaleFromPath, which folds it into a Set.
821
+ if (codes.length === 0) return rssPages;
822
+
823
+ const defaultCode = resolveLanguageWithFallback(null, languages).toLowerCase();
824
+ const selected = rssPages.filter(
825
+ (p) => (resolveLocaleFromPath(p.path, codes) || defaultCode).toLowerCase() === defaultCode,
826
+ );
827
+ // A tenant whose only changelog sits under a locale prefix would otherwise
828
+ // be left with nothing, and an empty `updates` means rssFeed === null, which
829
+ // SKIPS the R2 write entirely (upload-content-to-r2.ts) — the versioned key
830
+ // 404s while the legacy key goes on serving the old duplicated feed. Keeping
831
+ // every page there is no worse than today's behaviour for that tenant.
832
+ return selected.length > 0 ? selected : rssPages;
833
+ }
834
+
769
835
  /**
770
836
  * Generate all static artifacts for a project.
771
837
  *
@@ -798,7 +864,9 @@ export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArti
798
864
  // Extract <Update> entries once — they power BOTH the RSS feed and
799
865
  // changelog.json, so sharing the single pass keeps the widget's "latest" and
800
866
  // feed.xml structurally in sync (and avoids scanning every rss page twice).
801
- const updates = rssPages && rssPages.length > 0 ? extractUpdatesFromPages(rssPages) : [];
867
+ const updates = rssPages && rssPages.length > 0
868
+ ? extractUpdatesFromPages(selectDefaultLocaleRssPages(rssPages, languages))
869
+ : [];
802
870
 
803
871
  let rssFeed: string | null = null;
804
872
  if (updates.length > 0) {