Files
WRNexusJS/scripts/generate-ui-component-reference.mjs
T
2026-08-01 01:09:58 +05:30

209 lines
6.9 KiB
JavaScript

import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const uiRoot = join(import.meta.dirname, "..", "packages", "ui");
const componentsDir = join(uiRoot, "components");
const catalogPath = join(uiRoot, "component-catalog.json");
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const metadata = new Map(catalog.components.map((entry) => [entry.name, entry]));
async function writeFormatted(path, source) {
writeFileSync(path, source);
}
function block(source, keyword) {
const start = source.indexOf(`${keyword} {`);
if (start < 0) return "";
const open = source.indexOf("{", start);
let depth = 0;
for (let index = open; index < source.length; index++) {
if (source[index] === "{") depth++;
if (source[index] === "}" && --depth === 0) return source.slice(open + 1, index);
}
return "";
}
function propsOf(source) {
const props = [];
for (const line of block(source, "props").split(/\r?\n/)) {
const match = line
.trim()
.match(/^([A-Za-z][A-Za-z0-9_]*)(\?)?(?:\s*:\s*([^=]+?))?\s*(?:=\s*(.+))?$/);
if (!match) continue;
const [, name, optional, annotation, rawDefault] = match;
const value = rawDefault?.trim();
const type =
annotation?.trim() ||
(value === "true" || value === "false"
? "boolean"
: /^-?\d+(?:\.\d+)?$/.test(value ?? "")
? "number"
: "string");
const options = [...type.matchAll(/["']([^"']+)["']/g)].map((entry) => entry[1]);
props.push({
name,
type,
required: optional !== "?" && value === undefined,
default: value ?? null,
options,
});
}
return props;
}
function outputsOf(source) {
const body = block(source, "outputs");
const outputs = [];
let index = 0;
while (index < body.length) {
while (index < body.length && /\s/.test(body[index])) index++;
if (index >= body.length) break;
const nameMatch = /^[A-Za-z][A-Za-z0-9_]*/.exec(body.slice(index));
if (!nameMatch) {
index++;
continue;
}
const name = nameMatch[0];
index += name.length;
while (index < body.length && /\s/.test(body[index])) index++;
if (body[index] !== "(") {
index++;
continue;
}
const start = ++index;
let depth = 1;
let quote = null;
for (; index < body.length && depth > 0; index++) {
const char = body[index];
if (quote) {
if (char === "\\") index++;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === "`") quote = char;
else if (char === "(") depth++;
else if (char === ")") depth--;
}
const parameters = body.slice(start, index - 1).trim();
const payloadMatch = /^payload\??\s*:\s*([\s\S]+)$/.exec(parameters);
outputs.push({ name, payloadType: payloadMatch?.[1]?.trim() ?? null });
}
return outputs;
}
function words(name) {
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
}
const sourceEntries = readdirSync(componentsDir)
.filter((file) => file.endsWith(".wrn"))
.map((file) => {
const source = readFileSync(join(componentsDir, file), "utf8");
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
if (!declaration) {
throw new Error(`UI component source does not declare a component: ${file}`);
}
return { file, source, name: declaration[1] };
});
// Windows checkouts can temporarily contain legacy lowercase files beside the
// canonical component source. Generate one public contract per declaration and
// prefer the exact <ComponentName>.wrn filename when both exist.
const sourceByName = new Map();
for (const candidate of sourceEntries) {
const current = sourceByName.get(candidate.name);
const candidateCanonical = candidate.file === `${candidate.name}.wrn`;
const currentCanonical = current?.file === `${candidate.name}.wrn`;
if (!current || (candidateCanonical && !currentCanonical)) {
sourceByName.set(candidate.name, candidate);
}
}
const components = [...sourceByName.values()]
.map(({ file, source, name }) => {
const entry = metadata.get(name);
const slots = [...source.matchAll(/<slot(?:\s+name="([^"]+)")?/g)].map(
(match) => match[1] ?? "default",
);
const outputs = outputsOf(source);
const legacyEvents = [
...new Set(
[...source.matchAll(/@event\s+([A-Za-z][A-Za-z0-9_]*)\s*=\s*function/g)].map(
(match) => match[1],
),
),
];
for (const name of legacyEvents) {
if (!outputs.some((output) => output.name === name)) {
outputs.push({ name, payloadType: "unknown" });
}
}
return {
name,
mount: name,
category: entry?.category ?? "core",
purpose: entry?.purpose ?? `Reusable ${words(name)} component.`,
props: propsOf(source),
slots: [...new Set(slots)],
outputs,
events: outputs.map((output) => output.name),
source: `components/${file}`,
};
})
.sort((left, right) => left.name.localeCompare(right.name));
const catalogComponents = components.map((component) => ({
name: component.name,
category: component.category,
purpose: component.purpose,
}));
await writeFormatted(
catalogPath,
JSON.stringify({ ...catalog, components: catalogComponents }, null, 2) + "\n",
"json",
);
const referencePath = join(uiRoot, "component-reference.json");
await writeFormatted(
referencePath,
JSON.stringify(
{ generatedFrom: "packages/ui/components/*.wrn", count: components.length, components },
null,
2,
) + "\n",
"json",
);
const lines = [
"# WRNexus UI component reference",
"",
`This reference is generated from the ${components.length} packaged \`.wrn\` component sources. Props marked required have no default; all others show their runtime default.`,
"",
];
for (const category of [...new Set(components.map((component) => component.category))].sort()) {
lines.push(`## ${category[0].toUpperCase()}${category.slice(1)}`, "");
for (const component of components.filter((item) => item.category === category)) {
const props = component.props.length
? component.props
.map(
(prop) =>
`\`${prop.name}: ${prop.type}${prop.required ? " (required)" : ` = ${prop.default}`}\``,
)
.join(", ")
: "None";
lines.push(
`### ${component.name}`,
"",
component.purpose,
"",
`- Mount: \`data-component="${component.mount}"\``,
`- Props: ${props}`,
`- Slots: ${component.slots.length ? component.slots.map((slot) => `\`${slot}\``).join(", ") : "None"}`,
`- Outputs: ${component.outputs.length ? component.outputs.map((output) => `\`${output.name}(${output.payloadType ?? ""})\``).join(", ") : "None"}`,
"",
);
}
}
const componentsPath = join(uiRoot, "COMPONENTS.md");
await writeFormatted(componentsPath, `${lines.join("\n").trimEnd()}\n`, "markdown");