@writedocs/generator 0.4.9 → 0.4.10

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 (63) hide show
  1. package/astro.config.mjs +39 -2
  2. package/bin/writedocs.js +23 -0
  3. package/package.json +1 -1
  4. package/src/cli/convert.js +82 -0
  5. package/src/cli/generate-api-pages.js +56 -3
  6. package/src/components/Accordion.astro +2 -1
  7. package/src/components/AccordionGroup.astro +4 -1
  8. package/src/components/ApiPlayground.astro +6 -2
  9. package/src/components/ApiReferencePanel.astro +6 -2
  10. package/src/components/Badge.astro +2 -0
  11. package/src/components/Callout.astro +2 -1
  12. package/src/components/Card.astro +2 -1
  13. package/src/components/CardGroup.astro +2 -1
  14. package/src/components/Check.astro +1 -1
  15. package/src/components/CodeBlock.astro +94 -0
  16. package/src/components/CodeGroup.astro +2 -1
  17. package/src/components/Color.astro +2 -1
  18. package/src/components/ColorItem.astro +2 -1
  19. package/src/components/ColorRow.astro +2 -1
  20. package/src/components/Column.astro +19 -0
  21. package/src/components/Columns.astro +1 -1
  22. package/src/components/Danger.astro +1 -1
  23. package/src/components/Expandable.astro +2 -1
  24. package/src/components/Frame.astro +2 -1
  25. package/src/components/GitHubRepo.astro +2 -1
  26. package/src/components/Hint.astro +2 -1
  27. package/src/components/Icon.astro +3 -2
  28. package/src/components/Image.astro +2 -1
  29. package/src/components/Info.astro +1 -1
  30. package/src/components/Note.astro +1 -1
  31. package/src/components/Panel.astro +2 -1
  32. package/src/components/Parameter.astro +2 -1
  33. package/src/components/Prompt.astro +2 -1
  34. package/src/components/RequestExample.astro +2 -1
  35. package/src/components/ResponseExample.astro +2 -1
  36. package/src/components/Searchbar.astro +2 -1
  37. package/src/components/Step.astro +2 -1
  38. package/src/components/Steps.astro +4 -1
  39. package/src/components/Tab.astro +2 -1
  40. package/src/components/Tabs.astro +2 -1
  41. package/src/components/Tile.astro +2 -1
  42. package/src/components/Tip.astro +1 -1
  43. package/src/components/TreeFile.astro +2 -1
  44. package/src/components/TreeFolder.astro +2 -1
  45. package/src/components/Update.astro +2 -1
  46. package/src/components/Video.astro +2 -1
  47. package/src/components/View.astro +2 -1
  48. package/src/components/Warning.astro +1 -1
  49. package/src/components/class-names.ts +8 -0
  50. package/src/components/index.ts +2 -0
  51. package/src/content.config.ts +23 -2
  52. package/src/lib/content-check.js +36 -6
  53. package/src/lib/mdx-auto-hydrate.js +12 -0
  54. package/src/lib/mdx-inject-builtins.js +15 -0
  55. package/src/lib/mdx-inline-react.js +202 -0
  56. package/src/lib/mdx-mintlify.js +65 -0
  57. package/src/lib/mdx-substitute-variables.js +17 -0
  58. package/src/lib/mdx-unknown-components.js +56 -3
  59. package/src/lib/mintlify-convert.js +599 -0
  60. package/src/lib/openapi-ref.js +44 -0
  61. package/src/lib/openapi-render.ts +10 -1
  62. package/src/lib/pages.js +89 -17
  63. package/src/pages/[...slug].astro +8 -2
