/** Repeatable catalog hook/contract generation. Never rewrites behavior or existing attributes. */ import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "../packages/compiler/src/index.ts"; import { CORE_COMPONENT_CONTRACTS } from "../packages/design-system/src/core-contracts.ts"; import { CATALOG_PART_PROPERTIES } from "../packages/design-system/src/contract-properties.ts"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const apply = process.argv.includes("--write"); const directory = join(root, "packages/ui/components"); type ElementNode = { type: "element"; tag: string; attrs: Array<{ name: string; value: string; event?: boolean }>; children: unknown[]; }; type Profile = keyof typeof CATALOG_PART_PROPERTIES; const providerParts: Record> = { Chart: { canvas: "Chart.js draws canvas pixels; use the existing chart options/data API.", accessible: "Runtime-generated accessible data table is owned by the chart adapter.", }, DrawflowCanvas: { viewport: "Drawflow owns generated graph DOM and its styles. Use the existing provider options and CSS integration.", }, Map: { embeddedFrame: "Embedded map document is cross-origin and owned by the map provider." }, }; const familyNames: Record = { forms: [ "AdvancedSelect", "ColorPicker", "ComboBox", "DatePicker", "FileInput", "FileUploadProgress", "InputGroup", "InputNumber", "PinInput", "RangeSlider", "SearchBox", "StrongPassword", "TimePicker", "TogglePassword", ], navigation: [ "BackToTop", "Breadcrumb", "Dropdown", "Footer", "Link", "MegaMenu", "Navbar", "Pagination", "PreferenceSwitcher", "Scrollspy", "Sidebar", "TextLink", ], layout: [ "AuthSplitLayout", "CTASection", "FeatureGrid", "Grid", "Hero", "HeroActions", "LayoutSplitter", "Marquee", "MarketingSectionHeader", "MetricGrid", "PublicPageShell", "Section", "SectionHeader", "SplitHero", ], typography: ["Typography", "Kbd", "Blockquote"], media: ["Avatar", "AvatarGroup", "DeviceFrame", "Image"], providers: ["Chart", "DrawflowCanvas", "Map"], editors: ["FlowCanvas", "WysiwygEditor"], feedback: ["Skeleton", "Spinner", "Tooltip", "Progress", "Toaster", "LegendIndicator", "Rating"], }; const family = (name: string) => Object.entries(familyNames).find(([, names]) => names.includes(name))?.[0] ?? "collections"; const camel = (value: string) => value .replace(/[-_]([a-z0-9])/g, (_, letter: string) => letter.toUpperCase()) .replace(/[^a-zA-Z0-9]/g, ""); const hooks = /^data-wrn-(?:owner|part|component|axis-[\w-]+)$/; function stripHooks(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripHooks); if (value && typeof value === "object") return Object.fromEntries( Object.entries(value).map(([key, entry]) => [ key, key === "attrs" && Array.isArray(entry) ? entry.filter((attr) => !hooks.test(attr.name)) : stripHooks(entry), ]), ); return value; } function elements( value: unknown, depth = 0, output: Array<{ node: ElementNode; root: boolean }> = [], ): typeof output { if (Array.isArray(value)) { for (const item of value) elements(item, depth, output); return output; } if (!value || typeof value !== "object") return output; const record = value as Record; if (record.type === "element") { const node = value as ElementNode; output.push({ node, root: depth === 0 }); elements(node.children, depth + 1, output); } else for (const [key, entry] of Object.entries(record)) if (key !== "attrs") elements(entry, depth, output); return output; } /** Scan tags without treating > inside quotes or brace expressions as an end marker. */ function openingTags( source: string, start: number, end: number, ): Array<{ start: number; end: number; tag: string; raw: string }> { const result = []; for (let index = start; index < end; index++) { if (source.startsWith("", index) + 2; continue; } if (source[index] !== "<" || !/[A-Za-z]/.test(source[index + 1] ?? "")) continue; const match = /^<([A-Za-z][\w.-]*)\b/.exec(source.slice(index)); if (!match) continue; let quote = "", braces = 0, finish = index + match[0].length; for (; finish < end; finish++) { const char = source[finish]!; if (quote) { if (char === "\\") { finish++; continue; } if (char === quote) quote = ""; continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "{") braces++; else if (char === "}") braces--; else if (char === ">" && braces === 0) break; } if (finish >= end) throw Error("Unclosed view tag"); result.push({ start: index, end: finish + 1, tag: match[1]!, raw: source.slice(index, finish + 1), }); index = finish; } return result; } const catalog: Record = {}; const snapshots: Record = {}; const pendingWrites: Array<{ path: string; source: string }> = []; for (const file of readdirSync(directory) .filter((file) => file.endsWith(".wrn")) .sort()) { const path = `packages/ui/components/${file}`, current = readFileSync(join(root, path), "utf8"); const nameMatch = /\bcomponent\s+(\w+)/.exec(current); if (nameMatch && Object.hasOwn(CORE_COMPONENT_CONTRACTS, nameMatch[1]!)) continue; // Remove only this generator's attributes before regenerating; leave all original bytes intact. const source = current.replace( / data-wrn-(?:owner|part|component|axis-[\w-]+)=(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g, "", ); const ast = parse(source); const name = ast.name; if (!name || Object.hasOwn(CORE_COMPONENT_CONTRACTS, name)) continue; const view = /\bview\s*\{/.exec(source), style = /^\s*style\s*\{/m.exec(source.slice((view?.index ?? 0) + (view?.[0].length ?? 0))); if (!view || !style) throw Error(`${name}: missing view/style boundary`); const start = view.index + view[0].length, end = start + style.index; const nodes = elements(ast.view), tags = openingTags(source, start, end); if (tags.length !== nodes.length || tags.some((tag, index) => tag.tag !== nodes[index]?.node.tag)) throw Error(`${name}: scanner/parser tag mismatch (${tags.length}/${nodes.length})`); const props = ast.props ?? []; const axes: Record = {}; for (const prop of props) if ( [ "variant", "size", "color", "radius", "shadow", "pill", "shape", "rounded", "bordered", "flush", "compact", "dense", "separated", ].includes(prop.name) && prop.default !== undefined ) { let value: unknown; try { value = JSON.parse(prop.default); } catch { continue; } if (typeof value !== "string" && typeof value !== "boolean") continue; const values = new Set([String(value)]); for (const match of source.matchAll( new RegExp( `(?:wrn-component--${prop.name}-|wrn-[a-z-]+--${prop.name}-)([a-z0-9-]+)|data-${prop.name}=["']([a-z0-9-]+)["']`, "g", ), )) values.add(match[1] ?? match[2]!); if (typeof value === "boolean") { values.add("false"); values.add("true"); } axes[prop.name] = { default: String(value), values: [...values].sort() }; } const rootClass = nodes .filter((item) => item.root) .flatMap((item) => item.node.attrs.filter((attr) => attr.name === "class").map((attr) => attr.value), ) .join(" "); const baseClass = (rootClass.match(/\bwrn-(?!component\b|visually\b)[\w-]+/g) ?? []).find( (value) => !value.includes("--color-") && !value.includes("--size-"), ) ?? ""; const stem = baseClass.replace(/^wrn-next--|^wrn-/, ""); const parts: Record< string, { elements: string[]; states: Record; profile: Profile; ownership: "framework" | "provider" | "content" | "native"; role: string; properties: readonly string[]; shapeBinding: boolean; explicitAxes: Record; } > = {}; const regions: Record = {}; const edits: Array<{ position: number; text: string }> = []; for (let index = 0; index < nodes.length; index++) { const { node, isRoot } = { node: nodes[index]!.node, isRoot: nodes[index]!.root }; const tag = tags[index]!; if ( node.tag[0] !== node.tag[0]?.toLowerCase() || [ "slot", "path", "line", "rect", "circle", "polyline", "polygon", "defs", "stop", "linearGradient", "source", "option", "script", "style", ].includes(node.tag) ) continue; const attributes = Object.fromEntries( node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr.value]), ); const classes = (attributes.class ?? "").match(/\bwrn-[\w-]+/g) ?? []; const bem = classes.find((value) => value.includes("__")); const flat = classes.find( (value) => baseClass && value.startsWith(baseClass + "-") && !value.startsWith(baseClass + "--"), ); let suffix = bem?.split("__")[1]?.replace(new RegExp(`^${stem}-`), "") ?? flat?.slice(baseClass.length + 1); if (suffix?.includes("--")) suffix = suffix.split("--")[0]; let part = isRoot ? "root" : suffix ? camel(suffix) : node.tag === "canvas" ? "canvas" : node.tag === "iframe" ? "embeddedFrame" : node.tag === "svg" ? "vector" : ["input", "textarea", "select"].includes(node.tag) ? "control" : node.tag === "button" ? attributes["aria-expanded"] !== undefined ? "trigger" : "button" : node.tag === "a" ? "link" : /^h[1-6]$/.test(node.tag) ? "title" : node.tag === "label" ? "label" : undefined; if (Object.hasOwn(attributes, "data-drawflow-host")) part = "viewport"; if (!part) continue; const interactive = ["button", "input", "select", "textarea", "a"].includes(node.tag) || Object.hasOwn(attributes, "tabindex"); let profile: Profile = isRoot ? family(name) === "layout" ? "layout" : ["typography", "feedback"].includes(family(name)) ? "text" : "layout" : /^h[1-6]$/.test(node.tag) || /^(title|label|description|text|copy|caption|heading|eyebrow|help|hint|error|summary|value|meta|name|breadcrumb)/i.test( part, ) ? "text" : interactive ? "control" : /icon|spinner|indicator|dot|badgeMark|arrow|progressFill|track|gridPattern|decoration/i.test( part, ) ? "decoration" : /panel|surface|popover|menu|dialog|item|node$|card|tile|frame|content$|body$/i.test( part, ) ? "surface" : "layout"; if (["img", "picture", "video"].includes(node.tag)) profile = "media"; // Keep quantitative and provider-rendered geometry outside CSS recipes. let ownership: "framework" | "provider" | "content" | "native" = "framework"; if ( ["canvas", "iframe", "svg"].includes(node.tag) || attributes.type === "hidden" || /sr-only|visually-hidden/.test(attributes.class ?? "") ) profile = "managed"; if (["canvas", "svg"].includes(node.tag)) regions[part] = { ownership: "framework", parts: [part], limitations: "Rendered pixels and vector geometry are owned by the existing renderer; recipes style surrounding parts only.", }; if (providerParts[name]?.[part]) { profile = "managed"; ownership = "provider"; regions[part] = { ownership, parts: [part], limitations: providerParts[name]![part]! }; } if (node.tag === "iframe") { profile = "managed"; ownership = "provider"; regions[part] = { ownership, parts: [part], limitations: "Cross-origin embedded content cannot be styled through this contract.", }; } if ( ["range", "color", "file", "date", "time"].includes(attributes.type ?? "") || node.tag === "select" ) { ownership = "native"; profile = "text"; regions[part] = { ownership, parts: [part], limitations: "Only the outer native control is styled; picker dialogs, option popups, tracks and thumbs remain native or existing component CSS.", }; } if (Object.hasOwn(attributes, "contenteditable")) { ownership = "content"; profile = "text"; regions[part] = { ownership, parts: [part], limitations: "Styles apply to the editor container, never rewrite the user-authored document or formatting.", }; } // A root frame is a surface only if its own base selector declares geometry. if (isRoot && baseClass) { const escaped = baseClass; const cssBlock = new RegExp(`\\.${escaped}\\s*\\{([^}]*)\\}`, "g"); const ownCss = [...source.slice(end).matchAll(cssBlock)].map((match) => match[1]).join(" "); if ( /border(?:-radius)?\s*:|box-shadow\s*:/.test(ownCss) && !["layout", "typography", "feedback"].includes(family(name)) ) profile = "surface"; if (/border-radius\s*:/.test(ownCss) && ["Skeleton", "Tooltip", "Progress"].includes(name)) profile = "surface"; } if ( node.tag === "canvas" || node.tag === "iframe" || node.tag === "svg" || attributes.type === "hidden" || /sr-only|visually-hidden/.test(attributes.class ?? "") ) profile = "managed"; const states: Record = { hover: ":hover" }; if (interactive) Object.assign(states, { focusVisible: ":focus-visible", active: ":active" }); if ( ["input", "button", "select", "textarea"].includes(node.tag) || Object.hasOwn(attributes, "aria-disabled") ) states.disabled = ':is(:disabled,[aria-disabled="true"])'; if (Object.hasOwn(attributes, "aria-invalid") || Object.hasOwn(attributes, "data-invalid")) states.invalid = ':is([aria-invalid="true"],[data-invalid="true"])'; if (Object.hasOwn(attributes, "readonly") || Object.hasOwn(attributes, "aria-readonly")) states.readonly = ':is([readonly],[aria-readonly="true"])'; if (["checkbox", "radio"].includes(attributes.type ?? "")) { states.checked = ":checked"; states.unchecked = ":not(:checked)"; if (attributes.type === "checkbox") states.indeterminate = ":indeterminate"; } if (Object.hasOwn(attributes, "aria-expanded") || Object.hasOwn(attributes, "data-open")) states.open = ':is([aria-expanded="true"],[data-open="true"])'; if ( Object.hasOwn(attributes, "aria-selected") || Object.hasOwn(attributes, "data-selected") || Object.hasOwn(attributes, "aria-current") || Object.hasOwn(attributes, "data-active") ) states.selected = ':is([aria-selected="true"],[data-selected="true"],[aria-current="page"],[data-active="true"])'; const explicitAxes: Record = {}; const axisAttrs = Object.keys(axes).map((axis) => { const original = attributes[`data-${axis}`]; if (original && original !== `{${axis}}` && original.includes("{")) explicitAxes[axis] = original; const quote = original ? (new RegExp(`\\bdata-${axis}\\s*=\\s*(["'])`).exec(tag.raw)?.[1] ?? '"') : '"'; return `data-wrn-axis-${axis}=${quote}${original ?? `{${axis}}`}${quote}`; }); const previous = parts[part]; // Mixed structural roles sharing a part are conservatively limited to their intersection. const properties = previous ? previous.properties.filter((property) => (CATALOG_PART_PROPERTIES[profile] as readonly string[]).includes(property), ) : CATALOG_PART_PROPERTIES[profile]; parts[part] = { elements: [...new Set([...(previous?.elements ?? []), node.tag])], states: { ...previous?.states, ...states }, profile: previous?.profile === profile ? profile : previous ? "layout" : profile, ownership: previous?.ownership ?? ownership, role: node.tag === "kbd" || node.tag === "code" || node.tag === "pre" ? "code" : profile === "managed" ? "managed" : profile, properties, shapeBinding: previous?.shapeBinding || !!attributes["data-shape"] || !!attributes["data-rounded"], explicitAxes: { ...previous?.explicitAxes, ...explicitAxes }, }; // Existing core hooks are never rewritten; generator is idempotent. if (attributes["data-wrn-part"] !== undefined) { if (attributes["data-wrn-part"] !== part || attributes["data-wrn-owner"] !== name) throw Error(`${name}: conflicting existing hook`); continue; } const attrs = ` data-wrn-owner="${name}" data-wrn-part="${part}"${isRoot ? ` data-wrn-component="${name}"` : ""}${axisAttrs.length ? " " + axisAttrs.join(" ") : ""}`; edits.push({ position: tag.end - (tag.raw.endsWith("/>") ? 2 : 1), text: attrs }); } let output = source; for (const edit of edits.sort((a, b) => b.position - a.position)) output = output.slice(0, edit.position) + edit.text + output.slice(edit.position); if (JSON.stringify(stripHooks(ast)) !== JSON.stringify(stripHooks(parse(output)))) throw Error(`${name}: non-hook AST changed`); snapshots[path] = source; catalog[name] = { contractVersion: 1, component: name, source: path, family: family(name), support: "recipe-supported", axes, parts, slots: [ ...new Set( nodes .filter(({ node }) => node.tag === "slot") .map(({ node }) => node.attrs.find((attr) => attr.name === "name")?.value ?? "default"), ), ], tokens: [ ...new Set([...source.matchAll(/var\(\s*--wrn-([\w-]+)/g)].map((match) => match[1])), ].sort(), regions, constraints: [ "Only declared outer parts are styled. Existing events, markup, slots and state transitions are unchanged.", "Geometry, text scale and semantic color choices supplied by instance props retain precedence.", ], fixtures: [ "baseline", "square", "soft", "angular", "light", "dark", "narrow", "interaction-regression", ], compatibility: { engine: 1, componentContract: 1 }, }; if (output !== current) pendingWrites.push({ path, source: output }); } let serialized = JSON.stringify( catalog, (key, value) => { if (key === "properties" && Array.isArray(value)) for (const [profile, properties] of Object.entries(CATALOG_PART_PROPERTIES)) if (JSON.stringify(value) === JSON.stringify(properties)) return `__PROFILE_${profile}__`; return value; }, 2, ); serialized = serialized.replace(/"__PROFILE_(\w+)__"/g, "CATALOG_PART_PROPERTIES.$1"); const generated = `// Generated by scripts/rollout-design-contracts.ts. Review contract boundaries when changing markup.\nimport { CATALOG_PART_PROPERTIES } from './contract-properties.ts';\nexport const CATALOG_COMPONENT_CONTRACTS = ${serialized} as const;\n`; if (apply) { mkdirSync(join(root, ".tmp-tests"), { recursive: true }); const snapshotPath = join(root, ".tmp-tests/design-phase-3-before.json"); // Keep the first pre-migration snapshot for independent regression verification. try { readFileSync(snapshotPath); } catch { writeFileSync(snapshotPath, JSON.stringify(snapshots)); } for (const item of pendingWrites) writeFileSync(join(root, item.path), item.source); writeFileSync(join(root, "packages/design-system/src/catalog-contracts.ts"), generated); } console.log( `${apply ? "Wrote" : "Audited"} ${Object.keys(catalog).length} catalog contracts; every source passed non-hook AST preservation.`, );