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(/ 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");