@@ -0,0 +1,202 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { parse as acornParse } from 'acorn';
5
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
6
+ import { MINTLIFY_HOOKS } from './mdx-mintlify.js';
7
+ import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
8
+
9
+ // Mintlify lets a page define a React component inline and use it right
10
+ // there:
11
+ //
12
+ // export const Counter = () => {
13
+ // const [count, setCount] = useState(0)
14
+ // return <button onClick={() => setCount(count + 1)}>{count}</button>
15
+ // }
16
+ //
17
+ // <Counter />
18
+ //
19
+ // Under Astro that can't work as written: JSX inside an .mdx file compiles
20
+ // to Astro elements, not React ones, so a component that calls a hook fails
21
+ // the build ("Objects are not valid as a React child"). A .jsx/.tsx file,
22
+ // on the other hand, is compiled as React and hydrated in the browser (see
23
+ // mdx-auto-hydrate.js) - which is exactly what a snippet is.
24
+ //
25
+ // So an exported component that calls a React hook - or that's passed as a
26
+ // prop to a React snippet, which needs it to be React too - is moved out of
27
+ // the page into a generated .jsx file, and the page imports it from there,
28
+ // the same as if the author had written it as a snippet. It renders on the
29
+ // server and is interactive in the browser, like on Mintlify. Other
30
+ // components stay in the page (they render fine as they are).
31
+ //
32
+ // The generated file also gets: the page's own imports of JavaScript
33
+ // modules (snippets, packages), with relative paths made absolute; the
34
+ // page's other exports, in case the component uses them; and an import of
35
+ // the hooks it calls. It's written to writedocs' temp directory - never into
36
+ // the site's own folder - named by a hash of its content, so an edit in
37
+ // `writedocs dev` produces a new file instead of a stale cached one.
38
+ //
39
+ // writedocs' built-in components are Astro components, which React can't
40
+ // run - inside a moved component each one is a simple React stand-in, so it
41
+ // renders simplified. findInteractiveComponents() reports every such use,
42
+ // and both the build and `writedocs validate` warn about them with
43
+ // simplifiedBuiltinMessage(). ROADMAP.md tracks real React versions.
44
+
45
+ const HOOK_CALL = new RegExp(`\\b(?:${MINTLIFY_HOOKS.join('|')})\\s*\\(`);
46
+ const HOOK_NAME = new RegExp(`\\b(${MINTLIFY_HOOKS.join('|')})\\s*\\(`, 'g');
47
+ const BUILTIN_TAG = new RegExp(`<(${BUILTIN_COMPONENT_NAMES.join('|')})(?=[\\s/>.])`, 'g');
48
+
49
+ function declaredNames(stmt) {
50
+ const decl = stmt.declaration;
51
+ if (!decl) return [];
52
+ if (decl.type === 'VariableDeclaration') return decl.declarations.map((d) => d.id?.name).filter(Boolean);
53
+ return decl.id?.name ? [decl.id.name] : [];
54
+ }
55
+
56
+ function isJsModule(specifier) {
57
+ // A snippet or package the component could use - not MDX/Astro, which
58
+ // aren't importable from React, and not writedocs' own components.
59
+ if (/\.(mdx|md|astro)$/i.test(specifier)) return false;
60
+ if (specifier === 'writedocs/components') return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * The page components that will run as React - and the built-ins inside
66
+ * them that will render simplified. `source` is the text the tree was
67
+ * parsed from (statement offsets index into it). Returns:
68
+ * statements - every top-level import/export, as { node, stmt }
69
+ * extracted - the ones to move: { node, stmt, names, reason }, where
70
+ * reason is 'hooks' or 'prop'
71
+ * simplified - { component, builtin, reason, offset } for every built-in
72
+ * tag inside an extracted component (offset into `source`)
73
+ */
74
+ export function findInteractiveComponents(tree, source) {
75
+ const esmNodes = tree.children.filter((n) => n.type === 'mdxjsEsm' && n.data?.estree);
76
+ const statements = esmNodes.flatMap((n) => n.data.estree.body.map((stmt) => ({ node: n, stmt })));
77
+ const text = (stmt) => source.slice(stmt.start, stmt.end);
78
+
79
+ // Components imported from .jsx/.tsx - React islands. A page component
80
+ // handed to one of them as a prop has to be React too, or React gets an
81
+ // Astro element it can't render.
82
+ const reactTags = statements
83
+ .filter(({ stmt }) => stmt.type === 'ImportDeclaration' && /\.[jt]sx$/.test(stmt.source.value))
84
+ .flatMap(({ stmt }) => stmt.specifiers.map((sp) => sp.local.name));
85
+ const passedToReact = (name) => reactTags.some((tag) => new RegExp(`<${tag}\\b[^>]*=\\{\\s*${name}\\s*\\}`).test(source));
86
+
87
+ // A built-in the page imports under its own name from somewhere else (its
88
+ // own `Card` snippet, say) isn't writedocs' built-in.
89
+ const importedNames = new Set(
90
+ statements
91
+ .filter(({ stmt }) => stmt.type === 'ImportDeclaration' && isJsModule(stmt.source.value))
92
+ .flatMap(({ stmt }) => stmt.specifiers.map((sp) => sp.local.name))
93
+ );
94
+
95
+ const extracted = [];
96
+ const simplified = [];
97
+ for (const { node, stmt } of statements) {
98
+ if (stmt.type !== 'ExportNamedDeclaration') continue;
99
+ const names = declaredNames(stmt).filter((n) => /^[A-Z]/.test(n));
100
+ if (names.length === 0) continue;
101
+ const code = text(stmt);
102
+ const reason = HOOK_CALL.test(code) ? 'hooks' : names.some(passedToReact) ? 'prop' : null;
103
+ if (!reason) continue;
104
+ extracted.push({ node, stmt, names, reason });
105
+ for (const m of code.matchAll(BUILTIN_TAG)) {
106
+ if (importedNames.has(m[1])) continue;
107
+ simplified.push({ component: names[0], builtin: m[1], reason, offset: stmt.start + m.index });
108
+ }
109
+ }
110
+ return { statements, extracted, simplified, text };
111
+ }
112
+
113
+ /** The warning for one simplified built-in - worded once, for the build and
114
+ * for `writedocs validate`. Returns { message, suggestion }. */
115
+ export function simplifiedBuiltinMessage({ component, builtin, reason }) {
116
+ const why = reason === 'hooks' ? 'it uses React hooks' : "it's passed to a React snippet";
117
+ const result =
118
+ builtin === 'Icon'
119
+ ? 'renders nothing'
120
+ : builtin === 'CodeBlock'
121
+ ? 'renders as a plain code block, without syntax highlighting'
122
+ : 'shows only its content';
123
+ return {
124
+ message: `<${builtin}> inside <${component}> ${result}: <${component}> runs as a React component (${why}), and writedocs' built-in components can't run inside React.`,
125
+ suggestion: `Move the <${builtin}> out of <${component}>, or write <${component}> as a .jsx snippet with its own markup.`,
126
+ };
127
+ }
128
+
129
+ export function remarkExtractInlineReactComponents() {
130
+ return (tree, file) => {
131
+ const source = String(file.value ?? '');
132
+ const { statements, extracted, simplified, text } = findInteractiveComponents(tree, source);
133
+ if (extracted.length === 0 || !file.path) return;
134
+
135
+ // Same warnings `writedocs validate` gives (lib/content-check.js).
136
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
137
+ const relPath = path.relative(contentDir, file.path).split(path.sep).join('/');
138
+ for (const use of simplified) {
139
+ const line = source.slice(0, use.offset).split('\n').length;
140
+ console.warn(`[writedocs] ${relPath}:${line} - ${simplifiedBuiltinMessage(use).message}`);
141
+ }
142
+
143
+ const pageDir = path.dirname(file.path);
144
+ const absolute = (spec) => (spec.startsWith('.') ? path.resolve(pageDir, spec).split(path.sep).join('/') : spec);
145
+
146
+ const imports = statements
147
+ .filter(({ stmt }) => stmt.type === 'ImportDeclaration' && isJsModule(stmt.source.value))
148
+ .map(({ stmt }) => text(stmt).replace(stmt.source.raw, JSON.stringify(absolute(stmt.source.value))));
149
+ const otherExports = statements
150
+ .filter(({ stmt }) => stmt.type === 'ExportNamedDeclaration' && stmt.declaration && !extracted.some((e) => e.stmt === stmt))
151
+ .map(({ stmt }) => text(stmt));
152
+ const componentCode = extracted.map(({ stmt }) => text(stmt));
153
+ const hooks = [...new Set(componentCode.join('\n').matchAll(HOOK_NAME))].map((m) => m[1]);
154
+ const alreadyImported = imports.join('\n');
155
+ const hookImport = [...new Set(hooks)].filter((h) => !new RegExp(`\\b${h}\\b`).test(alreadyImported));
156
+
157
+ // A simple React stand-in for each built-in the moved code uses:
158
+ // CodeBlock a plain (unhighlighted) code block, the rest their children.
159
+ const movedCode = [...otherExports, ...componentCode].join('\n');
160
+ const standIns = BUILTIN_COMPONENT_NAMES.filter(
161
+ (name) => new RegExp(`<${name}[\\s/>.]`).test(movedCode) && !new RegExp(`\\b${name}\\b`).test(alreadyImported)
162
+ ).map((name) =>
163
+ name === 'CodeBlock'
164
+ ? 'const CodeBlock = ({ children, filename }) => (<div className="wd-code-block">{filename ? <div className="wd-code-title">{filename}</div> : null}<pre className="astro-code"><code>{children}</code></pre></div>);'
165
+ : `const ${name} = ({ children }) => <>{children}</>;`
166
+ );
167
+
168
+ const moduleSource = [
169
+ '// Generated by writedocs from an inline component in:',
170
+ `// ${file.path.split(path.sep).join('/')}`,
171
+ hookImport.length ? `import { ${hookImport.join(', ')} } from 'react';` : '',
172
+ ...imports,
173
+ ...standIns,
174
+ ...otherExports,
175
+ ...componentCode,
176
+ '',
177
+ ].join('\n');
178
+
179
+ const hash = crypto.createHash('sha1').update(moduleSource).digest('hex').slice(0, 12);
180
+ const dir = path.join(writedocsTempDir(contentDir), 'inline-components');
181
+ const modulePath = path.join(dir, `${path.basename(file.path).replace(/\.mdx$/i, '')}-${hash}.jsx`);
182
+ if (!fs.existsSync(modulePath)) {
183
+ fs.mkdirSync(dir, { recursive: true });
184
+ fs.writeFileSync(modulePath, moduleSource);
185
+ }
186
+
187
+ // Drop the moved declarations from the page, and import them instead.
188
+ for (const { node, stmt } of extracted) {
189
+ node.data.estree.body = node.data.estree.body.filter((s) => s !== stmt);
190
+ node.value = node.value.replace(text(stmt), '');
191
+ }
192
+ // Imported for this file's own use, and exported again under the same
193
+ // names - an .mdx snippet that defines the component is imported by
194
+ // other pages, which still expect the export to be there.
195
+ const names = extracted.flatMap(({ stmt }) => declaredNames(stmt));
196
+ const importSource =
197
+ `import { ${names.join(', ')} } from ${JSON.stringify(modulePath.split(path.sep).join('/'))};\n` +
198
+ `export { ${names.join(', ')} };\n`;
199
+ const estree = acornParse(importSource, { ecmaVersion: 'latest', sourceType: 'module' });
200
+ tree.children.unshift({ type: 'mdxjsEsm', value: importSource, data: { estree } });
201
+ };
202
+ }
@@ -1,4 +1,5 @@
1
1
  import { visit } from 'unist-util-visit';
2
+ import { parse as acornParse } from 'acorn';
2
3
 
3
4
  // Remark plugins for Mintlify components whose input can't be read from a
4
5
  // component's own props/slot at render time. Registered in
@@ -97,3 +98,67 @@ export function remarkMintlifyPromptText() {
97
98
  });
