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,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.130",
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,151 @@
1
+ import { visit } from 'estree-util-visit';
2
+ import type {
3
+ Program,
4
+ Node,
5
+ CallExpression,
6
+ Expression,
7
+ Property,
8
+ } from 'estree-jsx';
9
+
10
+ /**
11
+ * recmaGuardExpressions — wraps every AUTHOR-WRITTEN MDX expression in a
12
+ * `try/catch` so a render-time throw degrades to a silent drop instead of an
13
+ * HTTP 500. The final, RSC-correct layer of "docs never 500".
14
+ *
15
+ * WHY a recma plugin and not an error boundary: user MDX is rendered at request
16
+ * time by `<MDXRemote>` (an async Server Component). A bare expression like
17
+ * `{x, y}` in prose compiles to a free `(x, y)` reference that throws
18
+ * `ReferenceError: x is not defined` at RENDER. A `'use client'` class error
19
+ * boundary CANNOT catch a throw from an async Server Component during the App
20
+ * Router server render — the throw bypasses it to the route `error.tsx` and
21
+ * returns 500 (verified at runtime: the boundary's `componentDidCatch` never
22
+ * fired). `renderToStaticMarkup` as a server-side trial render is also out — it
23
+ * false-positives on every async component ("a component suspended while
24
+ * responding to synchronous input"). The only RSC-safe place to neutralize the
25
+ * throw is the compiled output itself, here.
26
+ *
27
+ * WHAT it transforms: MDX compiles every author expression `{EXPR}` into a child
28
+ * (or attribute) value inside a `_jsx(...)` / `_jsxs(...)` call. This plugin
29
+ * finds those values and rewrites
30
+ *
31
+ * EXPR → (() => { try { return (EXPR); } catch { return undefined; } })()
32
+ *
33
+ * On throw the expression yields `undefined`, which React renders as nothing —
34
+ * the one broken node vanishes and the rest of the page renders normally. A
35
+ * valid expression returns exactly what it did before (the IIFE only adds a
36
+ * catch), so there is no behavioral change for working content and no
37
+ * double-render or async false-positive.
38
+ *
39
+ * Author signal lives elsewhere: dropped expressions are surfaced to the author
40
+ * via the build-time `risky_expression` warning, NOT a per-render `console.*`
41
+ * here — a per-render log would flood SSR logs on every hit of a popular page
42
+ * (see builder/CLAUDE.md "No per-render console.warn in MDX components").
43
+ *
44
+ * SCOPE: covers expression throws (undefined identifiers, bad member access).
45
+ * It does NOT catch a *defined* component that throws inside its own render —
46
+ * `_jsx(Foo, props)` only constructs the element; `Foo`'s render throw is
47
+ * deferred past this layer. That residual class has no clean RSC-safe inline
48
+ * catch and was never observed in the wild.
49
+ */
50
+
51
+ const JSX_CALLEES = new Set(['_jsx', '_jsxs', '_jsxDEV']);
52
+
53
+ /**
54
+ * A prop value is an "author expression" worth guarding when it is neither
55
+ * static text nor a compiler-emitted element/spread:
56
+ * - `Literal` → static text/number/string child; cannot throw.
57
+ * - `SpreadElement` → `{...props}`; structural, never an author expression.
58
+ * - a `_jsx*(…)` call → a nested element the compiler emitted; its own
59
+ * children get visited and guarded independently.
60
+ * Everything else in a `children`/attribute position came from author `{EXPR}`
61
+ * (identifiers, member access, calls, conditionals, sequences, objects) and is
62
+ * exactly what can throw at render — so it gets wrapped.
63
+ */
64
+ function isAuthorExpr(node: Node | null | undefined): node is Expression {
65
+ if (!node || typeof (node as Node).type !== 'string') return false;
66
+ const type = (node as Node).type;
67
+ if (type === 'Literal') return false;
68
+ if (type === 'SpreadElement') return false;
69
+ if (type === 'JSXElement' || type === 'JSXFragment') return false;
70
+ if (
71
+ type === 'CallExpression' &&
72
+ (node as CallExpression).callee.type === 'Identifier' &&
73
+ JSX_CALLEES.has(((node as CallExpression).callee as { name: string }).name)
74
+ ) {
75
+ return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ /** Build `(() => { try { return (expr); } catch { return undefined; } })()`. */
81
+ function guard(expr: Expression): CallExpression {
82
+ return {
83
+ type: 'CallExpression',
84
+ optional: false,
85
+ arguments: [],
86
+ callee: {
87
+ type: 'ArrowFunctionExpression',
88
+ id: null,
89
+ expression: false,
90
+ generator: false,
91
+ async: false,
92
+ params: [],
93
+ body: {
94
+ type: 'BlockStatement',
95
+ body: [
96
+ {
97
+ type: 'TryStatement',
98
+ block: {
99
+ type: 'BlockStatement',
100
+ body: [{ type: 'ReturnStatement', argument: expr }],
101
+ },
102
+ handler: {
103
+ type: 'CatchClause',
104
+ param: null,
105
+ body: {
106
+ type: 'BlockStatement',
107
+ body: [
108
+ {
109
+ type: 'ReturnStatement',
110
+ argument: { type: 'Identifier', name: 'undefined' },
111
+ },
112
+ ],
113
+ },
114
+ },
115
+ finalizer: null,
116
+ },
117
+ ],
118
+ },
119
+ },
120
+ } as CallExpression;
121
+ }
122
+
123
+ export function recmaGuardExpressions() {
124
+ return (tree: Program) => {
125
+ visit(tree, (node) => {
126
+ if (
127
+ node.type !== 'CallExpression' ||
128
+ node.callee.type !== 'Identifier' ||
129
+ !JSX_CALLEES.has(node.callee.name)
130
+ ) {
131
+ return;
132
+ }
133
+ const props = node.arguments[1];
134
+ if (!props || props.type !== 'ObjectExpression') return;
135
+
136
+ for (const prop of props.properties) {
137
+ if (prop.type !== 'Property') continue;
138
+ const value = (prop as Property).value;
139
+ if (value.type === 'ArrayExpression') {
140
+ // `children: [ "text", _jsx(...), (x, y), ... ]`
141
+ value.elements = value.elements.map((el) =>
142
+ isAuthorExpr(el) ? guard(el) : el,
143
+ );
144
+ } else if (isAuthorExpr(value)) {
145
+ // `children: x` (single child) or `someAttr: undefinedRef`
146
+ (prop as Property).value = guard(value);
147
+ }
148
+ }
149
+ });
150
+ };
151
+ }
@@ -11,7 +11,7 @@
11
11
  // non-ISR local dev): passes `requestHeaders=null` and gets a
12
12
  // subdomain-canonical via `getBaseUrlFromConfig`.
13
13
  import { notFound } from 'next/navigation';
14
- import { MDXRemote } from 'next-mdx-remote/rsc';
14
+ import { MDXRemote, compileMDX } from 'next-mdx-remote/rsc';
15
15
  import type { Metadata } from 'next';
16
16
  import type { AnchorHTMLAttributes, ReactElement, ReactNode } from 'react';
17
17
  import { MDXComponents } from '@/components/mdx/MDXComponents';
@@ -24,6 +24,8 @@ import { SocialFooter } from '@/components/navigation/SocialFooter';
24
24
  import { ApiPageWrapper } from '@/components/mdx/ApiPage';
25
25
  import { OpenApiEndpoint } from '@/components/mdx/OpenApiEndpoint';
26
26
  import { OpenApiError } from '@/components/openapi/OpenApiError';
27
+ import { MdxRenderBoundary } from '@/components/errors/MdxRenderBoundary';
28
+ import { MdxErrorBlock } from '@/components/mdx/MdxErrorBlock';
27
29
  import { getHighlighter } from '@/lib/shiki-highlighter';
28
30
  import type { Highlighter } from 'shiki';
29
31
  import type { Pluggable } from 'unified';
@@ -48,6 +50,8 @@ import { getLatexRemarkPlugins, getLatexRehypePlugins } from '@/lib/latex-config
48
50
  import { getTypographyRemarkPlugins } from '@/lib/typography-config';
49
51
  import { remarkVisibility } from '@/lib/remark-visibility';
50
52
  import { recmaCompoundComponents } from '@/lib/recma-compound-components';
53
+ import { recmaCollectMissingRefs, type MissingRefCollector } from '@/lib/recma-collect-missing-refs';
54
+ import { recmaGuardExpressions } from '@/lib/recma-guard-expressions';
51
55
  import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
52
56
  import { extractHeadings } from '@/lib/heading-extractor';
53
57
  import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
@@ -465,6 +469,80 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
465
469
  }
466
470
  }
