feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,562 @@
|
||||
/** 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<string, Record<string, string>> = {
|
||||
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<string, string[]> = {
|
||||
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<string, unknown>;
|
||||
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)) {
|
||||
index = source.indexOf("-->", 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<string, unknown> = {};
|
||||
const snapshots: Record<string, string> = {};
|
||||
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<string, { default: string; values: string[] }> = {};
|
||||
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<string, string>;
|
||||
profile: Profile;
|
||||
ownership: "framework" | "provider" | "content" | "native";
|
||||
role: string;
|
||||
properties: readonly string[];
|
||||
shapeBinding: boolean;
|
||||
explicitAxes: Record<string, string>;
|
||||
}
|
||||
> = {};
|
||||
const regions: Record<string, { ownership: string; parts: string[]; limitations: string }> = {};
|
||||
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<string, string> = { 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<string, string> = {};
|
||||
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.`,
|
||||
);
|
||||
Reference in new issue
Block a user