diff --git a/packages/contracts/src/design-systems/components-manifest.ts b/packages/contracts/src/design-systems/components-manifest.ts new file mode 100644 index 0000000000..b4c1865c84 --- /dev/null +++ b/packages/contracts/src/design-systems/components-manifest.ts @@ -0,0 +1,450 @@ +export const COMPONENTS_MANIFEST_SCHEMA_VERSION = 1 as const; + +export type ComponentsManifestSchemaVersion = typeof COMPONENTS_MANIFEST_SCHEMA_VERSION; + +export type ComponentManifestGroupId = + | 'buttons' + | 'inputs' + | 'cards' + | 'badges' + | 'links' + | 'keyboard' + | 'icons' + | 'typography' + | 'layout'; + +export type ComponentManifestGroup = { + id: ComponentManifestGroupId; + label: string; + present: boolean; + selectors: string[]; + classes: string[]; + elements: string[]; + tokenReferences: string[]; +}; + +export type ComponentManifestLiteralInventory = { + colorExpressions: number; + pixelValues: number; + hardcodedFontFamilies: number; +}; + +export type ComponentsManifest = { + schemaVersion: ComponentsManifestSchemaVersion; + brandId: string; + source: { + componentsHtml: 'components.html'; + tokensCss?: 'tokens.css'; + }; + fixture: { + title?: string; + description?: string; + styleBlockCount: number; + selectorCount: number; + classCount: number; + elementCount: number; + }; + tokens: { + declared: string[]; + referenced: string[]; + unusedDeclared: string[]; + undeclaredReferenced: string[]; + }; + selectors: string[]; + classes: string[]; + elements: string[]; + groups: ComponentManifestGroup[]; + literals: ComponentManifestLiteralInventory; +}; + +export type ExtractComponentsManifestInput = { + brandId: string; + fixtureHtml: string; + tokensCss?: string; +}; + +type ComponentGroupDefinition = { + id: ComponentManifestGroupId; + label: string; + selectorMatchers: RegExp[]; + classMatchers: RegExp[]; + elementMatchers: RegExp[]; +}; + +const COMPONENT_GROUPS: ComponentGroupDefinition[] = [ + { + id: 'buttons', + label: 'Buttons and calls to action', + selectorMatchers: [/\bbutton\b/i, /\.btn(?:\b|[-_:])/i, /\[type=["']?(?:button|submit|reset)/i], + classMatchers: [/^btn(?:$|-)/i, /button/i, /cta/i], + elementMatchers: [/^button$/i], + }, + { + id: 'inputs', + label: 'Form fields and controls', + selectorMatchers: [/\binput\b/i, /\btextarea\b/i, /\bselect\b/i, /\.field(?:\b|[-_:])/i, /\blabel\b/i], + classMatchers: [/^field(?:$|-)/i, /input/i, /control/i, /form/i], + elementMatchers: [/^(input|textarea|select|label|form)$/i], + }, + { + id: 'cards', + label: 'Cards and panels', + selectorMatchers: [/\.card(?:\b|[-_:])/i, /\.panel(?:\b|[-_:])/i, /\.tile(?:\b|[-_:])/i], + classMatchers: [/^card(?:$|-)/i, /^panel(?:$|-)/i, /^tile(?:$|-)/i], + elementMatchers: [], + }, + { + id: 'badges', + label: 'Badges, chips, and status labels', + selectorMatchers: [/\.badge(?:\b|[-_:])/i, /\.chip(?:\b|[-_:])/i, /\.tag(?:\b|[-_:])/i, /\.pill(?:\b|[-_:])/i], + classMatchers: [/^badge(?:$|-)/i, /^chip(?:$|-)/i, /^tag(?:$|-)/i, /^pill(?:$|-)/i, /status/i], + elementMatchers: [], + }, + { + id: 'links', + label: 'Links and inline actions', + selectorMatchers: [/\ba\b/i, /\.link(?:\b|[-_:])/i], + classMatchers: [/^link(?:$|-)/i], + elementMatchers: [/^a$/i], + }, + { + id: 'keyboard', + label: 'Keyboard hints', + selectorMatchers: [/\bkbd\b/i, /\.kbd(?:\b|[-_:])/i], + classMatchers: [/^kbd(?:$|-)/i, /keyboard/i, /shortcut/i], + elementMatchers: [/^kbd$/i], + }, + { + id: 'icons', + label: 'Icon slots', + selectorMatchers: [/\.icon(?:\b|[-_:])/i, /\[aria-hidden=["']true["']\]/i], + classMatchers: [/^icon(?:$|-)/i], + elementMatchers: [/^svg$/i], + }, + { + id: 'typography', + label: 'Typography scale and text utilities', + selectorMatchers: [/\bh[1-6]\b/i, /\.lead(?:\b|[-_:])/i, /\.eyebrow(?:\b|[-_:])/i, /\.body-(?:muted|sm|small)\b/i], + classMatchers: [/^lead$/i, /^eyebrow$/i, /^body-(?:muted|sm|small)$/i, /caption/i], + elementMatchers: [/^h[1-6]$/i, /^p$/i], + }, + { + id: 'layout', + label: 'Layout primitives', + selectorMatchers: [ + /\.container(?:\b|[-_:])/i, + /\.stack-\d+\b/i, + /\.row-(?:between|center|start|end)\b/i, + /\bsection\b/i, + /\bmain\b/i, + /\bnav\b/i, + ], + classMatchers: [/^container$/i, /^stack-\d+$/i, /^row-(?:between|center|start|end)$/i, /grid/i, /layout/i], + elementMatchers: [/^(main|section|nav|header|footer)$/i], + }, +]; + +export function extractComponentsManifest({ + brandId, + fixtureHtml, + tokensCss, +}: ExtractComponentsManifestInput): ComponentsManifest { + const styleBlocks = extractStyleBlocks(fixtureHtml); + const css = styleBlocks.join('\n\n'); + const selectors = extractCssSelectors(css); + const selectorTokenReferences = extractSelectorTokenReferences(css); + const classes = extractHtmlClasses(fixtureHtml); + const elements = extractHtmlElements(fixtureHtml); + const declaredTokens = parseTokenNames(tokensCss ?? extractFirstRootBody(css) ?? ''); + const referencedTokens = extractTokenReferences(fixtureHtml); + + return { + schemaVersion: COMPONENTS_MANIFEST_SCHEMA_VERSION, + brandId, + source: + tokensCss === undefined + ? { componentsHtml: 'components.html' } + : { componentsHtml: 'components.html', tokensCss: 'tokens.css' }, + fixture: { + ...optionalText('title', extractTitle(fixtureHtml)), + ...optionalText('description', extractMetaDescription(fixtureHtml)), + styleBlockCount: styleBlocks.length, + selectorCount: selectors.length, + classCount: classes.length, + elementCount: elements.length, + }, + tokens: { + declared: declaredTokens, + referenced: referencedTokens, + unusedDeclared: declaredTokens.filter((token) => !referencedTokens.includes(token)), + undeclaredReferenced: + declaredTokens.length === 0 ? [] : referencedTokens.filter((token) => !declaredTokens.includes(token)), + }, + selectors, + classes, + elements, + groups: COMPONENT_GROUPS.map((definition) => + buildGroupManifest(definition, { + selectors, + selectorTokenReferences, + classes, + elements, + referencedTokens, + }), + ), + literals: countLiterals(stripRootBlocks(stripCssComments(css))), + }; +} + +export function summarizeComponentsManifestForPrompt(manifest: ComponentsManifest): string { + const presentGroups = manifest.groups + .filter((group) => group.present) + .map((group) => { + const selectors = group.selectors.slice(0, 8).join(', ') || 'none'; + const tokens = group.tokenReferences.slice(0, 10).join(', ') || 'none'; + return `- ${group.label}: selectors ${selectors}; tokens ${tokens}`; + }); + + return [ + `components.manifest schema v${manifest.schemaVersion} for ${manifest.brandId}`, + `Fixture: ${manifest.fixture.selectorCount} selectors, ${manifest.fixture.classCount} classes, ${manifest.tokens.declared.length} declared tokens, ${manifest.tokens.referenced.length} referenced tokens.`, + 'Available component groups:', + ...(presentGroups.length > 0 ? presentGroups : ['- none detected']), + ].join('\n'); +} + +function buildGroupManifest( + definition: ComponentGroupDefinition, + inventory: { + selectors: string[]; + selectorTokenReferences: Map; + classes: string[]; + elements: string[]; + referencedTokens: string[]; + }, +): ComponentManifestGroup { + const selectors = inventory.selectors.filter((selector) => + definition.selectorMatchers.some((matcher) => matcher.test(selector)), + ); + const classes = inventory.classes.filter((className) => + definition.classMatchers.some((matcher) => matcher.test(className)), + ); + const elements = inventory.elements.filter((element) => + definition.elementMatchers.some((matcher) => matcher.test(element)), + ); + const tokenReferences = uniqueSorted( + selectors.flatMap((selector) => inventory.selectorTokenReferences.get(selector) ?? []), + ); + + return { + id: definition.id, + label: definition.label, + present: selectors.length > 0 || classes.length > 0 || elements.length > 0, + selectors, + classes, + elements, + tokenReferences: tokenReferences.filter((token) => inventory.referencedTokens.includes(token)), + }; +} + +function extractStyleBlocks(html: string): string[] { + const blocks: string[] = []; + const stylePattern = /]*>([\s\S]*?)<\/style>/gi; + let match: RegExpExecArray | null; + while ((match = stylePattern.exec(html)) !== null) { + blocks.push((match[1] ?? '').trim()); + } + return blocks; +} + +function extractCssSelectors(css: string): string[] { + const selectors = new Set(); + const commentlessCss = stripContainerAtRuleHeaders(stripCssComments(css)); + const selectorPattern = /(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{/g; + let match: RegExpExecArray | null; + + while ((match = selectorPattern.exec(commentlessCss)) !== null) { + const rawSelectorList = match[1]?.trim(); + if (rawSelectorList == null || rawSelectorList.length === 0) continue; + if (rawSelectorList.includes(':root')) continue; + if (/^(?:from|to|\d+(?:\.\d+)?%)$/i.test(rawSelectorList)) continue; + + for (const selector of splitSelectorList(rawSelectorList)) { + const normalized = normalizeSelector(selector); + if (normalized.length > 0 && !normalized.startsWith('@')) { + selectors.add(normalized); + } + } + } + + return [...selectors].sort((a, b) => a.localeCompare(b)); +} + +function extractSelectorTokenReferences(css: string): Map { + const referencesBySelector = new Map>(); + const commentlessCss = stripContainerAtRuleHeaders(stripCssComments(css)); + const rulePattern = /(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{([^{}]*)\}/g; + let match: RegExpExecArray | null; + + while ((match = rulePattern.exec(commentlessCss)) !== null) { + const rawSelectorList = match[1]?.trim(); + const rawBody = match[2] ?? ''; + if (rawSelectorList == null || rawSelectorList.length === 0) continue; + if (rawSelectorList.includes(':root')) continue; + if (/^(?:from|to|\d+(?:\.\d+)?%)$/i.test(rawSelectorList)) continue; + + const tokenReferences = extractTokenReferences(rawBody); + if (tokenReferences.length === 0) continue; + + for (const selector of splitSelectorList(rawSelectorList)) { + const normalized = normalizeSelector(selector); + if (normalized.length === 0 || normalized.startsWith('@')) continue; + const selectorReferences = referencesBySelector.get(normalized) ?? new Set(); + for (const token of tokenReferences) { + selectorReferences.add(token); + } + referencesBySelector.set(normalized, selectorReferences); + } + } + + return new Map( + [...referencesBySelector.entries()] + .map(([selector, references]) => [selector, [...references].sort((a, b) => a.localeCompare(b))] as const) + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function splitSelectorList(selectorList: string): string[] { + const selectors: string[] = []; + let depth = 0; + let start = 0; + + for (let index = 0; index < selectorList.length; index += 1) { + const char = selectorList[index]; + if (char === '(' || char === '[') { + depth += 1; + continue; + } + if (char === ')' || char === ']') { + depth = Math.max(0, depth - 1); + continue; + } + if (char === ',' && depth === 0) { + selectors.push(selectorList.slice(start, index)); + start = index + 1; + } + } + + selectors.push(selectorList.slice(start)); + return selectors; +} + +function normalizeSelector(selector: string): string { + return selector.trim().replace(/\s+/g, ' '); +} + +function extractHtmlClasses(html: string): string[] { + const classes = new Set(); + const classPattern = /\bclass\s*=\s*(["'])(.*?)\1/gis; + let match: RegExpExecArray | null; + while ((match = classPattern.exec(html)) !== null) { + const classValue = match[2] ?? ''; + for (const className of classValue.split(/\s+/)) { + if (className.length > 0) classes.add(className); + } + } + return [...classes].sort((a, b) => a.localeCompare(b)); +} + +function extractHtmlElements(html: string): string[] { + const elements = new Set(); + const elementPattern = /<\s*([a-z][a-z0-9-]*)\b/gi; + let match: RegExpExecArray | null; + while ((match = elementPattern.exec(html)) !== null) { + const element = match[1]?.toLowerCase(); + if (element == null || element.startsWith('!')) continue; + elements.add(element); + } + return [...elements].sort((a, b) => a.localeCompare(b)); +} + +function parseTokenNames(css: string): string[] { + const tokens = new Set(); + const tokenPattern = /(--[a-zA-Z0-9_-]+)\s*:/g; + let match: RegExpExecArray | null; + while ((match = tokenPattern.exec(stripCssComments(css))) !== null) { + const token = match[1]; + if (token != null) tokens.add(token); + } + return [...tokens].sort((a, b) => a.localeCompare(b)); +} + +function extractTokenReferences(source: string): string[] { + const tokens = new Set(); + const tokenPattern = /var\(\s*(--[a-zA-Z0-9_-]+)/g; + let match: RegExpExecArray | null; + while ((match = tokenPattern.exec(source)) !== null) { + const token = match[1]; + if (token != null) tokens.add(token); + } + return [...tokens].sort((a, b) => a.localeCompare(b)); +} + +function extractFirstRootBody(css: string): string | null { + return stripCssComments(css).match(/:root(?!\[)\s*\{([\s\S]*?)\}/)?.[1] ?? null; +} + +function stripRootBlocks(css: string): string { + return css.replace(/:root(?:\[[^\]]+\])?\s*\{[\s\S]*?\}/g, ''); +} + +function stripCssComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +function stripContainerAtRuleHeaders(css: string): string { + return css.replace(/@(media|supports|container|layer)\b[^{]*\{/gi, '{'); +} + +function countLiterals(css: string): ComponentManifestLiteralInventory { + return { + colorExpressions: countMatches( + css, + /(?:#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)|color-mix\([^)]*\))/gi, + ), + pixelValues: countMatches(css, /(? a.localeCompare(b)); +} + +function extractTitle(html: string): string | undefined { + const value = /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim().replace(/\s+/g, ' '); + return value == null || value.length === 0 ? undefined : decodeBasicEntities(value); +} + +function extractMetaDescription(html: string): string | undefined { + const match = /]*\bname\s*=\s*["']description["'])(?=[^>]*\bcontent\s*=\s*(["'])([\s\S]*?)\1)[^>]*>/i.exec(html); + const value = match?.[2]?.trim().replace(/\s+/g, ' '); + return value == null || value.length === 0 ? undefined : decodeBasicEntities(value); +} + +function decodeBasicEntities(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function optionalText(key: Key, value: string | undefined): Record | Record { + return value === undefined ? {} : { [key]: value } as Record; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 28f302add2..e58b0168fc 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,6 +21,7 @@ export * from './api/registry.js'; export * from './api/research.js'; export * from './api/version.js'; export * from './examples.js'; +export * from './design-systems/components-manifest.js'; export * from './sse/common.js'; export * from './sse/chat.js'; export * from './sse/proxy.js'; diff --git a/packages/contracts/tests/components-manifest.test.ts b/packages/contracts/tests/components-manifest.test.ts new file mode 100644 index 0000000000..7ee8438836 --- /dev/null +++ b/packages/contracts/tests/components-manifest.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { extractComponentsManifest, summarizeComponentsManifestForPrompt } from '../src/design-systems/components-manifest.js'; + +describe('components manifest extraction', () => { + it('summarizes tokens, selectors, html classes, and component groups deterministically', () => { + const manifest = extractComponentsManifest({ + brandId: 'sample', + tokensCss: ':root { --bg: #fff; --accent: #05f; --radius-md: 12px; }', + fixtureHtml: ` + + + + Sample fixture + + + + +
+
+ +
+
+ + + `, + }); + + expect(manifest.schemaVersion).toBe(1); + expect(manifest.fixture).toMatchObject({ + title: 'Sample fixture', + description: 'A compact fixture.', + styleBlockCount: 1, + selectorCount: 4, + classCount: 3, + }); + expect(manifest.tokens.declared).toEqual(['--accent', '--bg', '--radius-md']); + expect(manifest.tokens.referenced).toEqual(['--accent', '--bg', '--radius-md']); + expect(manifest.selectors).toEqual(['.btn', '.card', '.stack-4', 'button']); + expect(manifest.classes).toEqual(['btn', 'card', 'stack-4']); + expect(manifest.groups.find((group) => group.id === 'buttons')).toMatchObject({ + present: true, + selectors: ['.btn', 'button'], + classes: ['btn'], + elements: ['button'], + }); + expect(manifest.groups.find((group) => group.id === 'layout')).toMatchObject({ + present: true, + selectors: ['.stack-4'], + classes: ['stack-4'], + elements: ['main'], + }); + expect(manifest.literals.pixelValues).toBe(1); + }); + + it('can render a concise prompt summary from a manifest', () => { + const manifest = extractComponentsManifest({ + brandId: 'sample', + fixtureHtml: '', + }); + + expect(summarizeComponentsManifestForPrompt(manifest)).toContain('components.manifest schema v1 for sample'); + expect(summarizeComponentsManifestForPrompt(manifest)).toContain('Buttons and calls to action'); + }); +}); diff --git a/scripts/extract-components-manifest.ts b/scripts/extract-components-manifest.ts new file mode 100644 index 0000000000..7c85ab0167 --- /dev/null +++ b/scripts/extract-components-manifest.ts @@ -0,0 +1,168 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + extractComponentsManifest, + summarizeComponentsManifestForPrompt, + type ComponentsManifest, +} from '../packages/contracts/src/design-systems/components-manifest.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); +const designSystemsRoot = path.join(repoRoot, 'design-systems'); +const skippedDesignSystemDirectories = new Set(['_schema']); + +type CliOptions = { + brandId?: string; + outPath?: string; + compact: boolean; + promptSummary: boolean; + help: boolean; +}; + +type ManifestCollection = { + schemaVersion: 1; + count: number; + manifests: ComponentsManifest[]; +}; + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const brandIds = options.brandId === undefined ? await discoverBrandIds() : [options.brandId]; + const manifests = await Promise.all(brandIds.map((brandId) => readManifest(brandId))); + + const output = options.promptSummary + ? manifests.map((manifest) => summarizeComponentsManifestForPrompt(manifest)).join('\n\n') + : JSON.stringify(toCollection(manifests), null, options.compact ? 0 : 2); + + if (options.outPath === undefined) { + console.log(output); + return; + } + + const resolvedOutPath = path.resolve(repoRoot, options.outPath); + await mkdir(path.dirname(resolvedOutPath), { recursive: true }); + await writeFile(resolvedOutPath, `${output}\n`, 'utf8'); + console.log( + `Wrote ${manifests.length} component manifest${manifests.length === 1 ? '' : 's'} to ${toRepositoryPath(resolvedOutPath)}.`, + ); +} + +function parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + compact: false, + promptSummary: false, + help: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + if (arg === '--compact') { + options.compact = true; + continue; + } + if (arg === '--prompt-summary') { + options.promptSummary = true; + continue; + } + if (arg === '--brand' || arg === '--design-system') { + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new Error(`${arg} requires a design-system id.`); + } + options.brandId = value; + index += 1; + continue; + } + if (arg === '--out') { + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new Error('--out requires a path.'); + } + options.outPath = value; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}\n\n${usage()}`); + } + + return options; +} + +async function discoverBrandIds(): Promise { + const entries = await readdir(designSystemsRoot, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && !skippedDesignSystemDirectories.has(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); +} + +async function readManifest(brandId: string): Promise { + const brandRoot = path.join(designSystemsRoot, brandId); + const fixturePath = path.join(brandRoot, 'components.html'); + const tokensPath = path.join(brandRoot, 'tokens.css'); + + const [fixtureHtml, tokensCss] = await Promise.all([readFile(fixturePath, 'utf8'), readOptionalFile(tokensPath)]); + return tokensCss === undefined + ? extractComponentsManifest({ brandId, fixtureHtml }) + : extractComponentsManifest({ brandId, fixtureHtml, tokensCss }); +} + +async function readOptionalFile(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch (err) { + if (isAbsenceError(err)) return undefined; + throw err; + } +} + +function isAbsenceError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const code = (err as { code?: unknown }).code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function toCollection(manifests: ComponentsManifest[]): ManifestCollection { + return { + schemaVersion: 1, + count: manifests.length, + manifests, + }; +} + +function toRepositoryPath(filePath: string): string { + return path.relative(repoRoot, filePath).split(path.sep).join('/'); +} + +function usage(): string { + return [ + 'Usage: pnpm exec tsx scripts/extract-components-manifest.ts [options]', + '', + 'Options:', + ' --brand Extract one design-system id instead of all ids.', + ' --design-system Alias for --brand.', + ' --out Write output under the repository root instead of stdout.', + ' --compact Emit compact JSON when not using --prompt-summary.', + ' --prompt-summary Emit the short text summary intended for agent prompts.', + ' --help Show this help text.', + ].join('\n'); +} + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); +if (isMain) { + main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +}