jamdesk 1.1.33 → 1.1.35
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/app/[[...slug]]/page.tsx +3 -2
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +76 -0
- package/vendored/app/api/raw-content/[...slug]/route.ts +7 -6
- package/vendored/components/mdx/MDXComponents.tsx +7 -0
- package/vendored/components/mdx/Steps.tsx +17 -1
- package/vendored/components/mdx/Visibility.tsx +20 -0
- package/vendored/components/navigation/TableOfContents.tsx +135 -53
- package/vendored/lib/content-negotiation.ts +29 -0
- package/vendored/lib/heading-extractor.ts +49 -2
- package/vendored/lib/preprocess-mdx.ts +7 -0
- package/vendored/lib/process-mdx-with-exports.ts +2 -0
- package/vendored/lib/r2-content.ts +6 -0
- package/vendored/lib/remark-visibility.ts +86 -0
- package/vendored/lib/search.ts +4 -2
- package/vendored/lib/snippet-loader-isr.ts +5 -2
- package/vendored/lib/static-artifacts.ts +7 -2
- package/vendored/lib/visibility-filter.ts +162 -0
- package/vendored/scripts/build-search-index.cjs +4 -1
- package/vendored/scripts/validate-links.cjs +27 -2
- package/vendored/scripts/visibility-filter.cjs +125 -0
- package/vendored/workspace-package-lock.json +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.35",
|
|
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",
|
|
@@ -41,6 +41,7 @@ import { PanelWrapper } from '@/components/mdx/PanelWrapper';
|
|
|
41
41
|
import { ViewWrapper } from '@/components/mdx/View';
|
|
42
42
|
import { getLatexRemarkPlugins, getLatexRehypePlugins } from '@/lib/latex-config';
|
|
43
43
|
import { getTypographyRemarkPlugins } from '@/lib/typography-config';
|
|
44
|
+
import { remarkVisibility } from '@/lib/remark-visibility';
|
|
44
45
|
import { recmaCompoundComponents } from '@/lib/recma-compound-components';
|
|
45
46
|
import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
|
|
46
47
|
import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
|
|
@@ -698,7 +699,7 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
698
699
|
// Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
|
|
699
700
|
...mdxSecurityOptions,
|
|
700
701
|
mdxOptions: {
|
|
701
|
-
remarkPlugins: [remarkGfm, ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
702
|
+
remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
702
703
|
rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
|
|
703
704
|
recmaPlugins: [recmaCompoundComponents],
|
|
704
705
|
},
|
|
@@ -724,7 +725,7 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
724
725
|
// Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
|
|
725
726
|
...mdxSecurityOptions,
|
|
726
727
|
mdxOptions: {
|
|
727
|
-
remarkPlugins: [remarkGfm, ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
728
|
+
remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
|
|
728
729
|
rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
|
|
729
730
|
recmaPlugins: [recmaCompoundComponents],
|
|
730
731
|
},
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
fetchMdxContent,
|
|
4
|
+
R2_NOT_FOUND_ERROR_NAMES,
|
|
5
|
+
R2_NOT_FOUND_MESSAGE_PREFIX,
|
|
6
|
+
} from '@/lib/r2-content';
|
|
7
|
+
import { filterVisibility } from '@/lib/visibility-filter';
|
|
8
|
+
import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
|
|
9
|
+
import {
|
|
10
|
+
acceptsMarkdown,
|
|
11
|
+
MARKDOWN_CACHE_PUBLIC,
|
|
12
|
+
MARKDOWN_CACHE_PRIVATE,
|
|
13
|
+
} from '@/lib/content-negotiation';
|
|
14
|
+
|
|
15
|
+
export const runtime = 'nodejs';
|
|
16
|
+
export const dynamic = 'force-dynamic';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Production `.md` export (and `Accept: text/markdown` canonical URLs).
|
|
20
|
+
* Fetches raw MDX from R2 via the existing in-memory-cached fetcher,
|
|
21
|
+
* applies the audience=agents filter, and returns as text/markdown with
|
|
22
|
+
* the same security headers the legacy /api/r2 path applied to .mdx.
|
|
23
|
+
*/
|
|
24
|
+
export async function GET(
|
|
25
|
+
request: Request,
|
|
26
|
+
{ params }: { params: Promise<{ project: string; slug: string[] }> }
|
|
27
|
+
) {
|
|
28
|
+
const { project, slug } = await params;
|
|
29
|
+
|
|
30
|
+
// Defense in depth: the proxy always resolves `project` from the domain
|
|
31
|
+
// resolver before routing here, but anything reachable via direct URL
|
|
32
|
+
// must be locked to the slug character set.
|
|
33
|
+
if (!VALID_SLUG_RE.test(project)) {
|
|
34
|
+
return new NextResponse('Bad request', { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pagePath = slug.join('/');
|
|
38
|
+
|
|
39
|
+
let raw: string;
|
|
40
|
+
try {
|
|
41
|
+
raw = await fetchMdxContent(project, pagePath);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : '';
|
|
44
|
+
const name = err instanceof Error ? err.name : '';
|
|
45
|
+
if (
|
|
46
|
+
msg.startsWith(R2_NOT_FOUND_MESSAGE_PREFIX) ||
|
|
47
|
+
R2_NOT_FOUND_ERROR_NAMES.has(name)
|
|
48
|
+
) {
|
|
49
|
+
return new NextResponse('Not found', { status: 404 });
|
|
50
|
+
}
|
|
51
|
+
return new NextResponse('Upstream unavailable', { status: 502 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const filtered = filterVisibility(raw, 'agents');
|
|
55
|
+
|
|
56
|
+
// Two paths reach this handler via proxy rewrite:
|
|
57
|
+
// (a) `.md` URL: unique CDN cache key, safe to share.
|
|
58
|
+
// (b) canonical URL + `Accept: text/markdown`: shares a cache key with
|
|
59
|
+
// the HTML page. Cloudflare ignores `Vary: Accept` on public entries,
|
|
60
|
+
// so a cached markdown response would poison browser requests.
|
|
61
|
+
const cacheControl = acceptsMarkdown(request.headers.get('accept'))
|
|
62
|
+
? MARKDOWN_CACHE_PRIVATE
|
|
63
|
+
: MARKDOWN_CACHE_PUBLIC;
|
|
64
|
+
|
|
65
|
+
return new NextResponse(filtered, {
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
68
|
+
'Content-Disposition': 'inline',
|
|
69
|
+
'Cache-Control': cacheControl,
|
|
70
|
+
'Vary': 'Accept',
|
|
71
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
72
|
+
'X-Frame-Options': 'DENY',
|
|
73
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { getContentLoader } from '@/lib/content-loader';
|
|
2
|
+
import { filterVisibility } from '@/lib/visibility-filter';
|
|
2
3
|
import { NextResponse } from 'next/server';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* Dev `.md` route. Applies the visibility filter with audience='agents'
|
|
7
|
+
* — strips for="humans" blocks, unwraps for="agents" blocks. Code-block-safe.
|
|
8
|
+
* Production `.md` goes through /api/markdown-export/[project]/[...slug].
|
|
7
9
|
*/
|
|
8
10
|
export async function GET(
|
|
9
11
|
_request: Request,
|
|
10
12
|
{ params }: { params: Promise<{ slug: string[] }> }
|
|
11
13
|
) {
|
|
12
14
|
const { slug } = await params;
|
|
13
|
-
const pagePath = slug.join('/');
|
|
14
|
-
|
|
15
15
|
try {
|
|
16
16
|
const loader = getContentLoader();
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const raw = await loader.getContent(slug.join('/'));
|
|
18
|
+
const filtered = filterVisibility(raw, 'agents');
|
|
19
|
+
return new NextResponse(filtered, {
|
|
19
20
|
headers: {
|
|
20
21
|
'Content-Type': 'text/markdown; charset=utf-8',
|
|
21
22
|
'Content-Disposition': 'inline',
|
|
@@ -31,6 +31,7 @@ import { Columns } from './Columns';
|
|
|
31
31
|
import { View, ViewProvider, ViewSelector, ViewWrapper } from './View';
|
|
32
32
|
import { YouTube } from './YouTube';
|
|
33
33
|
import { Video } from './Video';
|
|
34
|
+
import { Visibility } from './Visibility';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Extract language from a pre element for tab label
|
|
@@ -229,6 +230,12 @@ export const MDXComponents = {
|
|
|
229
230
|
YouTube,
|
|
230
231
|
// Video player for local video files
|
|
231
232
|
Video,
|
|
233
|
+
// Audience-conditional content. Filtered at MDX-compile time by
|
|
234
|
+
// lib/remark-visibility.ts for the HTML render path and by
|
|
235
|
+
// lib/visibility-filter.ts for text surfaces (.md export, llms-full.txt,
|
|
236
|
+
// search index). This React component is a fail-closed fallback for
|
|
237
|
+
// any <Visibility> that slips past both filters.
|
|
238
|
+
Visibility,
|
|
232
239
|
// Sized images from preprocess-mdx ( syntax).
|
|
233
240
|
// These are output as <SizedImage> JSX so they go through component mapping
|
|
234
241
|
// (raw <img> JSX in MDX bypasses the components provider).
|
|
@@ -2,6 +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
6
|
|
|
6
7
|
type TitleSize = 'p' | 'h2' | 'h3';
|
|
7
8
|
type IconType = 'regular' | 'solid' | 'light' | 'thin' | 'sharp-solid' | 'duotone' | 'brands';
|
|
@@ -129,9 +130,24 @@ function StepTitle({ title, titleSize }: { title: string; titleSize: TitleSize }
|
|
|
129
130
|
|
|
130
131
|
export const Step = memo(function Step({ title, children, stepNumber, isLast, icon, iconType, titleSize = 'p' }: StepProps) {
|
|
131
132
|
const containerClassName = isLast ? 'relative pb-0' : 'relative pb-8';
|
|
133
|
+
const slug = title ? generateSlug(title) || undefined : undefined;
|
|
134
|
+
|
|
135
|
+
// Emit both attrs together or neither — the DOM scanner keys off
|
|
136
|
+
// `data-step-number`, so a label without a number would be a dangling
|
|
137
|
+
// attribute, and a number without a label would produce a TOC entry with
|
|
138
|
+
// empty text.
|
|
139
|
+
const stepAttrs: { 'data-step-number'?: number; 'data-step-label'?: string } = {};
|
|
140
|
+
if (typeof stepNumber === 'number' && title) {
|
|
141
|
+
stepAttrs['data-step-number'] = stepNumber;
|
|
142
|
+
stepAttrs['data-step-label'] = title;
|
|
143
|
+
}
|
|
132
144
|
|
|
133
145
|
return (
|
|
134
|
-
<div
|
|
146
|
+
<div
|
|
147
|
+
className={containerClassName}
|
|
148
|
+
id={slug}
|
|
149
|
+
{...stepAttrs}
|
|
150
|
+
>
|
|
135
151
|
{/* Vertical line connecting to next step - positioned absolutely */}
|
|
136
152
|
{!isLast && (
|
|
137
153
|
<div
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface VisibilityProps {
|
|
4
|
+
for: 'humans' | 'agents';
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* <Visibility for="humans|agents"> — audience-conditional content.
|
|
10
|
+
*
|
|
11
|
+
* Filtering is normally done at MDX-compile time by `lib/remark-visibility.ts`
|
|
12
|
+
* (HTML render, snippets) or at the text level by `lib/visibility-filter.ts`
|
|
13
|
+
* (.md export, llms-full.txt). This React component is a defensive passthrough
|
|
14
|
+
* so if the filter ever misses (dynamic import, future MDX form, writer bug),
|
|
15
|
+
* the component still fails-closed for agents and fails-visible for humans.
|
|
16
|
+
*/
|
|
17
|
+
export function Visibility({ for: audience, children }: VisibilityProps) {
|
|
18
|
+
if (audience === 'agents') return null;
|
|
19
|
+
return <>{children}</>;
|
|
20
|
+
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
4
4
|
import { getIconClass } from '@/lib/icon-utils';
|
|
5
|
-
import {
|
|
5
|
+
import { extractHeadings } from '@/lib/heading-extractor';
|
|
6
6
|
|
|
7
7
|
interface TocItem {
|
|
8
8
|
id: string;
|
|
9
9
|
text: string;
|
|
10
10
|
level: number;
|
|
11
|
+
stepNumber?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
interface TableOfContentsProps {
|
|
@@ -196,75 +197,128 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
196
197
|
return () => observer.disconnect();
|
|
197
198
|
}, [updateThumb]);
|
|
198
199
|
|
|
199
|
-
//
|
|
200
|
+
// Source-order parse gives immediate headings; the MutationObserver below
|
|
201
|
+
// keeps the list in sync when dynamic components (Accordion, Tabs) reveal
|
|
202
|
+
// Steps after initial render.
|
|
200
203
|
useEffect(() => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (fenceMatch) {
|
|
210
|
-
if (!inCodeBlock) {
|
|
211
|
-
inCodeBlock = true;
|
|
212
|
-
fencePattern = fenceMatch[1];
|
|
213
|
-
continue;
|
|
214
|
-
} else if (line.startsWith(fencePattern)) {
|
|
215
|
-
inCodeBlock = false;
|
|
216
|
-
fencePattern = '';
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (inCodeBlock) continue;
|
|
221
|
-
|
|
222
|
-
const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
|
|
223
|
-
if (headingMatch) {
|
|
224
|
-
const level = headingMatch[1].length;
|
|
225
|
-
const text = headingMatch[2].trim();
|
|
226
|
-
const id = generateSlug(text);
|
|
227
|
-
if (id) items.push({ id, text, level });
|
|
228
|
-
}
|
|
204
|
+
const sourceItems: TocItem[] = extractHeadings(content)
|
|
205
|
+
.filter(h => h.level === 2 || h.level === 3)
|
|
206
|
+
.map(h => ({
|
|
207
|
+
id: h.id,
|
|
208
|
+
text: h.text,
|
|
209
|
+
level: h.level,
|
|
210
|
+
stepNumber: h.stepNumber,
|
|
211
|
+
}));
|
|
229
212
|
|
|
230
|
-
|
|
231
|
-
if (updateMatch) {
|
|
232
|
-
const text = updateMatch[1];
|
|
233
|
-
const id = generateSlug(text);
|
|
234
|
-
if (id) items.push({ id, text, level: 2 });
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
setHeadings(items);
|
|
213
|
+
setHeadings(sourceItems);
|
|
239
214
|
|
|
240
|
-
// DOM scan: single query to get all TOC elements in document order
|
|
241
215
|
const scanDomHeadings = () => {
|
|
242
|
-
const tocElements = document.querySelectorAll(
|
|
216
|
+
const tocElements = document.querySelectorAll<HTMLElement>(
|
|
217
|
+
'main h2[id], main h3[id], main [data-update-label], main [data-step-number]',
|
|
218
|
+
);
|
|
243
219
|
const newItems: TocItem[] = [];
|
|
244
220
|
const seenDomIds = new Set<string>();
|
|
221
|
+
const collisions: string[] = [];
|
|
245
222
|
|
|
246
223
|
tocElements.forEach((element) => {
|
|
247
224
|
const id = element.id;
|
|
248
|
-
if (!id
|
|
225
|
+
if (!id) return;
|
|
226
|
+
if (seenDomIds.has(id)) {
|
|
227
|
+
collisions.push(id);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stepNumberAttr = element.getAttribute('data-step-number');
|
|
232
|
+
const isStep = stepNumberAttr !== null && stepNumberAttr !== '';
|
|
233
|
+
const isUpdate = !isStep && element.hasAttribute('data-update-label');
|
|
234
|
+
|
|
235
|
+
let text = '';
|
|
236
|
+
let level = 2;
|
|
237
|
+
let stepNumber: number | undefined;
|
|
238
|
+
|
|
239
|
+
if (isStep) {
|
|
240
|
+
text = element.getAttribute('data-step-label')?.trim() || '';
|
|
241
|
+
level = 3;
|
|
242
|
+
const parsed = parseInt(stepNumberAttr as string, 10);
|
|
243
|
+
stepNumber = Number.isFinite(parsed) ? parsed : undefined;
|
|
244
|
+
} else if (isUpdate) {
|
|
245
|
+
text = element.getAttribute('data-update-label')?.trim() || '';
|
|
246
|
+
level = 2;
|
|
247
|
+
} else {
|
|
248
|
+
text = element.textContent?.trim() || '';
|
|
249
|
+
level = element.tagName === 'H2' ? 2 : 3;
|
|
250
|
+
}
|
|
249
251
|
|
|
250
|
-
const isUpdate = element.hasAttribute('data-update-label');
|
|
251
|
-
const text = isUpdate
|
|
252
|
-
? (element.getAttribute('data-update-label') || '')
|
|
253
|
-
: (element.textContent?.trim() || '');
|
|
254
252
|
if (!text) return;
|
|
255
253
|
|
|
256
254
|
seenDomIds.add(id);
|
|
257
|
-
|
|
258
|
-
newItems.push({ id, text, level });
|
|
255
|
+
newItems.push({ id, text, level, stepNumber });
|
|
259
256
|
});
|
|
260
257
|
|
|
261
|
-
|
|
262
|
-
|
|
258
|
+
// Dev-mode warning for slug collisions. In production builds, stay
|
|
259
|
+
// silent — browsers still render correctly (first match wins).
|
|
260
|
+
if (collisions.length > 0 && process.env.NODE_ENV !== 'production') {
|
|
261
|
+
console.warn(
|
|
262
|
+
`[TableOfContents] Duplicate anchor id(s) detected on this page: ${[...new Set(collisions)].join(', ')}. ` +
|
|
263
|
+
`This usually means a <Step title="X"> collides with a heading of the same text. ` +
|
|
264
|
+
`Only the first element will be targetable via fragment link or TOC click.`,
|
|
265
|
+
);
|
|
263
266
|
}
|
|
267
|
+
|
|
268
|
+
if (newItems.length === 0) return;
|
|
269
|
+
|
|
270
|
+
// Dedupe re-renders: if the new item list matches the current one
|
|
271
|
+
// position-for-position on (id, stepNumber), skip setState. This keeps
|
|
272
|
+
// the MutationObserver cheap when it fires on unrelated DOM changes.
|
|
273
|
+
setHeadings(prev => {
|
|
274
|
+
if (prev.length !== newItems.length) return newItems;
|
|
275
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
276
|
+
if (
|
|
277
|
+
prev[i].id !== newItems[i].id ||
|
|
278
|
+
prev[i].stepNumber !== newItems[i].stepNumber ||
|
|
279
|
+
prev[i].text !== newItems[i].text
|
|
280
|
+
) {
|
|
281
|
+
return newItems;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return prev;
|
|
285
|
+
});
|
|
264
286
|
};
|
|
265
287
|
|
|
266
|
-
|
|
267
|
-
|
|
288
|
+
// Coalesce scans to one per animation frame. cancelAnimationFrame in the
|
|
289
|
+
// cleanup below is only effective while scheduledScan is non-null — once
|
|
290
|
+
// the rAF callback starts, the functional setHeadings is safe on an
|
|
291
|
+
// unmounted tree because it returns prev when items are unchanged.
|
|
292
|
+
let scheduledScan: number | null = null;
|
|
293
|
+
const scheduleScan = () => {
|
|
294
|
+
if (scheduledScan !== null) return;
|
|
295
|
+
scheduledScan = requestAnimationFrame(() => {
|
|
296
|
+
scheduledScan = null;
|
|
297
|
+
scanDomHeadings();
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
scheduleScan();
|
|
302
|
+
|
|
303
|
+
const mainEl = document.querySelector('main');
|
|
304
|
+
const observer = mainEl ? new MutationObserver(scheduleScan) : null;
|
|
305
|
+
if (mainEl && observer) {
|
|
306
|
+
observer.observe(mainEl, {
|
|
307
|
+
childList: true,
|
|
308
|
+
subtree: true,
|
|
309
|
+
attributes: true,
|
|
310
|
+
attributeFilter: ['id', 'data-step-number', 'data-step-label', 'data-update-label'],
|
|
311
|
+
});
|
|
312
|
+
} else if (!mainEl && process.env.NODE_ENV !== 'production') {
|
|
313
|
+
console.warn(
|
|
314
|
+
'[TableOfContents] no <main> element found — late-mount TOC updates will not be observed.',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
if (scheduledScan !== null) cancelAnimationFrame(scheduledScan);
|
|
320
|
+
observer?.disconnect();
|
|
321
|
+
};
|
|
268
322
|
}, [content]);
|
|
269
323
|
|
|
270
324
|
// For API pages, hide TOC if there are code panels visible
|
|
@@ -415,6 +469,7 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
415
469
|
<div ref={containerRef} className="relative flex flex-col">
|
|
416
470
|
{headings.map((heading, index) => {
|
|
417
471
|
const isActive = activeAnchors.includes(heading.id);
|
|
472
|
+
const hasStepNumber = typeof heading.stepNumber === 'number';
|
|
418
473
|
|
|
419
474
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
420
475
|
e.preventDefault();
|
|
@@ -425,18 +480,45 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
425
480
|
}
|
|
426
481
|
};
|
|
427
482
|
|
|
483
|
+
// 30px leaves room for the absolute-positioned 18px step circle
|
|
484
|
+
// (insetInlineStart: 1) + a gap. Non-step H3s use the same value
|
|
485
|
+
// so mixed step/non-step groups align visually.
|
|
486
|
+
const startOffset = heading.level === 3
|
|
487
|
+
? 30
|
|
488
|
+
: getItemOffset(heading.level);
|
|
489
|
+
|
|
428
490
|
return (
|
|
429
491
|
<a
|
|
430
492
|
key={`${heading.id}-${index}`}
|
|
431
493
|
href={`#${heading.id}`}
|
|
432
494
|
onClick={handleClick}
|
|
433
495
|
data-active={isActive}
|
|
496
|
+
data-step={hasStepNumber || undefined}
|
|
434
497
|
className={`
|
|
435
498
|
relative block py-1.5 text-sm transition-colors duration-200
|
|
436
499
|
${getLinkClass(heading.level, isActive)}
|
|
437
500
|
`}
|
|
438
|
-
style={{ paddingInlineStart:
|
|
501
|
+
style={{ paddingInlineStart: startOffset }}
|
|
439
502
|
>
|
|
503
|
+
{hasStepNumber && (
|
|
504
|
+
<span
|
|
505
|
+
aria-hidden="true"
|
|
506
|
+
className="absolute flex items-center justify-center rounded-full text-[11px] font-medium"
|
|
507
|
+
style={{
|
|
508
|
+
insetInlineStart: 1,
|
|
509
|
+
top: '50%',
|
|
510
|
+
transform: 'translateY(-50%)',
|
|
511
|
+
width: 18,
|
|
512
|
+
height: 18,
|
|
513
|
+
backgroundColor: isActive
|
|
514
|
+
? 'var(--color-primary)'
|
|
515
|
+
: 'var(--color-bg-primary)',
|
|
516
|
+
color: isActive ? '#fff' : 'var(--color-text-muted)',
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
{heading.stepNumber}
|
|
520
|
+
</span>
|
|
521
|
+
)}
|
|
440
522
|
{heading.text}
|
|
441
523
|
</a>
|
|
442
524
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True if the Accept header contains a `text/markdown` offer with a
|
|
3
|
+
* non-zero q-value (or no q-value). Scans offers individually so
|
|
4
|
+
* `text/markdown;q=0` ("explicitly not markdown") doesn't trigger a match.
|
|
5
|
+
*
|
|
6
|
+
* Shared between the proxy (for URL rewriting) and the markdown-export
|
|
7
|
+
* route handler (for cache policy decisions) so both layers agree on
|
|
8
|
+
* what counts as an agent-facing request.
|
|
9
|
+
*/
|
|
10
|
+
export function acceptsMarkdown(accept: string | null | undefined): boolean {
|
|
11
|
+
if (!accept) return false;
|
|
12
|
+
// Browsers never send text/markdown; short-circuit on the ~99% hot path
|
|
13
|
+
// without allocating a lowercased copy or splitting into offers.
|
|
14
|
+
if (accept.search(/text\/markdown/i) === -1) return false;
|
|
15
|
+
return accept
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.split(',')
|
|
18
|
+
.some(offer => {
|
|
19
|
+
const [type, ...params] = offer.trim().split(';').map(s => s.trim());
|
|
20
|
+
if (type !== 'text/markdown') return false;
|
|
21
|
+
const qParam = params.find(p => p.startsWith('q='));
|
|
22
|
+
if (!qParam) return true;
|
|
23
|
+
const q = Number.parseFloat(qParam.slice(2));
|
|
24
|
+
return Number.isFinite(q) && q > 0;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const MARKDOWN_CACHE_PUBLIC = 'public, max-age=3600, s-maxage=86400';
|
|
29
|
+
export const MARKDOWN_CACHE_PRIVATE = 'private, no-store';
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Heading extractor for link validation.
|
|
3
3
|
* Extracts heading slugs from MDX content to validate #fragment links.
|
|
4
4
|
*
|
|
5
|
-
* Canonical source for generateSlug — imported by TableOfContents.tsx
|
|
5
|
+
* Canonical source for generateSlug — imported by TableOfContents.tsx, Update.tsx,
|
|
6
|
+
* and Steps.tsx.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export interface HeadingInfo {
|
|
@@ -10,6 +11,8 @@ export interface HeadingInfo {
|
|
|
10
11
|
text: string;
|
|
11
12
|
level: number;
|
|
12
13
|
line: number; // 1-indexed
|
|
14
|
+
/** When the entry is a <Step> inside a <Steps> block, this is its 1-based index within the block. */
|
|
15
|
+
stepNumber?: number;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -26,10 +29,20 @@ export function generateSlug(text: string): string {
|
|
|
26
29
|
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
|
|
27
30
|
const FENCE_REGEX = /^(`{3,}|~{3,})/;
|
|
28
31
|
const UPDATE_LABEL_REGEX = /<Update\s+label=["']([^"']+)["']/;
|
|
32
|
+
const STEPS_OPEN_REGEX = /<Steps(\s|>)/;
|
|
33
|
+
const STEPS_CLOSE_REGEX = /<\/Steps>/;
|
|
34
|
+
// Global flag — we iterate with matchAll so authors who inline multiple
|
|
35
|
+
// <Step> tags on one line get every one numbered.
|
|
36
|
+
// Alternation (not a character class) keeps the capture from terminating on
|
|
37
|
+
// apostrophes inside double-quoted attribute values (e.g. `<Step title="You're Live">`).
|
|
38
|
+
// MUST only be used with matchAll — direct `test`/`exec` would share
|
|
39
|
+
// lastIndex across calls and miscount.
|
|
40
|
+
const STEP_TITLE_REGEX = /<Step\s+[^>]*title=(?:"([^"]+)"|'([^']+)')/g;
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
43
|
* Extract all heading slugs from MDX content.
|
|
32
|
-
* Includes markdown headings
|
|
44
|
+
* Includes markdown headings, <Update label="..."> component anchors,
|
|
45
|
+
* and <Step title="..."> entries inside <Steps> blocks (with per-block numbering).
|
|
33
46
|
* Skips content inside fenced code blocks.
|
|
34
47
|
*/
|
|
35
48
|
export function extractHeadings(content: string): HeadingInfo[] {
|
|
@@ -37,6 +50,10 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
37
50
|
const lines = content.split('\n');
|
|
38
51
|
let inCodeBlock = false;
|
|
39
52
|
let fencePattern = '';
|
|
53
|
+
// Boolean (not depth counter) — resets on every <Steps> open so an unclosed
|
|
54
|
+
// block can't stick the counter across subsequent blocks.
|
|
55
|
+
let inStepsBlock = false;
|
|
56
|
+
let stepCounter = 0;
|
|
40
57
|
|
|
41
58
|
for (let i = 0; i < lines.length; i++) {
|
|
42
59
|
const line = lines[i];
|
|
@@ -56,6 +73,14 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
56
73
|
|
|
57
74
|
if (inCodeBlock) continue;
|
|
58
75
|
|
|
76
|
+
// <Steps> open resets the per-block counter unconditionally. Ordering
|
|
77
|
+
// matters: open-reset must run BEFORE step matching on the same line so
|
|
78
|
+
// `<Steps><Step title="A">...</Step>` numbers "A" as 1.
|
|
79
|
+
if (STEPS_OPEN_REGEX.test(line)) {
|
|
80
|
+
inStepsBlock = true;
|
|
81
|
+
stepCounter = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
const headingMatch = line.match(HEADING_REGEX);
|
|
60
85
|
if (headingMatch) {
|
|
61
86
|
const level = headingMatch[1].length;
|
|
@@ -74,6 +99,28 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
74
99
|
headings.push({ id, text, level: 2, line: i + 1 });
|
|
75
100
|
}
|
|
76
101
|
}
|
|
102
|
+
|
|
103
|
+
if (inStepsBlock) {
|
|
104
|
+
for (const match of line.matchAll(STEP_TITLE_REGEX)) {
|
|
105
|
+
const text = match[1] ?? match[2];
|
|
106
|
+
const id = generateSlug(text);
|
|
107
|
+
if (!id) continue;
|
|
108
|
+
stepCounter += 1;
|
|
109
|
+
headings.push({
|
|
110
|
+
id,
|
|
111
|
+
text,
|
|
112
|
+
level: 3,
|
|
113
|
+
line: i + 1,
|
|
114
|
+
stepNumber: stepCounter,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// </Steps> close runs AFTER step matching so `</Steps>` on the same line
|
|
120
|
+
// as the last <Step> still picks up that step.
|
|
121
|
+
if (STEPS_CLOSE_REGEX.test(line)) {
|
|
122
|
+
inStepsBlock = false;
|
|
123
|
+
}
|
|
77
124
|
}
|
|
78
125
|
|
|
79
126
|
return headings;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { ASSET_PREFIX, appendAssetVersion } from './docs-types';
|
|
10
10
|
import { checkForDeprecatedComponents } from './deprecated-components';
|
|
11
|
+
import { filterVisibility } from './visibility-filter';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* JSX components that contain markdown content requiring special preprocessing.
|
|
@@ -35,6 +36,7 @@ const JSX_CONTENT_COMPONENTS = [
|
|
|
35
36
|
'View',
|
|
36
37
|
'Varning',
|
|
37
38
|
'Avertissement',
|
|
39
|
+
'Visibility',
|
|
38
40
|
] as const;
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -935,6 +937,11 @@ export function preprocessMdx(content: string, options?: { assetVersion?: string
|
|
|
935
937
|
let processed = content;
|
|
936
938
|
const assetVersion = options?.assetVersion;
|
|
937
939
|
|
|
940
|
+
// Audience filter (code-block-safe). The remark plugin in page.tsx is the
|
|
941
|
+
// primary line of defense for HTML render; this backs up snippet files
|
|
942
|
+
// that flow through preprocessMdx independently.
|
|
943
|
+
processed = filterVisibility(processed, 'humans');
|
|
944
|
+
|
|
938
945
|
// Strip snippet imports
|
|
939
946
|
processed = stripSnippetImports(processed);
|
|
940
947
|
|
|
@@ -12,6 +12,7 @@ import remarkParse from 'remark-parse';
|
|
|
12
12
|
import remarkMdx from 'remark-mdx';
|
|
13
13
|
import { VFile } from 'vfile';
|
|
14
14
|
import { remarkExtractExports } from './remark-extract-exports';
|
|
15
|
+
import { remarkVisibility } from './remark-visibility';
|
|
15
16
|
import { compileInlineComponents } from './mdx-inline-components';
|
|
16
17
|
import { remarkExtractParamFields } from './remark-extract-param-fields';
|
|
17
18
|
import type { ExtractedParam } from './remark-extract-param-fields';
|
|
@@ -30,6 +31,7 @@ interface ProcessResult {
|
|
|
30
31
|
const mdxProcessor = unified()
|
|
31
32
|
.use(remarkParse)
|
|
32
33
|
.use(remarkMdx)
|
|
34
|
+
.use(remarkVisibility, { audience: 'humans' })
|
|
33
35
|
.use(remarkExtractExports)
|
|
34
36
|
.use(remarkExtractParamFields);
|
|
35
37
|
|
|
@@ -9,6 +9,12 @@ import type { DocsConfig } from './docs-types';
|
|
|
9
9
|
|
|
10
10
|
export type { DocsConfig };
|
|
11
11
|
|
|
12
|
+
export const R2_NOT_FOUND_ERROR_NAMES: ReadonlySet<string> = new Set([
|
|
13
|
+
'NoSuchKey',
|
|
14
|
+
'AccessDenied',
|
|
15
|
+
]);
|
|
16
|
+
export const R2_NOT_FOUND_MESSAGE_PREFIX = 'Page not found';
|
|
17
|
+
|
|
12
18
|
export function evictOldest<K, V>(cache: Map<K, V>, maxSize: number): void {
|
|
13
19
|
if (cache.size <= maxSize) return;
|
|
14
20
|
const toDelete = cache.size - maxSize;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remark-visibility
|
|
3
|
+
*
|
|
4
|
+
* MDX AST plugin that strips or unwraps <Visibility for="humans|agents">
|
|
5
|
+
* elements based on the consuming audience. Run this plugin BEFORE
|
|
6
|
+
* remarkExtractExports so exports inside filtered-out blocks are
|
|
7
|
+
* dropped along with the block.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the text-level filter in lib/visibility-filter.ts, this plugin
|
|
10
|
+
* is safe for:
|
|
11
|
+
* - code blocks (mdast `code` nodes aren't traversed as JSX)
|
|
12
|
+
* - nested <Visibility> elements
|
|
13
|
+
* - JSX expressions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Root, RootContent } from 'mdast';
|
|
17
|
+
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx';
|
|
18
|
+
|
|
19
|
+
type JsxEl = MdxJsxFlowElement | MdxJsxTextElement;
|
|
20
|
+
|
|
21
|
+
export interface RemarkVisibilityOptions {
|
|
22
|
+
audience: 'humans' | 'agents';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isVisibility(node: unknown): node is JsxEl {
|
|
26
|
+
if (!node || typeof node !== 'object') return false;
|
|
27
|
+
const n = node as { type?: string; name?: string };
|
|
28
|
+
return (
|
|
29
|
+
(n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement') &&
|
|
30
|
+
n.name === 'Visibility'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getForAttr(node: JsxEl): string | null {
|
|
35
|
+
for (const attr of node.attributes ?? []) {
|
|
36
|
+
if (attr.type === 'mdxJsxAttribute' && attr.name === 'for') {
|
|
37
|
+
if (typeof attr.value === 'string') return attr.value.toLowerCase();
|
|
38
|
+
// Expression-valued attribute — treat as unknown, leave alone.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function remarkVisibility(options: RemarkVisibilityOptions) {
|
|
46
|
+
const { audience } = options;
|
|
47
|
+
return function transform(tree: Root): void {
|
|
48
|
+
visitInPlace(tree, audience);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function visitInPlace(
|
|
53
|
+
parent: { children?: RootContent[] },
|
|
54
|
+
audience: 'humans' | 'agents',
|
|
55
|
+
): void {
|
|
56
|
+
if (!Array.isArray(parent.children)) return;
|
|
57
|
+
|
|
58
|
+
const next: RootContent[] = [];
|
|
59
|
+
for (const child of parent.children) {
|
|
60
|
+
if (isVisibility(child)) {
|
|
61
|
+
const target = getForAttr(child);
|
|
62
|
+
if (target === null) {
|
|
63
|
+
visitInPlace(child as unknown as { children?: RootContent[] }, audience);
|
|
64
|
+
next.push(child);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (target !== 'humans' && target !== 'agents') {
|
|
68
|
+
visitInPlace(child as unknown as { children?: RootContent[] }, audience);
|
|
69
|
+
next.push(child);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (target === audience) {
|
|
73
|
+
// Unwrap: recurse first so nested <Visibility> get filtered, then splice in children.
|
|
74
|
+
visitInPlace(child as unknown as { children?: RootContent[] }, audience);
|
|
75
|
+
next.push(...((child.children ?? []) as RootContent[]));
|
|
76
|
+
}
|
|
77
|
+
// Else: drop entirely.
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
visitInPlace(child as unknown as { children?: RootContent[] }, audience);
|
|
82
|
+
next.push(child);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
parent.children = next;
|
|
86
|
+
}
|
package/vendored/lib/search.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { parseFrontmatterLenient } from './frontmatter-utils';
|
|
4
|
+
import { filterVisibility } from './visibility-filter';
|
|
4
5
|
|
|
5
6
|
export interface SearchResult {
|
|
6
7
|
id: string;
|
|
@@ -79,8 +80,9 @@ export function buildSearchIndex(): SearchResult[] {
|
|
|
79
80
|
const fileContents = fs.readFileSync(filePath, 'utf8');
|
|
80
81
|
const { data, content } = parseFrontmatterLenient(fileContents);
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
-
const
|
|
83
|
+
// Filter for="agents" content out of the search index.
|
|
84
|
+
const visibleContent = filterVisibility(content, 'humans');
|
|
85
|
+
const sections = extractSections(visibleContent);
|
|
84
86
|
|
|
85
87
|
sections.forEach((section, idx) => {
|
|
86
88
|
const cleanContent = stripMarkdown(section.content);
|
|
@@ -14,6 +14,7 @@ import * as jsxRuntime from 'react/jsx-runtime';
|
|
|
14
14
|
import { transform } from '@babel/standalone';
|
|
15
15
|
import { fetchSnippet } from './r2-content';
|
|
16
16
|
import { extractSnippetImports } from './preprocess-mdx';
|
|
17
|
+
import { filterVisibility } from './visibility-filter';
|
|
17
18
|
|
|
18
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
20
|
type AnyComponent = React.ComponentType<any>;
|
|
@@ -240,8 +241,10 @@ async function compileSnippet(
|
|
|
240
241
|
return cached.result;
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
// Fetch from R2
|
|
244
|
-
|
|
244
|
+
// Fetch from R2 and strip <Visibility for="agents"> blocks before compiling.
|
|
245
|
+
// Filter before caching so cached components are already audience-filtered.
|
|
246
|
+
const rawSource = await fetchSnippet(projectSlug, snippetPath);
|
|
247
|
+
const source = filterVisibility(rawSource, 'humans');
|
|
245
248
|
|
|
246
249
|
// Check for 'use client' directive
|
|
247
250
|
const trimmed = source.trimStart();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { NavigationConfig } from './docs-types.js';
|
|
9
9
|
import { RECURSE_KEYS } from './enhance-navigation.js';
|
|
10
|
+
import { filterVisibility } from './visibility-filter.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Page metadata for artifact generation.
|
|
@@ -313,7 +314,7 @@ export function generateLlmsFullTxt(options: LlmsFullTxtOptions): string {
|
|
|
313
314
|
parts.push(`*${page.frontmatter.description}*\n\n`);
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
const content = page.content.trim();
|
|
317
|
+
const content = filterVisibility(page.content, 'agents').trim();
|
|
317
318
|
if (content) {
|
|
318
319
|
parts.push(`${content}\n\n`);
|
|
319
320
|
}
|
|
@@ -676,7 +677,11 @@ export function generateSearchData(pages: SearchPageInfo[]): string {
|
|
|
676
677
|
const pathWithoutExt = page.path.replace(/\.mdx?$/, '');
|
|
677
678
|
const slug = pathWithoutExt.replace(/\\/g, '/');
|
|
678
679
|
const pageType = inferPageType(slug);
|
|
679
|
-
|
|
680
|
+
// Filter for="agents" content out of the search index — the site
|
|
681
|
+
// search is a human-facing surface, so agent-only content must not
|
|
682
|
+
// leak into autocomplete.
|
|
683
|
+
const visibleContent = filterVisibility(page.content, 'humans');
|
|
684
|
+
const sections = extractSections(visibleContent);
|
|
680
685
|
|
|
681
686
|
sections.forEach((section, idx) => {
|
|
682
687
|
const cleanContent = stripMarkdown(section.content);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility text-level filter — used by the prod `.md` export route, the
|
|
3
|
+
* search-index generator, and the CJS build scripts, which can't run the
|
|
4
|
+
* MDX AST pipeline.
|
|
5
|
+
*
|
|
6
|
+
* For HTML render and snippets, the remark plugin at `lib/remark-visibility.ts`
|
|
7
|
+
* is used instead (AST-safe; handles nested tags and JSX expressions).
|
|
8
|
+
*
|
|
9
|
+
* This filter preserves:
|
|
10
|
+
* - Fenced code blocks (``` and ~~~), with or without trailing whitespace
|
|
11
|
+
* after the closer
|
|
12
|
+
* - CommonMark indented code blocks (4+ spaces or tab, preceded by a
|
|
13
|
+
* blank line or start-of-string)
|
|
14
|
+
* - Inline code (`...`)
|
|
15
|
+
*
|
|
16
|
+
* KNOWN LIMITATION: this filter is not nesting-safe. A non-greedy regex
|
|
17
|
+
* always consumes to the first </Visibility>. The fixpoint loop handles
|
|
18
|
+
* sibling blocks produced by unwrap, but nested <Visibility> in the text
|
|
19
|
+
* surface (.md export, llms-full.txt) produces mangled output. Customer
|
|
20
|
+
* docs discourage nesting. For the HTML render surface the remark plugin
|
|
21
|
+
* handles nesting correctly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type VisibilityAudience = 'humans' | 'agents';
|
|
25
|
+
|
|
26
|
+
// Attribute region tolerates `>` inside quoted values (writer-legal MDX
|
|
27
|
+
// like `<Visibility title="a>b">`). We alternate quoted strings vs
|
|
28
|
+
// non-special chars so `>` in a quoted value can't prematurely close the
|
|
29
|
+
// tag. Pattern: `"..."`, `'...'`, or a char that's not `>`/`"`/`'`.
|
|
30
|
+
const VISIBILITY_TAG =
|
|
31
|
+
/<Visibility\s+((?:"[^"]*"|'[^']*'|[^>"'])*?)(?:\/>|>([\s\S]*?)<\/Visibility>)/g;
|
|
32
|
+
// Anchor `for` on whitespace / start-of-attrs so attributes like
|
|
33
|
+
// `data-for="x"` or `my-for="x"` can't collide.
|
|
34
|
+
const FOR_ATTR = /(?:^|\s)for\s*=\s*["']([^"']+)["']/;
|
|
35
|
+
|
|
36
|
+
// Closing fence tolerates trailing whitespace (CommonMark §4.5).
|
|
37
|
+
const FENCED_BLOCK = /(^|\n)(```|~~~)[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g;
|
|
38
|
+
const INLINE_CODE = /`[^`\n]+`/g;
|
|
39
|
+
const INDENTED_LINE = /^(?: {4,}|\t)/;
|
|
40
|
+
// Cheap pre-check: indented code blocks require a \n followed by 4+ spaces
|
|
41
|
+
// or a tab. If neither appears, skip the line-split + per-line scan below.
|
|
42
|
+
const INDENT_PRECHECK = /\n(?: {4}|\t)/;
|
|
43
|
+
|
|
44
|
+
// Fixpoint-loop cap. Depth of re-matches in any real document is <<10;
|
|
45
|
+
// this guards against pathological / adversarial input.
|
|
46
|
+
const MAX_PASSES = 16;
|
|
47
|
+
|
|
48
|
+
// CommonMark indented code blocks (4+ spaces or a tab, preceded by a blank
|
|
49
|
+
// line or start-of-string). Returns half-open offset ranges. Trailing blank
|
|
50
|
+
// lines inside a block are excluded from the preserved range.
|
|
51
|
+
function findIndentedCodeRanges(content: string): Array<[number, number]> {
|
|
52
|
+
if (!INDENT_PRECHECK.test(content) && !INDENTED_LINE.test(content)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const ranges: Array<[number, number]> = [];
|
|
56
|
+
const lines = content.split('\n');
|
|
57
|
+
const offsets: number[] = new Array(lines.length + 1);
|
|
58
|
+
offsets[0] = 0;
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
offsets[i + 1] = offsets[i] + lines[i].length + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let blockStart = -1;
|
|
64
|
+
let trailingBlanks = 0;
|
|
65
|
+
let prevBlank = true; // start-of-string qualifies as "blank" for trigger
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
const isBlank = line.trim() === '';
|
|
69
|
+
const isIndented = INDENTED_LINE.test(line);
|
|
70
|
+
if (blockStart < 0) {
|
|
71
|
+
if (prevBlank && isIndented && !isBlank) {
|
|
72
|
+
blockStart = i;
|
|
73
|
+
trailingBlanks = 0;
|
|
74
|
+
}
|
|
75
|
+
} else if (isIndented && !isBlank) {
|
|
76
|
+
trailingBlanks = 0;
|
|
77
|
+
} else if (isBlank) {
|
|
78
|
+
trailingBlanks++;
|
|
79
|
+
} else {
|
|
80
|
+
ranges.push([offsets[blockStart], offsets[i - trailingBlanks]]);
|
|
81
|
+
blockStart = -1;
|
|
82
|
+
trailingBlanks = 0;
|
|
83
|
+
}
|
|
84
|
+
prevBlank = isBlank;
|
|
85
|
+
}
|
|
86
|
+
if (blockStart >= 0) {
|
|
87
|
+
const end = lines.length - trailingBlanks;
|
|
88
|
+
ranges.push([offsets[blockStart], offsets[end]]);
|
|
89
|
+
}
|
|
90
|
+
return ranges;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transformInlineAware(content: string, fn: (text: string) => string): string {
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
let last = 0;
|
|
96
|
+
for (const m of content.matchAll(INLINE_CODE)) {
|
|
97
|
+
const idx = m.index ?? 0;
|
|
98
|
+
out.push(fn(content.slice(last, idx)));
|
|
99
|
+
out.push(m[0]);
|
|
100
|
+
last = idx + m[0].length;
|
|
101
|
+
}
|
|
102
|
+
out.push(fn(content.slice(last)));
|
|
103
|
+
return out.join('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function transformOutsideIndentedCode(content: string, fn: (text: string) => string): string {
|
|
107
|
+
const ranges = findIndentedCodeRanges(content);
|
|
108
|
+
if (ranges.length === 0) return transformInlineAware(content, fn);
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
let last = 0;
|
|
111
|
+
for (const [start, end] of ranges) {
|
|
112
|
+
parts.push(transformInlineAware(content.slice(last, start), fn));
|
|
113
|
+
parts.push(content.slice(start, end));
|
|
114
|
+
last = end;
|
|
115
|
+
}
|
|
116
|
+
parts.push(transformInlineAware(content.slice(last), fn));
|
|
117
|
+
return parts.join('');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply a transform only to regions OUTSIDE fenced, indented, and inline code.
|
|
121
|
+
function transformOutsideCodeBlocks(
|
|
122
|
+
content: string,
|
|
123
|
+
fn: (text: string) => string,
|
|
124
|
+
): string {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
let last = 0;
|
|
127
|
+
for (const m of content.matchAll(FENCED_BLOCK)) {
|
|
128
|
+
const idx = m.index ?? 0;
|
|
129
|
+
parts.push(transformOutsideIndentedCode(content.slice(last, idx), fn));
|
|
130
|
+
parts.push(m[0]);
|
|
131
|
+
last = idx + m[0].length;
|
|
132
|
+
}
|
|
133
|
+
parts.push(transformOutsideIndentedCode(content.slice(last), fn));
|
|
134
|
+
return parts.join('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function filterVisibilityRaw(content: string, audience: VisibilityAudience): string {
|
|
138
|
+
return content.replace(VISIBILITY_TAG, (match, attrs: string, inner: string | undefined) => {
|
|
139
|
+
const forMatch = attrs.match(FOR_ATTR);
|
|
140
|
+
if (!forMatch) return match;
|
|
141
|
+
const target = forMatch[1].toLowerCase();
|
|
142
|
+
if (target !== 'humans' && target !== 'agents') return match;
|
|
143
|
+
if (target === audience) return inner ?? '';
|
|
144
|
+
return '';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Filter <Visibility> blocks from MDX text. Safe for fenced, indented, and
|
|
150
|
+
* inline code. Iterates to a fixpoint so unwrap passes surface newly-
|
|
151
|
+
* adjacent tags.
|
|
152
|
+
*/
|
|
153
|
+
export function filterVisibility(content: string, audience: VisibilityAudience): string {
|
|
154
|
+
let prev: string;
|
|
155
|
+
let out = content;
|
|
156
|
+
for (let i = 0; i < MAX_PASSES; i++) {
|
|
157
|
+
prev = out;
|
|
158
|
+
out = transformOutsideCodeBlocks(prev, t => filterVisibilityRaw(t, audience));
|
|
159
|
+
if (out === prev) break;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
@@ -23,6 +23,7 @@ const matter = require('gray-matter');
|
|
|
23
23
|
const os = require('os');
|
|
24
24
|
const { create, insertMultiple } = require('@orama/orama');
|
|
25
25
|
const { persist } = require('@orama/plugin-data-persistence');
|
|
26
|
+
const { filterVisibility } = require('./visibility-filter.cjs');
|
|
26
27
|
|
|
27
28
|
// Concurrency control for parallel file processing
|
|
28
29
|
const CONCURRENCY = parseInt(process.env.SEARCH_INDEX_CONCURRENCY || '') || os.cpus().length * 2;
|
|
@@ -256,7 +257,9 @@ async function buildSearchIndex() {
|
|
|
256
257
|
try {
|
|
257
258
|
const fileContents = await fsPromises.readFile(filePath, 'utf8');
|
|
258
259
|
const { data, content } = parseFrontmatterLenient(fileContents);
|
|
259
|
-
|
|
260
|
+
// Filter for="agents" content out of the search index.
|
|
261
|
+
const visibleContent = filterVisibility(content, 'humans');
|
|
262
|
+
const sections = extractSections(visibleContent);
|
|
260
263
|
|
|
261
264
|
const docs = [];
|
|
262
265
|
const normalizedSlug = slug.replace(/\\/g, '/');
|
|
@@ -219,16 +219,26 @@ function generateSlug(text) {
|
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
221
|
* Extract heading slugs from MDX content.
|
|
222
|
-
* Includes markdown headings
|
|
222
|
+
* Includes markdown headings, <Update label="..."> anchors, and
|
|
223
|
+
* <Step title="..."> anchors inside a <Steps> block.
|
|
223
224
|
* Skips content inside fenced code blocks.
|
|
224
225
|
*
|
|
225
|
-
*
|
|
226
|
+
* Must stay semantically compatible with extractHeadings() in lib/heading-extractor.ts
|
|
227
|
+
* for slug collection. stepNumber tracking is intentionally omitted here — this
|
|
228
|
+
* function returns a Set<string>, not HeadingInfo[].
|
|
226
229
|
*/
|
|
227
230
|
function extractHeadingSlugs(content) {
|
|
228
231
|
const slugs = new Set();
|
|
229
232
|
const lines = content.split('\n');
|
|
230
233
|
let inCodeBlock = false;
|
|
231
234
|
let fencePattern = '';
|
|
235
|
+
let inStepsBlock = false;
|
|
236
|
+
|
|
237
|
+
const STEPS_OPEN_REGEX = /<Steps(\s|>)/;
|
|
238
|
+
const STEPS_CLOSE_REGEX = /<\/Steps>/;
|
|
239
|
+
// Alternation, not a character class — must not terminate at apostrophes
|
|
240
|
+
// inside double-quoted attribute values (e.g. <Step title="You're Live">).
|
|
241
|
+
const STEP_TITLE_REGEX = /<Step\s+[^>]*title=(?:"([^"]+)"|'([^']+)')/g;
|
|
232
242
|
|
|
233
243
|
for (let i = 0; i < lines.length; i++) {
|
|
234
244
|
const line = lines[i];
|
|
@@ -248,6 +258,10 @@ function extractHeadingSlugs(content) {
|
|
|
248
258
|
|
|
249
259
|
if (inCodeBlock) continue;
|
|
250
260
|
|
|
261
|
+
if (STEPS_OPEN_REGEX.test(line)) {
|
|
262
|
+
inStepsBlock = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
251
265
|
const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
252
266
|
if (headingMatch) {
|
|
253
267
|
const slug = generateSlug(headingMatch[1].trim());
|
|
@@ -259,6 +273,17 @@ function extractHeadingSlugs(content) {
|
|
|
259
273
|
const slug = generateSlug(updateMatch[1]);
|
|
260
274
|
if (slug) slugs.add(slug);
|
|
261
275
|
}
|
|
276
|
+
|
|
277
|
+
if (inStepsBlock) {
|
|
278
|
+
for (const match of line.matchAll(STEP_TITLE_REGEX)) {
|
|
279
|
+
const slug = generateSlug(match[1] ?? match[2]);
|
|
280
|
+
if (slug) slugs.add(slug);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (STEPS_CLOSE_REGEX.test(line)) {
|
|
285
|
+
inStepsBlock = false;
|
|
286
|
+
}
|
|
262
287
|
}
|
|
263
288
|
|
|
264
289
|
return slugs;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJS mirror of lib/visibility-filter.ts.
|
|
3
|
+
*
|
|
4
|
+
* Used by CJS build scripts (generate-llms-full.cjs, build-search-index.cjs)
|
|
5
|
+
* that can't import the ESM TypeScript source. Behavior must stay
|
|
6
|
+
* byte-for-byte identical to the TS filter so that runtime and build-time
|
|
7
|
+
* outputs match (see `__tests__/lib/visibility-filter.test.ts` for the
|
|
8
|
+
* behaviors we pin).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const VIS_TAG =
|
|
14
|
+
/<Visibility\s+((?:"[^"]*"|'[^']*'|[^>"'])*?)(?:\/>|>([\s\S]*?)<\/Visibility>)/g;
|
|
15
|
+
const VIS_FOR = /(?:^|\s)for\s*=\s*["']([^"']+)["']/;
|
|
16
|
+
const VIS_FENCED = /(^|\n)(```|~~~)[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g;
|
|
17
|
+
const VIS_INLINE = /`[^`\n]+`/g;
|
|
18
|
+
const VIS_INDENTED_LINE = /^(?: {4,}|\t)/;
|
|
19
|
+
const VIS_INDENT_PRECHECK = /\n(?: {4}|\t)/;
|
|
20
|
+
const VIS_MAX_PASSES = 16;
|
|
21
|
+
|
|
22
|
+
function findIndentedCodeRanges(content) {
|
|
23
|
+
if (!VIS_INDENT_PRECHECK.test(content) && !VIS_INDENTED_LINE.test(content)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const ranges = [];
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
const offsets = new Array(lines.length + 1);
|
|
29
|
+
offsets[0] = 0;
|
|
30
|
+
for (let i = 0; i < lines.length; i++) {
|
|
31
|
+
offsets[i + 1] = offsets[i] + lines[i].length + 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let blockStart = -1;
|
|
35
|
+
let trailingBlanks = 0;
|
|
36
|
+
let prevBlank = true;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
const isBlank = line.trim() === '';
|
|
40
|
+
const isIndented = VIS_INDENTED_LINE.test(line);
|
|
41
|
+
if (blockStart < 0) {
|
|
42
|
+
if (prevBlank && isIndented && !isBlank) {
|
|
43
|
+
blockStart = i;
|
|
44
|
+
trailingBlanks = 0;
|
|
45
|
+
}
|
|
46
|
+
} else if (isIndented && !isBlank) {
|
|
47
|
+
trailingBlanks = 0;
|
|
48
|
+
} else if (isBlank) {
|
|
49
|
+
trailingBlanks++;
|
|
50
|
+
} else {
|
|
51
|
+
ranges.push([offsets[blockStart], offsets[i - trailingBlanks]]);
|
|
52
|
+
blockStart = -1;
|
|
53
|
+
trailingBlanks = 0;
|
|
54
|
+
}
|
|
55
|
+
prevBlank = isBlank;
|
|
56
|
+
}
|
|
57
|
+
if (blockStart >= 0) {
|
|
58
|
+
const end = lines.length - trailingBlanks;
|
|
59
|
+
ranges.push([offsets[blockStart], offsets[end]]);
|
|
60
|
+
}
|
|
61
|
+
return ranges;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function filterVisibilityRaw(content, audience) {
|
|
65
|
+
return content.replace(VIS_TAG, (match, attrs, inner) => {
|
|
66
|
+
const m = attrs.match(VIS_FOR);
|
|
67
|
+
if (!m) return match;
|
|
68
|
+
const t = m[1].toLowerCase();
|
|
69
|
+
if (t !== 'humans' && t !== 'agents') return match;
|
|
70
|
+
if (t === audience) return inner == null ? '' : inner;
|
|
71
|
+
return '';
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transformInlineAware(content, fn) {
|
|
76
|
+
const out = [];
|
|
77
|
+
let last = 0;
|
|
78
|
+
for (const m of content.matchAll(VIS_INLINE)) {
|
|
79
|
+
const idx = m.index ?? 0;
|
|
80
|
+
out.push(fn(content.slice(last, idx)));
|
|
81
|
+
out.push(m[0]);
|
|
82
|
+
last = idx + m[0].length;
|
|
83
|
+
}
|
|
84
|
+
out.push(fn(content.slice(last)));
|
|
85
|
+
return out.join('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function transformOutsideIndentedCode(content, fn) {
|
|
89
|
+
const ranges = findIndentedCodeRanges(content);
|
|
90
|
+
if (ranges.length === 0) return transformInlineAware(content, fn);
|
|
91
|
+
const parts = [];
|
|
92
|
+
let last = 0;
|
|
93
|
+
for (const [start, end] of ranges) {
|
|
94
|
+
parts.push(transformInlineAware(content.slice(last, start), fn));
|
|
95
|
+
parts.push(content.slice(start, end));
|
|
96
|
+
last = end;
|
|
97
|
+
}
|
|
98
|
+
parts.push(transformInlineAware(content.slice(last), fn));
|
|
99
|
+
return parts.join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function filterVisibilityOnce(content, audience) {
|
|
103
|
+
const parts = [];
|
|
104
|
+
let last = 0;
|
|
105
|
+
for (const m of content.matchAll(VIS_FENCED)) {
|
|
106
|
+
const idx = m.index ?? 0;
|
|
107
|
+
parts.push(transformOutsideIndentedCode(content.slice(last, idx), t => filterVisibilityRaw(t, audience)));
|
|
108
|
+
parts.push(m[0]);
|
|
109
|
+
last = idx + m[0].length;
|
|
110
|
+
}
|
|
111
|
+
parts.push(transformOutsideIndentedCode(content.slice(last), t => filterVisibilityRaw(t, audience)));
|
|
112
|
+
return parts.join('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function filterVisibility(content, audience) {
|
|
116
|
+
let out = content;
|
|
117
|
+
for (let i = 0; i < VIS_MAX_PASSES; i++) {
|
|
118
|
+
const prev = out;
|
|
119
|
+
out = filterVisibilityOnce(prev, audience);
|
|
120
|
+
if (out === prev) break;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { filterVisibility };
|
|
@@ -2980,19 +2980,19 @@
|
|
|
2980
2980
|
"license": "MIT"
|
|
2981
2981
|
},
|
|
2982
2982
|
"node_modules/electron-to-chromium": {
|
|
2983
|
-
"version": "1.5.
|
|
2984
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2985
|
-
"integrity": "sha512-
|
|
2983
|
+
"version": "1.5.344",
|
|
2984
|
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
|
|
2985
|
+
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
|
|
2986
2986
|
"license": "ISC"
|
|
2987
2987
|
},
|
|
2988
2988
|
"node_modules/enhanced-resolve": {
|
|
2989
|
-
"version": "5.
|
|
2990
|
-
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.
|
|
2991
|
-
"integrity": "sha512-
|
|
2989
|
+
"version": "5.21.0",
|
|
2990
|
+
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
|
2991
|
+
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
|
2992
2992
|
"license": "MIT",
|
|
2993
2993
|
"dependencies": {
|
|
2994
2994
|
"graceful-fs": "^4.2.4",
|
|
2995
|
-
"tapable": "^2.3.
|
|
2995
|
+
"tapable": "^2.3.3"
|
|
2996
2996
|
},
|
|
2997
2997
|
"engines": {
|
|
2998
2998
|
"node": ">=10.13.0"
|