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.
Files changed (75) hide show
  1. package/dist/__tests__/unit/deps-sync.test.js +6 -1
  2. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  3. package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts +2 -0
  4. package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/migrate-noindex-warning.test.js +115 -0
  6. package/dist/__tests__/unit/migrate-noindex-warning.test.js.map +1 -0
  7. package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts +2 -0
  8. package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js +43 -0
  10. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -0
  11. package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts +2 -0
  12. package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts.map +1 -0
  13. package/dist/__tests__/unit/snippet-scanner-sync.test.js +43 -0
  14. package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -0
  15. package/dist/__tests__/unit/validate-risky-expressions.test.d.ts +17 -0
  16. package/dist/__tests__/unit/validate-risky-expressions.test.d.ts.map +1 -0
  17. package/dist/__tests__/unit/validate-risky-expressions.test.js +111 -0
  18. package/dist/__tests__/unit/validate-risky-expressions.test.js.map +1 -0
  19. package/dist/__tests__/unit/validate-snippets.test.d.ts +19 -0
  20. package/dist/__tests__/unit/validate-snippets.test.d.ts.map +1 -0
  21. package/dist/__tests__/unit/validate-snippets.test.js +190 -0
  22. package/dist/__tests__/unit/validate-snippets.test.js.map +1 -0
  23. package/dist/commands/migrate/convert.d.ts +11 -0
  24. package/dist/commands/migrate/convert.d.ts.map +1 -1
  25. package/dist/commands/migrate/convert.js +22 -0
  26. package/dist/commands/migrate/convert.js.map +1 -1
  27. package/dist/commands/migrate/index.d.ts.map +1 -1
  28. package/dist/commands/migrate/index.js +6 -1
  29. package/dist/commands/migrate/index.js.map +1 -1
  30. package/dist/commands/validate.d.ts.map +1 -1
  31. package/dist/commands/validate.js +38 -0
  32. package/dist/commands/validate.js.map +1 -1
  33. package/dist/lib/deps.js +1 -1
  34. package/dist/lib/risky-expression-scanner.d.ts +29 -0
  35. package/dist/lib/risky-expression-scanner.d.ts.map +1 -0
  36. package/dist/lib/risky-expression-scanner.js +191 -0
  37. package/dist/lib/risky-expression-scanner.js.map +1 -0
  38. package/dist/lib/snippet-scanner.d.ts +78 -0
  39. package/dist/lib/snippet-scanner.d.ts.map +1 -0
  40. package/dist/lib/snippet-scanner.js +198 -0
  41. package/dist/lib/snippet-scanner.js.map +1 -0
  42. package/dist/lib/validate-risky-expressions.d.ts +34 -0
  43. package/dist/lib/validate-risky-expressions.d.ts.map +1 -0
  44. package/dist/lib/validate-risky-expressions.js +54 -0
  45. package/dist/lib/validate-risky-expressions.js.map +1 -0
  46. package/dist/lib/validate-snippets.d.ts +37 -0
  47. package/dist/lib/validate-snippets.d.ts.map +1 -0
  48. package/dist/lib/validate-snippets.js +71 -0
  49. package/dist/lib/validate-snippets.js.map +1 -0
  50. package/package.json +7 -2
  51. package/vendored/components/errors/MdxRenderBoundary.tsx +52 -0
  52. package/vendored/components/mdx/MDXComponents.tsx +3 -0
  53. package/vendored/components/mdx/MdxErrorBlock.tsx +42 -0
  54. package/vendored/lib/isr-build-executor.ts +13 -0
  55. package/vendored/lib/recma-collect-missing-refs.ts +51 -0
  56. package/vendored/lib/recma-guard-expressions.ts +151 -0
  57. package/vendored/lib/render-doc-page.tsx +121 -30
  58. package/vendored/lib/risky-expression-scanner.ts +221 -0
  59. package/vendored/lib/snippet-scanner.ts +237 -0
  60. package/vendored/lib/static-artifacts.ts +22 -10
  61. package/vendored/lib/static-file-route.ts +19 -8
  62. package/vendored/shared/status-reporter.ts +1 -1
  63. package/vendored/workspace-package-lock.json +7 -7
  64. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
  65. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
  66. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
  67. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
  68. package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
  69. package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
  70. package/dist/__tests__/unit/language-filter.test.js +0 -166
  71. package/dist/__tests__/unit/language-filter.test.js.map +0 -1
  72. package/dist/lib/language-filter.d.ts +0 -31
  73. package/dist/lib/language-filter.d.ts.map +0 -1
  74. package/dist/lib/language-filter.js +0 -14
  75. 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
- /** Block all crawlers - generates empty llms.txt */
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, noindex = false, visibility } = options;
185
+ const { name, description, baseUrl, pages, hostAtDocs = false, visibility } = options;
181
186
 
182
- if (noindex) {
183
- return '';
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
- /** Block all crawlers - generates empty file */
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, noindex = false, visibility } = options;
386
+ const { name, pages, visibility } = options;
376
387
 
377
- if (noindex) {
378
- return '';
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), and that subdomain carries
49
- * `x-jd-noindex: true` whenever the project has a custom domain. Suppressing it
50
- * here would 404 the metadata fetch and permanently break the widget's unread
51
- * dot for every custom-domain customer. It's a deliberately public artifact
52
- * (served with `Access-Control-Allow-Origin: *`), not a URL-inventory file.
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', 'llms.txt', 'llms-full.txt', 'feed.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 (!content) {
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.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.366",
2942
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz",
2943
- "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==",
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.1",
2948
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
2949
- "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=dev-workspace-symlinks.test.d.ts.map
@@ -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":""}