jamdesk 1.1.129 → 1.1.131

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 (91) 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-mdx.test.js +54 -0
  4. package/dist/__tests__/unit/migrate-mdx.test.js.map +1 -1
  5. package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts +2 -0
  6. package/dist/__tests__/unit/migrate-noindex-warning.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/migrate-noindex-warning.test.js +115 -0
  8. package/dist/__tests__/unit/migrate-noindex-warning.test.js.map +1 -0
  9. package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts +2 -0
  10. package/dist/__tests__/unit/risky-expression-scanner-sync.test.d.ts.map +1 -0
  11. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js +43 -0
  12. package/dist/__tests__/unit/risky-expression-scanner-sync.test.js.map +1 -0
  13. package/dist/__tests__/unit/snippet-component-convert.test.d.ts +2 -0
  14. package/dist/__tests__/unit/snippet-component-convert.test.d.ts.map +1 -0
  15. package/dist/__tests__/unit/snippet-component-convert.test.js +105 -0
  16. package/dist/__tests__/unit/snippet-component-convert.test.js.map +1 -0
  17. package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts +2 -0
  18. package/dist/__tests__/unit/snippet-scanner-sync.test.d.ts.map +1 -0
  19. package/dist/__tests__/unit/snippet-scanner-sync.test.js +43 -0
  20. package/dist/__tests__/unit/snippet-scanner-sync.test.js.map +1 -0
  21. package/dist/__tests__/unit/validate-risky-expressions.test.d.ts +17 -0
  22. package/dist/__tests__/unit/validate-risky-expressions.test.d.ts.map +1 -0
  23. package/dist/__tests__/unit/validate-risky-expressions.test.js +111 -0
  24. package/dist/__tests__/unit/validate-risky-expressions.test.js.map +1 -0
  25. package/dist/__tests__/unit/validate-snippets.test.d.ts +19 -0
  26. package/dist/__tests__/unit/validate-snippets.test.d.ts.map +1 -0
  27. package/dist/__tests__/unit/validate-snippets.test.js +190 -0
  28. package/dist/__tests__/unit/validate-snippets.test.js.map +1 -0
  29. package/dist/commands/migrate/convert-mdx.d.ts +12 -0
  30. package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
  31. package/dist/commands/migrate/convert-mdx.js +72 -2
  32. package/dist/commands/migrate/convert-mdx.js.map +1 -1
  33. package/dist/commands/migrate/convert.d.ts +11 -0
  34. package/dist/commands/migrate/convert.d.ts.map +1 -1
  35. package/dist/commands/migrate/convert.js +22 -0
  36. package/dist/commands/migrate/convert.js.map +1 -1
  37. package/dist/commands/migrate/index.d.ts.map +1 -1
  38. package/dist/commands/migrate/index.js +12 -8
  39. package/dist/commands/migrate/index.js.map +1 -1
  40. package/dist/commands/migrate/snippet-component-convert.d.ts +45 -0
  41. package/dist/commands/migrate/snippet-component-convert.d.ts.map +1 -0
  42. package/dist/commands/migrate/snippet-component-convert.js +222 -0
  43. package/dist/commands/migrate/snippet-component-convert.js.map +1 -0
  44. package/dist/commands/validate.d.ts.map +1 -1
  45. package/dist/commands/validate.js +39 -0
  46. package/dist/commands/validate.js.map +1 -1
  47. package/dist/lib/deps.d.ts.map +1 -1
  48. package/dist/lib/deps.js +4 -1
  49. package/dist/lib/deps.js.map +1 -1
  50. package/dist/lib/risky-expression-scanner.d.ts +32 -0
  51. package/dist/lib/risky-expression-scanner.d.ts.map +1 -0
  52. package/dist/lib/risky-expression-scanner.js +195 -0
  53. package/dist/lib/risky-expression-scanner.js.map +1 -0
  54. package/dist/lib/snippet-scanner.d.ts +78 -0
  55. package/dist/lib/snippet-scanner.d.ts.map +1 -0
  56. package/dist/lib/snippet-scanner.js +198 -0
  57. package/dist/lib/snippet-scanner.js.map +1 -0
  58. package/dist/lib/validate-risky-expressions.d.ts +34 -0
  59. package/dist/lib/validate-risky-expressions.d.ts.map +1 -0
  60. package/dist/lib/validate-risky-expressions.js +54 -0
  61. package/dist/lib/validate-risky-expressions.js.map +1 -0
  62. package/dist/lib/validate-snippets.d.ts +37 -0
  63. package/dist/lib/validate-snippets.d.ts.map +1 -0
  64. package/dist/lib/validate-snippets.js +71 -0
  65. package/dist/lib/validate-snippets.js.map +1 -0
  66. package/package.json +7 -2
  67. package/vendored/components/errors/MdxRenderBoundary.tsx +52 -0
  68. package/vendored/components/mdx/MDXComponents.tsx +3 -0
  69. package/vendored/components/mdx/MdxErrorBlock.tsx +42 -0
  70. package/vendored/lib/isr-build-executor.ts +13 -0
  71. package/vendored/lib/recma-collect-missing-refs.ts +51 -0
  72. package/vendored/lib/recma-guard-expressions.ts +230 -0
  73. package/vendored/lib/render-doc-page.tsx +121 -30
  74. package/vendored/lib/risky-expression-scanner.ts +225 -0
  75. package/vendored/lib/snippet-scanner.ts +237 -0
  76. package/vendored/lib/static-artifacts.ts +22 -10
  77. package/vendored/lib/static-file-route.ts +19 -8
  78. package/vendored/shared/status-reporter.ts +1 -1
  79. package/vendored/workspace-package-lock.json +11 -10
  80. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
  81. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
  82. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
  83. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
  84. package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
  85. package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
  86. package/dist/__tests__/unit/language-filter.test.js +0 -166
  87. package/dist/__tests__/unit/language-filter.test.js.map +0 -1
  88. package/dist/lib/language-filter.d.ts +0 -31
  89. package/dist/lib/language-filter.d.ts.map +0 -1
  90. package/dist/lib/language-filter.js +0 -14
  91. package/dist/lib/language-filter.js.map +0 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Risky-expression Validation (CLI)
