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.
Files changed (48) hide show
  1. package/README.md +22 -0
  2. package/dist/__tests__/unit/dev-validate-cache.test.d.ts +2 -0
  3. package/dist/__tests__/unit/dev-validate-cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/unit/dev-validate-cache.test.js +116 -0
  5. package/dist/__tests__/unit/dev-validate-cache.test.js.map +1 -0
  6. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  7. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +105 -0
  9. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  10. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  11. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  12. package/dist/__tests__/unit/language-filter.test.js +95 -0
  13. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  14. package/dist/__tests__/unit/vendored-sync.test.js +4 -0
  15. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  16. package/dist/commands/dev.d.ts +48 -3
  17. package/dist/commands/dev.d.ts.map +1 -1
  18. package/dist/commands/dev.js +134 -41
  19. package/dist/commands/dev.js.map +1 -1
  20. package/dist/index.js +15 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/language-filter.d.ts +32 -0
  23. package/dist/lib/language-filter.d.ts.map +1 -0
  24. package/dist/lib/language-filter.js +28 -0
  25. package/dist/lib/language-filter.js.map +1 -0
  26. package/package.json +1 -1
  27. package/vendored/app/[[...slug]]/page.tsx +41 -28
  28. package/vendored/app/api/isr-health/route.ts +6 -4
  29. package/vendored/components/mdx/StepSlugContext.tsx +57 -0
  30. package/vendored/components/mdx/Steps.tsx +2 -2
  31. package/vendored/components/navigation/TableOfContents.tsx +77 -5
  32. package/vendored/lib/cache-tags.ts +25 -0
  33. package/vendored/lib/cache-utils.ts +19 -0
  34. package/vendored/lib/heading-extractor.ts +25 -6
  35. package/vendored/lib/indexnow.ts +1 -1
  36. package/vendored/lib/middleware-helpers.ts +103 -15
  37. package/vendored/lib/navigation-resolver.ts +1 -1
  38. package/vendored/lib/openapi-isr.ts +13 -8
  39. package/vendored/lib/r2-cleanup.ts +70 -0
  40. package/vendored/lib/r2-content.ts +0 -24
  41. package/vendored/lib/r2-manifest.ts +13 -3
  42. package/vendored/lib/revalidation-helpers.ts +41 -11
  43. package/vendored/lib/revalidation-trigger.ts +104 -28
  44. package/vendored/lib/scanner-blocklist.ts +265 -0
  45. package/vendored/lib/snippet-compiler-isr.ts +5 -2
  46. package/vendored/scripts/validate-links.cjs +17 -6
  47. package/vendored/workspace-package-lock.json +12 -12
  48. 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
- <MDXRemote
712
- source={openApiEndpointData
713
- ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
714
- : content}
715
- components={AllComponentsWithInline}
716
- options={{
717
- // Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
718
- ...mdxSecurityOptions,
719
- mdxOptions: {
720
- remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
721
- rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
722
- recmaPlugins: [recmaCompoundComponents],
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
- <MDXRemote
740
- source={content}
741
- components={AllComponentsWithInline}
742
- options={{
743
- // Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
744
- ...mdxSecurityOptions,
745
- mdxOptions: {
746
- remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
747
- rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
748
- recmaPlugins: [recmaCompoundComponents],
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: number;
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 from actual cache modules
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
- configEntries: getConfigCacheSize(),
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 { generateSlug } from '@/lib/heading-extractor';
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 = title ? generateSlug(title) || undefined : undefined;
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 18px step circle
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-[11px] font-medium"
593
+ className="absolute flex items-center justify-center rounded-full text-[9px] font-medium"
522
594
  style={{
523
- insetInlineStart: 1,
595
+ insetInlineStart: 3,
524
596
  top: '50%',
525
597
  transform: 'translateY(-50%)',
526
- width: 18,
527
- height: 18,
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 id = generateSlug(text);
89
- if (id) {
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 id = generateSlug(text);
98
- if (id) {
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 id = generateSlug(text);
107
- if (!id) continue;
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,
@@ -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: getChangedPaths() already strips extensions, but guard against raw file paths
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', // Asset serving from R2 (app/api/assets/[...path])
315
- '/api/ev', // Analytics events (app/api/ev)
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/mcp', // MCP endpoint (app/api/mcp/[project])
321
- '/api/og', // OG image generation (app/api/og)
322
- '/api/playground', // API playground (token, proxy, demo) — must skip hostAtDocs redirect
323
- '/api/r2', // R2 content serving (app/api/r2/[project]/[...path])
324
- '/api/revalidate', // Cache revalidation (app/api/revalidate)
325
- '/api/search-ev', // Search analytics proxy (app/api/search-ev)
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 './r2-content';
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
- /** Clear OpenAPI cache. If projectSlug is provided, clears only that project. */
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