jamdesk 1.1.210 → 1.1.211
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/package.json +1 -1
- package/vendored/components/mdx/CodeGroup.tsx +14 -7
- package/vendored/components/ui/CodePanel.tsx +67 -64
- package/vendored/lib/language-utils.ts +61 -14
- package/vendored/lib/languages-artifact.ts +18 -6
- package/vendored/lib/middleware-helpers.ts +27 -17
- package/vendored/lib/page-timestamps.ts +24 -5
- package/vendored/lib/rehype-code-meta.ts +12 -6
- package/vendored/lib/shiki-transformers.ts +4 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.211",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -221,10 +221,10 @@ function isTitledMdxFence(block: ReactElement): boolean {
|
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
223
|
* Children that are neither code fences nor the blank text nodes MDX emits
|
|
224
|
-
* between block children.
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
224
|
+
* between block children. They are rendered, never dropped: before the
|
|
225
|
+
* fence-recognition fix such content was visible (the whole group fell back to
|
|
226
|
+
* a plain <div>), so dropping it now would be a content regression. See the
|
|
227
|
+
* CodeGroup body for where each one goes.
|
|
228
228
|
*/
|
|
229
229
|
function isRenderableExtra(child: ReactNode): boolean {
|
|
230
230
|
if (unwrapCodeFence(child) !== null) return false;
|
|
@@ -273,7 +273,13 @@ export const CodeGroup = memo(function CodeGroup({ children }: CodeGroupProps) {
|
|
|
273
273
|
return <div>{children}</div>;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
// Anything before the first fence introduces the group and renders ABOVE
|
|
277
|
+
// the panel. Everything after it — between fences or trailing — renders
|
|
278
|
+
// below, in source order: a tab strip has nowhere to put prose between two
|
|
279
|
+
// tabs. codeBlocks is non-empty here, so a fence exists.
|
|
280
|
+
const firstFenceIndex = allChildren.findIndex((child) => unwrapCodeFence(child) !== null);
|
|
281
|
+
const leading = allChildren.slice(0, firstFenceIndex).filter(isRenderableExtra);
|
|
282
|
+
const trailing = allChildren.slice(firstFenceIndex + 1).filter(isRenderableExtra);
|
|
277
283
|
|
|
278
284
|
// Extract title from first code block (only shown for single blocks)
|
|
279
285
|
const title = codeBlocks.length === 1 ? getTitle(codeBlocks[0]) : undefined;
|
|
@@ -304,12 +310,13 @@ export const CodeGroup = memo(function CodeGroup({ children }: CodeGroupProps) {
|
|
|
304
310
|
|
|
305
311
|
const panel = <CodePanel tabs={tabs} title={title} className="my-6" enableFullscreen />;
|
|
306
312
|
|
|
307
|
-
if (
|
|
313
|
+
if (leading.length === 0 && trailing.length === 0) return panel;
|
|
308
314
|
|
|
309
315
|
return (
|
|
310
316
|
<>
|
|
317
|
+
{leading}
|
|
311
318
|
{panel}
|
|
312
|
-
{
|
|
319
|
+
{trailing}
|
|
313
320
|
</>
|
|
314
321
|
);
|
|
315
322
|
});
|
|
@@ -118,6 +118,46 @@ export function extractTextContent(node: ReactNode): string {
|
|
|
118
118
|
return '';
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* An icon button in the panel header — expand or copy. Both header variants
|
|
123
|
+
* render the same pair; only the margin differs, so it is the one prop.
|
|
124
|
+
*/
|
|
125
|
+
function HeaderIconButton({
|
|
126
|
+
onClick,
|
|
127
|
+
title,
|
|
128
|
+
ariaLabel,
|
|
129
|
+
marginClass,
|
|
130
|
+
children,
|
|
131
|
+
}: {
|
|
132
|
+
onClick: () => void;
|
|
133
|
+
title: string;
|
|
134
|
+
ariaLabel: string;
|
|
135
|
+
marginClass: string;
|
|
136
|
+
children: ReactNode;
|
|
137
|
+
}) {
|
|
138
|
+
return (
|
|
139
|
+
<button
|
|
140
|
+
onClick={onClick}
|
|
141
|
+
className={['p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer', marginClass]
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join(' ')}
|
|
144
|
+
style={{ color: codePanelColors.textMuted }}
|
|
145
|
+
onMouseEnter={(e) => {
|
|
146
|
+
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
147
|
+
e.currentTarget.style.color = codePanelColors.text;
|
|
148
|
+
}}
|
|
149
|
+
onMouseLeave={(e) => {
|
|
150
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
151
|
+
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
152
|
+
}}
|
|
153
|
+
title={title}
|
|
154
|
+
aria-label={ariaLabel}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</button>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
/**
|
|
122
162
|
* Reusable code panel component with tabs, copy button, and syntax highlighting support.
|
|
123
163
|
* Self-contained with CSS variable fallbacks - works standalone or with theme overrides.
|
|
@@ -288,6 +328,13 @@ export function CodePanel({
|
|
|
288
328
|
setTimeout(() => setCopied(false), 2000);
|
|
289
329
|
};
|
|
290
330
|
|
|
331
|
+
const expandIcon = <i className="fa-solid fa-expand text-[14px]" aria-hidden="true" />;
|
|
332
|
+
const copyIcon = copied ? (
|
|
333
|
+
<i className="fa-solid fa-check text-[14px] text-emerald-500" aria-hidden="true" />
|
|
334
|
+
) : (
|
|
335
|
+
<i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
|
|
336
|
+
);
|
|
337
|
+
|
|
291
338
|
return (
|
|
292
339
|
<div
|
|
293
340
|
ref={panelRef}
|
|
@@ -317,47 +364,25 @@ export function CodePanel({
|
|
|
317
364
|
<div className="flex items-center">
|
|
318
365
|
{/* Expand Button */}
|
|
319
366
|
{enableFullscreen && (
|
|
320
|
-
<
|
|
367
|
+
<HeaderIconButton
|
|
321
368
|
onClick={() => setIsModalOpen(true)}
|
|
322
|
-
className="p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer"
|
|
323
|
-
style={{ color: codePanelColors.textMuted }}
|
|
324
|
-
onMouseEnter={(e) => {
|
|
325
|
-
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
326
|
-
e.currentTarget.style.color = codePanelColors.text;
|
|
327
|
-
}}
|
|
328
|
-
onMouseLeave={(e) => {
|
|
329
|
-
e.currentTarget.style.backgroundColor = 'transparent';
|
|
330
|
-
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
331
|
-
}}
|
|
332
369
|
title="Open in fullscreen"
|
|
333
|
-
|
|
370
|
+
ariaLabel="Open in fullscreen"
|
|
371
|
+
marginClass=""
|
|
334
372
|
>
|
|
335
|
-
|
|
336
|
-
</
|
|
373
|
+
{expandIcon}
|
|
374
|
+
</HeaderIconButton>
|
|
337
375
|
)}
|
|
338
376
|
{/* Copy Button */}
|
|
339
377
|
{!copyHidden && (
|
|
340
|
-
<
|
|
378
|
+
<HeaderIconButton
|
|
341
379
|
onClick={handleCopy}
|
|
342
|
-
className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : ''}`}
|
|
343
|
-
style={{ color: codePanelColors.textMuted }}
|
|
344
|
-
onMouseEnter={(e) => {
|
|
345
|
-
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
346
|
-
e.currentTarget.style.color = codePanelColors.text;
|
|
347
|
-
}}
|
|
348
|
-
onMouseLeave={(e) => {
|
|
349
|
-
e.currentTarget.style.backgroundColor = 'transparent';
|
|
350
|
-
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
351
|
-
}}
|
|
352
380
|
title="Copy code"
|
|
353
|
-
|
|
381
|
+
ariaLabel="Copy code to clipboard"
|
|
382
|
+
marginClass={enableFullscreen ? 'ml-1' : ''}
|
|
354
383
|
>
|
|
355
|
-
{
|
|
356
|
-
|
|
357
|
-
) : (
|
|
358
|
-
<i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
|
|
359
|
-
)}
|
|
360
|
-
</button>
|
|
384
|
+
{copyIcon}
|
|
385
|
+
</HeaderIconButton>
|
|
361
386
|
)}
|
|
362
387
|
</div>
|
|
363
388
|
</div>
|
|
@@ -447,47 +472,25 @@ export function CodePanel({
|
|
|
447
472
|
</div>
|
|
448
473
|
{/* Expand Button */}
|
|
449
474
|
{enableFullscreen && (
|
|
450
|
-
<
|
|
475
|
+
<HeaderIconButton
|
|
451
476
|
onClick={() => setIsModalOpen(true)}
|
|
452
|
-
className="p-1.5 rounded-md transition-colors flex-shrink-0 ml-auto cursor-pointer"
|
|
453
|
-
style={{ color: codePanelColors.textMuted }}
|
|
454
|
-
onMouseEnter={(e) => {
|
|
455
|
-
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
456
|
-
e.currentTarget.style.color = codePanelColors.text;
|
|
457
|
-
}}
|
|
458
|
-
onMouseLeave={(e) => {
|
|
459
|
-
e.currentTarget.style.backgroundColor = 'transparent';
|
|
460
|
-
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
461
|
-
}}
|
|
462
477
|
title="Open in fullscreen"
|
|
463
|
-
|
|
478
|
+
ariaLabel="Open in fullscreen"
|
|
479
|
+
marginClass="ml-auto"
|
|
464
480
|
>
|
|
465
|
-
|
|
466
|
-
</
|
|
481
|
+
{expandIcon}
|
|
482
|
+
</HeaderIconButton>
|
|
467
483
|
)}
|
|
468
484
|
{/* Fixed copy button */}
|
|
469
485
|
{!copyHidden && (
|
|
470
|
-
<
|
|
486
|
+
<HeaderIconButton
|
|
471
487
|
onClick={handleCopy}
|
|
472
|
-
className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : 'ml-auto'}`}
|
|
473
|
-
style={{ color: codePanelColors.textMuted }}
|
|
474
|
-
onMouseEnter={(e) => {
|
|
475
|
-
e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
|
|
476
|
-
e.currentTarget.style.color = codePanelColors.text;
|
|
477
|
-
}}
|
|
478
|
-
onMouseLeave={(e) => {
|
|
479
|
-
e.currentTarget.style.backgroundColor = 'transparent';
|
|
480
|
-
e.currentTarget.style.color = codePanelColors.textMuted;
|
|
481
|
-
}}
|
|
482
488
|
title="Copy code"
|
|
483
|
-
|
|
489
|
+
ariaLabel="Copy code to clipboard"
|
|
490
|
+
marginClass={enableFullscreen ? 'ml-1' : 'ml-auto'}
|
|
484
491
|
>
|
|
485
|
-
{
|
|
486
|
-
|
|
487
|
-
) : (
|
|
488
|
-
<i className="fa-regular fa-copy text-[14px]" aria-hidden="true" />
|
|
489
|
-
)}
|
|
490
|
-
</button>
|
|
492
|
+
{copyIcon}
|
|
493
|
+
</HeaderIconButton>
|
|
491
494
|
)}
|
|
492
495
|
</div>
|
|
493
496
|
{/* Custom scrollbar track - always visible when there's overflow */}
|
|
@@ -19,7 +19,8 @@ export const LANGUAGE_CODES = LANGUAGE_CODES_JSON as readonly LanguageCode[];
|
|
|
19
19
|
/** BCP-47 syntax check (2-3 letter primary tag + optional 2-4 letter
|
|
20
20
|
* region/script). Shared by the chat and docs-search REST endpoints to
|
|
21
21
|
* keep their request-validation contracts identical. Limitation:
|
|
22
|
-
* 3-segment tags like `zh-Hant-HK` are rejected.
|
|
22
|
+
* 3-segment tags like `zh-Hant-HK` are rejected. negotiateLanguage does
|
|
23
|
+
* NOT use it — Accept-Language needs the looser LANGUAGE_RANGE_RE. */
|
|
23
24
|
export const BCP47_LANGUAGE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -577,6 +578,55 @@ export function resolveLanguageWithFallback(
|
|
|
577
578
|
*/
|
|
578
579
|
const MAX_ACCEPT_LANGUAGE_SEGMENTS = 20;
|
|
579
580
|
|
|
581
|
+
/**
|
|
582
|
+
* An Accept-Language range after lowercasing and `_` → `-`: a 1-8 letter
|
|
583
|
+
* primary subtag, then any number of 1-8 alphanumeric subtags (RFC 4647 §2.1).
|
|
584
|
+
* Deliberately NOT BCP47_LANGUAGE_RE — that is the REST endpoints' request
|
|
585
|
+
* contract, and it rejects tags real browsers send: numeric regions (`es-419`)
|
|
586
|
+
* and three-subtag tags (`zh-Hant-TW`, `sr-Latn-RS`).
|
|
587
|
+
*/
|
|
588
|
+
const LANGUAGE_RANGE_RE = /^[a-z]{1,8}(?:-[a-z0-9]{1,8})*$/;
|
|
589
|
+
|
|
590
|
+
/** RFC 5646 §4.4.1's minimum tag buffer. No browser sends a longer tag, and it
|
|
591
|
+
* bounds the truncation loop below on an attacker-controlled header. */
|
|
592
|
+
const MAX_LANGUAGE_TAG_LENGTH = 35;
|
|
593
|
+
|
|
594
|
+
/** Chinese regions and the script each one writes in. Browsers send the
|
|
595
|
+
* region (`zh-TW`); docs.json offers the script (`zh-Hant`). */
|
|
596
|
+
const CHINESE_REGION_SCRIPT: Record<string, 'zh-hant' | 'zh-hans'> = {
|
|
597
|
+
tw: 'zh-hant',
|
|
598
|
+
hk: 'zh-hant',
|
|
599
|
+
mo: 'zh-hant',
|
|
600
|
+
cn: 'zh-hans',
|
|
601
|
+
sg: 'zh-hans',
|
|
602
|
+
my: 'zh-hans',
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Lookup order for one lowercased, validated tag: the tag itself, then with
|
|
607
|
+
* subtags dropped from the right (RFC 4647 §3.4). Chinese also gets its script
|
|
608
|
+
* inserted before the bare `zh` (`zh-tw` → `zh-tw`, `zh-hant`, `zh`). A tag
|
|
609
|
+
* that NAMES the Hant script never falls back to bare `zh`: this platform
|
|
610
|
+
* labels that 简体中文 (LANGUAGE_DISPLAY_NAMES), so the reader would get the
|
|
611
|
+
* other script. Staying on the default language is the better answer.
|
|
612
|
+
*/
|
|
613
|
+
function lookupCandidates(tag: string): string[] {
|
|
614
|
+
const subtags = tag.split('-');
|
|
615
|
+
const candidates: string[] = [];
|
|
616
|
+
for (let n = subtags.length; n > 0; n--) candidates.push(subtags.slice(0, n).join('-'));
|
|
617
|
+
if (subtags[0] !== 'zh') return candidates;
|
|
618
|
+
|
|
619
|
+
const rest = subtags.slice(1);
|
|
620
|
+
if (rest.includes('hant')) return candidates.filter((c) => c !== 'zh');
|
|
621
|
+
if (rest.includes('hans')) return candidates;
|
|
622
|
+
const script = rest
|
|
623
|
+
.map((s) => (Object.hasOwn(CHINESE_REGION_SCRIPT, s) ? CHINESE_REGION_SCRIPT[s] : undefined))
|
|
624
|
+
.find(Boolean);
|
|
625
|
+
// candidates always ends with the bare 'zh'; the script goes just before it.
|
|
626
|
+
if (script) candidates.splice(candidates.length - 1, 0, script);
|
|
627
|
+
return candidates;
|
|
628
|
+
}
|
|
629
|
+
|
|
580
630
|
/**
|
|
581
631
|
* Pick the best available language for an Accept-Language header.
|
|
582
632
|
*
|
|
@@ -586,9 +636,10 @@ const MAX_ACCEPT_LANGUAGE_SEGMENTS = 20;
|
|
|
586
636
|
* serve. This function never throws; middleware has no error boundary, so a
|
|
587
637
|
* throw here would 500 the page.
|
|
588
638
|
*
|
|
589
|
-
* Matching is case-insensitive
|
|
590
|
-
*
|
|
591
|
-
*
|
|
639
|
+
* Matching is case-insensitive RFC 4647 lookup: each tag is tried whole, then
|
|
640
|
+
* with subtags dropped from the right (`zh-hant-tw` → `zh-hant`), so the most
|
|
641
|
+
* specific language the site offers wins. Chinese is script-aware — see
|
|
642
|
+
* lookupCandidates.
|
|
592
643
|
*/
|
|
593
644
|
export function negotiateLanguage(
|
|
594
645
|
header: string | null,
|
|
@@ -609,10 +660,10 @@ export function negotiateLanguage(
|
|
|
609
660
|
|
|
610
661
|
for (const segment of segments) {
|
|
611
662
|
const [rawTag, ...params] = segment.trim().split(';');
|
|
612
|
-
const tag = rawTag.trim().toLowerCase();
|
|
663
|
+
const tag = rawTag.trim().toLowerCase().replace(/_/g, '-');
|
|
613
664
|
// '*' means "anything" — it expresses no preference, so we decline to guess.
|
|
614
665
|
if (!tag || tag === '*') continue;
|
|
615
|
-
if (!
|
|
666
|
+
if (tag.length > MAX_LANGUAGE_TAG_LENGTH || !LANGUAGE_RANGE_RE.test(tag)) continue;
|
|
616
667
|
|
|
617
668
|
let q = 1;
|
|
618
669
|
for (const param of params) {
|
|
@@ -636,14 +687,10 @@ export function negotiateLanguage(
|
|
|
636
687
|
const defaultLower = defaultLang.toLowerCase();
|
|
637
688
|
|
|
638
689
|
for (const { tag } of parsed) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
// Pass 2: base language ('fr-ca' -> 'fr').
|
|
644
|
-
const base = tag.split('-')[0];
|
|
645
|
-
const baseMatch = availableByLower.get(base);
|
|
646
|
-
if (baseMatch) return baseMatch.toLowerCase() === defaultLower ? null : baseMatch;
|
|
690
|
+
for (const candidate of lookupCandidates(tag)) {
|
|
691
|
+
const match = availableByLower.get(candidate);
|
|
692
|
+
if (match) return match.toLowerCase() === defaultLower ? null : match;
|
|
693
|
+
}
|
|
647
694
|
}
|
|
648
695
|
|
|
649
696
|
return null;
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
* side imports THIS module `import type`-only, so none of that reaches the
|
|
15
15
|
* middleware bundle.
|
|
16
16
|
*
|
|
17
|
-
* `languages` and `defaultLanguage`
|
|
18
|
-
*
|
|
17
|
+
* `languages` and `defaultLanguage` keep the casing docs.json declares. The
|
|
18
|
+
* schema enum makes that the canonical casing, and it is the only one `/<code>`
|
|
19
|
+
* renders for (isValidLanguageCode is case-sensitive), so the edge compares
|
|
20
|
+
* case-insensitively but emits these strings verbatim. Descriptors written
|
|
21
|
+
* before 2026-09-22 are all-lowercase; the edge reads both.
|
|
19
22
|
*/
|
|
20
23
|
import { normalizeLanguageList } from './locale-helpers';
|
|
21
24
|
import { findFirstPage } from './root-page-slug';
|
|
@@ -107,8 +110,9 @@ function normalizePagePath(value: string): string {
|
|
|
107
110
|
* if it is the one `/<code>` lands on; two hundred are not enough if it is
|
|
108
111
|
* missing. Resolution goes through findFirstPage — the same module the HTML
|
|
109
112
|
* renderer and the markdown export both use — so this check cannot drift
|
|
110
|
-
* from what the URL actually serves; it is called with the
|
|
111
|
-
* because
|
|
113
|
+
* from what the URL actually serves; it is called with the DECLARED code
|
|
114
|
+
* because findFirstPage matches the nav block exactly and that is the
|
|
115
|
+
* segment decideLanguageRedirect emits.
|
|
112
116
|
*
|
|
113
117
|
* The default language is exempt from (2). It is never a redirect destination
|
|
114
118
|
* — decideLanguageRedirect returns null as soon as the negotiated language is
|
|
@@ -137,13 +141,21 @@ export function buildLanguagesArtifact(
|
|
|
137
141
|
// have silently changed which pages get translated or how chunks are
|
|
138
142
|
// locale-tagged — a much bigger blast radius than this feature.
|
|
139
143
|
const eligible = (nav?.languages ?? []).filter((l) => l.hidden !== true && !l.href);
|
|
140
|
-
|
|
144
|
+
// normalizeLanguageList decides the default and lowercases the code for its
|
|
145
|
+
// chunk-locale caller. The code is taken back from the entry it came from
|
|
146
|
+
// (same order, one-to-one): lowercased, findFirstPage misses a `pt-BR` block
|
|
147
|
+
// and the redirect would emit a `/pt-br` that does not render.
|
|
148
|
+
const entries = normalizeLanguageList(eligible).map((entry, i) => ({
|
|
149
|
+
code: eligible[i].language,
|
|
150
|
+
isDefault: entry.isDefault,
|
|
151
|
+
}));
|
|
141
152
|
|
|
142
153
|
const built = new Set(builtPagePaths.map(normalizePagePath));
|
|
143
154
|
const routable = entries.filter((entry) => {
|
|
144
155
|
if (entry.isDefault) return true;
|
|
145
156
|
const landing = normalizePagePath(findFirstPage(docsConfig, entry.code));
|
|
146
|
-
|
|
157
|
+
// normalizePagePath lowercases, so the prefix must be lowercase too.
|
|
158
|
+
return landing.startsWith(`${entry.code.toLowerCase()}/`) && built.has(landing);
|
|
147
159
|
});
|
|
148
160
|
|
|
149
161
|
if (routable.length < 2) return noRoutingDescriptor();
|
|
@@ -1344,17 +1344,30 @@ export async function customDomainOnlyBlock(args: {
|
|
|
1344
1344
|
* feature for every subpath-mounted site.
|
|
1345
1345
|
*/
|
|
1346
1346
|
export function isLocaleRootPath(pathname: string): boolean {
|
|
1347
|
+
return localeRootBase(pathname) !== null;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* The redirect base for a locale root — `''` for the site root, `'/docs'` for
|
|
1352
|
+
* the hostAtDocs root — or null for any other path. The ONE place the
|
|
1353
|
+
* trailing-slash trim lives: isLocaleRootPath gates on it and
|
|
1354
|
+
* decideLanguageRedirect builds its destination from it, so the two cannot
|
|
1355
|
+
* disagree about what a root is.
|
|
1356
|
+
*/
|
|
1357
|
+
export function localeRootBase(pathname: string): '' | '/docs' | null {
|
|
1347
1358
|
// The type says string, and the only call site (proxy.ts's `if (routing?.rewrite)`
|
|
1348
1359
|
// guard, plus the always-string `request.nextUrl.pathname` fallback) guarantees
|
|
1349
1360
|
// 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
|
|
1361
|
+
// no error boundary: a throw here 500s every docs page, while returning null just
|
|
1351
1362
|
// declines to redirect, the same fail-safe direction as every other path in this
|
|
1352
1363
|
// feature. One line of insurance against a future caller that isn't as careful.
|
|
1353
|
-
if (typeof pathname !== 'string') return
|
|
1364
|
+
if (typeof pathname !== 'string') return null;
|
|
1354
1365
|
const trimmed = pathname.endsWith('/') && pathname.length > 1
|
|
1355
1366
|
? pathname.slice(0, -1)
|
|
1356
1367
|
: pathname;
|
|
1357
|
-
|
|
1368
|
+
if (trimmed === '' || trimmed === '/') return '';
|
|
1369
|
+
if (trimmed === '/docs') return '/docs';
|
|
1370
|
+
return null;
|
|
1358
1371
|
}
|
|
1359
1372
|
|
|
1360
1373
|
/**
|
|
@@ -1508,7 +1521,8 @@ export function decideLanguageRedirect(
|
|
|
1508
1521
|
if (isBotUserAgent(userAgent)) return null;
|
|
1509
1522
|
// Bare docs root only. An explicit /<lang> root is the visitor's own stated
|
|
1510
1523
|
// language and is never redirected away from — see isLocaleRootPath.
|
|
1511
|
-
|
|
1524
|
+
const base = localeRootBase(pathname);
|
|
1525
|
+
if (base === null) return null;
|
|
1512
1526
|
if (routing.languages.length < 2) return null;
|
|
1513
1527
|
|
|
1514
1528
|
const available = routing.languages;
|
|
@@ -1525,20 +1539,16 @@ export function decideLanguageRedirect(
|
|
|
1525
1539
|
// nothing" — not "fall back to the default". An earlier draft wrote
|
|
1526
1540
|
// `?? defaultLang` here, which turned every unmatched visitor into a
|
|
1527
1541
|
// redirect to the default root.
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
if (!
|
|
1542
|
+
// Kept in the descriptor's own casing, never lowercased: it becomes the URL
|
|
1543
|
+
// segment and the cookie value, and `/pt-BR` renders where `/pt-br` does not
|
|
1544
|
+
// (isValidLanguageCode is case-sensitive). Both sources already return a
|
|
1545
|
+
// member of `available` as spelled there — cookieMatch by construction,
|
|
1546
|
+
// negotiateLanguage by contract.
|
|
1547
|
+
const chosen = cookieMatch ?? negotiateLanguage(acceptLanguage, available, defaultLang);
|
|
1548
|
+
if (!chosen) return null;
|
|
1535
1549
|
|
|
1536
1550
|
// Already where they belong: the bare root IS the default-language root.
|
|
1537
|
-
if (
|
|
1551
|
+
if (chosen.toLowerCase() === defaultLang) return null;
|
|
1538
1552
|
|
|
1539
|
-
|
|
1540
|
-
? pathname.slice(0, -1)
|
|
1541
|
-
: pathname;
|
|
1542
|
-
const base = trimmed === '/docs' ? '/docs' : '';
|
|
1543
|
-
return { pathname: `${base}/${negotiated}`, language: negotiated, viaCookie: !!cookieMatch };
|
|
1553
|
+
return { pathname: `${base}/${chosen}`, language: chosen, viaCookie: !!cookieMatch };
|
|
1544
1554
|
}
|
|
@@ -143,6 +143,14 @@ export function injectLastUpdated(content: string, date: string): string {
|
|
|
143
143
|
return `---\n${newInner}\n---\n${content.slice(match[0].length)}`;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* `YYYY-MM-DD`, optionally followed by an ISO time (`T` or a space, `HH:MM`,
|
|
148
|
+
* optional seconds and fraction, optional `Z` / `±HH:MM` / `±HHMM`). Hours
|
|
149
|
+
* 00-23, minutes/seconds 00-59.
|
|
150
|
+
*/
|
|
151
|
+
const ISO_DATE_RE =
|
|
152
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
153
|
+
|
|
146
154
|
/**
|
|
147
155
|
* Normalize a frontmatter date value to `YYYY-MM-DD`, or undefined.
|
|
148
156
|
*
|
|
@@ -152,17 +160,28 @@ export function injectLastUpdated(content: string, date: string): string {
|
|
|
152
160
|
* a Date must be formatted in UTC — rendering it raw produces a
|
|
153
161
|
* timezone-shifted `Date.toString()` inside `<time dateTime>`, the same bug
|
|
154
162
|
* injectLastUpdated() quotes its own output to avoid.
|
|
163
|
+
*
|
|
164
|
+
* Strings are matched against ISO_DATE_RE and the LITERAL date part is used —
|
|
165
|
+
* never `new Date(string)`, which parses a bare ISO date as UTC but anything
|
|
166
|
+
* else in local time (so "September 1, 2026" moved a day under a non-UTC TZ)
|
|
167
|
+
* and invents a year for "Sept 1" (2001 in V8). Impossible calendar dates
|
|
168
|
+
* (2026-02-30) are rejected rather than rolled over. Anything rejected returns
|
|
169
|
+
* undefined, so resolveLastUpdated falls back to the git date.
|
|
155
170
|
*/
|
|
156
171
|
function normalizeDateValue(value: unknown): string | undefined {
|
|
157
172
|
if (value instanceof Date) {
|
|
158
173
|
return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
|
|
159
174
|
}
|
|
160
175
|
if (typeof value !== 'string') return undefined;
|
|
161
|
-
const
|
|
162
|
-
if (!
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
const match = ISO_DATE_RE.exec(value.trim());
|
|
177
|
+
if (!match) return undefined;
|
|
178
|
+
const [, year, month, day] = match;
|
|
179
|
+
const utc = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
180
|
+
const isRealDate =
|
|
181
|
+
utc.getUTCFullYear() === Number(year) &&
|
|
182
|
+
utc.getUTCMonth() === Number(month) - 1 &&
|
|
183
|
+
utc.getUTCDate() === Number(day);
|
|
184
|
+
return isRealDate ? `${year}-${month}-${day}` : undefined;
|
|
166
185
|
}
|
|
167
186
|
|
|
168
187
|
/**
|
|
@@ -87,7 +87,7 @@ function parseShowLineNumbers(meta: string): boolean {
|
|
|
87
87
|
* with any quoted title stripped first so `title="My nocopy guide"` (the word
|
|
88
88
|
* appearing inside free-text title, not as the flag) does not trigger it either.
|
|
89
89
|
*/
|
|
90
|
-
function parseNocopy(meta: string): boolean {
|
|
90
|
+
export function parseNocopy(meta: string): boolean {
|
|
91
91
|
return /\bnocopy\b/.test(meta.replace(/title=(["'])(?:(?!\1).)*\1/g, ''));
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -290,18 +290,24 @@ export const rehypeRestoreDataTitle: Plugin<[], Root> = () => {
|
|
|
290
290
|
const dataMeta = node.properties['data-meta'] as string | undefined;
|
|
291
291
|
const dataLanguage = node.properties['data-language'] as string | undefined;
|
|
292
292
|
|
|
293
|
-
if (!dataMeta
|
|
293
|
+
if (!dataMeta) return;
|
|
294
294
|
|
|
295
|
-
// An explicit `title="..."` always wins
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
295
|
+
// An explicit `title="..."` always wins — checked BEFORE the colon
|
|
296
|
+
// guard below, or `title="Step 1: config.ts"` lost its caption.
|
|
297
|
+
// Promote the PARSED value, never the raw `data-meta` string — using
|
|
298
|
+
// raw meta here shipped `title="/snippets/counter.tsx"` as the
|
|
299
|
+
// literal on-page caption instead of `/snippets/counter.tsx`
|
|
300
|
+
// (pre-existing since 7ed2dfe26).
|
|
299
301
|
const explicitTitle = parseTitle(dataMeta);
|
|
300
302
|
if (explicitTitle) {
|
|
301
303
|
node.properties['data-title'] = explicitTitle;
|
|
302
304
|
return;
|
|
303
305
|
}
|
|
304
306
|
|
|
307
|
+
// Colon-bearing meta without an explicit title looks like a status
|
|
308
|
+
// code (`200: OK`), not a caption — leave it alone.
|
|
309
|
+
if (dataMeta.includes(':')) return;
|
|
310
|
+
|
|
305
311
|
// Strip nocopy BEFORE classifying, not after: `JavaScript nocopy`
|
|
306
312
|
// must still read as the "javascript" language label. Classifying
|
|
307
313
|
// the raw meta first would test "JavaScript nocopy" against
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
transformerMetaHighlight,
|
|
13
13
|
transformerNotationDiff,
|
|
14
14
|
} from '@shikijs/transformers';
|
|
15
|
+
import { parseNocopy } from './rehype-code-meta';
|
|
15
16
|
|
|
16
17
|
// Class names for CSS styling
|
|
17
18
|
export const LINE_HIGHLIGHT_CLASS = 'highlighted';
|
|
@@ -51,11 +52,9 @@ function getParsedMeta(meta: string): ParsedMeta {
|
|
|
51
52
|
showLineNumbers: /\bshowLineNumbers\b/.test(meta),
|
|
52
53
|
startLine: parseStartLineImpl(meta),
|
|
53
54
|
title: parseTitleImpl(meta),
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
// caption mangled by the strip in rehypeRestoreDataTitle.
|
|
58
|
-
nocopy: /\bnocopy\b/.test(meta.replace(/title=(["'])(?:(?!\1).)*\1/g, '')),
|
|
55
|
+
// Shared with rehypeCodeMeta/rehypeRestoreDataTitle so the pre-Shiki and
|
|
56
|
+
// post-Shiki stages cannot disagree about which fences are flagged.
|
|
57
|
+
nocopy: parseNocopy(meta),
|
|
59
58
|
};
|
|
60
59
|
|
|
61
60
|
// Prevent unbounded cache growth
|