3
+ *
4
+ * Scans MDX pages for `{x, y}`-style expressions that reference an undefined
5
+ * identifier — the literal-looking-braces bug (MDX evaluates `{…}` as
6
+ * JavaScript). On the cloud these throw at render but are silently dropped by
7
+ * the recma guard (never a 500) and surfaced as a `risky_expression` build
8
+ * warning. The CLI runs the SAME scanner locally so authors catch the dropped
9
+ * expression before pushing.
10
+ *
11
+ * Reuses the pure scanner from risky-expression-scanner.ts (synced from
12
+ * build-service) so the matching logic is byte-identical to the cloud build —
13
+ * no drift. PRECISION over recall, by design (see the scanner's header).
14
+ *
15
+ * Scans EVERY project MDX file (not just navigation pages), mirroring the
16
+ * cloud build's per-file scan in build.ts.
17
+ */
18
+ import fs from 'fs-extra';
19
+ import path from 'path';
20
+ import { findRiskyExpressions } from './risky-expression-scanner.js';
21
+ const RISKY_EXPRESSION_WARNING_CAP = 50;
22
+ /**
23
+ * Validate risky `{…}` expressions across the project's MDX files.
24
+ *
25
+ * @param projectDir - Absolute path to the docs project root.
26
+ * @param mdxFiles - MDX/MD file paths to scan. Each may be absolute or
27
+ * relative to projectDir. Callers should pass the SAME
28
+ * full project walk used for image-ref/snippet validation.
29
+ * @returns Array of RiskyExpressionWarning (empty when clean). Never throws.
30
+ */
31
+ export async function validateRiskyExpressions(projectDir, mdxFiles) {
32
+ const warnings = [];
33
+ for (const file of mdxFiles) {
34
+ if (warnings.length >= RISKY_EXPRESSION_WARNING_CAP)
35
+ break;
36
+ const abs = path.isAbsolute(file) ? file : path.join(projectDir, file);
37
+ let content;
38
+ try {
39
+ content = await fs.readFile(abs, 'utf-8');
40
+ }
41
+ catch {
42
+ continue; // missing file is its own check elsewhere
43
+ }
44
+ const relFile = path.relative(projectDir, abs);
45
+ const issues = findRiskyExpressions(content, relFile);
46
+ for (const issue of issues) {
47
+ if (warnings.length >= RISKY_EXPRESSION_WARNING_CAP)
48
+ break;
49
+ warnings.push({ file: relFile, message: issue.message });
50
+ }
51
+ }
52
+ return warnings;
53
+ }
54
+ //# sourceMappingURL=validate-risky-expressions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-risky-expressions.js","sourceRoot":"","sources":["../../src/lib/validate-risky-expressions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,MAAM,UAAU,CAAC;AAC1B,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AASrE,MAAM,4BAA4B,GAAG,EAAE,CAAC;AAExC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,UAAkB,EAClB,QAAkB;IAElB,MAAM,QAAQ,GAA6B,EAAE,CAAC;IAE9C,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,IAAI,QAAQ,CAAC,MAAM,IAAI,4BAA4B;YAAE,MAAM;QAE3D,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;QACvE,IAAI,OAAe,CAAC;QACpB,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAC5C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS,CAAC,0CAA0C;QACtD,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,oBAAoB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACtD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,IAAI,QAAQ,CAAC,MAAM,IAAI,4BAA4B;gBAAE,MAAM;YAC3D,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3D,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Snippet Validation (CLI)
3
+ *
4
+ * Scans MDX pages for snippet-related author issues that would surface as
5
+ * build warnings on the cloud: (1) `<Snippet>` tag usage, which Jamdesk does
6
+ * not support; (2) `import … from '/snippets/…'` that references a file that
7
+ * doesn't exist in the project's snippets/ directory.
8
+ *
9
+ * Reuses the pure scanner from snippet-scanner.ts (synced from build-service)
10
+ * so the matching logic is byte-identical to the cloud build — no drift.
11
+ *
12
+ * Scans EVERY project MDX file (not just navigation pages), mirroring the
13
+ * cloud build's per-file scan in build.ts — an orphan page with a `<Snippet>`
14
+ * tag warns at build time, so the CLI must surface it too.
15
+ */
16
+ import { type SnippetIssue } from './snippet-scanner.js';
17
+ export interface SnippetWarning {
18
+ /** Page path relative to projectDir (e.g. "guide/intro.mdx") */
19
+ file: string;
20
+ /** Discriminates the two warning variants (drives the CLI hint copy). */
21
+ variant: SnippetIssue['variant'];
22
+ /** Author-facing guidance message */
23
+ message: string;
24
+ }
25
+ /**
26
+ * Validate snippet usage across the project's MDX files.
27
+ *
28
+ * @param projectDir - Absolute path to the docs project root.
29
+ * @param mdxFiles - MDX/MD file paths to scan. Each may be absolute or
30
+ * relative to projectDir. Callers should pass the SAME
31
+ * full project walk used for image-ref validation so the
32
+ * CLI surfaces the same issues as the cloud build
33
+ * (orphan pages included, not just navigation pages).
34
+ * @returns Array of SnippetWarning (empty when clean). Never throws.
35
+ */
36
+ export declare function validateSnippets(projectDir: string, mdxFiles: string[]): Promise<SnippetWarning[]>;
37
+ //# sourceMappingURL=validate-snippets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-snippets.d.ts","sourceRoot":"","sources":["../../src/lib/validate-snippets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,EAA0C,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEjG,MAAM,WAAW,cAAc;IAC7B,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC;IACjC,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;CACjB;AAqBD;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAAE,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAwB3B"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Snippet Validation (CLI)
3
+ *
4
+ * Scans MDX pages for snippet-related author issues that would surface as
5
+ * build warnings on the cloud: (1) `<Snippet>` tag usage, which Jamdesk does
6
+ * not support; (2) `import … from '/snippets/…'` that references a file that
7
+ * doesn't exist in the project's snippets/ directory.
8
+ *
9
+ * Reuses the pure scanner from snippet-scanner.ts (synced from build-service)
10
+ * so the matching logic is byte-identical to the cloud build — no drift.
11
+ *
12
+ * Scans EVERY project MDX file (not just navigation pages), mirroring the
13
+ * cloud build's per-file scan in build.ts — an orphan page with a `<Snippet>`
14
+ * tag warns at build time, so the CLI must surface it too.
15
+ */
16
+ import fs from 'fs-extra';
17
+ import path from 'path';
18
+ import { findSnippetIssues, normalizeSnippetRef } from './snippet-scanner.js';
19
+ /**
20
+ * Walk the project's snippet files and return the extensionless key set used
21
+ * by `findSnippetIssues` for membership lookups.
22
+ */
23
+ async function collectSnippetKeys(projectDir) {
24
+ const snippetsDir = path.join(projectDir, 'snippets');
25
+ if (!(await fs.pathExists(snippetsDir))) {
26
+ return new Set();
27
+ }
28
+ const { glob } = await import('glob');
29
+ const files = await glob('**/*.{mdx,tsx,jsx}', {
30
+ cwd: snippetsDir,
31
+ ignore: ['node_modules/**'],
32
+ });
33
+ return new Set(files.map(normalizeSnippetRef));
34
+ }
35
+ const SNIPPET_WARNING_CAP = 50;
36
+ /**
37
+ * Validate snippet usage across the project's MDX files.
38
+ *
39
+ * @param projectDir - Absolute path to the docs project root.
40
+ * @param mdxFiles - MDX/MD file paths to scan. Each may be absolute or
41
+ * relative to projectDir. Callers should pass the SAME
42
+ * full project walk used for image-ref validation so the
43
+ * CLI surfaces the same issues as the cloud build
44
+ * (orphan pages included, not just navigation pages).
45
+ * @returns Array of SnippetWarning (empty when clean). Never throws.
46
+ */
47
+ export async function validateSnippets(projectDir, mdxFiles) {
48
+ const snippetKeys = await collectSnippetKeys(projectDir);
49
+ const warnings = [];
50
+ for (const file of mdxFiles) {
51
+ if (warnings.length >= SNIPPET_WARNING_CAP)
52
+ break;
53
+ const abs = path.isAbsolute(file) ? file : path.join(projectDir, file);
54
+ let content;
55
+ try {
56
+ content = await fs.readFile(abs, 'utf-8');
57
+ }
58
+ catch {
59
+ continue; // missing file is its own check elsewhere
60
+ }
61
+ const relFile = path.relative(projectDir, abs);
62
+ const issues = findSnippetIssues(content, relFile, snippetKeys);
63
+ for (const issue of issues) {
64
+ if (warnings.length >= SNIPPET_WARNING_CAP)
65
+ break;
66
+ warnings.push({ file: relFile, variant: issue.variant, message: issue.message });
67
+ }
68
+ }
69
+ return warnings;
70
+ }
71
+ //# sourceMappingURL=validate-snippets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-snippets.js","sourceRoot":"","sources":["../../src/lib/validate-snippets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,MAAM,UAAU,CAAC;AAC1B,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAqB,MAAM,sBAAsB,CAAC;AAWjG;;;GAGG;AACH,KAAK,UAAU,kBAAkB,CAAC,UAAkB;IAClD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;IACtD,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACxC,OAAO,IAAI,GAAG,EAAE,CAAC;IACnB,CAAC;IACD,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,CAAC;IACtC,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,oBAAoB,EAAE;QAC7C,GAAG,EAAE,WAAW;QAChB,MAAM,EAAE,CAAC,iBAAiB,CAAC;KAC5B,CAAC,CAAC;IACH,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,UAAkB,EAClB,QAAkB;IAElB,MAAM,WAAW,GAAG,MAAM,kBAAkB,CAAC,UAAU,CAAC,CAAC;IACzD,MAAM,QAAQ,GAAqB,EAAE,CAAC;IAEtC,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,IAAI,QAAQ,CAAC,MAAM,IAAI,mBAAmB;YAAE,MAAM;QAElD,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;QACvE,IAAI,OAAe,CAAC;QACpB,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAC5C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS,CAAC,0CAA0C;QACtD,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;QAChE,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,IAAI,QAAQ,CAAC,MAAM,IAAI,mBAAmB;gBAAE,MAAM;YAClD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.129",
3
+ "version": "1.1.131",
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",
@@ -108,6 +108,7 @@
108
108
  "chalk": "^5.3.0",
109
109
  "commander": "^14.0.3",
110
110
  "dictionary-en": "^4.0.0",
111
+ "estree-util-visit": "^2.0.0",
111
112
  "fastest-levenshtein": "^1.0.16",
112
113
  "fs-extra": "^11.2.0",
113
114
  "github-slugger": "^2.0.0",
@@ -118,7 +119,11 @@
118
119
  "nspell": "^2.1.5",
119
120
  "open": "^11.0.0",
120
121
  "ora": "^9.4.0",
121
- "tar": "^7.5.15"
122
+ "remark-mdx": "^3.1.1",
123
+ "remark-parse": "^11.0.0",
124
+ "tar": "^7.5.15",
125
+ "unified": "^11.0.5",
126
+ "unist-util-visit": "^5.1.0"
122
127
  },
