Genesis
This commit is contained in:
185
vite-plugin-component-docs.js
Normal file
185
vite-plugin-component-docs.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { parse } from '@babel/parser';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Vite plugin — "component docs" virtual module.
|
||||
*
|
||||
* Reads JSDoc blocks tagged `@component` from the source files listed in
|
||||
* SOURCES, parses them into a registry, and exposes it to the dev gallery as
|
||||
* `import { componentDocs } from 'virtual:component-docs'`.
|
||||
*
|
||||
* The JSDoc format (kept next to the implementation):
|
||||
*
|
||||
* Block comment: "/**" ... "*/" (see parseDoc for tag handling)
|
||||
*
|
||||
* @component PanelTile ← component name used in the gallery
|
||||
* @import dashgui/panels ← sub-path export for the import snippet
|
||||
* @defaultExport ← optional: `import X from '…'` (default export)
|
||||
*
|
||||
* @prop {type} [name=default] - Description ← optional props use [name=default]
|
||||
* @prop {type} requiredProp - Description ← props without brackets are required
|
||||
* @prop {type} [optionalProp] - Description ← optional without a default
|
||||
*
|
||||
* @behaviour A behaviour note.
|
||||
* @behaviour Another behaviour note.
|
||||
*/
|
||||
const VIRTUAL_ID = 'virtual:component-docs';
|
||||
const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_ID;
|
||||
|
||||
const SOURCES = [
|
||||
{ file: 'src/panels/PanelTile.jsx', importPath: 'dashgui/panels' },
|
||||
{ file: 'src/panels/EditablePanelTile.jsx', importPath: 'dashgui/panels' },
|
||||
{ file: 'src/ElevatedPill.jsx', importPath: 'dashgui/ElevatedPill' },
|
||||
{ file: 'src/SingleLineTextEdit.jsx', importPath: 'dashgui/SingleLineTextEdit' },
|
||||
{ file: 'src/tiles/Tile.jsx', importPath: 'dashgui/tiles' },
|
||||
{ file: 'src/lists/RecordList.jsx', importPath: 'dashgui/lists' },
|
||||
];
|
||||
|
||||
/** Parse a single `@prop {type} [name=default] - description` line. */
|
||||
function parseProp(text) {
|
||||
const typeMatch = text.match(/^\{(.*?)\}\s+(.*)$/s);
|
||||
const type = typeMatch ? typeMatch[1].trim() : '';
|
||||
const rest = typeMatch ? typeMatch[2].trim() : text.trim();
|
||||
|
||||
let name;
|
||||
let defaultValue;
|
||||
let required = true;
|
||||
let description = '';
|
||||
|
||||
const optionalMatch = rest.match(/^\[([^\]=]*)(?:=([^\]]*))?\]\s*(?:-\s*)?(.*)$/s);
|
||||
if (optionalMatch) {
|
||||
required = false;
|
||||
name = optionalMatch[1].trim();
|
||||
defaultValue = optionalMatch[2] !== undefined ? optionalMatch[2].trim() : undefined;
|
||||
description = optionalMatch[3].trim();
|
||||
} else {
|
||||
const reqMatch = rest.match(/^([^\s-]+)(?:\s*-\s*)?(.*)$/s);
|
||||
if (reqMatch) {
|
||||
name = reqMatch[1].trim();
|
||||
description = reqMatch[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { name, type, required, default: defaultValue, description };
|
||||
}
|
||||
|
||||
/** Parse a `/** … */` comment value (as provided by @babel/parser). */
|
||||
function parseDoc(commentValue, fallback) {
|
||||
const lines = commentValue
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
||||
.filter((l, idx, arr) => !(l === '' && (idx === 0 || idx === arr.length - 1 || arr[idx - 1] === '')));
|
||||
|
||||
const descLines = [];
|
||||
const props = [];
|
||||
const behaviour = [];
|
||||
let name = fallback.name || '';
|
||||
let importPath = fallback.importPath || '';
|
||||
let defaultExport = false;
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length && !lines[i].startsWith('@')) {
|
||||
if (lines[i] !== '') descLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
for (; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^@(\w+)\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const [, tag, rest] = m;
|
||||
switch (tag) {
|
||||
case 'component':
|
||||
name = rest.trim();
|
||||
break;
|
||||
case 'import':
|
||||
importPath = rest.trim();
|
||||
break;
|
||||
case 'defaultExport':
|
||||
defaultExport = true;
|
||||
break;
|
||||
case 'prop':
|
||||
props.push(parseProp(rest.trim()));
|
||||
break;
|
||||
case 'behaviour':
|
||||
behaviour.push(rest.trim());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importPath, defaultExport, description: descLines.join(' '), props, behaviour };
|
||||
}
|
||||
|
||||
/** Walk a source file and collect every `@component`-tagged JSDoc block. */
|
||||
function extractFromFile(root, source) {
|
||||
const fullPath = path.join(root, source.file);
|
||||
let code;
|
||||
try {
|
||||
code = fs.readFileSync(fullPath, 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(`[component-docs] cannot read ${source.file}: ${err.message}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'classProperties'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[component-docs] cannot parse ${source.file}: ${err.message}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const found = {};
|
||||
for (const stmt of ast.program.body) {
|
||||
const comments = stmt.leadingComments || [];
|
||||
const doc = comments[comments.length - 1];
|
||||
if (!doc || doc.type !== 'CommentBlock' || !doc.value.includes('@component')) continue;
|
||||
|
||||
const parsed = parseDoc(doc.value, { name: '', importPath: source.importPath });
|
||||
if (!parsed.name) continue;
|
||||
found[parsed.name] = parsed;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function buildRegistry(root) {
|
||||
const registry = {};
|
||||
for (const source of SOURCES) {
|
||||
Object.assign(registry, extractFromFile(root, source));
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function componentDocsPlugin() {
|
||||
const documentedFiles = SOURCES.map((s) => s.file);
|
||||
|
||||
return {
|
||||
name: 'component-docs',
|
||||
enforce: 'pre',
|
||||
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID;
|
||||
return null;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id !== RESOLVED_VIRTUAL_ID) return null;
|
||||
const registry = buildRegistry(process.cwd());
|
||||
return `export const componentDocs = ${JSON.stringify(registry)};`;
|
||||
},
|
||||
|
||||
handleHotUpdate(ctx) {
|
||||
const { file, server } = ctx;
|
||||
if (!documentedFiles.some((f) => file.endsWith(f))) return;
|
||||
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
|
||||
if (mod) server.moduleGraph.invalidateModule(mod);
|
||||
server.ws.send({ type: 'full-reload' });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user