jamdesk 1.1.129 → 1.1.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/unit/deps-sync.test.js +6 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts +2 -0
- package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts.map +1 -0
- package/dist/__tests__/unit/migrate-noindex-warning.test.js +115 -0
- package/dist/__tests__/unit/migrate-noindex-warning.test.js.map +1 -0
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.js +43 -0
- package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -0
- package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/snippet-scanner-sync.test.js +43 -0
- package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -0
- package/dist/__tests__/unit/validate-risky-expressions.test.d.ts +17 -0
- package/dist/__tests__/unit/validate-risky-expressions.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-risky-expressions.test.js +111 -0
- package/dist/__tests__/unit/validate-risky-expressions.test.js.map +1 -0
- package/dist/__tests__/unit/validate-snippets.test.d.ts +19 -0
- package/dist/__tests__/unit/validate-snippets.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-snippets.test.js +190 -0
- package/dist/__tests__/unit/validate-snippets.test.js.map +1 -0
- package/dist/commands/migrate/convert.d.ts +11 -0
- package/dist/commands/migrate/convert.d.ts.map +1 -1
- package/dist/commands/migrate/convert.js +22 -0
- package/dist/commands/migrate/convert.js.map +1 -1
- package/dist/commands/migrate/index.d.ts.map +1 -1
- package/dist/commands/migrate/index.js +6 -1
- package/dist/commands/migrate/index.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +38 -0
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/deps.js +1 -1
- package/dist/lib/risky-expression-scanner.d.ts +29 -0
- package/dist/lib/risky-expression-scanner.d.ts.map +1 -0
- package/dist/lib/risky-expression-scanner.js +191 -0
- package/dist/lib/risky-expression-scanner.js.map +1 -0
- package/dist/lib/snippet-scanner.d.ts +78 -0
- package/dist/lib/snippet-scanner.d.ts.map +1 -0
- package/dist/lib/snippet-scanner.js +198 -0
- package/dist/lib/snippet-scanner.js.map +1 -0
- package/dist/lib/validate-risky-expressions.d.ts +34 -0
- package/dist/lib/validate-risky-expressions.d.ts.map +1 -0
- package/dist/lib/validate-risky-expressions.js +54 -0
- package/dist/lib/validate-risky-expressions.js.map +1 -0
- package/dist/lib/validate-snippets.d.ts +37 -0
- package/dist/lib/validate-snippets.d.ts.map +1 -0
- package/dist/lib/validate-snippets.js +71 -0
- package/dist/lib/validate-snippets.js.map +1 -0
- package/package.json +7 -2
- package/vendored/components/errors/MdxRenderBoundary.tsx +52 -0
- package/vendored/components/mdx/MDXComponents.tsx +3 -0
- package/vendored/components/mdx/MdxErrorBlock.tsx +42 -0
- package/vendored/lib/isr-build-executor.ts +13 -0
- package/vendored/lib/recma-collect-missing-refs.ts +51 -0
- package/vendored/lib/recma-guard-expressions.ts +151 -0
- package/vendored/lib/render-doc-page.tsx +121 -30
- package/vendored/lib/risky-expression-scanner.ts +221 -0
- package/vendored/lib/snippet-scanner.ts +237 -0
- package/vendored/lib/static-artifacts.ts +22 -10
- package/vendored/lib/static-file-route.ts +19 -8
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/workspace-package-lock.json +7 -7
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
- package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
- package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
- package/dist/__tests__/unit/language-filter.test.js +0 -166
- package/dist/__tests__/unit/language-filter.test.js.map +0 -1
- package/dist/lib/language-filter.d.ts +0 -31
- package/dist/lib/language-filter.d.ts.map +0 -1
- package/dist/lib/language-filter.js +0 -14
- package/dist/lib/language-filter.js.map +0 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risky-expression scanner
|
|
3
|
+
*
|
|
4
|
+
* Best-effort BUILD-TIME detector for MDX expressions that reference an
|
|
5
|
+
* undefined identifier and would therefore throw `ReferenceError` at render —
|
|
6
|
+
* the `{x, y}` class of bug (an author writes literal-looking braces in prose;
|
|
7
|
+
* MDX evaluates `{…}` as JavaScript). At render those throws are now neutralized
|
|
8
|
+
* by `recmaGuardExpressions` (silent drop, never a 500); this scanner exists
|
|
9
|
+
* only to TELL THE AUTHOR which expressions were dropped, via the
|
|
10
|
+
* `risky_expression` build warning.
|
|
11
|
+
*
|
|
12
|
+
* PRECISION over recall, by design. Runtime correctness is owned by the recma
|
|
13
|
+
* guard, so this scanner is purely advisory:
|
|
14
|
+
* - A false negative (missed risky expression) is harmless — the guard still
|
|
15
|
+
* drops it at render, no 500.
|
|
16
|
+
* - A false positive (warning on a valid expression) is annoying noise.
|
|
17
|
+
* So it only flags an expression when a free identifier is PROVABLY unbound, and
|
|
18
|
+
* it SKIPS any expression containing a function/arrow (whose params introduce
|
|
19
|
+
* local bindings this scanner deliberately does not model).
|
|
20
|
+
*
|
|
21
|
+
* Cloud Run does not compile MDX (ISR compiles on demand at request time), so
|
|
22
|
+
* this runs a lightweight `remark-parse` + `remark-mdx` parse to recover the
|
|
23
|
+
* expression and ESM (import/export) nodes with their attached estree.
|
|
24
|
+
*/
|
|
25
|
+
import { unified } from 'unified';
|
|
26
|
+
import remarkParse from 'remark-parse';
|
|
27
|
+
import remarkMdx from 'remark-mdx';
|
|
28
|
+
import { visit as visitMdast } from 'unist-util-visit';
|
|
29
|
+
import { visit as visitEstree } from 'estree-util-visit';
|
|
30
|
+
import type { Node as EsNode } from 'estree-jsx';
|
|
31
|
+
|
|
32
|
+
/** A single risky MDX expression found on a page. */
|
|
33
|
+
export interface RiskyExpressionIssue {
|
|
34
|
+
/** The expression source between the braces, e.g. `"x, y"`. */
|
|
35
|
+
expression: string;
|
|
36
|
+
/** The undefined identifier names that make it throw, e.g. `["x", "y"]`. */
|
|
37
|
+
undefinedRefs: string[];
|
|
38
|
+
/** Author-facing guidance — identical between build and CLI. */
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Identifiers always available to an MDX expression at render that are NOT
|
|
44
|
+
* file-level bindings: JS globals plus the MDX-provided scope. Whitelisted so a
|
|
45
|
+
* valid `{Math.max(…)}` / `{props.x}` / `{frontmatter.title}` never false-warns.
|
|
46
|
+
*/
|
|
47
|
+
const ALWAYS_IN_SCOPE = new Set<string>([
|
|
48
|
+
// MDX render scope
|
|
49
|
+
'props', 'frontmatter', 'arguments', 'undefined', 'null', 'this',
|
|
50
|
+
// value globals
|
|
51
|
+
'NaN', 'Infinity', 'globalThis', 'console',
|
|
52
|
+
'Math', 'JSON', 'Date', 'Object', 'Array', 'String', 'Number', 'Boolean',
|
|
53
|
+
'RegExp', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'Promise', 'Proxy',
|
|
54
|
+
'Reflect', 'BigInt', 'Error', 'Intl', 'URL', 'URLSearchParams',
|
|
55
|
+
// function globals
|
|
56
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'String', 'Number',
|
|
57
|
+
'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
|
|
58
|
+
'structuredClone', 'fetch', 'atob', 'btoa',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Recursively collect bound names from a destructuring/identifier pattern. */
|
|
62
|
+
function collectPatternNames(pattern: EsNode | null | undefined, out: Set<string>): void {
|
|
63
|
+
if (!pattern || typeof (pattern as EsNode).type !== 'string') return;
|
|
64
|
+
const node = pattern as Record<string, unknown> & { type: string };
|
|
65
|
+
switch (node.type) {
|
|
66
|
+
case 'Identifier':
|
|
67
|
+
out.add(node.name as string);
|
|
68
|
+
break;
|
|
69
|
+
case 'ObjectPattern':
|
|
70
|
+
for (const prop of (node.properties as EsNode[]) || []) {
|
|
71
|
+
const p = prop as Record<string, unknown> & { type: string };
|
|
72
|
+
if (p.type === 'RestElement') collectPatternNames(p.argument as EsNode, out);
|
|
73
|
+
else collectPatternNames(p.value as EsNode, out);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'ArrayPattern':
|
|
77
|
+
for (const el of (node.elements as (EsNode | null)[]) || []) collectPatternNames(el, out);
|
|
78
|
+
break;
|
|
79
|
+
case 'RestElement':
|
|
80
|
+
collectPatternNames(node.argument as EsNode, out);
|
|
81
|
+
break;
|
|
82
|
+
case 'AssignmentPattern':
|
|
83
|
+
collectPatternNames(node.left as EsNode, out);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Names that `import …`/`export …` introduce into expression scope. */
|
|
89
|
+
function collectBindingsFromEsm(estree: EsNode, out: Set<string>): void {
|
|
90
|
+
visitEstree(estree as never, (node) => {
|
|
91
|
+
const n = node as Record<string, unknown> & { type: string };
|
|
92
|
+
if (n.type === 'ImportDeclaration') {
|
|
93
|
+
for (const spec of (n.specifiers as EsNode[]) || []) {
|
|
94
|
+
const s = spec as unknown as { local?: { name?: string } };
|
|
95
|
+
if (s.local?.name) out.add(s.local.name);
|
|
96
|
+
}
|
|
97
|
+
} else if (n.type === 'ExportNamedDeclaration' || n.type === 'ExportDefaultDeclaration') {
|
|
98
|
+
const decl = n.declaration as (Record<string, unknown> & { type: string }) | undefined;
|
|
99
|
+
if (!decl) return;
|
|
100
|
+
if (decl.type === 'VariableDeclaration') {
|
|
101
|
+
for (const d of (decl.declarations as EsNode[]) || []) {
|
|
102
|
+
collectPatternNames((d as unknown as { id?: EsNode }).id, out);
|
|
103
|
+
}
|
|
104
|
+
} else if (
|
|
105
|
+
(decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
|
|
106
|
+
(decl.id as { name?: string } | undefined)?.name
|
|
107
|
+
) {
|
|
108
|
+
out.add((decl.id as { name: string }).name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when the estree contains any function/arrow (→ skip: local scopes). */
|
|
115
|
+
function containsFunction(estree: EsNode): boolean {
|
|
116
|
+
let found = false;
|
|
117
|
+
visitEstree(estree as never, (node) => {
|
|
118
|
+
const t = (node as EsNode).type;
|
|
119
|
+
if (
|
|
120
|
+
t === 'FunctionDeclaration' ||
|
|
121
|
+
t === 'FunctionExpression' ||
|
|
122
|
+
t === 'ArrowFunctionExpression'
|
|
123
|
+
) {
|
|
124
|
+
found = true;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collect the free identifier names *referenced* by a function-free expression.
|
|
132
|
+
* Excludes non-reference positions: non-computed member `.property`, non-shorthand
|
|
133
|
+
* object keys, and labels.
|
|
134
|
+
*/
|
|
135
|
+
function collectReferencedNames(estree: EsNode): string[] {
|
|
136
|
+
const names = new Set<string>();
|
|
137
|
+
visitEstree(estree as never, (node, _key, _index, ancestors) => {
|
|
138
|
+
if ((node as EsNode).type !== 'Identifier') return;
|
|
139
|
+
const name = (node as { name: string }).name;
|
|
140
|
+
const parent = ancestors[ancestors.length - 1] as
|
|
141
|
+
| (Record<string, unknown> & { type: string })
|
|
142
|
+
| undefined;
|
|
143
|
+
if (parent) {
|
|
144
|
+
// `a.b` — `b` is a property name, not a reference (unless computed `a[b]`).
|
|
145
|
+
if (parent.type === 'MemberExpression' && parent.computed === false && parent.property === node) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// `{ a: x }` — `a` is a key, not a reference (shorthand `{a}` IS a ref).
|
|
149
|
+
if (
|
|
150
|
+
parent.type === 'Property' &&
|
|
151
|
+
parent.computed === false &&
|
|
152
|
+
parent.key === node &&
|
|
153
|
+
parent.shorthand === false
|
|
154
|
+
) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
names.add(name);
|
|
159
|
+
});
|
|
160
|
+
return Array.from(names);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scan a page's MDX `content` for expressions that reference undefined
|
|
165
|
+
* identifiers and would throw at render (now silently dropped by the recma
|
|
166
|
+
* guard). Returns one issue per risky expression.
|
|
167
|
+
*
|
|
168
|
+
* @param content Raw MDX source of the page.
|
|
169
|
+
* @param pagePath Page path relative to the docs root (used in messages).
|
|
170
|
+
*/
|
|
171
|
+
export function findRiskyExpressions(content: string, pagePath: string): RiskyExpressionIssue[] {
|
|
172
|
+
let tree: ReturnType<ReturnType<typeof unified>['parse']>;
|
|
173
|
+
try {
|
|
174
|
+
tree = unified().use(remarkParse).use(remarkMdx).parse(content);
|
|
175
|
+
} catch {
|
|
176
|
+
// Malformed MDX (a true syntax error) is handled by the separate
|
|
177
|
+
// pre-compile guard; the scanner must never crash a build over a parse.
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Pass 1: file-level bindings from every import/export block.
|
|
182
|
+
const bindings = new Set<string>();
|
|
183
|
+
visitMdast(tree as never, 'mdxjsEsm', (node: { data?: { estree?: EsNode } }) => {
|
|
184
|
+
if (node.data?.estree) collectBindingsFromEsm(node.data.estree, bindings);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Pass 2: each text/flow expression — flag provably-unbound references.
|
|
188
|
+
const issues: RiskyExpressionIssue[] = [];
|
|
189
|
+
const expressionTypes = new Set(['mdxTextExpression', 'mdxFlowExpression']);
|
|
190
|
+
visitMdast(tree as never, (node: { type: string; value?: string; data?: { estree?: EsNode } }) => {
|
|
191
|
+
if (!expressionTypes.has(node.type)) return;
|
|
192
|
+
const estree = node.data?.estree;
|
|
193
|
+
if (!estree) return;
|
|
194
|
+
// Empty `{}` or comment-only `{/* … */}` expressions have no statement.
|
|
195
|
+
const body = (estree as { body?: EsNode[] }).body;
|
|
196
|
+
if (!body || body.length === 0) return;
|
|
197
|
+
if (containsFunction(estree)) return; // precision: don't model local scopes
|
|
198
|
+
|
|
199
|
+
const referenced = collectReferencedNames(estree);
|
|
200
|
+
const undefinedRefs = referenced.filter(
|
|
201
|
+
(name) => !bindings.has(name) && !ALWAYS_IN_SCOPE.has(name),
|
|
202
|
+
);
|
|
203
|
+
if (undefinedRefs.length === 0) return;
|
|
204
|
+
|
|
205
|
+
const expr = (node.value ?? '').trim();
|
|
206
|
+
const refList = undefinedRefs.map((r) => `\`${r}\``).join(', ');
|
|
207
|
+
// Backtick-wrap so the message renders cleanly as Markdown in the dashboard
|
|
208
|
+
// + build-warnings email (a bare `{x}` would be dropped as unknown HTML).
|
|
209
|
+
issues.push({
|
|
210
|
+
expression: expr,
|
|
211
|
+
undefinedRefs,
|
|
212
|
+
message:
|
|
213
|
+
`\`{${expr}}\` in \`${pagePath}\` references undefined ${refList}. ` +
|
|
214
|
+
`MDX evaluates \`{…}\` as JavaScript, so this expression was dropped to ` +
|
|
215
|
+
`avoid a render error. If you meant literal braces, escape them as ` +
|
|
216
|
+
`\`\\{${expr}\\}\` or write \`{'{${expr}}'}\`.`,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return issues;
|
|
221
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippet Scanner
|
|
3
|
+
*
|
|
4
|
+
* Pure scanner for author snippet-usage issues in MDX pages.
|
|
5
|
+
* Returns neutral SnippetIssue objects — no build-service-specific types —
|
|
6
|
+
* so the CLI (Task 3.4) can import this module directly without pulling in
|
|
7
|
+
* the rest of isr-build-executor (fast-glob, openapi validator, etc.).
|
|
8
|
+
*
|
|
9
|
+
* SINGLE SOURCE OF TRUTH: this module is re-exported from isr-build-executor.ts
|
|
10
|
+
* and synced to cli/src/lib/snippet-scanner.ts by vendor.js.
|
|
11
|
+
* Do NOT duplicate this logic elsewhere.
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT: This module must remain import-free (zero `import` statements).
|
|
14
|
+
* The CLI imports it directly to avoid pulling in heavy deps.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Strip fenced code blocks, inline code spans, YAML frontmatter, and HTML
|
|
19
|
+
* comments from MDX content so that examples demonstrating `<Snippet>` syntax
|
|
20
|
+
* do not trigger false-positive warnings.
|
|
21
|
+
*
|
|
22
|
+
* Four passes, applied in order:
|
|
23
|
+
*
|
|
24
|
+
* 0a. **YAML frontmatter** — ONLY when `content` begins at the very start of
|
|
25
|
+
* the string with a `---` fence line. Strips from that opening `---`
|
|
26
|
+
* through the matching closing `---` line. The regex is anchored to the
|
|
27
|
+
* string start so a `---` thematic-break (horizontal rule) in the middle
|
|
28
|
+
* of the body is NOT mistaken for frontmatter — body content after a
|
|
29
|
+
* mid-document `---` remains scannable.
|
|
30
|
+
*
|
|
31
|
+
* 0b. **HTML comments** — strips `<!--…-->` (non-greedy, multi-line) globally.
|
|
32
|
+
* An unterminated `<!--` with no closing `-->` does NOT match (conservative
|
|
33
|
+
* — leaves following content scannable).
|
|
34
|
+
*
|
|
35
|
+
* 1. **Fenced code blocks** — line-based state machine. A line whose
|
|
36
|
+
* trimmed-left text starts with three or more backticks (``` ` ```) or
|
|
37
|
+
* three or more tildes (`~~~`), with an optional language/info string after
|
|
38
|
+
* the opening fence, toggles fence state. Lines inside a fence are
|
|
39
|
+
* replaced with empty lines (newline structure is preserved so nothing on
|
|
40
|
+
* adjacent lines accidentally concatenates across a stripped region).
|
|
41
|
+
*
|
|
42
|
+
* 2. **Inline code spans** — a simple global replace of `` `…` `` (single-
|
|
43
|
+
* backtick spans, not crossing line boundaries) with a single space.
|
|
44
|
+
* Applied after fence removal so fence bodies are already gone.
|
|
45
|
+
*
|
|
46
|
+
* Pure function — zero imports, no side effects.
|
|
47
|
+
*/
|
|
48
|
+
function stripCodeRegions(content: string): string {
|
|
49
|
+
// Pass 0a: strip YAML frontmatter — ONLY when anchored at string start.
|
|
50
|
+
// The regex anchors at ^ (start of string, not start of line) so a `---`
|
|
51
|
+
// horizontal-rule in the middle of the body is never treated as frontmatter.
|
|
52
|
+
// Replacement is a single newline so that line offsets for subsequent passes
|
|
53
|
+
// are not dramatically shifted (body content stays intact).
|
|
54
|
+
let stripped = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, '\n');
|
|
55
|
+
|
|
56
|
+
// Pass 0b: strip HTML comments (non-greedy, multi-line).
|
|
57
|
+
// `[\s\S]*?` matches across newlines; non-greedy ensures each `-->` closes
|
|
58
|
+
// its nearest preceding `<!--`. An unterminated `<!--` with no `-->` leaves
|
|
59
|
+
// the rest of the content scannable (no match → no removal).
|
|
60
|
+
stripped = stripped.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
61
|
+
|
|
62
|
+
// Pass 1: strip fenced code blocks via a line-based state machine.
|
|
63
|
+
const lines = stripped.split('\n');
|
|
64
|
+
let inFence = false;
|
|
65
|
+
let fenceChar = ''; // '`' or '~'
|
|
66
|
+
let fenceLen = 0; // length of opening fence run (≥3)
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
const trimmed = lines[i].trimStart();
|
|
70
|
+
// Detect a fence marker: 3+ identical backticks or tildes at line start.
|
|
71
|
+
const fenceMatch = /^(`{3,}|~{3,})/.exec(trimmed);
|
|
72
|
+
if (fenceMatch) {
|
|
73
|
+
const ch = fenceMatch[1][0];
|
|
74
|
+
const len = fenceMatch[1].length;
|
|
75
|
+
if (!inFence) {
|
|
76
|
+
// Opening fence — enter fenced state; blank this line.
|
|
77
|
+
inFence = true;
|
|
78
|
+
fenceChar = ch;
|
|
79
|
+
fenceLen = len;
|
|
80
|
+
lines[i] = '';
|
|
81
|
+
} else if (ch === fenceChar && len >= fenceLen) {
|
|
82
|
+
// Closing fence — exit fenced state; blank this line.
|
|
83
|
+
inFence = false;
|
|
84
|
+
fenceChar = '';
|
|
85
|
+
fenceLen = 0;
|
|
86
|
+
lines[i] = '';
|
|
87
|
+
} else {
|
|
88
|
+
// A fence-like line INSIDE a fence (e.g. nested backticks in a tilde
|
|
89
|
+
// fence) — treat as content and blank it.
|
|
90
|
+
lines[i] = '';
|
|
91
|
+
}
|
|
92
|
+
} else if (inFence) {
|
|
93
|
+
// Inside a fence — blank the content line.
|
|
94
|
+
lines[i] = '';
|
|
95
|
+
}
|
|
96
|
+
// Outside a fence: leave the line unchanged.
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// An unterminated fence intentionally strips to EOF: if the loop ends with
|
|
100
|
+
// `inFence` still true, the author opened a fence and never closed it. Per
|
|
101
|
+
// CommonMark §4.5 (which micromark/MDX implement) an unterminated fence
|
|
102
|
+
// extends to the end of the document, so the renderer treats ALL post-fence
|
|
103
|
+
// content — including any <Snippet> — as literal code text. It never renders
|
|
104
|
+
// as a live component and cannot throw, so it genuinely is not live and must
|
|
105
|
+
// not warn. Restoring those post-fence lines would diverge from the renderer
|
|
106
|
+
// and reintroduce exactly the false-positive class this stripper fixes.
|
|
107
|
+
|
|
108
|
+
const withoutFences = lines.join('\n');
|
|
109
|
+
|
|
110
|
+
// Pass 2: strip inline code spans (single-backtick, no newlines).
|
|
111
|
+
return withoutFences.replace(/`[^`\n]+`/g, ' ');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalize a `/snippets/…` import path or a snippet filename (from
|
|
116
|
+
* `collectSnippetFiles`) to a consistent, extensionless key used for
|
|
117
|
+
* membership lookups.
|
|
118
|
+
*
|
|
119
|
+
* Strips, in order:
|
|
120
|
+
* 1. A leading `/snippets/` segment (import paths from author MDX).
|
|
121
|
+
* 2. Any remaining leading `/` (defensive — collectSnippetFiles returns
|
|
122
|
+
* relative paths without a leading slash, but import paths vary).
|
|
123
|
+
* 3. The file extension (`.mdx`, `.tsx`, `.jsx`), so a file on disk at
|
|
124
|
+
* `snippets/shared/card.tsx` (key `"shared/card"`) matches an import
|
|
125
|
+
* written as `/snippets/shared/card.mdx` (also normalizes to
|
|
126
|
+
* `"shared/card"`).
|
|
127
|
+
*
|
|
128
|
+
* SINGLE SOURCE OF TRUTH for import-path → key derivation — mirrors the role
|
|
129
|
+
* of `normalizeOpenApiRefKey` for OpenAPI refs. The build and CLI (Task 3.4)
|
|
130
|
+
* both call this so the matching logic can never drift.
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeSnippetRef(ref: string): string {
|
|
133
|
+
return ref
|
|
134
|
+
.replace(/^\/snippets\//, '') // strip /snippets/ prefix (import paths)
|
|
135
|
+
.replace(/^\//, '') // strip any remaining leading /
|
|
136
|
+
.replace(/\.(mdx|tsx|jsx)$/, ''); // strip recognized extensions
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A single author-facing issue found on a docs page relating to snippets.
|
|
141
|
+
* Intentionally NOT a `BuildWarning` — keeping the scanner pure (string in,
|
|
142
|
+
* issues out) lets the CLI (Task 3.4) map issues to its own output format
|
|
143
|
+
* without importing build-service-specific types.
|
|
144
|
+
*/
|
|
145
|
+
export interface SnippetIssue {
|
|
146
|
+
/** Discriminates the two warning variants. */
|
|
147
|
+
variant: 'unsupported_tag' | 'missing_import';
|
|
148
|
+
/**
|
|
149
|
+
* The file reference extracted from the tag's `file=` attr or the import
|
|
150
|
+
* path. `null` when a bare `<Snippet/>` with no `file=` attribute is used.
|
|
151
|
+
*/
|
|
152
|
+
ref: string | null;
|
|
153
|
+
/**
|
|
154
|
+
* Author-facing guidance message — identical between build and CLI so both
|
|
155
|
+
* surfaces give consistent advice.
|
|
156
|
+
*/
|
|
157
|
+
message: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scan a single page's MDX `content` and return every snippet-related author
|
|
162
|
+
* mistake as a `SnippetIssue`.
|
|
163
|
+
*
|
|
164
|
+
* Fenced code blocks (``` ``` ``` and `~~~`) and inline code spans are stripped
|
|
165
|
+
* from `content` before scanning, so examples in documentation that demonstrate
|
|
166
|
+
* `<Snippet>` syntax do not produce false-positive warnings.
|
|
167
|
+
*
|
|
168
|
+
* Two distinct variants are detected:
|
|
169
|
+
*
|
|
170
|
+
* 1. **`unsupported_tag`** — `<Snippet …/>` JSX tag usage. Mintlify supports
|
|
171
|
+
* this mechanism; Jamdesk does not. Our renderer replaces every `<Snippet>`
|
|
172
|
+
* with an inline error placeholder REGARDLESS of whether the referenced file
|
|
173
|
+
* exists — so the tag is always broken for the reader and always warnable.
|
|
174
|
+
* (An existence check here would wrongly suppress the common case where the
|
|
175
|
+
* author copied a Mintlify doc and the snippet file happens to exist.)
|
|
176
|
+
*
|
|
177
|
+
* 2. **`missing_import`** — `import X from '/snippets/…'` where the normalized
|
|
178
|
+
* target is NOT found among the project's snippet files. The supported
|
|
179
|
+
* pattern for importing snippets; only warn when the file is absent.
|
|
180
|
+
*
|
|
181
|
+
* @param content - Raw MDX source of the page being scanned.
|
|
182
|
+
* @param pagePath - Page path relative to the docs root (used in messages).
|
|
183
|
+
* @param snippetKeys - Extensionless snippet keys already reduced via
|
|
184
|
+
* `normalizeSnippetRef`; built ONCE by the caller from
|
|
185
|
+
* `collectSnippetFiles(…).map(normalizeSnippetRef)`.
|
|
186
|
+
*/
|
|
187
|
+
export function findSnippetIssues(
|
|
188
|
+
content: string,
|
|
189
|
+
pagePath: string,
|
|
190
|
+
snippetKeys: Set<string>,
|
|
191
|
+
): SnippetIssue[] {
|
|
192
|
+
const issues: SnippetIssue[] = [];
|
|
193
|
+
|
|
194
|
+
// Strip fenced code blocks and inline code spans so that documentation
|
|
195
|
+
// examples demonstrating <Snippet> syntax do not trigger false positives.
|
|
196
|
+
const scannableContent = stripCodeRegions(content);
|
|
197
|
+
|
|
198
|
+
// — Variant 1: <Snippet …> tag (unsupported, always warn) ——————————————
|
|
199
|
+
const tagRe = /<Snippet\b[^>]*>/g;
|
|
200
|
+
let tagMatch: RegExpExecArray | null;
|
|
201
|
+
while ((tagMatch = tagRe.exec(scannableContent)) !== null) {
|
|
202
|
+
const tagSrc = tagMatch[0];
|
|
203
|
+
// Try to extract the file= attribute value (single or double quotes).
|
|
204
|
+
const fileAttrMatch = /file=["']([^"']+)["']/.exec(tagSrc);
|
|
205
|
+
const ref = fileAttrMatch ? fileAttrMatch[1] : null;
|
|
206
|
+
// Backtick-wrap the leading `<Snippet>` token: these messages render as
|
|
207
|
+
// Markdown/HTML in the dashboard + build-warnings email, where a bare
|
|
208
|
+
// <Snippet> would be parsed as an unknown HTML tag and silently dropped.
|
|
209
|
+
const message = ref
|
|
210
|
+
? `\`<Snippet>\` is not supported — convert to an import: \`import X from '/snippets/${ref}'\` then \`<X/>\`.`
|
|
211
|
+
: `\`<Snippet>\` is not supported — convert to an import: \`import X from '/snippets/<file>'\` then \`<X/>\`.`;
|
|
212
|
+
issues.push({ variant: 'unsupported_tag', ref, message });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// — Variant 2: import … from '/snippets/…' with a missing target ————————
|
|
216
|
+
// Absolute /snippets/ only: Jamdesk imports are root-relative
|
|
217
|
+
// (next-mdx-remote only resolves `/snippets/<name>`). Anchoring the capture
|
|
218
|
+
// at `/snippets/` rejects `./snippets/foo` / `../snippets/foo` — those would
|
|
219
|
+
// survive normalizeSnippetRef's strip and produce a spurious missing_import.
|
|
220
|
+
const importRe = /import\s+[^'"]*from\s+['"](\/snippets\/[^'"]+)['"]/g;
|
|
221
|
+
let importMatch: RegExpExecArray | null;
|
|
222
|
+
while ((importMatch = importRe.exec(scannableContent)) !== null) {
|
|
223
|
+
const importPath = importMatch[1];
|
|
224
|
+
const key = normalizeSnippetRef(importPath);
|
|
225
|
+
if (!snippetKeys.has(key)) {
|
|
226
|
+
// Extract just the filename for the "checked" hint in the message.
|
|
227
|
+
const fileName = importPath.replace(/^.*\/snippets\//, '');
|
|
228
|
+
issues.push({
|
|
229
|
+
variant: 'missing_import',
|
|
230
|
+
ref: importPath,
|
|
231
|
+
message: `Snippet \`${fileName}\` imported in \`${pagePath}\` was not found; checked \`snippets/${fileName}\`.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return issues;
|
|
237
|
+
}
|
|
@@ -157,7 +157,12 @@ export interface LlmsTxtOptions {
|
|
|
157
157
|
pages: PageMetadata[];
|
|
158
158
|
/** Whether docs are hosted at /docs path */
|
|
159
159
|
hostAtDocs?: boolean;
|
|
160
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Site-level noindex flag (from seo.metatags.robots). Passed through for
|
|
162
|
+
* interface parity with other artifact options but NOT used to blank the
|
|
163
|
+
* output — AI agents fetch llms.txt directly and do not honor robots
|
|
164
|
+
* directives. Per-page filtering (hidden / noindex frontmatter) still applies.
|
|
165
|
+
*/
|
|
161
166
|
noindex?: boolean;
|
|
162
167
|
/**
|
|
163
168
|
* Visibility inputs from lib/visibility.ts. When provided, filtering
|
|
@@ -177,11 +182,12 @@ export interface LlmsTxtOptions {
|
|
|
177
182
|
* @returns Plain text string
|
|
178
183
|
*/
|
|
179
184
|
export function generateLlmsTxt(options: LlmsTxtOptions): string {
|
|
180
|
-
const { name, description, baseUrl, pages, hostAtDocs = false,
|
|
185
|
+
const { name, description, baseUrl, pages, hostAtDocs = false, visibility } = options;
|
|
181
186
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
187
|
+
// NOTE: noindex is intentionally NOT respected here. AI agents fetch llms.txt
|
|
188
|
+
// directly and do not honor robots directives, so this file is generated
|
|
189
|
+
// regardless of the site's indexing state. Per-page hidden/noindex filtering
|
|
190
|
+
// (via isPageMetadataIncluded below) still applies.
|
|
185
191
|
|
|
186
192
|
const urlPrefix = hostAtDocs ? '/docs' : '';
|
|
187
193
|
|
|
@@ -354,7 +360,12 @@ export interface LlmsFullTxtOptions {
|
|
|
354
360
|
name: string;
|
|
355
361
|
/** Pages with full content */
|
|
356
362
|
pages: LlmsFullPageInfo[];
|
|
357
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Site-level noindex flag (from seo.metatags.robots). Passed through for
|
|
365
|
+
* interface parity with other artifact options but NOT used to blank the
|
|
366
|
+
* output — AI agents fetch llms-full.txt directly and do not honor robots
|
|
367
|
+
* directives. Per-page filtering (hidden / noindex frontmatter) still applies.
|
|
368
|
+
*/
|
|
358
369
|
noindex?: boolean;
|
|
359
370
|
/**
|
|
360
371
|
* Visibility inputs from lib/visibility.ts. When provided, filtering
|
|
@@ -372,11 +383,12 @@ export interface LlmsFullTxtOptions {
|
|
|
372
383
|
* giving consistent hidden/orphan/searchable handling across all artifacts.
|
|
373
384
|
*/
|
|
374
385
|
export function generateLlmsFullTxt(options: LlmsFullTxtOptions): string {
|
|
375
|
-
const { name, pages,
|
|
386
|
+
const { name, pages, visibility } = options;
|
|
376
387
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
// NOTE: noindex is intentionally NOT respected here. AI agents fetch
|
|
389
|
+
// llms-full.txt directly and do not honor robots directives, so this file is
|
|
390
|
+
// generated regardless of the site's indexing state. Per-page hidden/noindex
|
|
391
|
+
// filtering (via computePageVisibility/frontmatter checks below) still applies.
|
|
380
392
|
|
|
381
393
|
const visiblePages = pages.filter(p => {
|
|
382
394
|
const path = p.path.replace(/\.mdx?$/, '');
|
|
@@ -37,6 +37,12 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
37
37
|
* (`x-jd-noindex: true`). These advertise the project's URL inventory and
|
|
38
38
|
* have no business being served from a non-canonical host.
|
|
39
39
|
*
|
|
40
|
+
* `x-jd-noindex: true` is set by `proxy.ts` (`projectHeaderOptsForCanonical`)
|
|
41
|
+
* when a project uses `hostAtDocs` WITHOUT a custom domain — i.e. its public
|
|
42
|
+
* face is the raw `<slug>.jamdesk.app` subdomain with no canonical elsewhere.
|
|
43
|
+
* Once a custom domain is registered, the header is replaced by a canonical-
|
|
44
|
+
* host override and noindex is no longer set.
|
|
45
|
+
*
|
|
40
46
|
* `robots.txt` is handled separately — it returns a Disallow-all directive,
|
|
41
47
|
* not a 404, so crawlers get an explicit "don't crawl" signal.
|
|
42
48
|
*
|
|
@@ -45,20 +51,25 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
45
51
|
*
|
|
46
52
|
* `changelog.json` is intentionally NOT in this list either, and must stay out:
|
|
47
53
|
* the embeddable widget always fetches it from `<slug>.jamdesk.app` (custom
|
|
48
|
-
* domains can't serve the `?embed=1` render),
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* domains can't serve the `?embed=1` render), so suppressing it here would 404
|
|
55
|
+
* the metadata fetch and permanently break the widget's unread dot for every
|
|
56
|
+
* custom-domain customer. It's a deliberately public artifact (served with
|
|
57
|
+
* `Access-Control-Allow-Origin: *`), not a URL-inventory file.
|
|
58
|
+
*
|
|
59
|
+
* `llms.txt` and `llms-full.txt` are intentionally NOT in this list: AI agents
|
|
60
|
+
* (ChatGPT, Perplexity, Claude, etc.) fetch these files directly and do not
|
|
61
|
+
* honor robots directives. Suppressing them would silently exclude noindex
|
|
62
|
+
* projects from AI-agent discovery entirely, with no benefit to crawl hygiene.
|
|
53
63
|
*/
|
|
54
64
|
const NOINDEX_SUPPRESSED_FILES = new Set([
|
|
55
|
-
'sitemap.xml', '
|
|
65
|
+
'sitemap.xml', 'feed.xml',
|
|
56
66
|
]);
|
|
57
67
|
|
|
58
68
|
/**
|
|
59
69
|
* Build a no-store synthetic response for direct upstream subdomain hits, or
|
|
60
70
|
* return null if this filename should fall through to the R2 fetch even when
|
|
61
|
-
* `x-jd-noindex: true` is set (i.e., `search-data.json`
|
|
71
|
+
* `x-jd-noindex: true` is set (i.e., `search-data.json`, `changelog.json`,
|
|
72
|
+
* `llms.txt`, `llms-full.txt` — see `NOINDEX_SUPPRESSED_FILES` for the why).
|
|
62
73
|
*
|
|
63
74
|
* Cache-Control: no-store — the noindex flag flips off the moment a project
|
|
64
75
|
* registers a customDomain; CDN edge caching would lock new customers out
|
|
@@ -168,7 +179,7 @@ export function createStaticFileHandler(
|
|
|
168
179
|
try {
|
|
169
180
|
const content = await fetchStaticFile(projectSlug, filename);
|
|
170
181
|
|
|
171
|
-
if (
|
|
182
|
+
if (content === null) {
|
|
172
183
|
log('warn', `${label} not found in R2`, { projectSlug });
|
|
173
184
|
return new NextResponse(`${label} not found`, { status: 404 });
|
|
174
185
|
}
|
|
@@ -22,7 +22,7 @@ export interface ProgressUpdate {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Warning types that can occur during builds (non-blocking) */
|
|
25
|
-
export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_page' | 'missing_openapi_ref' | 'inline_code_on_api_page' | 'invalid_openapi_spec';
|
|
25
|
+
export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_page' | 'missing_openapi_ref' | 'inline_code_on_api_page' | 'invalid_openapi_spec' | 'missing_snippet' | 'risky_expression';
|
|
26
26
|
|
|
27
27
|
/** Build warning structure */
|
|
28
28
|
export interface BuildWarning {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"shiki": "^4.0.1",
|
|
62
62
|
"tailwindcss": "^4.2.4",
|
|
63
63
|
"typescript": "^6.0.3",
|
|
64
|
-
"unified": "^11.0.
|
|
64
|
+
"unified": "^11.0.5",
|
|
65
65
|
"unist-util-visit": "^5.1.0"
|
|
66
66
|
}
|
|
67
67
|
},
|
|
@@ -2938,15 +2938,15 @@
|
|
|
2938
2938
|
"license": "MIT"
|
|
2939
2939
|
},
|
|
2940
2940
|
"node_modules/electron-to-chromium": {
|
|
2941
|
-
"version": "1.5.
|
|
2942
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2943
|
-
"integrity": "sha512-
|
|
2941
|
+
"version": "1.5.367",
|
|
2942
|
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz",
|
|
2943
|
+
"integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==",
|
|
2944
2944
|
"license": "ISC"
|
|
2945
2945
|
},
|
|
2946
2946
|
"node_modules/enhanced-resolve": {
|
|
2947
|
-
"version": "5.22.
|
|
2948
|
-
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.
|
|
2949
|
-
"integrity": "sha512-
|
|
2947
|
+
"version": "5.22.2",
|
|
2948
|
+
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
|
|
2949
|
+
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
|
|
2950
2950
|
"license": "MIT",
|
|
2951
2951
|
"dependencies": {
|
|
2952
2952
|
"graceful-fs": "^4.2.4",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dev-workspace-symlinks.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/dev-workspace-symlinks.test.ts"],"names":[],"mappings":""}
|