467
471
 
472
+ // ── Single pre-render compile pass ("docs never 500") ──────────────────────
473
+ // A compile/syntax throw (e.g. an unclosed `<Tabs>`) happens inside
474
+ // `MDXRemote`'s own `await compileMDX`, OUTSIDE the client render boundary's
475
+ // subtree — so the boundary CANNOT catch it; it would 500 via `error.tsx`.
476
+ // Compile ONCE here, in try/catch, against the EXACT source the chosen branch
477
+ // will render (the API branch strips `<ResponseExample>` when an OpenAPI
478
+ // endpoint resolved; otherwise plain `content`), with the SAME security +
479
+ // mdxOptions the call sites use. On failure we substitute an inline
480
+ // `<MdxErrorBlock>` instead of ever handing known-bad source to MDXRemote.
481
+ //
482
+ // The same compile doubles as unknown-component collection: a recma plugin
483
+ // records every `_missingMdxReference("X", …)` name; subtracting the provided
484
+ // keys yields the referenced-but-unprovided capitalized components, for which
485
+ // we inject per-name placeholder fallbacks (own-key injection) below.
486
+ const effectiveSource = openApiEndpointData
487
+ ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
488
+ : content;
489
+ const missingRefCollector: MissingRefCollector = { names: [] };
490
+ let mdxError: string | null = null;
491
+ let mdxCompileMs: number | null = null;
492
+ const mdxCompileStart = performance.now();
493
+ try {
494
+ await compileMDX({
495
+ source: effectiveSource,
496
+ components: AllComponentsWithInline,
497
+ options: {
498
+ ...mdxSecurityOptions,
499
+ mdxOptions: {
500
+ ...getCommonMdxOptions(config, highlighter),
501
+ recmaPlugins: [
502
+ recmaCompoundComponents,
503
+ recmaCollectMissingRefs(missingRefCollector),
504
+ recmaGuardExpressions,
505
+ ],
506
+ },
507
+ },
508
+ });
509
+ } catch (err) {
510
+ // Syntax/compile failure — the only path that can 500 around MDXRemote.
511
+ // Degrade to an inline placeholder; the chosen branch renders it instead
512
+ // of calling MDXRemote with source known to throw at compile.
513
+ mdxError = err instanceof Error ? err.message : String(err);
514
+ logger.warn(`[MDX] compile failed for ${pagePath}: ${mdxError}`);
515
+ }
516
+ mdxCompileMs = Math.round(performance.now() - mdxCompileStart);
517
+
518
+ // Unknown components = referenced-but-unprovided, capitalized, non-dotted.
519
+ // Dotted names (e.g. "Tree.Folder") are dropped: compound components are
520
+ // already redirected to their flat name by recmaCompoundComponents, and a
521
+ // dotted string can't be a provider own-key. Subtract the provided keys.
522
+ const providedKeys = new Set(Object.keys(AllComponentsWithInline));
523
+ const unknownComponentNames = Array.from(new Set(missingRefCollector.names)).filter(
524
+ (name) =>
525
+ !name.includes('.') &&
526
+ /^[A-Z]/.test(name) &&
527
+ !providedKeys.has(name),
528
+ );
529
+
530
+ // Own-key injection: a real placeholder entry per unknown name. The compiled
531
+ // MDX does `{ ...props.components }` (own-enumerable copy only — a Proxy trap
532
+ // would never fire), so each unknown component needs a literal own-key to
533
+ // resolve to the placeholder instead of throwing `_missingMdxReference`.
534
+ const ComponentsWithUnknownFallback = {
535
+ ...AllComponentsWithInline,
536
+ ...Object.fromEntries(
537
+ unknownComponentNames.map((name) => [
538
+ name,
539
+ function UnknownComponent() {
540
+ return <MdxErrorBlock label={`unknown component <${name}>`} />;
541
+ },
542
+ ]),
543
+ ),
544
+ };
545
+
468
546
  // Gate on VERCEL_REGION so the telemetry only fires in Vercel runtime
