@writedocs/generator 0.4.8 → 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.
- package/astro.config.mjs +45 -2
- package/bin/writedocs.js +190 -139
- package/package.json +2 -1
- package/src/cli/convert.js +82 -0
- package/src/cli/generate-api-pages.js +56 -3
- package/src/components/Accordion.astro +2 -1
- package/src/components/AccordionGroup.astro +4 -1
- package/src/components/ApiPlayground.astro +6 -2
- package/src/components/ApiReferencePanel.astro +6 -2
- package/src/components/AppIcon.astro +14 -2
- package/src/components/Badge.astro +2 -0
- package/src/components/Callout.astro +2 -1
- package/src/components/Card.astro +2 -1
- package/src/components/CardGroup.astro +2 -1
- package/src/components/Check.astro +1 -1
- package/src/components/CodeBlock.astro +94 -0
- package/src/components/CodeGroup.astro +2 -1
- package/src/components/Color.astro +2 -1
- package/src/components/ColorItem.astro +2 -1
- package/src/components/ColorRow.astro +2 -1
- package/src/components/Column.astro +19 -0
- package/src/components/Columns.astro +1 -1
- package/src/components/Danger.astro +1 -1
- package/src/components/Expandable.astro +2 -1
- package/src/components/Frame.astro +2 -1
- package/src/components/GitHubRepo.astro +2 -1
- package/src/components/Hint.astro +2 -1
- package/src/components/Icon.astro +3 -2
- package/src/components/Image.astro +2 -1
- package/src/components/Info.astro +1 -1
- package/src/components/Note.astro +1 -1
- package/src/components/Panel.astro +2 -1
- package/src/components/Parameter.astro +2 -1
- package/src/components/Prompt.astro +2 -1
- package/src/components/RequestExample.astro +2 -1
- package/src/components/ResponseExample.astro +2 -1
- package/src/components/Searchbar.astro +2 -1
- package/src/components/Step.astro +2 -1
- package/src/components/Steps.astro +4 -1
- package/src/components/Tab.astro +2 -1
- package/src/components/Tabs.astro +2 -1
- package/src/components/Tile.astro +2 -1
- package/src/components/Tip.astro +1 -1
- package/src/components/TreeFile.astro +2 -1
- package/src/components/TreeFolder.astro +2 -1
- package/src/components/Update.astro +2 -1
- package/src/components/Video.astro +2 -1
- package/src/components/View.astro +2 -1
- package/src/components/Warning.astro +1 -1
- package/src/components/class-names.ts +8 -0
- package/src/components/index.ts +2 -0
- package/src/content.config.ts +29 -129
- package/src/lib/config-schema.js +124 -0
- package/src/lib/config-schema.ts +1574 -1437
- package/src/lib/config.ts +7 -140
- package/src/lib/content-check.js +262 -0
- package/src/lib/icons.js +109 -0
- package/src/lib/mdx-auto-hydrate.js +12 -0
- package/src/lib/mdx-inject-builtins.js +16 -1
- package/src/lib/mdx-inline-react.js +202 -0
- package/src/lib/mdx-mintlify.js +65 -0
- package/src/lib/mdx-substitute-variables.js +17 -0
- package/src/lib/mdx-unknown-components.js +149 -0
- package/src/lib/mintlify-convert.js +599 -0
- package/src/lib/openapi-ref.js +44 -0
- package/src/lib/openapi-render.ts +10 -1
- package/src/lib/pages.js +150 -0
- 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
|
+
}
|
package/src/lib/mdx-mintlify.js
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { visit } from 'unist-util-visit';
|
|
3
|
+
import { parse as acornParse } from 'acorn';
|
|
4
|
+
import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
|
|
5
|
+
|
|
6
|
+
// Which JSX elements in an MDX file are components writedocs can't resolve
|
|
7
|
+
// - neither a built-in nor something the file imports or defines itself.
|
|
8
|
+
// MDX fails the whole build on one ("Expected component `X` to be
|
|
9
|
+
// defined"), which turned every custom component in a migrated Mintlify
|
|
10
|
+
// project into a build-breaking hunt, one component per build. Shared by
|
|
11
|
+
// the build (remarkUnknownComponentFallback below) and `writedocs
|
|
12
|
+
// validate` (lib/content-check.js), so both agree on exactly which
|
|
13
|
+
// elements are unknown. Plain JavaScript so validate can load it with
|
|
14
|
+
// plain Node.
|
|
15
|
+
|
|
16
|
+
// Dotted built-ins: the root and the parts it has (see
|
|
17
|
+
// src/components/compound.ts). `<Tree.Leaf>` is unknown even though
|
|
18
|
+
// `Tree` isn't.
|
|
19
|
+
const BUILTIN_MEMBERS = {
|
|
20
|
+
Tree: ['Folder', 'File'],
|
|
21
|
+
FileTree: ['Folder', 'File'],
|
|
22
|
+
Color: ['Row', 'Item'],
|
|
23
|
+
GitHub: ['Repo'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function localBindings(tree) {
|
|
27
|
+
const names = new Set();
|
|
28
|
+
visit(tree, 'mdxjsEsm', (node) => {
|
|
29
|
+
for (const stmt of node.data?.estree?.body ?? []) {
|
|
30
|
+
if (stmt.type === 'ImportDeclaration') {
|
|
31
|
+
for (const s of stmt.specifiers) if (s.local?.name) names.add(s.local.name);
|
|
32
|
+
} else if (stmt.type === 'ExportNamedDeclaration') {
|
|
33
|
+
const decl = stmt.declaration;
|
|
34
|
+
if (decl?.type === 'VariableDeclaration') {
|
|
35
|
+
for (const d of decl.declarations) if (d.id?.type === 'Identifier') names.add(d.id.name);
|
|
36
|
+
} else if (decl?.id?.name) {
|
|
37
|
+
names.add(decl.id.name); // export function X / export class X
|
|
38
|
+
}
|
|
39
|
+
for (const s of stmt.specifiers ?? []) if (s.exported?.name) names.add(s.exported.name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every JSX element in an MDX tree that names a component this file can't
|
|
47
|
+
* resolve, in document order: [{ node, name, line, column }]. Lowercase
|
|
48
|
+
* names are plain HTML elements and never count. */
|
|
49
|
+
export function findUnknownComponents(tree) {
|
|
50
|
+
const bound = localBindings(tree);
|
|
51
|
+
const unknown = [];
|
|
52
|
+
visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
|
|
53
|
+
if (!node.name) return; // a fragment, <>...</>
|
|
54
|
+
const [root, ...members] = node.name.split('.');
|
|
55
|
+
if (!/^[A-Z]/.test(root)) return; // <div>, <svg>, <foo.bar> - HTML or a lowercase member expression
|
|
56
|
+
if (bound.has(root)) return;
|
|
57
|
+
if (BUILTIN_COMPONENT_NAMES.includes(root)) {
|
|
58
|
+
if (members.length === 0) return;
|
|
59
|
+
if (members.length === 1 && BUILTIN_MEMBERS[root]?.includes(members[0])) return;
|
|
60
|
+
}
|
|
61
|
+
unknown.push({
|
|
62
|
+
node,
|
|
63
|
+
name: node.name,
|
|
64
|
+
line: node.position?.start?.line,
|
|
65
|
+
column: node.position?.start?.column,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
unknown.push(...findUnknownInCode(tree, bound));
|
|
69
|
+
return unknown;
|
|
70
|
+
}
|
|
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
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Remark plugin: an unknown component renders its children and nothing
|
|
106
|
+
* else - the element becomes a fragment - with a warning naming the file
|
|
107
|
+
* and line, instead of failing the build. The site still builds and reads
|
|
108
|
+
* sensibly (the component's text is still there), and the warning, plus
|
|
109
|
+
* `writedocs validate`, say what to fix.
|
|
110
|
+
*/
|
|
111
|
+
export function remarkUnknownComponentFallback() {
|
|
112
|
+
return (tree, file) => {
|
|
113
|
+
const found = findUnknownComponents(tree);
|
|
114
|
+
if (found.length === 0) return;
|
|
115
|
+
const contentDir = process.env.WRITEDOCS_CONTENT_DIR;
|
|
116
|
+
const filePath = file.path
|
|
117
|
+
? contentDir
|
|
118
|
+
? path.relative(contentDir, file.path).split(path.sep).join('/')
|
|
119
|
+
: file.path
|
|
120
|
+
: '(unknown file)';
|
|
121
|
+
const stubs = new Set();
|
|
122
|
+
for (const { node, name, line, inCode, root } of found) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`[writedocs] ${filePath}${line ? `:${line}` : ''} - unknown component <${name}>, showing only its content. ` +
|
|
125
|
+
'Remove it, or define it in a snippet.'
|
|
126
|
+
);
|
|
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 } });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|