123
128
  "devDependencies": {
124
129
  "@mdx-js/mdx": "^3.1.1",
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * MdxRenderBoundary — request-time error boundary around `<MDXRemote>`.
5
+ *
6
+ * User MDX is compiled and rendered at request time. A render-time throw —
7
+ * an undefined component ("Expected component `Snippet` to be defined") or a
8
+ * bare `{x, y}` expression (ReferenceError) — currently returns HTTP 500 with
9
+ * no boundary. These errors throw at RENDER (not compile), so only a render
10
+ * boundary can catch them. The compiled fallback is therefore the last line of
11
+ * defense for "docs never 500".
12
+ *
13
+ * Hand-rolled class component (React error boundaries require a class; there is
14
+ * no Hooks equivalent). NO new dependency — `react-error-boundary` is
15
+ * deliberately not used.
16
+ *
17
+ * `fallback` is a server-rendered element constructed by the server parent
18
+ * (e.g. `<MdxErrorBlock/>`, a server component) and passed in as a prop, so the
19
+ * client boundary never needs to import a server component.
20
+ */
21
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
22
+
23
+ interface MdxRenderBoundaryProps {
24
+ children: ReactNode;
25
+ fallback: ReactNode;
26
+ }
27
+
28
+ interface MdxRenderBoundaryState {
29
+ hasError: boolean;
30
+ }
31
+
32
+ export class MdxRenderBoundary extends Component<
33
+ MdxRenderBoundaryProps,
34
+ MdxRenderBoundaryState
35
+ > {
36
+ state: MdxRenderBoundaryState = { hasError: false };
37
+
38
+ static getDerivedStateFromError(): MdxRenderBoundaryState {
39
+ return { hasError: true };
40
+ }
41
+
42
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
43
+ console.error('[mdx-render-boundary] caught render error', error);
44
+ // componentStack names the compiled MDX node that threw — the most useful
45
+ // triage signal when user MDX 500s in production.
46
+ console.error('[mdx-render-boundary] component stack', errorInfo.componentStack);
47
+ }
48
+
49
+ render() {
50
+ return this.state.hasError ? this.props.fallback : this.props.children;
51
+ }
52
+ }
@@ -43,6 +43,7 @@ import { View, ViewProvider, ViewSelector, ViewWrapper } from './View';
43
43
  import { YouTube } from './YouTube';