469
547
  // (production + preview). In `jamdesk dev` the structured JSON would
470
548
  // pollute the user's terminal on every page render. `[r2-timing]` logs
@@ -477,6 +555,11 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
477
555
  snippetInlineMs,
478
556
  openApiMs,
479
557
  openApiCandidates,
558
+ // Cost of the mandatory never-500 syntax-guard compile pass (unknown-name
559
+ // collection piggybacks for free). Watch this in prod for a regression.
560
+ mdxCompileMs,
561
+ unknownComponentCount: unknownComponentNames.length,
562
+ mdxCompileFailed: mdxError !== null,
480
563
  });
481
564
  }
482
565
 
@@ -607,21 +690,25 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
607
690
  <OpenApiError message={openApiError} slug={slug.join('/')} />
608
691
  )}
609
692
 
610
- <StepSlugProvider entries={stepEntries}>
611
- <MDXRemote
612
- source={openApiEndpointData
613
- ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
614
- : content}
615
- components={AllComponentsWithInline}
616
- options={{
617
- ...mdxSecurityOptions,
618
- mdxOptions: {
619
- ...getCommonMdxOptions(config, highlighter),
620
- recmaPlugins: [recmaCompoundComponents],
621
- },
622
- }}
623
- />
624
- </StepSlugProvider>
693
+ {mdxError ? (
694
+ <MdxErrorBlock label="page content" />
695
+ ) : (
696
+ <MdxRenderBoundary fallback={<MdxErrorBlock label="page content" />}>
697
+ <StepSlugProvider entries={stepEntries}>
698
+ <MDXRemote
699
+ source={effectiveSource}
700
+ components={ComponentsWithUnknownFallback}
701
+ options={{
702
+ ...mdxSecurityOptions,
703
+ mdxOptions: {
704
+ ...getCommonMdxOptions(config, highlighter),
705
+ recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
706
+ },
707
+ }}
708
+ />
709
+ </StepSlugProvider>
710
+ </MdxRenderBoundary>
711
+ )}
625
712
  </div>
