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