44
44
  import { Video } from './Video';
45
45
  import { Visibility } from './Visibility';
46
+ import { MdxErrorBlock } from './MdxErrorBlock';
46
47
 
47
48
  /**
48
49
  * Extract language from a pre element for tab label
@@ -253,6 +254,8 @@ export const MDXComponents = {
253
254
  // search index). This React component is a fail-closed fallback for
254
255
  // any <Visibility> that slips past both filters.
255
256
  Visibility,
257
+ // Mintlify-style <Snippet file=…/> is unsupported (snippets are import-based) — placeholder, never throw.
258
+ Snippet: ({ file }: { file?: string }) => <MdxErrorBlock label={file ? `snippet ${file}` : 'snippet'} />,
256
259
  // Sized images from preprocess-mdx (![alt](url =WIDTHx) syntax).
257
260
  // These are output as <SizedImage> JSX so they go through component mapping
258
261
  // (raw <img> JSX in MDX bypasses the components provider).
@@ -0,0 +1,42 @@
1
+ /**
2
+ * MdxErrorBlock — inline placeholder shown in place of MDX content that failed
3
+ * to render at request time (an undefined component, a bare `{x, y}` expression
4
+ * that throws a ReferenceError, etc.). Rendered as the fallback of
5
+ * `MdxRenderBoundary` so a single broken page degrades to a visible note
6
+ * instead of an HTTP 500.
7
+ *
8
+ * Server component, no client JS. Uses inline `style` with CSS-var fallbacks
9
+ * (mirrors OpenApiError) — docs themes don't guarantee Tailwind utilities are
10
+ * compiled, so Tailwind classes can't be relied on here. `role="note"` (a
11
+ * softer signal than OpenApiError's `role="alert"`) because a per-block render
12
+ * failure is informational, not a hard page-level error.
13
+ */
14
+
15
+ interface MdxErrorBlockProps {
16
+ label?: string;
17
+ }
18
+
19
+ export function MdxErrorBlock({ label }: MdxErrorBlockProps) {
20
+ return (
21
+ <div
22
+ role="note"
23
+ style={{
24
+ margin: '1rem 0',
25
+ padding: '0.75rem 1rem',
26
+ borderRadius: '8px',
27
+ border: '1px solid #f59e0b',
28
+ backgroundColor: 'rgba(245, 158, 11, 0.08)',
29
+ fontSize: '0.875rem',
30
+ lineHeight: 1.5,
31
+ color: 'var(--color-text-primary, #1e293b)',
32
+ }}
33
+ >
34
+ <span style={{ fontWeight: 600 }}>⚠ This content couldn’t be displayed</span>
35
+ {label ? (
36
+ <span style={{ marginLeft: '0.375rem', color: 'var(--color-text-muted, #64748b)' }}>
37
+ ({label})
38
+ </span>
39
+ ) : null}
40
+ </div>
41
+ );
42
+ }
@@ -134,6 +134,19 @@ export function normalizeOpenApiRefKey(ref: unknown): string | null {
134
134
  return normalized || null;
135
135
  }