626
713
 
627
714
  {!embed && <PageNavigation currentSlug={slug.join('/')} config={config} />}
@@ -632,20 +719,24 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
632
719
  );
633
720
  }
634
721
 
635
- const mdxContent = (
636
- <StepSlugProvider entries={stepEntries}>
637
- <MDXRemote
638
- source={content}
639
- components={AllComponentsWithInline}
640
- options={{
641
- ...mdxSecurityOptions,
642
- mdxOptions: {
643
- ...getCommonMdxOptions(config, highlighter),
644
- recmaPlugins: [recmaCompoundComponents],
645
- },
646
- }}
647
- />
648
- </StepSlugProvider>
722
+ const mdxContent = mdxError ? (
723
+ <MdxErrorBlock label="page content" />
724
+ ) : (
725
+ <MdxRenderBoundary fallback={<MdxErrorBlock label="page content" />}>
726
+ <StepSlugProvider entries={stepEntries}>
727
+ <MDXRemote
728
+ source={content}
729
+ components={ComponentsWithUnknownFallback}
730
+ options={{
731
+ ...mdxSecurityOptions,
732
+ mdxOptions: {
733
+ ...getCommonMdxOptions(config, highlighter),
734
+ recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
735
+ },
736
+ }}
737
+ />
738
+ </StepSlugProvider>
739
+ </MdxRenderBoundary>
649
740
  );
650
741
 
651
742
  const articleContent = wrap(