137 lines
4.6 KiB
JavaScript
137 lines
4.6 KiB
JavaScript
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { format, resolveConfig } from "prettier";
|
|
|
|
const uiRoot = join(import.meta.dirname, "..", "packages", "ui");
|
|
const componentsDir = join(uiRoot, "components");
|
|
const catalog = JSON.parse(readFileSync(join(uiRoot, "component-catalog.json"), "utf8"));
|
|
const metadata = new Map(catalog.components.map((entry) => [entry.name, entry]));
|
|
|
|
async function writeFormatted(path, source, parser) {
|
|
const config = (await resolveConfig(path)) ?? {};
|
|
const formatted = await format(source, {
|
|
...config,
|
|
filepath: path,
|
|
parser,
|
|
});
|
|
writeFileSync(path, formatted);
|
|
}
|
|
|
|
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, annotation, rawDefault] = match;
|
|
const value = rawDefault?.trim();
|
|
const type =
|
|
annotation?.trim() ||
|
|
(value === "true" || value === "false"
|
|
? "boolean"
|
|
: /^-?\d+(?:\.\d+)?$/.test(value ?? "")
|
|
? "number"
|
|
: "string");
|
|
props.push({ name, type, required: value === undefined, default: value ?? null });
|
|
}
|
|
return props;
|
|
}
|
|
|
|
function words(name) {
|
|
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
}
|
|
|
|
const components = 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}`);
|
|
}
|
|
const name = declaration[1];
|
|
const entry = metadata.get(name);
|
|
const slots = [...source.matchAll(/<slot(?:\s+name="([^"]+)")?/g)].map(
|
|
(match) => match[1] ?? "default",
|
|
);
|
|
const declaredEvents = [
|
|
...new Set(
|
|
[...source.matchAll(/@event\s+([A-Za-z][A-Za-z0-9_]*)\s*=\s*function/g)].map(
|
|
(match) => match[1],
|
|
),
|
|
),
|
|
];
|
|
// Only `@event` declarations form the public component contract. Native
|
|
// handlers in a component's implementation are private wiring and must not
|
|
// leak into the generated consumer-facing reference.
|
|
const events = declaredEvents;
|
|
return {
|
|
name,
|
|
mount: name,
|
|
category: entry?.category ?? "core",
|
|
purpose: entry?.purpose ?? `Reusable ${words(name)} component.`,
|
|
props: propsOf(source),
|
|
slots: [...new Set(slots)],
|
|
events,
|
|
source: `components/${file}`,
|
|
};
|
|
})
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
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"}`,
|
|
`- Events: ${component.events.length ? component.events.map((event) => `\`${event}\``).join(", ") : "None"}`,
|
|
"",
|
|
);
|
|
}
|
|
}
|
|
const componentsPath = join(uiRoot, "COMPONENTS.md");
|
|
await writeFormatted(componentsPath, `${lines.join("\n").trimEnd()}\n`, "markdown");
|