136
136
 
137
+ // Re-export the pure snippet scanner so build.ts and the Task 3.2 test can
138
+ // continue importing from isr-build-executor without any changes, while the
139
+ // CLI (Task 3.4) imports from snippet-scanner.ts directly (no heavy deps).
140
+ export { normalizeSnippetRef, findSnippetIssues } from './snippet-scanner.js';
141
+ export type { SnippetIssue } from './snippet-scanner.js';
142
+
143
+ // Re-export the pure risky-expression scanner on the same channel. The build
144
+ // surfaces `risky_expression` warnings to tell authors which `{x, y}`-style
145
+ // expressions the recma render guard silently dropped (see
146
+ // lib/recma-guard-expressions.ts).
147
+ export { findRiskyExpressions } from './risky-expression-scanner.js';
148
+ export type { RiskyExpressionIssue } from './risky-expression-scanner.js';
149
+
137
150
  /**
138
151
  * Resolve a docs.json `api.openapi` value into the list of string refs the build
139
152
  * collects/validates, plus any non-blocking warnings for forms the build can't
@@ -0,0 +1,51 @@
1
+ import { visit } from 'estree-util-visit';
2
+ import type { Program } from 'estree-jsx';
3
+
4
+ /**
5
+ * recmaCollectMissingRefs — names the components that user MDX references but
6
+ * the provider doesn't supply, so the caller can inject per-name placeholder
7
+ * fallbacks instead of letting `<Foo/>` throw `_missingMdxReference` at render
8
+ * (an HTTP 500). Part of the "docs never 500" wiring.
9
+ *
10
+ * MDX's compiled output emits, for every provider-expected component, a guard:
11
+ *
12
+ * if (!Foo) _missingMdxReference("Foo", true);
13
+ *
14
+ * The string-literal first argument is the referenced component name. Inline
15
+ * `export const X = …` components and imported components are LOCAL bindings,
16
+ * not provider lookups, so MDX never emits a `_missingMdxReference` for them —
17
+ * they don't appear here. (Verified against `@mdx-js/mdx` compiled output.)
18
+ *
19
+ * The plugin records the raw collected names on `collector.names`; the caller
20
+ * subtracts the provided component keys to get the genuinely-unknown set. We do
21
+ * NOT filter dotted names (e.g. `"Tree.Folder"`, emitted for `<Tree.Folder>`
22
+ * member access) here — the caller drops them, because a fallback keyed on a
23
+ * dotted string can't be looked up as a provider own-key anyway, and compound
24
+ * components are already redirected to their flat name by
25
+ * `recmaCompoundComponents` (which must run BEFORE this plugin in the
26
+ * `recmaPlugins` array so the flat-name rewrite is in place first).
27
+ *
28
+ * Factory-with-collector shape (not a bare plugin) because recma plugins can't
29
+ * return data — the caller reads `collector.names` after `compileMDX` resolves.
30
+ */
31
+ export interface MissingRefCollector {
32
+ names: string[];
33
+ }
34
+
35
+ export function recmaCollectMissingRefs(collector: MissingRefCollector) {
36
+ return () => (tree: Program) => {
37
+ visit(tree, (node) => {
38
+ if (
39
+ node.type !== 'CallExpression' ||
40
+ node.callee.type !== 'Identifier' ||
41
+ node.callee.name !== '_missingMdxReference'
42
+ ) {
43
+ return;
44
+ }
45
+ const arg0 = node.arguments[0];
46
+ if (arg0 && arg0.type === 'Literal' && typeof arg0.value === 'string') {
47
+ collector.names.push(arg0.value);
48
+ }
49
+ });
50
+ };
51
+ }
@@ -0,0 +1,230 @@
1
+ import { generate } from 'astring';
2
+ import { visit } from 'estree-util-visit';
3
+ import type {
4
+ Program,
5
+ Node,
6
+ CallExpression,
7
+ Expression,
8
+ Property,
9
+ } from 'estree-jsx';
10
+
11
+ /**
12
+ * recmaGuardExpressions — wraps every AUTHOR-WRITTEN MDX expression in a
13
+ * `try/catch` so a render-time throw degrades to a silent drop instead of an
14
+ * HTTP 500. The final, RSC-correct layer of "docs never 500".
15
+ *
16
+ * WHY a recma plugin and not an error boundary: user MDX is rendered at request
17
+ * time by `<MDXRemote>` (an async Server Component). A bare expression like
18
+ * `{x, y}` in prose compiles to a free `(x, y)` reference that throws
19
+ * `ReferenceError: x is not defined` at RENDER. A `'use client'` class error
20
+ * boundary CANNOT catch a throw from an async Server Component during the App
21
+ * Router server render — the throw bypasses it to the route `error.tsx` and
22
+ * returns 500 (verified at runtime: the boundary's `componentDidCatch` never
23
+ * fired). `renderToStaticMarkup` as a server-side trial render is also out — it
24
+ * false-positives on every async component ("a component suspended while
25
+ * responding to synchronous input"). The only RSC-safe place to neutralize the
26
+ * throw is the compiled output itself, here.
27
+ *
28
+ * WHAT it transforms: MDX compiles every author expression `{EXPR}` into a child
29
+ * (or attribute) value inside a `_jsx(...)` / `_jsxs(...)` call. This plugin
30
+ * finds those values and rewrites them. There are two catch behaviors by position:
31
+ *
32
+ * children/text position:
33
+ * EXPR → (() => { try { return (EXPR); }
34
+ * catch (e) { return e instanceof ReferenceError
35
+ * ? "{<literal>}" : undefined; } })()
36
+ * attribute position:
37
+ * EXPR → (() => { try { return (EXPR); } catch { return undefined; } })()
38
+ *
39
+ * On any throw the expression yields `undefined`, which React renders as nothing
40
+ * — the one broken node vanishes and the rest of the page renders normally. A
41
+ * valid expression returns exactly what it did before (the IIFE only adds a
42
+ * catch), so there is no behavioral change for working content and no
43
+ * double-render or async false-positive.
44
+ *
45
+ * RENDER-LITERAL refinement (children/text only): the most common author mistake
46
+ * is prose with literal braces or a typo'd variable — `coordinates: {x, y} pixel
47
+ * positions`. MDX evaluates `{x, y}` as JS and `x` is undefined → `ReferenceError`
48
+ * → the text used to vanish (`coordinates: pixel positions`). For text positions
49
+ * we instead render the literal source the author typed (`{x, y}`), reconstructed
50
+ * at COMPILE time via `astring` and embedded as a string. This applies ONLY to
51
+ * `ReferenceError` (undefined identifier — the "author meant literal / typo'd a
52
+ * ref" class); every other throw (e.g. a `TypeError` from member access on a
53
+ * defined-but-null value — a genuinely broken template) still returns `undefined`
54
+ * and drops, exactly as before. Attribute expressions never render a literal — a
55
+ * literal string in a prop is meaningless — so they keep the plain `undefined`
56
+ * catch.
57
+ *
58
+ * Author signal lives elsewhere: dropped expressions are surfaced to the author
59
+ * via the build-time `risky_expression` warning, NOT a per-render `console.*`
60
+ * here — a per-render log would flood SSR logs on every hit of a popular page
61
+ * (see builder/CLAUDE.md "No per-render console.warn in MDX components").
62
+ *
63
+ * SCOPE: covers expression throws (undefined identifiers, bad member access).
64
+ * It does NOT catch a *defined* component that throws inside its own render —
65
+ * `_jsx(Foo, props)` only constructs the element; `Foo`'s render throw is
66
+ * deferred past this layer. That residual class has no clean RSC-safe inline
67
+ * catch and was never observed in the wild.
68
+ */
69
+
70
+ const JSX_CALLEES = new Set(['_jsx', '_jsxs', '_jsxDEV']);
71
+
72
+ /**
73
+ * A prop value is an "author expression" worth guarding when it is neither
74
+ * static text nor a compiler-emitted element/spread:
75
+ * - `Literal` → static text/number/string child; cannot throw.
76
+ * - `SpreadElement` → `{...props}`; structural, never an author expression.
77
+ * - a `_jsx*(…)` call → a nested element the compiler emitted; its own
78
+ * children get visited and guarded independently.
79
+ * Everything else in a `children`/attribute position came from author `{EXPR}`
80
+ * (identifiers, member access, calls, conditionals, sequences, objects) and is
81
+ * exactly what can throw at render — so it gets wrapped.
82
+ */
83
+ function isAuthorExpr(node: Node | null | undefined): node is Expression {
84
+ if (!node || typeof (node as Node).type !== 'string') return false;
85
+ const type = (node as Node).type;
86
+ if (type === 'Literal') return false;
87
+ if (type === 'SpreadElement') return false;
88
+ if (type === 'JSXElement' || type === 'JSXFragment') return false;
89
+ if (
90
+ type === 'CallExpression' &&
91
+ (node as CallExpression).callee.type === 'Identifier' &&
92
+ JSX_CALLEES.has(((node as CallExpression).callee as { name: string }).name)
93
+ ) {
94
+ return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Reconstruct an author expression's `{…}` source so a ReferenceError in a text
101
+ * position can render the literal the author typed instead of vanishing.
102
+ *
103
+ * astring wraps a top-level `SequenceExpression` in parens (`x, y` → `(x, y)`),
104
+ * but the author wrote `{x, y}` — so a sequence's parts are serialized
105
+ * individually and re-joined to avoid the spurious inner parens. Compact options
106
+ * keep multi-line nodes (objects) on a single line. Returns `null` if the node
107
+ * can't be serialized (unexpected node type), so the caller falls back to the
108
+ * plain `undefined`-returning guard rather than emitting broken output.
109
+ */
110
+ function expressionToLiteral(expr: Expression): string | null {
111
+ const opts = { indent: '', lineEnd: '' } as const;
112
+ const ser = (node: Expression): string =>
113
+ generate(node as unknown as Parameters<typeof generate>[0], opts);
114
+ try {
115
+ const inner =
116
+ expr.type === 'SequenceExpression'
117
+ ? expr.expressions.map((e) => ser(e as Expression)).join(', ')
118
+ : ser(expr);
119
+ return '{' + inner + '}';
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** The identifier/string name of a `_jsx` prop key (`children`, `data-v`, …). */
126
+ function propKeyName(prop: Property): string | undefined {
127
+ const key = prop.key;
128
+ if (key.type === 'Identifier') return key.name;
129
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * Build the guarding IIFE around `expr`.
135
+ *
136
+ * `literal` (children/text positions only) is the `{…}` source to render when the
137
+ * expression throws a `ReferenceError`; pass `null` (attribute positions, or when
138
+ * serialization failed) to keep the plain `catch → undefined` behavior.
139
+ *
140
+ * literal: (() => { try { return (expr); }
141
+ * catch (e) { return e instanceof ReferenceError
142
+ * ? "{literal}" : undefined; } })()
143
+ * no literal: (() => { try { return (expr); } catch { return undefined; } })()
144
+ */
145
+ function guard(expr: Expression, literal: string | null): CallExpression {
146
+ const catchReturn: Expression =
147
+ literal === null
148
+ ? { type: 'Identifier', name: 'undefined' }
149
+ : {
150
+ type: 'ConditionalExpression',
151
+ test: {
152
+ type: 'BinaryExpression',
153
+ operator: 'instanceof',
154
+ left: { type: 'Identifier', name: 'e' },
155
+ right: { type: 'Identifier', name: 'ReferenceError' },
156
+ },
157
+ consequent: { type: 'Literal', value: literal },
158
+ alternate: { type: 'Identifier', name: 'undefined' },
159
+ };
160
+ return {
161
+ type: 'CallExpression',
162
+ optional: false,
163
+ arguments: [],
164
+ callee: {
165
+ type: 'ArrowFunctionExpression',
166
+ id: null,
167
+ expression: false,
168
+ generator: false,
169
+ async: false,
170
+ params: [],
171
+ body: {
172
+ type: 'BlockStatement',
173
+ body: [
174
+ {
175
+ type: 'TryStatement',
176
+ block: {
177
+ type: 'BlockStatement',
178
+ body: [{ type: 'ReturnStatement', argument: expr }],
179
+ },
180
+ handler: {
181
+ type: 'CatchClause',
182
+ param: literal === null ? null : { type: 'Identifier', name: 'e' },
183
+ body: {
184
+ type: 'BlockStatement',
185
+ body: [{ type: 'ReturnStatement', argument: catchReturn }],
186
+ },
187
+ },
188
+ finalizer: null,
189
+ },
190
+ ],
191
+ },
192
+ },
193
+ } as CallExpression;
194
+ }
195
+
196
+ export function recmaGuardExpressions() {
197
+ return (tree: Program) => {
198
+ visit(tree, (node) => {
199
+ if (
200
+ node.type !== 'CallExpression' ||
201
+ node.callee.type !== 'Identifier' ||
202
+ !JSX_CALLEES.has(node.callee.name)
203
+ ) {
204
+ return;
205
+ }
206
+ const props = node.arguments[1];
207
+ if (!props || props.type !== 'ObjectExpression') return;
208
+
209
+ for (const prop of props.properties) {
210
+ if (prop.type !== 'Property') continue;
211
+ // Render-literal fallback applies only to text flow (`children`); an
212
+ // attribute expression that throws degrades to `undefined` (a literal
213
+ // string in a prop slot is meaningless).
214
+ const isText = propKeyName(prop as Property) === 'children';
215
+ const literalOf = (e: Expression): string | null =>
216
+ isText ? expressionToLiteral(e) : null;
217
+ const value = (prop as Property).value;
218
+ if (value.type === 'ArrayExpression') {
219
+ // `children: [ "text", _jsx(...), (x, y), ... ]`
220
+ value.elements = value.elements.map((el) =>
221
+ isAuthorExpr(el) ? guard(el, literalOf(el)) : el,
222
+ );
223
+ } else if (isAuthorExpr(value)) {
224
+ // `children: x` (single child) or `someAttr: undefinedRef`
225
+ (prop as Property).value = guard(value, literalOf(value));
226
+ }
227
+ }
228
+ });
229
+ };
230
+ }