98
99
  };
99
100
  }
101
+
102
+ // Mintlify pre-injects these React hooks into every page and snippet, so
103
+ // Mintlify content calls them without importing them.
104
+ export const MINTLIFY_HOOKS = ['useState', 'useEffect', 'useRef', 'useCallback', 'useMemo', 'useContext', 'useReducer'];
105
+
106
+ /** The hooks `code` calls but doesn't import or declare itself. */
107
+ export function missingHooks(code, alreadyBound = new Set()) {
108
+ return MINTLIFY_HOOKS.filter((hook) => {
109
+ if (alreadyBound.has(hook)) return false;
110
+ if (!new RegExp(`\\b${hook}\\s*\\(`).test(code)) return false;
111
+ // Imported (import { useState } / import { useState as x }) or declared
112
+ // (const useState = ...) by the file itself - leave it alone.
113
+ const declared = new RegExp(`(import\\s*\\{[^}]*\\b${hook}\\b[^}]*\\}\\s*from)|((const|let|var|function)\\s+${hook}\\b)`);
114
+ return !declared.test(code);
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Mintlify's pre-injected React hooks, for MDX pages: a page whose own
120
+ * code (an `export const Counter = () => { const [n, setN] = useState(0) }`)
121
+ * calls a hook it never imported gets `import { ... } from 'react'` added.
122
+ * Snippet .jsx/.tsx files get the same from mintlifyReactHooksPlugin()
123
+ * below. (A component defined inside a page renders once, at build time -
124
+ * only components imported from a .jsx/.tsx snippet are hydrated in the
125
+ * browser; see mdx-auto-hydrate.js.)
126
+ */
127
+ export function remarkMintlifyReactHooks() {
128
+ return (tree) => {
129
+ const bound = new Set();
130
+ const code = [];
131
+ visit(tree, ['mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression'], (node) => {
132
+ code.push(node.value ?? '');
133
+ if (node.type !== 'mdxjsEsm') return;
134
+ for (const stmt of node.data?.estree?.body ?? []) {
135
+ if (stmt.type === 'ImportDeclaration') for (const s of stmt.specifiers) if (s.local?.name) bound.add(s.local.name);
136
+ }
137
+ });
138
+ const missing = missingHooks(code.join('\n'), bound);
139
+ if (missing.length === 0) return;
140
+ const source = `import { ${missing.join(', ')} } from 'react';\n`;
141
+ const estree = acornParse(source, { ecmaVersion: 'latest', sourceType: 'module' });
142
+ tree.children.unshift({ type: 'mdxjsEsm', value: source, data: { estree } });
143
+ };
144
+ }
145
+
146
+ /** The same for a site's own .jsx/.tsx files (snippets) - a Vite plugin,
147
+ * since those never go through the MDX pipeline. Only files inside the
148
+ * site's content directory are touched, never this package's own. */
149
+ export function mintlifyReactHooksPlugin(contentDir) {
150
+ const root = contentDir.split('\\').join('/').replace(/\/+$/, '') + '/';
151
+ return {
152
+ name: 'writedocs:mintlify-react-hooks',
153
+ enforce: 'pre',
154
+ transform(code, id) {
155
+ const file = id.split('?')[0].split('\\').join('/');
156
+ if (!/\.[jt]sx$/.test(file) || !file.startsWith(root) || file.includes('/node_modules/')) return null;
157
+ const missing = missingHooks(code);
158
+ if (missing.length === 0) return null;
159
+ // After any leading directives ('use client'), which must stay first.
160
+ const directives = /^(\s*(['"])use [a-z]+\2;?\s*)*/.exec(code)[0];
161
+ return { code: `${directives}import { ${missing.join(', ')} } from 'react';\n${code.slice(directives.length)}`, map: null };
162
+ },
163
+ };
164
+ }
@@ -25,6 +25,8 @@ import { visit } from 'unist-util-visit';
25
25
  // either, so it round-trips as plain text the same way `{{key}}` would
26
26
  // have in an ordinary (non-MDX) Markdown file.
27
27
  const PLACEHOLDER = /\[\[\s*([\w.-]+)\s*\]\]/g;
28
+ // The inside of a `{{key}}` expression, as MDX parsed it: `{key}`.
29
+ const MINTLIFY_PLACEHOLDER = /^\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*$/;
28
30
 
29
31
  /**
30
32
  * A remark plugin that replaces `[[key]]` placeholders in a page's prose
@@ -62,5 +64,20 @@ export function remarkSubstituteVariables(variables) {
62
64
  Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : match
63
65
  );
64
66
  });
67
+
68
+ // Mintlify's syntax for the same thing, `{{key}}`. MDX has already
69
+ // parsed that as a JavaScript expression by the time this runs - an
70
+ // object literal `{key}` inside `{...}` - so it's matched as an
71
+ // expression node, not text, and replaced with the value as text. Only
72
+ // for keys the site defines; any other expression is left alone. (A key
73
+ // with a hyphen isn't valid JavaScript there, so MDX rejects
74
+ // `{{my-key}}` before this runs - `writedocs convert` warns about those.)
75
+ visit(tree, ['mdxTextExpression', 'mdxFlowExpression'], (node, index, parent) => {
76
+ const key = MINTLIFY_PLACEHOLDER.exec(node.value ?? '')?.[1];
77
+ if (!key || !parent || index === undefined) return;
78
+ if (!Object.prototype.hasOwnProperty.call(variables, key)) return;
79
+ const text = { type: 'text', value: String(variables[key]) };
80
+ parent.children[index] = node.type === 'mdxFlowExpression' ? { type: 'paragraph', children: [text] } : text;
81
+ });
65
82
  };
66
83
  }
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { visit } from 'unist-util-visit';
3
+ import { parse as acornParse } from 'acorn';
3
4
  import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
4
5
 
5
6
  // Which JSX elements in an MDX file are components writedocs can't resolve
@@ -64,9 +65,42 @@ export function findUnknownComponents(tree) {
64
65
  column: node.position?.start?.column,
65
66
  });
66
67
  });
68
+ unknown.push(...findUnknownInCode(tree, bound));
67
69
  return unknown;
68
70
  }
69
71
 
72
+ /** The same, for JSX inside the page's own code - `export const X = () =>
73
+ * <Widget />` or a `{...}` expression - which isn't part of the content
74
+ * tree. Matched by text, so a name counts as known when the code declares
75
+ * it anywhere (`const Widget`, `function Widget`, a destructured
76
+ * `{ Widget }` parameter), not only at the top level. These entries have
77
+ * no `node` (nothing to turn into a fragment) and `inCode: true`. */
78
+ function findUnknownInCode(tree, bound) {
79
+ const found = [];
80
+ const seen = new Set();
81
+ const codeNodes = [];
82
+ visit(tree, ['mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression'], (node) => {
83
+ if (node.value) codeNodes.push(node);
84
+ });
85
+ const allCode = codeNodes.map((n) => n.value).join('\n');
86
+ const declaredInCode = (name) =>
87
+ new RegExp(`\\b(?:const|let|var|function|class)\\s+${name}\\b|[{,(]\\s*(?:\\.\\.\\.)?${name}\\s*[,}=):]`).test(allCode);
88
+ for (const node of codeNodes) {
89
+ for (const m of node.value.matchAll(/<([A-Z][A-Za-z0-9]*)((?:\.[A-Za-z0-9]+)*)[\s/>]/g)) {
90
+ const root = m[1];
91
+ const members = m[2] ? m[2].slice(1).split('.') : [];
92
+ const name = [root, ...members].join('.');
93
+ if (seen.has(name) || bound.has(root) || declaredInCode(root)) continue;
94
+ if (BUILTIN_COMPONENT_NAMES.includes(root) && (members.length === 0 || (members.length === 1 && BUILTIN_MEMBERS[root]?.includes(members[0])))) continue;
95
+ seen.add(name);
96
+ const before = node.value.slice(0, m.index);
97
+ const line = (node.position?.start?.line ?? 0) + (before.match(/\n/g)?.length ?? 0);
98
+ found.push({ node: null, name, line: line || undefined, column: undefined, inCode: true, root });
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
70
104
  /**
71
105
  * Remark plugin: an unknown component renders its children and nothing
72
106
  * else - the element becomes a fragment - with a warning naming the file
@@ -84,13 +118,32 @@ export function remarkUnknownComponentFallback() {
84
118
  ? path.relative(contentDir, file.path).split(path.sep).join('/')
85
119
  : file.path
86
120
  : '(unknown file)';
87
- for (const { node, name, line } of found) {
121
+ const stubs = new Set();
122
+ for (const { node, name, line, inCode, root } of found) {
88
123
  console.warn(
89
124
  `[writedocs] ${filePath}${line ? `:${line}` : ''} - unknown component <${name}>, showing only its content. ` +
90
125
  'Remove it, or define it in a snippet.'
91
126
  );
92
- node.name = null;
93
- node.attributes = [];
127
+ if (inCode) {
128
+ stubs.add(root);
129
+ } else {
130
+ node.name = null;
131
+ node.attributes = [];
132
+ }
133
+ }
134
+ // Inside code there's no element to turn into a fragment - the name
135
+ // itself has to exist, or rendering throws "X is not defined". Each one
136
+ // gets a stand-in component that renders its children (a dotted
137
+ // `<Widget.Part>` gets its parts on the same stand-in).
138
+ if (stubs.size > 0) {
139
+ const source = [...stubs]
140
+ .map(
141
+ (name) =>
142
+ `export const ${name} = new Proxy((props) => props?.children ?? null, { get: (fn, key) => (key in fn ? fn[key] : (props) => props?.children ?? null) });`
143
+ )
144
+ .join('\n');
145
+ const estree = acornParse(source, { ecmaVersion: 'latest', sourceType: 'module' });
146
+ tree.children.unshift({ type: 'mdxjsEsm', value: source, data: { estree } });
94
147
  }
95
148
  };
96
149
  }