jamdesk 1.1.41 → 1.1.43
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/README.md +22 -0
- package/dist/__tests__/unit/dev-validate-cache.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-validate-cache.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-validate-cache.test.js +116 -0
- package/dist/__tests__/unit/dev-validate-cache.test.js.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +105 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +95 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +4 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/commands/dev.d.ts +48 -3
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +134 -41
- package/dist/commands/dev.js.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/language-filter.d.ts +32 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +28 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/package.json +1 -1
- package/vendored/app/[[...slug]]/page.tsx +41 -28
- package/vendored/app/api/isr-health/route.ts +6 -4
- package/vendored/components/mdx/StepSlugContext.tsx +57 -0
- package/vendored/components/mdx/Steps.tsx +2 -2
- package/vendored/components/navigation/TableOfContents.tsx +77 -5
- package/vendored/lib/cache-tags.ts +25 -0
- package/vendored/lib/cache-utils.ts +19 -0
- package/vendored/lib/heading-extractor.ts +25 -6
- package/vendored/lib/indexnow.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +103 -15
- package/vendored/lib/navigation-resolver.ts +1 -1
- package/vendored/lib/openapi-isr.ts +13 -8
- package/vendored/lib/r2-cleanup.ts +70 -0
- package/vendored/lib/r2-content.ts +0 -24
- package/vendored/lib/r2-manifest.ts +13 -3
- package/vendored/lib/revalidation-helpers.ts +41 -11
- package/vendored/lib/revalidation-trigger.ts +104 -28
- package/vendored/lib/scanner-blocklist.ts +265 -0
- package/vendored/lib/snippet-compiler-isr.ts +5 -2
- package/vendored/scripts/validate-links.cjs +17 -6
- package/vendored/workspace-package-lock.json +12 -12
- package/vendored/lib/cache-keys.ts +0 -117
|
@@ -44,6 +44,8 @@ import { getTypographyRemarkPlugins } from '@/lib/typography-config';
|
|
|
44
44
|
import { remarkVisibility } from '@/lib/remark-visibility';
|
|
45
45
|
import { recmaCompoundComponents } from '@/lib/recma-compound-components';
|
|
46
46
|
import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
|
|
47
|
+
import { extractHeadings } from '@/lib/heading-extractor';
|
|
48
|
+
import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
|
|
47
49
|
import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
|
|
48
50
|
import { mdxSecurityOptions } from '@/lib/mdx-security-options';
|
|
49
51
|
import fs from 'fs';
|
|
@@ -435,6 +437,13 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
435
437
|
// (snippets are injected globally via AllComponents)
|
|
436
438
|
const content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
|
|
437
439
|
|
|
440
|
+
// Pre-resolve unique slugs for every <Step> on the page so duplicate titles
|
|
441
|
+
// across separate <Steps> blocks get -2, -3, ... suffixes that match the
|
|
442
|
+
// build-time TOC. Provider supplies the list to <Step> via context.
|
|
443
|
+
const stepEntries: StepSlugEntry[] = extractHeadings(content)
|
|
444
|
+
.filter(h => typeof h.stepNumber === 'number')
|
|
445
|
+
.map(h => ({ title: h.text, slug: h.id }));
|
|
446
|
+
|
|
438
447
|
// Extract and compile inline component exports from MDX
|
|
439
448
|
// Only pass MDXComponents (server-compatible) to inline extraction
|
|
440
449
|
const { inlineComponents, paramFields } = await extractInlineComponents(content, MDXComponents);
|
|
@@ -708,21 +717,23 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
708
717
|
{/* Additional MDX content — strip <ResponseExample> on OpenAPI pages
|
|
709
718
|
(auto-generated ResponseExamplePanel already handles responses) */}
|
|
710
719
|
<ImagePriorityProvider>
|
|
711
|
-
<
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
720
|
+
<StepSlugProvider entries={stepEntries}>
|
|
721
|
+
<MDXRemote
|
|
722
|
+
source={openApiEndpointData
|
|
723
|
+
? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
|
|
724
|
+
: content}
|
|
725
|
+
components={AllComponentsWithInline}
|
|
726
|
+
options={{
|
|
727
|
+
// Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
|
|
728
|
+
...mdxSecurityOptions,
|
|
729
|
+
mdxOptions: {
|
|
730
|
+
remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
731
|
+
rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
|
|
732
|
+
recmaPlugins: [recmaCompoundComponents],
|
|
733
|
+
},
|
|
734
|
+
}}
|
|
735
|
+
/>
|
|
736
|
+
</StepSlugProvider>
|
|
726
737
|
</ImagePriorityProvider>
|
|
727
738
|
</div>
|
|
728
739
|
|
|
@@ -736,19 +747,21 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
736
747
|
|
|
737
748
|
// MDX content for non-API pages (extracted for conditional ViewWrapper wrapping)
|
|
738
749
|
const mdxContent = (
|
|
739
|
-
<
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
750
|
+
<StepSlugProvider entries={stepEntries}>
|
|
751
|
+
<MDXRemote
|
|
752
|
+
source={content}
|
|
753
|
+
components={AllComponentsWithInline}
|
|
754
|
+
options={{
|
|
755
|
+
// Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
|
|
756
|
+
...mdxSecurityOptions,
|
|
757
|
+
mdxOptions: {
|
|
758
|
+
remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
759
|
+
rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
|
|
760
|
+
recmaPlugins: [recmaCompoundComponents],
|
|
761
|
+
},
|
|
762
|
+
}}
|
|
763
|
+
/>
|
|
764
|
+
</StepSlugProvider>
|
|
752
765
|
);
|
|
753
766
|
|
|
754
767
|
// Shared article content for non-API pages
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { NextResponse } from 'next/server';
|
|
9
|
-
import { getConfigCacheSize } from '@/lib/r2-content';
|
|
10
9
|
import { getSnippetCacheSize } from '@/lib/snippet-compiler-isr';
|
|
11
10
|
import { getOpenApiCacheSize } from '@/lib/openapi-isr';
|
|
12
11
|
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
@@ -14,7 +13,9 @@ import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
|
14
13
|
interface HealthCheck {
|
|
15
14
|
status: 'ok' | 'degraded' | 'unhealthy';
|
|
16
15
|
cache: {
|
|
17
|
-
configEntries
|
|
16
|
+
// configEntries removed — config is now in Next.js Data Cache (unstable_cache),
|
|
17
|
+
// which is opaque from inside the lambda. Use cacheLayer field instead.
|
|
18
|
+
cacheLayer: string;
|
|
18
19
|
snippetEntries: number;
|
|
19
20
|
openApiEntries: number;
|
|
20
21
|
};
|
|
@@ -40,9 +41,10 @@ export async function GET() {
|
|
|
40
41
|
heapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
|
|
41
42
|
};
|
|
42
43
|
|
|
43
|
-
// Cache stats
|
|
44
|
+
// Cache stats — config/mdx/snippet/openapi raw content now in unstable_cache (opaque).
|
|
45
|
+
// snippetEntries and openApiEntries reflect the in-memory parsed-object layers only.
|
|
44
46
|
const cache = {
|
|
45
|
-
|
|
47
|
+
cacheLayer: 'unstable_cache',
|
|
46
48
|
snippetEntries: getSnippetCacheSize(),
|
|
47
49
|
openApiEntries: getOpenApiCacheSize(),
|
|
48
50
|
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useRef, ReactNode } from 'react';
|
|
4
|
+
import { generateSlug } from '@/lib/heading-extractor';
|
|
5
|
+
|
|
6
|
+
export interface StepSlugEntry {
|
|
7
|
+
title: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StepSlugContextValue {
|
|
12
|
+
// Per-title counter — advances each time useStepSlug is called for that title.
|
|
13
|
+
counts: Map<string, number>;
|
|
14
|
+
entries: StepSlugEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const StepSlugCtx = createContext<StepSlugContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export function StepSlugProvider({
|
|
20
|
+
entries,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
entries: StepSlugEntry[];
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
// useRef so the same Map persists across renders. We rely on the same
|
|
27
|
+
// contract React's own useId rests on: source-order rendering during SSR
|
|
28
|
+
// and during client hydration produces matching ids.
|
|
29
|
+
const ref = useRef<StepSlugContextValue | null>(null);
|
|
30
|
+
if (ref.current === null || ref.current.entries !== entries) {
|
|
31
|
+
ref.current = { counts: new Map(), entries };
|
|
32
|
+
}
|
|
33
|
+
return <StepSlugCtx.Provider value={ref.current}>{children}</StepSlugCtx.Provider>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the unique slug for a Step with this title. Each call advances the
|
|
38
|
+
* per-title counter, so two Steps with the same title get sequential slugs
|
|
39
|
+
* from the provider's `entries` list. Falls back to `generateSlug(title)` if
|
|
40
|
+
* no provider is mounted (preview/storybook/etc).
|
|
41
|
+
*/
|
|
42
|
+
export function useStepSlug(title: string | undefined): string | undefined {
|
|
43
|
+
const ctx = useContext(StepSlugCtx);
|
|
44
|
+
if (!title) return undefined;
|
|
45
|
+
if (!ctx) return generateSlug(title) || undefined;
|
|
46
|
+
|
|
47
|
+
const idx = ctx.counts.get(title) ?? 0;
|
|
48
|
+
ctx.counts.set(title, idx + 1);
|
|
49
|
+
|
|
50
|
+
let seen = 0;
|
|
51
|
+
for (const entry of ctx.entries) {
|
|
52
|
+
if (entry.title !== title) continue;
|
|
53
|
+
if (seen === idx) return entry.slug;
|
|
54
|
+
seen += 1;
|
|
55
|
+
}
|
|
56
|
+
return generateSlug(title) || undefined;
|
|
57
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ReactNode, Children, cloneElement, isValidElement, memo } from 'react';
|
|
4
4
|
import { getIconClass } from '@/lib/icon-utils';
|
|
5
|
-
import {
|
|
5
|
+
import { useStepSlug } from './StepSlugContext';
|
|
6
6
|
|
|
7
7
|
type TitleSize = 'p' | 'h2' | 'h3';
|
|
8
8
|
type IconType = 'regular' | 'solid' | 'light' | 'thin' | 'sharp-solid' | 'duotone' | 'brands';
|
|
@@ -130,7 +130,7 @@ function StepTitle({ title, titleSize }: { title: string; titleSize: TitleSize }
|
|
|
130
130
|
|
|
131
131
|
export const Step = memo(function Step({ title, children, stepNumber, isLast, icon, iconType, titleSize = 'p' }: StepProps) {
|
|
132
132
|
const containerClassName = isLast ? 'relative pb-0' : 'relative pb-8';
|
|
133
|
-
const slug =
|
|
133
|
+
const slug = useStepSlug(title);
|
|
134
134
|
|
|
135
135
|
// Emit both attrs together or neither — the DOM scanner keys off
|
|
136
136
|
// `data-step-number`, so a label without a number would be a dangling
|
|
@@ -53,6 +53,31 @@ function getOffsetRelativeTo(element: HTMLElement, container: HTMLElement): numb
|
|
|
53
53
|
return offset;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Breathing-room buffer at top/bottom edges of the TOC scroll container so
|
|
57
|
+
// the active item doesn't sit flush against the edge after auto-scrolling.
|
|
58
|
+
// 80px lines up with the TOC's `py-6 sm:py-10` outer padding (PageColumns.tsx
|
|
59
|
+
// wrapper); update both together if that padding changes.
|
|
60
|
+
const TOC_SCROLL_PAD = 80;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk up from `start` looking for the nearest ancestor whose computed
|
|
64
|
+
* overflow-y is `auto` or `scroll`. Returns null if none is found before
|
|
65
|
+
* reaching <body>. The TOC lives inside `<aside class="toc-scroll">` which
|
|
66
|
+
* has `xl:overflow-y-auto`; this helper finds it without hard-coding a
|
|
67
|
+
* selector so the component remains reusable in other layouts.
|
|
68
|
+
*/
|
|
69
|
+
function findScrollableAncestor(start: HTMLElement): HTMLElement | null {
|
|
70
|
+
let el: HTMLElement | null = start.parentElement;
|
|
71
|
+
while (el && el !== document.body) {
|
|
72
|
+
const cs = getComputedStyle(el);
|
|
73
|
+
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
|
|
74
|
+
return el;
|
|
75
|
+
}
|
|
76
|
+
el = el.parentElement;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
/**
|
|
57
82
|
* Calculate the thumb position (top + height) spanning all active anchors.
|
|
58
83
|
*/
|
|
@@ -105,6 +130,53 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
105
130
|
updateThumb();
|
|
106
131
|
}, [updateThumb]);
|
|
107
132
|
|
|
133
|
+
// Keep the active TOC item visible inside the TOC's own scroll container
|
|
134
|
+
// (the right-side `<aside class="toc-scroll">` is independent of the center
|
|
135
|
+
// content scroll, so it doesn't follow the user's reading position on its
|
|
136
|
+
// own). When `activeAnchors` changes, find the first active link, locate
|
|
137
|
+
// the nearest scrollable ancestor, and nudge ONLY that ancestor's scrollTop
|
|
138
|
+
// — never `scrollIntoView`, which would also scroll <main>/window and yank
|
|
139
|
+
// the user's reading position in the center column.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (activeAnchors.length === 0) return;
|
|
142
|
+
const container = containerRef.current;
|
|
143
|
+
if (!container) return;
|
|
144
|
+
|
|
145
|
+
const firstActiveId = activeAnchors[0];
|
|
146
|
+
const link = container.querySelector<HTMLElement>(
|
|
147
|
+
`a[href="#${firstActiveId}"]`,
|
|
148
|
+
);
|
|
149
|
+
if (!link) return;
|
|
150
|
+
|
|
151
|
+
const scroller = findScrollableAncestor(container);
|
|
152
|
+
if (!scroller) return;
|
|
153
|
+
|
|
154
|
+
const linkRect = link.getBoundingClientRect();
|
|
155
|
+
// Bail out if the link hasn't laid out yet. Two real cases this catches:
|
|
156
|
+
// 1. First commit before the browser has measured boxes (or jsdom's
|
|
157
|
+
// default 0×0 rect in unit tests).
|
|
158
|
+
// 2. Sub-xl breakpoints where the TOC's parent <aside class="hidden
|
|
159
|
+
// xl:block"> is `display: none` — TableOfContents is still mounted
|
|
160
|
+
// but every descendant returns 0×0 rects. Without this guard, the
|
|
161
|
+
// ancestor walker below would pick `<main>` (which has
|
|
162
|
+
// overflow-y:auto in our layout) and scroll the page itself.
|
|
163
|
+
if (linkRect.width === 0 && linkRect.height === 0) return;
|
|
164
|
+
|
|
165
|
+
const scrollerRect = scroller.getBoundingClientRect();
|
|
166
|
+
// If the scroller is shorter than 2× the padding, the "already visible"
|
|
167
|
+
// window `[top + pad, bottom - pad]` collapses to nothing and both branches
|
|
168
|
+
// below would fire on alternating ticks, oscillating scrollTop. Bail out
|
|
169
|
+
// — the scroller is too short to position usefully anyway. Reachable on
|
|
170
|
+
// 200%-zoomed iframe-embedded docs viewers (~220px aside).
|
|
171
|
+
if (scrollerRect.height < TOC_SCROLL_PAD * 2) return;
|
|
172
|
+
|
|
173
|
+
if (linkRect.top < scrollerRect.top + TOC_SCROLL_PAD) {
|
|
174
|
+
scroller.scrollTop += linkRect.top - scrollerRect.top - TOC_SCROLL_PAD;
|
|
175
|
+
} else if (linkRect.bottom > scrollerRect.bottom - TOC_SCROLL_PAD) {
|
|
176
|
+
scroller.scrollTop += linkRect.bottom - scrollerRect.bottom + TOC_SCROLL_PAD;
|
|
177
|
+
}
|
|
178
|
+
}, [activeAnchors]);
|
|
179
|
+
|
|
108
180
|
// Regenerate SVG path on headings change + resize
|
|
109
181
|
useEffect(() => {
|
|
110
182
|
const container = containerRef.current;
|
|
@@ -495,7 +567,7 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
495
567
|
}
|
|
496
568
|
};
|
|
497
569
|
|
|
498
|
-
// 30px leaves room for the absolute-positioned
|
|
570
|
+
// 30px leaves room for the absolute-positioned 14px step circle
|
|
499
571
|
// (insetInlineStart: 1) + a gap. Non-step H3s use the same value
|
|
500
572
|
// so mixed step/non-step groups align visually.
|
|
501
573
|
const startOffset = heading.level === 3
|
|
@@ -518,13 +590,13 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
518
590
|
{hasStepNumber && (
|
|
519
591
|
<span
|
|
520
592
|
aria-hidden="true"
|
|
521
|
-
className="absolute flex items-center justify-center rounded-full text-[
|
|
593
|
+
className="absolute flex items-center justify-center rounded-full text-[9px] font-medium"
|
|
522
594
|
style={{
|
|
523
|
-
insetInlineStart:
|
|
595
|
+
insetInlineStart: 3,
|
|
524
596
|
top: '50%',
|
|
525
597
|
transform: 'translateY(-50%)',
|
|
526
|
-
width:
|
|
527
|
-
height:
|
|
598
|
+
width: 14,
|
|
599
|
+
height: 14,
|
|
528
600
|
backgroundColor: isActive
|
|
529
601
|
? 'var(--color-primary)'
|
|
530
602
|
: 'var(--color-bg-primary)',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache tag helpers for unstable_cache + revalidateTag (Next.js Data Cache).
|
|
3
|
+
*
|
|
4
|
+
* Tag shape (keep stable — referenced by /api/revalidate callers in
|
|
5
|
+
* build-service Cloud Run and by revalidation-helpers.ts invalidators):
|
|
6
|
+
*
|
|
7
|
+
* project:<slug> invalidates ALL data for a project
|
|
8
|
+
* config:<slug> invalidates only the docs.json config
|
|
9
|
+
* mdx:<slug>:<path> invalidates one page
|
|
10
|
+
* snippet:<slug>:<path> invalidates one snippet
|
|
11
|
+
* openapi:<slug>:<path> invalidates one OpenAPI spec
|
|
12
|
+
* manifest:<slug> invalidates the project path manifest
|
|
13
|
+
* static:<slug>:<filename> invalidates one static file (custom.css, custom.js)
|
|
14
|
+
*
|
|
15
|
+
* Not related to the `project-<slug>` hyphen-tagged CDN header set by
|
|
16
|
+
* app/api/r2/[project]/[...path]/route.ts — that's a separate edge-cache
|
|
17
|
+
* namespace on the R2 binary route and does not intersect with these.
|
|
18
|
+
*/
|
|
19
|
+
export const projectCacheTag = (slug: string): string => `project:${slug}`;
|
|
20
|
+
export const configCacheTag = (slug: string): string => `config:${slug}`;
|
|
21
|
+
export const mdxCacheTag = (slug: string, path: string): string => `mdx:${slug}:${path}`;
|
|
22
|
+
export const snippetCacheTag = (slug: string, path: string): string => `snippet:${slug}:${path}`;
|
|
23
|
+
export const openapiCacheTag = (slug: string, path: string): string => `openapi:${slug}:${path}`;
|
|
24
|
+
export const manifestCacheTag = (slug: string): string => `manifest:${slug}`;
|
|
25
|
+
export const staticCacheTag = (slug: string, filename: string): string => `static:${slug}:${filename}`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure in-memory cache helpers. Kept in a standalone module so client
|
|
3
|
+
* bundles can import it without pulling in server-only `next/cache` APIs
|
|
4
|
+
* via r2-content.ts (Turbopack rejects transitive imports of
|
|
5
|
+
* `revalidateTag` into client components).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function evictOldest<K, V extends { timestamp: number }>(
|
|
9
|
+
cache: Map<K, V>,
|
|
10
|
+
maxSize: number
|
|
11
|
+
): void {
|
|
12
|
+
if (cache.size <= maxSize) return;
|
|
13
|
+
|
|
14
|
+
const entries = [...cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
15
|
+
const toDelete = entries.slice(0, cache.size - maxSize);
|
|
16
|
+
for (const [key] of toDelete) {
|
|
17
|
+
cache.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -26,6 +26,19 @@ export function generateSlug(text: string): string {
|
|
|
26
26
|
.replace(/^-+|-+$/g, '');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Suffix `base` with -2, -3, ... if it has been seen before. First occurrence
|
|
31
|
+
* keeps the bare slug for backwards compatibility with existing inbound links.
|
|
32
|
+
* Mutates `seen` in place — caller owns the lifetime (one Map per page).
|
|
33
|
+
*
|
|
34
|
+
* Mirrored in scripts/validate-links.cjs — keep both in sync.
|
|
35
|
+
*/
|
|
36
|
+
export function uniquifySlug(seen: Map<string, number>, base: string): string {
|
|
37
|
+
const count = seen.get(base) ?? 0;
|
|
38
|
+
seen.set(base, count + 1);
|
|
39
|
+
return count === 0 ? base : `${base}-${count + 1}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
|
|
30
43
|
const FENCE_REGEX = /^(`{3,}|~{3,})/;
|
|
31
44
|
const UPDATE_LABEL_REGEX = /<Update\s+label=["']([^"']+)["']/;
|
|
@@ -54,6 +67,9 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
54
67
|
// block can't stick the counter across subsequent blocks.
|
|
55
68
|
let inStepsBlock = false;
|
|
56
69
|
let stepCounter = 0;
|
|
70
|
+
// Page-scoped slug counter so duplicate titles (across H2s, Update labels,
|
|
71
|
+
// and Steps) get -2, -3, ... suffixes in source order.
|
|
72
|
+
const seenSlugs = new Map<string, number>();
|
|
57
73
|
|
|
58
74
|
for (let i = 0; i < lines.length; i++) {
|
|
59
75
|
const line = lines[i];
|
|
@@ -85,8 +101,9 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
85
101
|
if (headingMatch) {
|
|
86
102
|
const level = headingMatch[1].length;
|
|
87
103
|
const text = headingMatch[2].trim();
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
104
|
+
const base = generateSlug(text);
|
|
105
|
+
if (base) {
|
|
106
|
+
const id = uniquifySlug(seenSlugs, base);
|
|
90
107
|
headings.push({ id, text, level, line: i + 1 });
|
|
91
108
|
}
|
|
92
109
|
}
|
|
@@ -94,8 +111,9 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
94
111
|
const updateMatch = line.match(UPDATE_LABEL_REGEX);
|
|
95
112
|
if (updateMatch) {
|
|
96
113
|
const text = updateMatch[1];
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
114
|
+
const base = generateSlug(text);
|
|
115
|
+
if (base) {
|
|
116
|
+
const id = uniquifySlug(seenSlugs, base);
|
|
99
117
|
headings.push({ id, text, level: 2, line: i + 1 });
|
|
100
118
|
}
|
|
101
119
|
}
|
|
@@ -103,9 +121,10 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
103
121
|
if (inStepsBlock) {
|
|
104
122
|
for (const match of line.matchAll(STEP_TITLE_REGEX)) {
|
|
105
123
|
const text = match[1] ?? match[2];
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
124
|
+
const base = generateSlug(text);
|
|
125
|
+
if (!base) continue;
|
|
108
126
|
stepCounter += 1;
|
|
127
|
+
const id = uniquifySlug(seenSlugs, base);
|
|
109
128
|
headings.push({
|
|
110
129
|
id,
|
|
111
130
|
text,
|
package/vendored/lib/indexnow.ts
CHANGED
|
@@ -35,7 +35,7 @@ export function buildChangedUrls(
|
|
|
35
35
|
if (changedPaths.length === 0) return [];
|
|
36
36
|
const prefix = hostAtDocs ? '/docs' : '';
|
|
37
37
|
return changedPaths.map((p) => {
|
|
38
|
-
// Defensive:
|
|
38
|
+
// Defensive: callers strip the .mdx extension, but guard against raw file paths
|
|
39
39
|
const clean = p.replace(/\.mdx?$/, '');
|
|
40
40
|
return `${baseUrl}${prefix}/${clean}`;
|
|
41
41
|
});
|
|
@@ -27,6 +27,89 @@ import type { NextRequest } from 'next/server';
|
|
|
27
27
|
*/
|
|
28
28
|
export const VALID_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Mangle a request pathname so it routes into the (docs)/[project] sub-tree.
|
|
32
|
+
* The proxy applies this rewrite internally; user-visible URLs are unchanged.
|
|
33
|
+
*
|
|
34
|
+
* Slug must be URL-safe — VALID_SLUG_RE catches that upstream, so we
|
|
35
|
+
* fail loud here if something slipped through.
|
|
36
|
+
*/
|
|
37
|
+
export function injectProjectSlugIntoPath(
|
|
38
|
+
pathname: string,
|
|
39
|
+
projectSlug: string,
|
|
40
|
+
_hostAtDocs: boolean,
|
|
41
|
+
): string {
|
|
42
|
+
if (!pathname || !pathname.startsWith('/')) {
|
|
43
|
+
throw new Error(`injectProjectSlugIntoPath: invalid pathname '${pathname}'`);
|
|
44
|
+
}
|
|
45
|
+
if (!VALID_SLUG_RE.test(projectSlug)) {
|
|
46
|
+
throw new Error(`injectProjectSlugIntoPath: invalid slug '${projectSlug}'`);
|
|
47
|
+
}
|
|
48
|
+
// hostAtDocs note: the user URL has /docs prefix; we still inject [project]
|
|
49
|
+
// BEFORE everything else so the route is /[project]/docs/<rest>. The
|
|
50
|
+
// existing (docs)/[project]/[[...slug]] catch-all handles either shape.
|
|
51
|
+
if (pathname === '/') return `/${projectSlug}/`;
|
|
52
|
+
return `/${projectSlug}${pathname}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Names of static endpoint route handlers that live at the bare app/<name>/route.ts
|
|
57
|
+
* (and their app/docs/<name>/route.ts twins for hostAtDocs sites). They render
|
|
58
|
+
* project-aware output, but do so by reading x-project-slug headers — they are
|
|
59
|
+
* NOT inside the (docs)/[project] sub-tree, so rewriting their URL to inject a
|
|
60
|
+
* /[project]/ prefix would route to a path that doesn't exist and 404.
|
|
61
|
+
*
|
|
62
|
+
* KEEP IN SYNC with the actual files on disk: every app/<name>/route.ts must
|
|
63
|
+
* appear here. The Step 5 verification grep guards against drift.
|
|
64
|
+
*/
|
|
65
|
+
export const STATIC_ENDPOINT_NAMES = [
|
|
66
|
+
'sitemap.xml',
|
|
67
|
+
'llms.txt',
|
|
68
|
+
'llms-full.txt',
|
|
69
|
+
'robots.txt',
|
|
70
|
+
'feed.xml',
|
|
71
|
+
'docs.json',
|
|
72
|
+
'search-data.json',
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
/** Precomputed path set so the hot-path check avoids per-call template allocs. */
|
|
76
|
+
const STATIC_ENDPOINT_PATHS = new Set<string>(
|
|
77
|
+
STATIC_ENDPOINT_NAMES.flatMap((n) => [`/${n}`, `/docs/${n}`]),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Roots that bypass the (docs) rewrite — both `/X` exact and `/X/...` prefix. */
|
|
81
|
+
const SYSTEM_PATH_ROOTS = [
|
|
82
|
+
'/_next', // Next.js asset traffic — most-common bypass for docs sites
|
|
83
|
+
'/api',
|
|
84
|
+
'/_jd',
|
|
85
|
+
'/.well-known',
|
|
86
|
+
'/jd/unlock',
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
/** True if pathname is exactly `root` or lives under `root/`. */
|
|
90
|
+
export function isPathOrUnder(pathname: string, root: string): boolean {
|
|
91
|
+
return pathname === root || pathname.startsWith(root + '/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return true when a pathname should be internally rewritten into the
|
|
96
|
+
* (docs)/[project] route group. Stays outside the group for:
|
|
97
|
+
* - SYSTEM_PATH_ROOTS (api routes, Next assets, _jd, .well-known, unlock)
|
|
98
|
+
* - STATIC_ENDPOINT_NAMES files (sitemap.xml, llms.txt, robots.txt, feed.xml,
|
|
99
|
+
* docs.json, search-data.json — and their
|
|
100
|
+
* /docs/<name> twins for hostAtDocs sites)
|
|
101
|
+
*
|
|
102
|
+
* MDX pages under /docs/<slug> (hostAtDocs MDX content) ARE rewritten — only
|
|
103
|
+
* the named static endpoints above are excluded.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldRewriteIntoDocsGroup(pathname: string): boolean {
|
|
106
|
+
if (STATIC_ENDPOINT_PATHS.has(pathname)) return false;
|
|
107
|
+
for (const root of SYSTEM_PATH_ROOTS) {
|
|
108
|
+
if (isPathOrUnder(pathname, root)) return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
30
113
|
/**
|
|
31
114
|
* Result of project resolution.
|
|
32
115
|
*/
|
|
@@ -311,19 +394,26 @@ export async function checkRedirects(
|
|
|
311
394
|
* - /api/ogimage → NOT skipped (could be a doc page)
|
|
312
395
|
*/
|
|
313
396
|
export const INTERNAL_API_ROUTES = [
|
|
314
|
-
'/api/assets',
|
|
315
|
-
'/api/
|
|
316
|
-
'/api/indexnow', // IndexNow key verification (app/api/indexnow/[key])
|
|
317
|
-
'/api/isr-health', // Health check endpoint (app/api/isr-health)
|
|
318
|
-
'/api/chat', // Chat endpoint (app/api/chat/[project])
|
|
397
|
+
'/api/assets', // Asset serving from R2 (app/api/assets/[...path])
|
|
398
|
+
'/api/chat', // Chat endpoint (app/api/chat/[project]) — gated explicitly in proxy.ts
|
|
319
399
|
'/api/docs-search', // Docs Search API (app/api/docs-search/[project]/search)
|
|
320
|
-
'/api/
|
|
321
|
-
'/api/
|
|
322
|
-
'/api/
|
|
323
|
-
'/api/
|
|
324
|
-
'/api/
|
|
325
|
-
'/api/
|
|
326
|
-
]
|
|
400
|
+
'/api/ev', // Analytics events (app/api/ev)
|
|
401
|
+
'/api/indexnow', // IndexNow key verification (app/api/indexnow/[key])
|
|
402
|
+
'/api/isr-health', // Health check endpoint (app/api/isr-health)
|
|
403
|
+
'/api/mcp', // MCP endpoint (app/api/mcp/[project]) — gated explicitly in proxy.ts
|
|
404
|
+
'/api/og', // OG image generation (app/api/og)
|
|
405
|
+
'/api/playground', // API playground (token, proxy, demo) — must skip hostAtDocs redirect
|
|
406
|
+
'/api/r2', // R2 content serving (app/api/r2/[project]/[...path]) — gated explicitly in proxy.ts
|
|
407
|
+
'/api/revalidate', // Cache revalidation (app/api/revalidate)
|
|
408
|
+
'/api/search-ev', // Search analytics proxy (app/api/search-ev)
|
|
409
|
+
// NOTE: /api/jd is intentionally NOT here — /api/jd/unlock reads
|
|
410
|
+
// x-project-slug + x-host-at-docs headers that middleware sets.
|
|
411
|
+
// NOTE: /api/markdown-export is intentionally NOT here either — it serves
|
|
412
|
+
// project MDX, which on password-protected sites must hit the implicit
|
|
413
|
+
// auth gate at proxy.ts:362. Bypassing middleware would leak content.
|
|
414
|
+
// /api/raw-content is also kept off the list (dev-only; throws in ISR
|
|
415
|
+
// mode anyway, so adding it would be a no-op with worse documentation).
|
|
416
|
+
] as const;
|
|
327
417
|
|
|
328
418
|
/**
|
|
329
419
|
* Check if path is an internal API route.
|
|
@@ -338,9 +428,7 @@ export const INTERNAL_API_ROUTES = [
|
|
|
338
428
|
* isInternalApiRoute('/api/users/list') // false (doc page)
|
|
339
429
|
*/
|
|
340
430
|
export function isInternalApiRoute(pathname: string): boolean {
|
|
341
|
-
return INTERNAL_API_ROUTES.some(route =>
|
|
342
|
-
pathname === route || pathname.startsWith(route + '/')
|
|
343
|
-
);
|
|
431
|
+
return INTERNAL_API_ROUTES.some((route) => isPathOrUnder(pathname, route));
|
|
344
432
|
}
|
|
345
433
|
|
|
346
434
|
/**
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
|
|
23
23
|
import { normalizeNavPage, getIconName } from './docs-types';
|
|
24
24
|
import { getLanguageDisplayInfo, extractLanguageFromPath } from './language-utils';
|
|
25
|
-
import { evictOldest } from './
|
|
25
|
+
import { evictOldest } from './cache-utils';
|
|
26
26
|
|
|
27
27
|
// =============================================================================
|
|
28
28
|
// TYPE GUARDS
|
|
@@ -94,16 +94,21 @@ export async function resolveOpenApiSpec(
|
|
|
94
94
|
return fetchOpenApiSpecFromR2(projectSlug, specRef);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Clear the in-memory parsed-spec Map (covers external-URL specs not wrapped
|
|
99
|
+
* in unstable_cache). Does NOT emit a project: revalidateTag — that would
|
|
100
|
+
* discard config/mdx/snippet/static entries unrelated to OpenAPI; the caller
|
|
101
|
+
* (executeRevalidation) dispatches the project tag explicitly when needed.
|
|
102
|
+
*/
|
|
98
103
|
export function clearOpenApiCache(projectSlug?: string): void {
|
|
99
|
-
if (projectSlug) {
|
|
100
|
-
for (const key of specCache.keys()) {
|
|
101
|
-
if (key.startsWith(`r2:${projectSlug}:`)) {
|
|
102
|
-
specCache.delete(key);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
} else {
|
|
104
|
+
if (!projectSlug) {
|
|
106
105
|
specCache.clear();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const key of specCache.keys()) {
|
|
109
|
+
if (key.startsWith(`r2:${projectSlug}:`)) {
|
|
110
|
+
specCache.delete(key);
|
|
111
|
+
}
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
|