jamdesk 1.1.205 → 1.1.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts +20 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +9 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/dist/lib/deps.js.map +1 -1
- package/package.json +5 -5
- package/vendored/app/[[...slug]]/page.tsx +1 -102
- package/vendored/app/api/jd/auth/logout/route.ts +36 -4
- package/vendored/app/api/jd/unlock/route.ts +1 -4
- package/vendored/app/layout.tsx +43 -4
- package/vendored/components/CodeBlockCopyButton.tsx +7 -2
- package/vendored/components/layout/LayoutWrapper.tsx +7 -0
- package/vendored/components/mdx/CodeGroup.tsx +161 -13
- package/vendored/components/mdx/MDXComponents.tsx +93 -50
- package/vendored/components/navigation/Header.tsx +7 -0
- package/vendored/components/navigation/LanguageSelector.tsx +35 -0
- package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
- package/vendored/components/ui/CodePanel.tsx +88 -51
- package/vendored/components/ui/CodePanelModal.tsx +57 -36
- package/vendored/hooks/useWheelScrollChaining.ts +181 -0
- package/vendored/lib/auth-plane-write-warning.ts +84 -0
- package/vendored/lib/docs-types.ts +12 -0
- package/vendored/lib/jwt-key-build-warning.ts +38 -0
- package/vendored/lib/language-cookie.ts +20 -0
- package/vendored/lib/language-matcher.ts +124 -0
- package/vendored/lib/language-utils.ts +103 -0
- package/vendored/lib/languages-artifact.ts +160 -0
- package/vendored/lib/layout-helpers.tsx +16 -3
- package/vendored/lib/mdx-import-scan.ts +110 -0
- package/vendored/lib/middleware-helpers.ts +228 -1
- package/vendored/lib/page-timestamps.ts +36 -0
- package/vendored/lib/rehype-code-meta.ts +74 -9
- package/vendored/lib/render-doc-page.tsx +14 -3
- package/vendored/lib/revalidation-helpers.ts +3 -0
- package/vendored/lib/root-page-slug.ts +9 -4
- package/vendored/lib/shiki-transformers.ts +13 -0
- package/vendored/lib/static-artifacts.ts +70 -2
- package/vendored/lib/theme-preview-context.tsx +39 -0
- package/vendored/lib/theme-preview.ts +150 -0
- package/vendored/lib/unlock-audit.ts +10 -0
- package/vendored/next.config.js +32 -0
- package/vendored/schema/docs-schema.json +21 -0
- package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
- package/vendored/workspace-package-lock.json +231 -177
|
@@ -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
|
);
|
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|