From 57adaca6271738c892086c456d43073251db3f2c Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:07:22 -0400 Subject: [PATCH 1/3] chore: start @directededges/specs-schema v0.28.0 development --- packages/schema/CHANGELOG.md | 9 +++++++++ packages/schema/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 7de72d1..3dabf16 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Specs schema will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.28.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.27.0] - 2026-07-01 Dashed strokes are now first-class, typed data instead of falling through as an unrecognized style, and subcomponents carry enough Figma node identity for reverse-direction tools to resolve them back to a canvas location without a side-channel lookup. diff --git a/packages/schema/package.json b/packages/schema/package.json index 341e4b0..06e767d 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.27.0", + "version": "0.28.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", From eed30401963230f7bbb881d426f543f9489d0717 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:18:56 -0400 Subject: [PATCH 2/3] chore: start @directededges/specs-cli v0.24.0 development --- packages/cli/CHANGELOG.md | 9 +++++++++ packages/cli/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a4a1184..c27139b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.24.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.23.0] - 2026-07-01 Specs generated by the CLI now capture dashed stroke styling and record where each subcomponent lives back in the source Figma file — both come from upstream engine and schema updates, with no CLI-side changes required to pick them up. diff --git a/packages/cli/package.json b/packages/cli/package.json index 33695c2..13c1680 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.23.0", + "version": "0.24.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", From f558491494b566731be2885c7181e98d4ff22ec8 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:28:47 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(cli):=20add=20component-md=20transform?= =?UTF-8?q?er=20=E2=80=94=20deterministic=20single-file=20markdown=20refer?= =?UTF-8?q?ence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits component.md per component from api.yaml + variants.yaml: overview with variant axes and contents manifest, props (slot constraints, code-only markers, examples), bindings incl. variant-scoped with When column, states classification with unclassified-value rows, anatomy with element-presence pivot, layout trees with diffs vs default, element styles incl. content, typography, element×property color map, numbered variant deltas with instanceOf/content rows and resolution rule, exact-cover-minimized invalid combinations, linked subcomponent files, instance-example configurations, and provenance. Token references render verbatim with resolved rawValue when the spec carries it. Hardened via three-lens adversarial review (React / SwiftUI / Compose consumers) against 134 fixture components across two workspaces; byte-deterministic across repeated runs. Co-Authored-By: Claude Fable 5 --- package-lock.json | 14 +- packages/cli/src/transforms/ComponentMd.ts | 1034 +++++++++++++++++ packages/cli/src/transforms/index.ts | 2 + .../tests/unit/transforms/ComponentMd.test.ts | 625 ++++++++++ .../content/docs/cli/commands/transform.md | 1 + .../docs/cli/transforms/component-md.md | 63 + site/src/content/docs/cli/transforms/index.md | 1 + 7 files changed, 1737 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/transforms/ComponentMd.ts create mode 100644 packages/cli/tests/unit/transforms/ComponentMd.test.ts create mode 100644 site/src/content/docs/cli/transforms/component-md.md diff --git a/package-lock.json b/package-lock.json index 01dd134..7edbb8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,13 @@ "@figma/rest-api-spec": "^0.36.0", "@types/fs-extra": "^11.0.4", "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.25.5", "javascript-obfuscator": "^5.3.0", "typescript": "^5.3.2", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "ws": "^8.21.0" } }, "node_modules/@directededges/specs-cli": { @@ -1618,7 +1620,7 @@ }, "packages/cli": { "name": "@directededges/specs-cli", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "dependencies": { "@directededges/specs-from-figma": "^0.25.0", @@ -1639,6 +1641,12 @@ "vitest": "^3.0.0" } }, + "packages/cli/node_modules/@directededges/specs-schema": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@directededges/specs-schema/-/specs-schema-0.27.0.tgz", + "integrity": "sha512-sOEGZjasqCmImeu8eC9CD9+HcYoEKdCxPTWwxmIEf9zpo+H3UT83z+NEpcCnpWyuvYRpUD3lmRrXR1sUPvzOXQ==", + "license": "CC-BY-4.0" + }, "packages/cli/node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -2067,7 +2075,7 @@ }, "packages/schema": { "name": "@directededges/specs-schema", - "version": "0.27.0", + "version": "0.28.0", "license": "CC-BY-4.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/packages/cli/src/transforms/ComponentMd.ts b/packages/cli/src/transforms/ComponentMd.ts new file mode 100644 index 0000000..370862a --- /dev/null +++ b/packages/cli/src/transforms/ComponentMd.ts @@ -0,0 +1,1034 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import type { Transformer, TransformerContext } from '../Types/Transformer.js'; +import { CONCEPT_TABLE, buildOmittedProps, type ProcessingStates } from './states.js'; +import { isTokenRef } from './css/values.js'; + +type Rec = Record; + +/** + * Emits `component.md` — a deterministic, single-file markdown reference for a + * component, projected verbatim from the spec (api.yaml + variants.yaml). + * No inference: every value is a mechanical restatement of spec data. + */ +export class ComponentMdTransformer implements Transformer { + readonly name = 'component-md'; + + async run(apiYaml: Rec, context: TransformerContext): Promise { + const { outputDir, componentKey } = context; + + const variantsYaml = await readSiblingYaml(outputDir, 'variants.yaml'); + const examplesYaml = await readSiblingYaml(outputDir, 'examples.yaml'); + + const spec = mergeConcerns(apiYaml, variantsYaml, examplesYaml); + const lines = buildComponentMd(spec, componentKey, context.processingStates); + await fs.writeFile(path.join(outputDir, 'component.md'), lines.join('\n') + '\n', 'utf-8'); + + // Subcomponents — each gets its own subfolder/component.md + const apiSubs = (apiYaml.subcomponents ?? {}) as Rec; + const variantSubs = ((variantsYaml?.subcomponents ?? {})) as Rec; + for (const [subKey, subApiRaw] of Object.entries(apiSubs)) { + const subApi = subApiRaw as Rec; + const subVariants = (variantSubs[subKey] ?? {}) as Rec; + const subSpec = mergeConcerns(subApi, subVariants, undefined); + const subLines = buildComponentMd(subSpec, subKey, context.processingStates, componentKey); + const subDir = path.join(outputDir, subKey); + await fs.ensureDir(subDir); + await fs.writeFile(path.join(subDir, 'component.md'), subLines.join('\n') + '\n', 'utf-8'); + } + } +} + +async function readSiblingYaml(dir: string, name: string): Promise { + const p = path.join(dir, name); + if (!fs.existsSync(p)) return undefined; + const raw = await fs.readFile(p, 'utf-8'); + return yaml.parse(raw) as Rec; +} + +/** Combine api/variants/examples concerns into one spec-shaped record. api.yaml may already be monolithic. */ +function mergeConcerns(api: Rec, variants: Rec | undefined, examples: Rec | undefined): Rec { + return { + ...api, + default: variants?.default ?? api.default, + variants: variants?.variants ?? api.variants ?? [], + invalidVariantCombinations: variants?.invalidVariantCombinations ?? api.invalidVariantCombinations, + slotContentExamples: examples?.slotContentExamples ?? api.slotContentExamples, + instanceExamples: examples?.instanceExamples ?? api.instanceExamples, + }; +} + +// --------------------------------------------------------------------------- +// Document assembly +// --------------------------------------------------------------------------- + +function buildComponentMd( + spec: Rec, + componentKey: string, + processingStates: ProcessingStates | undefined, + parentKey?: string, +): string[] { + const props = (spec.props ?? {}) as Rec; + const anatomy = (spec.anatomy ?? {}) as Rec; + const dflt = (spec.default ?? {}) as Rec; + const variants = (spec.variants ?? []) as Rec[]; + const subcomponents = (spec.subcomponents ?? {}) as Rec; + const title = (spec.title as string) ?? componentKey; + + const lines: string[] = []; + lines.push(`# ${title}`); + lines.push(''); + lines.push( + '> Generated by `specs transform component-md` — a deterministic projection of the component spec.', + '> Every value is verbatim from the spec; regenerate rather than edit.', + `> Canonical data: \`api.yaml\` · \`variants.yaml\`.${parentKey ? ` Subcomponent of \`${parentKey}\`.` : ''}`, + '>', + '> **How to read.** To resolve a configuration: start from Element styles (default), then apply every', + '> variant delta whose configuration matches, in the order listed — later values win per property.', + '> `unset` removes the default value. `← prop` marks a value bound to a prop;', + '> `` if `prop` op → a : b `` marks a conditional value. Tokens render as `` `path` (type, raw value) ``.', + '> Shadows render as `[inset] offset-x offset-y blur spread color`.', + ); + + section(lines, 'Overview', buildOverview(title, props, anatomy, dflt, variants, subcomponents, spec)); + section(lines, 'Props', buildProps(props, anatomy, dflt, variants, processingStates)); + section(lines, 'Bindings', buildBindings(anatomy, dflt, variants, props), true); + section(lines, 'States', buildStates(props, processingStates), true); + section(lines, 'Anatomy', buildAnatomy(anatomy, dflt, variants, props)); + section(lines, 'Layout', buildLayout(dflt, variants)); + section(lines, 'Element styles (default)', buildDefaultStyles(dflt)); + section(lines, 'Typography', buildTypography(anatomy, dflt, variants), true); + section(lines, 'Color', buildColor(dflt, variants), true); + section(lines, 'Variant deltas', buildVariantDeltas(dflt, variants), true); + section(lines, 'Invalid combinations', buildInvalidCombinations(spec, props, variants), true); + section(lines, 'Subcomponents', buildSubcomponents(subcomponents), true); + section(lines, 'Examples', buildExamples(spec, props), true); + section(lines, 'Provenance', buildProvenance(spec)); + + return lines; +} + +/** Append a `## title` section. When optional, omit the section entirely if body is empty. */ +function section(lines: string[], title: string, body: string[], optional = false): void { + if (optional && body.length === 0) return; + lines.push('', `## ${title}`, ''); + if (body.length === 0) { + lines.push('_None._'); + } else { + lines.push(...body); + } +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +function buildOverview( + title: string, + props: Rec, + anatomy: Rec, + dflt: Rec, + variants: Rec[], + subcomponents: Rec, + spec: Rec, +): string[] { + const counts: string[] = []; + counts.push(`${plural(Object.keys(props).length, 'prop')}`); + counts.push(`${plural(Object.keys(anatomy).length, 'anatomy element')}`); + counts.push(`${plural(variants.length, 'variant delta')}`); + if (Object.keys(subcomponents).length > 0) { + counts.push(`${plural(Object.keys(subcomponents).length, 'subcomponent')}`); + } + const body = [`${title} defines ${counts.join(', ')}.`]; + + const axes = variantAxes(props, variants); + if (axes.length > 0) { + // Axes never referenced by any delta configuration or invalid combination are flagged, + // so silence reads as "no recorded impact" instead of missing data. + const referencedKeys = new Set(); + for (const v of variants) { + for (const key of Object.keys((v.configuration ?? {}) as Rec)) referencedKeys.add(key); + } + for (const combo of ((spec.invalidVariantCombinations ?? []) as Rec[])) { + for (const key of Object.keys(combo)) referencedKeys.add(key); + } + + body.push(''); + const parts = axes.map(([key, prop]) => { + const p = prop as Rec; + const inert = !referencedKeys.has(key) ? ' — no style impact recorded' : ''; + if (p.type === 'boolean') return `\`${key}\` (boolean; default \`${p.default}\`${inert})`; + const values = ((p.enum ?? []) as unknown[]).map(v => String(v)).join(' · '); + return `\`${key}\` (${values}; default \`${p.default}\`${inert})`; + }); + body.push(`**Variant axes.** ${parts.join(' — ')}`); + + // Section manifest with defined counts, so a reader can budget the file from the top. + const layoutTrees = countDistinctLayouts(dflt, variants); + const invalidCount = ((spec.invalidVariantCombinations ?? []) as Rec[]).length; + const space = configurationSpace(axes); + const manifest: string[] = [`${variants.length} variant deltas`, `${plural(layoutTrees, 'layout tree')}`]; + if (invalidCount > 0) manifest.push(`${invalidCount} invalid combinations`); + if (space > 0) manifest.push(`configuration space ${space}`); + body.push(''); + body.push(`**Contents.** ${manifest.join(' · ')}`); + } + return body; +} + +/** Product of axis cardinalities (enum length; booleans count 2). */ +function configurationSpace(axes: Array<[string, Rec]>): number { + let product = 1; + for (const [, p] of axes) { + product *= p.type === 'boolean' ? 2 : Array.isArray(p.enum) ? (p.enum as unknown[]).length : 1; + } + return axes.length > 0 ? product : 0; +} + +function countDistinctLayouts(dflt: Rec, variants: Rec[]): number { + const shapes = new Set([JSON.stringify(dflt.layout ?? [])]); + for (const v of variants) { + if (v.layout) shapes.add(JSON.stringify(v.layout)); + } + return shapes.size; +} + +/** Axes = enum props, plus booleans that appear in any variant configuration or carry a Figma VARIANT extension. */ +function variantAxes(props: Rec, variants: Rec[]): Array<[string, Rec]> { + const configuredKeys = new Set(); + for (const v of variants) { + for (const key of Object.keys((v.configuration ?? {}) as Rec)) configuredKeys.add(key); + } + return Object.entries(props) + .map(([k, p]) => [k, p as Rec] as [string, Rec]) + .filter(([k, p]) => { + if (p.type === 'string' && Array.isArray(p.enum)) return true; + if (p.type === 'boolean') { + if (configuredKeys.has(k)) return true; + const ext = ((p.$extensions as Rec)?.['com.figma'] as Rec)?.type; + return ext === 'VARIANT'; + } + return false; + }); +} + +function buildProps( + props: Rec, + anatomy: Rec, + dflt: Rec, + variants: Rec[], + processingStates: ProcessingStates | undefined, +): string[] { + const entries = Object.entries(props); + if (entries.length === 0) return ['_No props._']; + + const omitted = buildOmittedProps(processingStates ?? {}); + const bindingsByProp = collectBindings(anatomy, dflt, variants, props); + + const rows: string[][] = []; + for (const [key, raw] of entries) { + const p = raw as Rec; + const type = p.type as string; + const nullable = p.nullable === true || p.default === null; + const typeLabel = + type === 'string' && Array.isArray(p.enum) ? 'enum' : nullable && type === 'string' ? 'string \\| null' : type; + const dfltLabel = 'default' in p ? code(formatScalar(p.default)) : '—'; + const values = Array.isArray(p.enum) ? (p.enum as unknown[]).map(v => code(String(v))).join(' ') : '—'; + + const notes: string[] = []; + if (type === 'slot') { + notes.push(slotConstraintNote(p)); + } + const sourceKind = (((p.$extensions as Rec)?.['com.figma'] as Rec)?.source as Rec | undefined)?.kind; + if (sourceKind === 'codeOnlyProp') notes.push('code-only prop — not rendered in Figma'); + if (omitted.has(key)) notes.push('browser-driven state — omitted from `contract.ts`'); + const bound = bindingsByProp.get(key); + if (bound) notes.push(bound.join('; ')); + if (Array.isArray(p.examples) && p.examples.length > 0) { + notes.push(`e.g. ${code(String((p.examples as unknown[])[0]))}`); + } + + rows.push([code(key), typeLabel, dfltLabel, values, notes.join('; ') || '—']); + } + return table(['Prop', 'Type', 'Default', 'Values', 'Notes'], rows); +} + +/** `slot` plus any child constraints the spec declares (minChildren / maxChildren / anyOf). */ +function slotConstraintNote(p: Rec): string { + const constraints: string[] = []; + if (typeof p.minChildren === 'number') constraints.push(`min ${p.minChildren}`); + if (typeof p.maxChildren === 'number') constraints.push(`max ${p.maxChildren}`); + if (Array.isArray(p.anyOf)) constraints.push(`accepts ${(p.anyOf as unknown[]).map(x => code(String(x))).join(' ')}`); + return constraints.length > 0 ? `slot (${constraints.join(', ')})` : 'slot'; +} + +/** Walk element properties for `$binding` refs (direct or inside conditionals); index prose by prop name. */ +function collectBindings(anatomy: Rec, dflt: Rec, variants: Rec[], props: Rec): Map { + const byProp = new Map(); + for (const { element, property, propName, condition, scope } of findBindings(anatomy, dflt, variants)) { + if (!(propName in props)) continue; + const list = byProp.get(propName) ?? []; + const when = scope ? ` (when ${scope})` : ''; + list.push(condition ? `${condition} → \`${element}.${property}\`${when}` : `bound to \`${element}.${property}\`${when}`); + byProp.set(propName, list); + } + return byProp; +} + +interface FoundBinding { + element: string; + property: string; + propName: string; + condition?: string; + /** Formatted configuration when the binding appears only in a variant, undefined for default-scope. */ + scope?: string; +} + +function findBindings(anatomy: Rec, dflt: Rec, variants: Rec[]): FoundBinding[] { + const found: FoundBinding[] = []; + const seen = new Set(); + + const scan = (element: string, elData: Rec, scope?: string) => { + const targets: Rec = { + ...(elData.styles as Rec), + instanceOf: elData.instanceOf, + content: elData.content, + children: elData.children, + visible: (elData.styles as Rec)?.visible ?? elData.visible, + }; + for (const [property, value] of Object.entries(targets)) { + if (value === null || value === undefined) continue; + const binding = extractBinding(value); + if (!binding) continue; + const key = `${element}.${property}.${binding.propName}`; + if (seen.has(key)) continue; + seen.add(key); + found.push({ element, property, propName: binding.propName, condition: binding.condition, scope }); + } + }; + + // Default scope first so it wins the dedup over variant-scoped repeats. + for (const [el, raw] of Object.entries((dflt.elements ?? {}) as Rec)) scan(el, raw as Rec); + for (const [el, raw] of Object.entries(anatomy)) scan(el, raw as Rec); + for (const v of variants) { + const scope = formatConfiguration((v.configuration ?? {}) as Rec); + for (const [el, raw] of Object.entries((v.elements ?? {}) as Rec)) scan(el, raw as Rec, scope); + } + return found; +} + +function extractBinding(value: unknown): { propName: string; condition?: string; outcome?: string } | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Rec; + if (typeof v.$binding === 'string') { + return { propName: bindingPointerToProp(v.$binding) }; + } + if (typeof v.if === 'object' && v.if !== null) { + const ifBlock = v.if as Rec; + const cond = (ifBlock.condition ?? {}) as Rec; + const args = (cond.args ?? {}) as Rec; + const binding = args.value as Rec | undefined; + if (binding && typeof binding.$binding === 'string') { + const propName = bindingPointerToProp(binding.$binding); + const op = cond.operation as string; + const compareTo = 'compareTo' in args ? ` ${formatScalar(args.compareTo)}` : ''; + const outcome = + 'then' in ifBlock || 'else' in ifBlock + ? `→ ${formatScalar(ifBlock.then)} : ${formatScalar(ifBlock.else)}` + : undefined; + return { propName, condition: `\`${propName}\` ${op}${compareTo}`, outcome }; + } + } + return null; +} + +function bindingPointerToProp(pointer: string): string { + return pointer.replace(/^#\/props\//, ''); +} + +function buildBindings(anatomy: Rec, dflt: Rec, variants: Rec[], props: Rec): string[] { + const found = findBindings(anatomy, dflt, variants).filter(b => b.propName in props); + if (found.length === 0) return []; + const rows = found.map(b => [ + code(b.element), + code(b.property), + code(b.propName), + b.condition ?? 'direct', + b.scope ?? 'default', + ]); + return table(['Element', 'Property', 'Bound prop', 'Condition', 'When'], rows); +} + +function buildStates(props: Rec, processingStates: ProcessingStates | undefined): string[] { + if (!processingStates) return []; + + interface StateRow { + concept: string; + prop: string; + value: string; + entry: { contract?: 'omit' | 'keep' }; + } + const valid: StateRow[] = []; + const matchedByProp = new Map>(); + + for (const [concept, entry] of Object.entries(processingStates)) { + const prop = props[entry.prop] as Rec | undefined; + if (!prop) continue; + const candidates = [entry.value ?? 'true', concept].map(s => String(s).toLowerCase()); + + if (Array.isArray(prop.enum)) { + // Only surface concepts whose activating value can actually match an enum value; + // display the enum's own casing. + const match = (prop.enum as unknown[]).find(v => candidates.includes(String(v).toLowerCase())); + if (match === undefined) continue; + valid.push({ concept, prop: entry.prop, value: String(match), entry }); + const set = matchedByProp.get(entry.prop) ?? new Set(); + set.add(String(match)); + matchedByProp.set(entry.prop, set); + } else if (prop.type === 'boolean') { + // Boolean props can only be activated by true/false. + const v = String(entry.value ?? 'true').toLowerCase(); + if (v !== 'true' && v !== 'false') continue; + valid.push({ concept, prop: entry.prop, value: v, entry }); + } else { + valid.push({ concept, prop: entry.prop, value: String(entry.value ?? 'true'), entry }); + } + } + if (valid.length === 0) return []; + + const rows = valid.map(({ concept, prop, value, entry }) => { + const def = CONCEPT_TABLE[concept]; + const contract = entry.contract ?? def?.contract ?? 'keep'; + return [ + code(concept), + code(prop), + code(value), + def ? code(def.selector) : '—', + contract === 'omit' ? 'omitted — browser-driven' : 'included — consumer-controlled', + ]; + }); + + // Enum values on classified props with no concept mapping and not the base default — + // surfaced so "unclassified" is distinguishable from "no such state". + for (const [propKey, matched] of matchedByProp) { + const prop = props[propKey] as Rec; + if (!Array.isArray(prop.enum)) continue; + for (const enumValue of prop.enum as unknown[]) { + const s = String(enumValue); + if (matched.has(s)) continue; + if (String(prop.default) === s) continue; // base state — covered by defaults + rows.push(['—', code(propKey), code(s), '—', 'unclassified — no concept mapping']); + } + } + + return [ + 'Semantic state classification from `config.processing.states`. Browser-driven states are fired by the platform, not set by consumers; non-web platforms map the concept, not the selector.', + '', + ...table(['State', 'Prop', 'Value', 'Web selector(s)', 'Contract'], rows), + ]; +} + +function buildAnatomy(anatomy: Rec, dflt: Rec, variants: Rec[], props: Rec): string[] { + const entries = Object.entries(anatomy); + if (entries.length === 0) return ['_No anatomy._']; + const defaultLayoutKeys = new Set(flattenLayout((dflt.layout ?? []) as unknown[])); + + // Element-presence pivot: which variant layouts contain each element. + const variantLayouts = variants.filter(v => Array.isArray(v.layout)); + const presenceFor = (key: string): string => { + const inDefault = defaultLayoutKeys.has(key); + if (variantLayouts.length === 0) return inDefault ? 'default' : '—'; + const inConfigs = variantLayouts.filter(v => flattenLayout(v.layout as unknown[]).includes(key)); + const inAll = inConfigs.length === variantLayouts.length; + if (inDefault && inAll) return 'always'; + if (inDefault && inConfigs.length === 0) return 'default only'; + if (!inDefault && inAll) return 'all variant layouts (not default)'; + const configs = inConfigs.map(v => formatConfiguration((v.configuration ?? {}) as Rec)); + const list = configs.length <= 4 ? configs.join(' · ') : `${plural(configs.length, 'variant layout')}`; + return inDefault ? `default · ${list}` : list; + }; + + const rows = entries.map(([key, raw]) => { + const el = raw as Rec; + const notes: string[] = []; + if (el.detectedIn) notes.push(`detected in ${normalizeDetectedIn(String(el.detectedIn), props)}`); + return [ + code(key), + String(el.type ?? '—'), + el.instanceOf ? formatValue(el.instanceOf) : '—', + presenceFor(key), + notes.join('; ') || '—', + ]; + }); + return table(['Element', 'Type', 'Instance of', 'Presence', 'Notes'], rows); +} + +/** + * Translate a raw Figma configuration string (`Appearance=Solid, Size=Small`) into + * prop vocabulary (`appearance: Solid, size: Small`). Keys match case-insensitively + * ignoring non-alphanumerics; boolean values lowercase; unmatched segments stay verbatim. + */ +function normalizeDetectedIn(detectedIn: string, props: Rec): string { + const canonical = new Map(); + for (const key of Object.keys(props)) { + canonical.set(key.toLowerCase().replace(/[^a-z0-9]/g, ''), key); + } + const parts = detectedIn.split(', ').map(segment => { + const eq = segment.indexOf('='); + if (eq === -1) return segment; + const rawKey = segment.slice(0, eq); + const rawValue = segment.slice(eq + 1); + const propKey = canonical.get(rawKey.toLowerCase().replace(/[^a-z0-9]/g, '')); + if (!propKey) return segment; + return `${propKey}: ${normalizePropValue(rawValue, props[propKey] as Rec)}`; + }); + return code(parts.join(', ')); +} + +/** Normalize a raw variant value to the prop's declared casing (booleans lowercase, enums by case-insensitive match). */ +function normalizePropValue(rawValue: string, prop: Rec | undefined): string { + if (!prop) return rawValue; + if (prop.type === 'boolean') return rawValue.toLowerCase(); + if (Array.isArray(prop.enum)) { + const match = (prop.enum as unknown[]).find(v => String(v).toLowerCase() === rawValue.toLowerCase()); + if (match !== undefined) return String(match); + } + return rawValue; +} + +function buildLayout(dflt: Rec, variants: Rec[]): string[] { + const body: string[] = []; + const defaultLayout = (dflt.layout ?? []) as unknown[]; + const defaultKeys = new Set(flattenLayout(defaultLayout)); + body.push('### Default', ''); + body.push(...renderLayoutTree(defaultLayout)); + + // Group variant layouts by identical shape so repeated trees render once. + const groups = new Map(); + for (const v of variants) { + if (!v.layout) continue; + const key = JSON.stringify(v.layout); + const g = groups.get(key) ?? { configs: [], layout: v.layout as unknown[] }; + g.configs.push(formatConfiguration((v.configuration ?? {}) as Rec)); + groups.set(key, g); + } + + const changed = [...groups.values()].filter(g => JSON.stringify(g.layout) !== JSON.stringify(defaultLayout)); + if (changed.length > 0) { + body.push('', '### Variant layout changes', ''); + for (const g of changed) { + body.push(`**${g.configs.join('** · **')}**`, ''); + const diff = layoutDiff(defaultKeys, new Set(flattenLayout(g.layout))); + if (diff) body.push(`_vs default: ${diff}_`, ''); + body.push(...renderLayoutTree(g.layout), ''); + } + while (body[body.length - 1] === '') body.pop(); + } + return body; +} + +/** Element-set diff between two layouts: `+ added − removed`. Empty string when sets match (pure restructure). */ +function layoutDiff(defaultKeys: Set, keys: Set): string { + const added = [...keys].filter(k => !defaultKeys.has(k)); + const removed = [...defaultKeys].filter(k => !keys.has(k)); + const parts: string[] = []; + if (added.length > 0) parts.push(`+ ${added.join(', ')}`); + if (removed.length > 0) parts.push(`− ${removed.join(', ')}`); + return parts.join(' · '); +} + +function renderLayoutTree(layout: unknown[], depth = 0): string[] { + const lines: string[] = []; + for (const node of layout) { + if (typeof node === 'string') { + lines.push(`${' '.repeat(depth)}- ${node}`); + } else if (typeof node === 'object' && node !== null) { + for (const [key, children] of Object.entries(node as Rec)) { + lines.push(`${' '.repeat(depth)}- ${key}`); + if (Array.isArray(children)) lines.push(...renderLayoutTree(children, depth + 1)); + } + } + } + return lines; +} + +function flattenLayout(layout: unknown[]): string[] { + const keys: string[] = []; + for (const node of layout) { + if (typeof node === 'string') keys.push(node); + else if (typeof node === 'object' && node !== null) { + for (const [key, children] of Object.entries(node as Rec)) { + keys.push(key); + if (Array.isArray(children)) keys.push(...flattenLayout(children)); + } + } + } + return keys; +} + +/** Rows for one element's non-style facts: content and (in deltas) instanceOf. */ +function elementFactRows(el: Rec, includeInstanceOf: boolean): string[][] { + const rows: string[][] = []; + if (includeInstanceOf && el.instanceOf !== undefined) { + rows.push([code('instanceOf'), formatValue(el.instanceOf)]); + } + if (el.content !== undefined) { + rows.push([code('content'), formatValue(el.content)]); + } + return rows; +} + +function buildDefaultStyles(dflt: Rec): string[] { + const elements = (dflt.elements ?? {}) as Rec; + const body: string[] = []; + for (const [elKey, raw] of Object.entries(elements)) { + const el = raw as Rec; + const rows: string[][] = elementFactRows(el, false); + for (const [prop, value] of flattenStyles((el.styles ?? {}) as Rec)) { + rows.push([code(prop), formatValue(value)]); + } + for (const [prop, value] of Object.entries((el.propConfigurations ?? {}) as Rec)) { + rows.push([code(`propConfigurations.${prop}`), formatValue(value)]); + } + if (rows.length === 0) continue; + const suffix = el.instanceOf ? ` — instance of ${formatValue(el.instanceOf)}` : ''; + body.push(`### ${elKey}${suffix}`, ''); + body.push(...table(['Property', 'Value'], rows), ''); + } + while (body[body.length - 1] === '') body.pop(); + return body.length > 0 ? body : ['_No default element styles._']; +} + +const TYPOGRAPHY_PROPS = new Set(['typography', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textCase', 'textDecoration']); + +function buildTypography(anatomy: Rec, dflt: Rec, variants: Rec[]): string[] { + const rows: string[][] = []; + const elements = (dflt.elements ?? {}) as Rec; + for (const [elKey, raw] of Object.entries(elements)) { + const styles = ((raw as Rec).styles ?? {}) as Rec; + for (const [prop, value] of Object.entries(styles)) { + if (!TYPOGRAPHY_PROPS.has(prop)) continue; + const overrides = variantOverridesFor(variants, elKey, prop); + rows.push([code(elKey), code(prop), formatValue(value), overrides.length > 0 ? overrides.join('; ') : '—']); + } + } + if (rows.length === 0) return []; + return table(['Element', 'Property', 'Default', 'Variant overrides'], rows); +} + +function buildColor(dflt: Rec, variants: Rec[]): string[] { + // Collect every (element, property) pair carrying a color-typed value anywhere. + const pairs = new Map(); + const collect = (elements: Rec) => { + for (const [elKey, raw] of Object.entries(elements)) { + for (const [prop, value] of Object.entries(((raw as Rec).styles ?? {}) as Rec)) { + if (isColorValue(value, prop)) pairs.set(`${elKey} ${prop}`, { element: elKey, prop }); + } + } + }; + collect((dflt.elements ?? {}) as Rec); + for (const v of variants) collect((v.elements ?? {}) as Rec); + + if (pairs.size === 0) return []; + + const rows: string[][] = []; + const defaultElements = (dflt.elements ?? {}) as Rec; + const sorted = [...pairs.values()].sort((a, b) => + a.element === b.element ? a.prop.localeCompare(b.prop) : a.element.localeCompare(b.element), + ); + for (const { element, prop } of sorted) { + const defaultValue = (((defaultElements[element] as Rec)?.styles ?? {}) as Rec)[prop]; + const overrides = variantOverridesFor(variants, element, prop); + rows.push([ + code(element), + code(prop), + defaultValue !== undefined ? formatValue(defaultValue) : '—', + overrides.length > 0 ? overrides.join('; ') : '—', + ]); + } + return table(['Element', 'Property', 'Default', 'Variant overrides'], rows); +} + +/** `configuration → value` strings for every variant that overrides element.prop. */ +function variantOverridesFor(variants: Rec[], element: string, prop: string): string[] { + const out: string[] = []; + for (const v of variants) { + const styles = ((((v.elements ?? {}) as Rec)[element] as Rec)?.styles ?? {}) as Rec; + if (prop in styles) { + out.push(`${formatConfiguration((v.configuration ?? {}) as Rec)} → ${formatValue(styles[prop])}`); + } + } + return out; +} + +function buildVariantDeltas(dflt: Rec, variants: Rec[]): string[] { + if (variants.length === 0) return []; + const body: string[] = []; + const defaultKeys = new Set(flattenLayout((dflt.layout ?? []) as unknown[])); + + let index = 0; + for (const v of variants) { + index++; + const rows: string[][] = []; + const hoisted: string[] = []; + for (const [elKey, raw] of Object.entries((v.elements ?? {}) as Rec)) { + const el = raw as Rec; + for (const [prop, value] of elementFactRows(el, true)) { + rows.push([code(elKey), prop, value]); + } + for (const [prop, value] of flattenStyles((el.styles ?? {}) as Rec)) { + if (isColorValue(value, prop)) { + hoisted.push(`${code(`${elKey}.${prop}`)}`); // color deltas live in the Color section + continue; + } + rows.push([code(elKey), code(prop), formatValue(value)]); + } + for (const [prop, value] of Object.entries((el.propConfigurations ?? {}) as Rec)) { + rows.push([code(elKey), code(`propConfigurations.${prop}`), formatValue(value)]); + } + } + const hasLayoutChange = Boolean(v.layout); + if (rows.length === 0 && !hasLayoutChange && hoisted.length === 0) continue; + + body.push(`### ${index}. ${formatConfiguration((v.configuration ?? {}) as Rec)}`, ''); + if (hasLayoutChange) { + const diff = layoutDiff(defaultKeys, new Set(flattenLayout(v.layout as unknown[]))); + body.push(`_Layout${diff ? `: ${diff}` : ' restructured'} — see [Layout](#layout)._`, ''); + } + if (hoisted.length > 0) { + body.push(`_Color: ${hoisted.join(', ')} — see [Color](#color)._`, ''); + } + if (rows.length > 0) body.push(...table(['Element', 'Property', 'Value'], rows), ''); + } + while (body[body.length - 1] === '') body.pop(); + if (body.length === 0) return []; + return [ + 'Deltas apply on top of Element styles (default), in the order listed; later values win per property.', + '', + ...body, + ]; +} + +/** Config-space cap above which invalid-combination minimization is skipped. */ +const MINIMIZE_SPACE_CAP = 4096; + +function buildInvalidCombinations(spec: Rec, props: Rec, variants: Rec[]): string[] { + const combos = (spec.invalidVariantCombinations ?? []) as Rec[]; + if (combos.length === 0) return []; + + // Normalize values to prop casing so pattern matching is exact. + const normalized: Array> = combos.map(c => { + const entries = Object.entries(c).map( + ([k, v]) => [k, normalizePropValue(String(v), props[k] as Rec | undefined)] as [string, string], + ); + return new Map(entries); + }); + + const minimized = minimizeInvalidCombinations(normalized, props, variants); + const lines = minimized.map(m => `- ${code([...m].map(([k, v]) => `${k}: ${v}`).join(', '))}`); + if (minimized.length < combos.length) { + lines.push( + '', + `_Reduced from ${combos.length} spec entries; these patterns cover exactly the same configurations._`, + ); + } + return lines; +} + +/** + * Exact-cover minimization: an axis may be dropped from a combo only when every + * configuration the reduced pattern matches is invalid according to the original + * list. Deterministic (fixed axis and combo order); skipped for large config spaces. + */ +function minimizeInvalidCombinations( + combos: Array>, + props: Rec, + variants: Rec[], +): Array> { + const axes = variantAxes(props, variants); + const axisValues = new Map(); + for (const [key, p] of axes) { + axisValues.set(key, p.type === 'boolean' ? ['true', 'false'] : ((p.enum ?? []) as unknown[]).map(String)); + } + // Boolean props referenced only by invalid combinations still span the space. + for (const combo of combos) { + for (const key of combo.keys()) { + if (axisValues.has(key)) continue; + const p = props[key] as Rec | undefined; + if (p?.type === 'boolean') axisValues.set(key, ['true', 'false']); + } + } + + let space = 1; + for (const values of axisValues.values()) space *= values.length; + if (axisValues.size === 0) space = 0; + const comboKeysKnown = combos.every(c => [...c.keys()].every(k => axisValues.has(k))); + if (space === 0 || space > MINIMIZE_SPACE_CAP || !comboKeysKnown) { + // Fall back to plain subsumption (drop combos containing a shorter listed combo). + const subsumed = new Set(); + for (let i = 0; i < combos.length; i++) { + for (let j = 0; j < combos.length; j++) { + if (i === j || subsumed.has(i) || subsumed.has(j)) continue; + if (combos[i].size < combos[j].size && [...combos[i]].every(([k, v]) => combos[j].get(k) === v)) { + subsumed.add(j); + } + } + } + return combos.filter((_, i) => !subsumed.has(i)); + } + + // Enumerate the full configuration space once. + const axisKeys = [...axisValues.keys()]; + let configs: Array> = [new Map()]; + for (const key of axisKeys) { + const next: Array> = []; + for (const partial of configs) { + for (const value of axisValues.get(key)!) { + next.push(new Map(partial).set(key, value)); + } + } + configs = next; + } + + const matches = (pattern: Map, config: Map) => + [...pattern].every(([k, v]) => config.get(k) === v); + const isInvalid = (config: Map) => combos.some(c => matches(c, config)); + + const reduced: Array> = []; + for (const combo of combos) { + let pattern = new Map(combo); + let dropped = true; + while (dropped) { + dropped = false; + for (const key of [...pattern.keys()]) { + const candidate = new Map(pattern); + candidate.delete(key); + if (candidate.size === 0) continue; + if (configs.filter(c => matches(candidate, c)).every(isInvalid)) { + pattern = candidate; + dropped = true; + break; + } + } + } + reduced.push(pattern); + } + + // Dedupe identical patterns and drop patterns subsumed by shorter ones, keeping first-occurrence order. + const kept: Array> = []; + for (const pattern of reduced) { + const isDuplicate = kept.some( + k => k.size === pattern.size && [...k].every(([key, v]) => pattern.get(key) === v), + ); + if (isDuplicate) continue; + kept.push(pattern); + } + const final: Array> = []; + for (const pattern of kept) { + const subsumedByOther = kept.some( + other => other !== pattern && other.size < pattern.size && [...other].every(([k, v]) => pattern.get(k) === v), + ); + if (!subsumedByOther) final.push(pattern); + } + return final; +} + +function buildSubcomponents(subcomponents: Rec): string[] { + const entries = Object.entries(subcomponents); + if (entries.length === 0) return []; + const rows = entries.map(([key, raw]) => { + const sub = raw as Rec; + const props = Object.keys((sub.props ?? {}) as Rec); + return [ + `[${code(key)}](${key}/component.md)`, + String(sub.title ?? '—'), + props.length > 0 ? props.map(code).join(' ') : '—', + ]; + }); + return table(['Subcomponent', 'Title', 'Props'], rows); +} + +function buildExamples(spec: Rec, props: Rec): string[] { + const body: string[] = []; + const instanceExamples = (spec.instanceExamples ?? {}) as Rec; + const slotContentExamples = (spec.slotContentExamples ?? {}) as Rec; + + if (Object.keys(instanceExamples).length > 0) { + body.push('**Instance examples** — prop values that differ from the defaults. Full data: `examples.yaml`.', ''); + const rows: string[][] = []; + for (const [name, raw] of Object.entries(instanceExamples)) { + const ex = raw as Rec; + const configs = (ex.propConfigurations ?? {}) as Rec; + const nonDefault = Object.entries(configs).filter(([k, v]) => { + const p = props[k] as Rec | undefined; + return !p || !('default' in p) || String(p.default) !== String(v); + }); + const summary = + nonDefault.length > 0 + ? nonDefault.map(([k, v]) => `${k}: ${formatScalar(v)}`).join(', ') + : '(all defaults)'; + rows.push([code(name), code(summary)]); + } + body.push(...table(['Example', 'Non-default configuration'], rows)); + } + if (Object.keys(slotContentExamples).length > 0) { + if (body.length > 0) body.push(''); + body.push( + `**Slot content examples** — full element data in \`examples.yaml\`: ${Object.keys(slotContentExamples).map(code).join(' ')}`, + ); + } + return body; +} + +function buildProvenance(spec: Rec): string[] { + const metadata = (spec.metadata ?? {}) as Rec; + const generator = (metadata.generator ?? {}) as Rec; + const schema = (metadata.schema ?? {}) as Rec; + const source = (metadata.source ?? {}) as Rec; + + const parts: string[] = []; + if (metadata.author) parts.push(`author ${metadata.author}`); + if (metadata.lastUpdated) parts.push(`updated ${metadata.lastUpdated}`); + if (generator.name) parts.push(`generator ${generator.name}${generator.version ? ` v${generator.version}` : ''}`); + if (schema.version) parts.push(`schema ${schema.version}`); + if (source.nodeId) parts.push(`source node ${source.nodeId}`); + return parts.length > 0 ? [parts.join(' · ')] : ['_No metadata._']; +} + +// --------------------------------------------------------------------------- +// Value formatting +// --------------------------------------------------------------------------- + +/** Flatten Sides-style objects (padding.top …) and effects groups one level; pass everything else through. */ +function flattenStyles(styles: Rec): Array<[string, unknown]> { + const out: Array<[string, unknown]> = []; + for (const [prop, value] of Object.entries(styles)) { + if (isSidesObject(value)) { + for (const [side, sv] of Object.entries(value as Rec)) { + out.push([`${prop}.${side}`, sv]); + } + } else if ( + prop === 'effects' && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !isTokenRef(value) + ) { + for (const [kind, ev] of Object.entries(value as Rec)) { + out.push([`${prop}.${kind}`, ev]); + } + } else { + out.push([prop, value]); + } + } + return out; +} + +function isSidesObject(v: unknown): boolean { + if (typeof v !== 'object' || v === null || Array.isArray(v)) return false; + const keys = Object.keys(v as Rec); + return keys.length > 0 && keys.every(k => ['top', 'end', 'bottom', 'start'].includes(k)); +} + +const COLOR_PROPS = new Set(['backgroundColor', 'textColor', 'strokes']); + +function isColorValue(value: unknown, prop: string): boolean { + if (isTokenRef(value)) return (value as Rec).$type === 'color'; + if (typeof value === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(value)) return true; + if (COLOR_PROPS.has(prop) && value !== undefined) return true; + return false; +} + +function formatValue(value: unknown): string { + if (value === null) return 'unset'; + if (value === undefined) return '—'; + if (isTokenRef(value)) { + const t = value as { $token: string; $type?: string; $extensions?: Rec }; + const raw = tokenRawValue(t); + const qualifier = [t.$type, raw].filter(Boolean).join(', '); + return `${code(t.$token)}${qualifier ? ` (${qualifier})` : ''}`; + } + if (Array.isArray(value)) { + return value.map(v => formatValue(v)).join('; '); + } + if (typeof value === 'object') { + const v = value as Rec; + // JSON Pointer refs (e.g. instanceOf: { $ref: '#/subcomponents/x' }) render as the pointer basename. + if (typeof v.$ref === 'string') { + const segments = v.$ref.split('/'); + return code(segments[segments.length - 1]); + } + // Shadow-shaped objects render as a compact CSS-like string (offset-x offset-y blur spread color). + if ('offsetX' in v || 'blur' in v) { + const parts = [ + v.visible === false ? '(hidden)' : null, + v.inset ? 'inset' : null, + v.offsetX, + v.offsetY, + v.blur, + v.spread, + v.color, + ] + .filter(x => x !== null && x !== undefined) + .map(String); + return code(parts.join(' ')); + } + const binding = extractBinding(value); + if (binding) { + if (!binding.condition) return `← ${code(binding.propName)}`; + return `if ${binding.condition}${binding.outcome ? ` ${binding.outcome}` : ''}`; + } + // Inline typography objects render as `family style size/line-height`. + if ('fontFamily' in v || 'fontSize' in v) { + const size = [v.fontSize, v.lineHeight].filter(x => x !== undefined).join('/'); + const parts = [v.fontFamily, v.fontStyle, size].filter(x => x !== undefined && x !== '').map(String); + return code(parts.join(' ')); + } + return code(JSON.stringify(v)); + } + return code(formatScalar(value)); +} + +/** Resolved raw value from $extensions["com.figma"].rawValue, when the token format carries it. */ +function tokenRawValue(t: { $extensions?: Rec }): string | null { + const raw = ((t.$extensions?.['com.figma'] as Rec | undefined) ?? {}).rawValue; + if (raw === undefined || raw === null) return null; + if (typeof raw === 'object') { + const hex = (raw as Rec).hex; + return typeof hex === 'string' ? hex : null; + } + return String(raw); +} + +function formatScalar(value: unknown): string { + if (value === null) return 'null'; + return String(value); +} + +function formatConfiguration(configuration: Rec): string { + const parts = Object.entries(configuration).map(([k, v]) => `${k}: ${v}`); + return parts.length > 0 ? code(parts.join(', ')) : code('(default)'); +} + +// --------------------------------------------------------------------------- +// Markdown primitives +// --------------------------------------------------------------------------- + +function table(headers: string[], rows: string[][]): string[] { + const lines: string[] = []; + lines.push(`| ${headers.join(' | ')} |`); + lines.push(`|${headers.map(() => '---').join('|')}|`); + for (const row of rows) { + lines.push(`| ${row.map(cell => cell.replace(/\|/g, '\\|')).join(' | ')} |`); + } + return lines; +} + +function code(s: string): string { + return `\`${s}\``; +} + +function plural(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? '' : 's'}`; +} diff --git a/packages/cli/src/transforms/index.ts b/packages/cli/src/transforms/index.ts index 8f359e3..8d0b3f8 100644 --- a/packages/cli/src/transforms/index.ts +++ b/packages/cli/src/transforms/index.ts @@ -1,10 +1,12 @@ import type { Transformer } from '../Types/Transformer.js'; import { ContractTransformer } from './Contract.js'; import { CssTransformer } from './Css.js'; +import { ComponentMdTransformer } from './ComponentMd.js'; const ALL_TRANSFORMERS: Transformer[] = [ new ContractTransformer(), new CssTransformer(), + new ComponentMdTransformer(), ]; const BY_NAME = new Map(ALL_TRANSFORMERS.map(t => [t.name, t])); diff --git a/packages/cli/tests/unit/transforms/ComponentMd.test.ts b/packages/cli/tests/unit/transforms/ComponentMd.test.ts new file mode 100644 index 0000000..a85f1eb --- /dev/null +++ b/packages/cli/tests/unit/transforms/ComponentMd.test.ts @@ -0,0 +1,625 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import yaml from 'yaml'; +import { ComponentMdTransformer } from '../../../src/transforms/ComponentMd.js'; +import type { ProcessingStates } from '../../../src/transforms/states.js'; + +const transformer = new ComponentMdTransformer(); + +function makeContext(dir: string, componentKey = 'dsButton', processingStates?: ProcessingStates) { + return { outputDir: dir, componentKey, tokensFormat: 'TOKEN', outputFormat: 'YAML' as const, processingStates }; +} + +async function run( + dir: string, + apiYaml: Record, + variantsYaml?: Record, + processingStates?: ProcessingStates, +) { + if (variantsYaml) { + await fs.writeFile(path.join(dir, 'variants.yaml'), yaml.stringify(variantsYaml), 'utf-8'); + } + await transformer.run(apiYaml, makeContext(dir, 'dsButton', processingStates)); + return fs.readFile(path.join(dir, 'component.md'), 'utf-8'); +} + +describe('ComponentMdTransformer', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'component-md-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it('has name "component-md"', () => { + expect(transformer.name).toBe('component-md'); + }); + + it('writes component.md with the title as H1 and a regeneration note', async () => { + const out = await run(tmpDir, { title: 'DS Button' }); + expect(out).toContain('# DS Button'); + expect(out).toContain('specs transform component-md'); + }); + + it('falls back to componentKey when there is no title', async () => { + const out = await run(tmpDir, {}); + expect(out).toContain('# dsButton'); + }); + + it('emits an overview with counts and variant axes', async () => { + const out = await run( + tmpDir, + { + title: 'DS Button', + anatomy: { root: { type: 'container' }, label: { type: 'text' } }, + props: { + variant: { type: 'string', default: 'primary', enum: ['primary', 'secondary'] }, + disabled: { type: 'boolean', default: false }, + }, + }, + { default: {}, variants: [{ configuration: { disabled: true } }] }, + ); + expect(out).toContain('2 props, 2 anatomy elements, 1 variant delta'); + expect(out).toContain('**Variant axes.**'); + expect(out).toContain('`variant` (primary · secondary; default `primary` — no style impact recorded)'); + expect(out).toContain('`disabled` (boolean; default `false`)'); + expect(out).toContain('**Contents.** 1 variant deltas · 1 layout tree · configuration space 4'); + }); + + it('emits the props table with enum values and defaults', async () => { + const out = await run(tmpDir, { + props: { size: { type: 'string', default: 'medium', enum: ['small', 'medium', 'large'] } }, + }); + expect(out).toContain('| `size` | enum | `medium` | `small` `medium` `large` |'); + }); + + it('marks browser-driven state props as omitted from the contract', async () => { + const states: ProcessingStates = { hover: { prop: 'state', value: 'hover' } }; + const out = await run( + tmpDir, + { props: { state: { type: 'string', default: 'rest', enum: ['rest', 'hover'] } } }, + undefined, + states, + ); + expect(out).toContain('browser-driven state — omitted from `contract.ts`'); + expect(out).toContain('## States'); + expect(out).toContain('| `hover` | `state` | `hover` | `:hover` | omitted — browser-driven |'); + }); + + it('excludes state concepts whose value cannot match the enum', async () => { + const states: ProcessingStates = { + hover: { prop: 'state', value: 'hover' }, + 'focus-within': { prop: 'state', value: 'focused' }, + }; + const out = await run( + tmpDir, + { props: { state: { type: 'string', default: 'rest', enum: ['rest', 'hover'] } } }, + undefined, + states, + ); + expect(out).toContain('`:hover`'); + expect(out).not.toContain('focus-within'); + }); + + it('renders anatomy with $ref instanceOf as the pointer basename', async () => { + const out = await run(tmpDir, { + anatomy: { icon: { type: 'instance', instanceOf: { $ref: '#/subcomponents/startVisual' } } }, + }); + expect(out).toContain('| `icon` | instance | `startVisual` |'); + }); + + it('notes detectedIn anatomy elements', async () => { + const out = await run(tmpDir, { + anatomy: { focusRing: { type: 'container', detectedIn: 'State=Focus' } }, + }); + expect(out).toContain('detected in `State=Focus`'); + }); + + it('renders the default layout tree and deduplicates identical variant layouts', async () => { + const out = await run( + tmpDir, + { anatomy: { root: { type: 'container' } } }, + { + default: { layout: [{ root: ['icon'] }] }, + variants: [ + { configuration: { state: 'hover' }, layout: [{ root: ['leftIcon'] }] }, + { configuration: { state: 'active' }, layout: [{ root: ['leftIcon'] }] }, + ], + }, + ); + expect(out).toContain('### Default'); + expect(out).toContain('- root\n - icon'); + expect(out).toContain('**`state: hover`** · **`state: active`**'); + // The shared tree renders once + expect(out.match(/- root\n {2}- leftIcon/g)).toHaveLength(1); + }); + + it('renders token references verbatim with their $type', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { + root: { styles: { backgroundColor: { $token: 'DS Color/Action/Initial', $type: 'color' } } }, + }, + }, + }, + ); + expect(out).toContain('`DS Color/Action/Initial` (color)'); + }); + + it('flattens padding sides and effects shadows', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { + root: { + styles: { + padding: { top: 0, start: 8 }, + effects: { shadows: [{ visible: true, offsetX: 0, offsetY: 1, blur: 3, spread: 1, color: '#000000' }] }, + }, + }, + }, + }, + }, + ); + expect(out).toContain('| `padding.top` | `0` |'); + expect(out).toContain('| `padding.start` | `8` |'); + expect(out).toContain('| `effects.shadows` | `0 1 3 1 #000000` |'); + }); + + it('builds the color inversion table with variant overrides', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { root: { styles: { backgroundColor: { $token: 'Color/Initial', $type: 'color' } } } }, + }, + variants: [ + { + configuration: { state: 'hover' }, + elements: { root: { styles: { backgroundColor: { $token: 'Color/Hover', $type: 'color' } } } }, + }, + ], + }, + ); + expect(out).toContain('## Color'); + expect(out).toContain('`state: hover` → `Color/Hover` (color)'); + }); + + it('keeps color deltas out of the variant deltas section', async () => { + const out = await run( + tmpDir, + {}, + { + default: { elements: {} }, + variants: [ + { + configuration: { state: 'hover' }, + elements: { + root: { + styles: { + backgroundColor: { $token: 'Color/Hover', $type: 'color' }, + height: 36, + }, + }, + }, + }, + ], + }, + ); + const deltas = out.slice(out.indexOf('## Variant deltas')); + expect(deltas).toContain('| `root` | `height` | `36` |'); + expect(deltas).not.toContain('Color/Hover'); + }); + + it('documents prop bindings, including conditional visibility', async () => { + const out = await run( + tmpDir, + { + props: { + label: { type: 'string' }, + children: { type: 'slot', default: null }, + }, + }, + { + default: { + elements: { + label: { content: { $binding: '#/props/label' } }, + children: { + styles: { + visible: { + if: { + condition: { operation: 'isNull', args: { value: { $binding: '#/props/children' } } }, + then: false, + else: true, + }, + }, + }, + }, + }, + }, + }, + ); + expect(out).toContain('## Bindings'); + expect(out).toContain('| `label` | `content` | `label` | direct |'); + expect(out).toContain('| `children` | `visible` | `children` | `children` isNull |'); + // Conditional style value carries the then/else outcome + expect(out).toContain('if `children` isNull → false : true'); + }); + + it('lists invalid variant combinations', async () => { + const out = await run( + tmpDir, + {}, + { + default: {}, + variants: [], + invalidVariantCombinations: [{ disabled: true, state: 'hover' }], + }, + ); + expect(out).toContain('## Invalid combinations'); + expect(out).toContain('- `disabled: true, state: hover`'); + }); + + it('writes subcomponent component.md files and links them', async () => { + const out = await run(tmpDir, { + title: 'DS Button', + subcomponents: { + counter: { title: 'DS Counter', props: { count: { type: 'number', default: 0 } } }, + }, + }); + expect(out).toContain('## Subcomponents'); + expect(out).toContain('[`counter`](counter/component.md)'); + const sub = await fs.readFile(path.join(tmpDir, 'counter', 'component.md'), 'utf-8'); + expect(sub).toContain('# DS Counter'); + expect(sub).toContain('Subcomponent of `dsButton`'); + expect(sub).toContain('| `count` | number | `0` |'); + }); + + it('summarizes instance examples with non-default configurations', async () => { + await fs.writeFile( + path.join(tmpDir, 'examples.yaml'), + yaml.stringify({ + instanceExamples: { + primaryExample: { propConfigurations: { size: 'l', label: 'Book now' } }, + }, + }), + 'utf-8', + ); + const out = await run(tmpDir, { + props: { size: { type: 'string', default: 'm', enum: ['s', 'm', 'l'] }, label: { type: 'string' } }, + }); + expect(out).toContain('## Examples'); + expect(out).toContain('| `primaryExample` | `size: l, label: Book now` |'); + }); + + it('filters default-valued props out of example configurations', async () => { + await fs.writeFile( + path.join(tmpDir, 'examples.yaml'), + yaml.stringify({ + instanceExamples: { + plain: { propConfigurations: { size: 'm' } }, + }, + }), + 'utf-8', + ); + const out = await run(tmpDir, { + props: { size: { type: 'string', default: 'm', enum: ['s', 'm', 'l'] } }, + }); + expect(out).toContain('| `plain` | `(all defaults)` |'); + }); + + it('renders provenance from metadata', async () => { + const out = await run(tmpDir, { + metadata: { + author: 'Nathan', + lastUpdated: '2026-07-01T00:00:00.000Z', + generator: { name: 'specs-cli', version: '0.24.0' }, + schema: { version: '0.23.0' }, + source: { nodeId: '1:2' }, + }, + }); + expect(out).toContain('author Nathan'); + expect(out).toContain('generator specs-cli v0.24.0'); + expect(out).toContain('schema 0.23.0'); + expect(out).toContain('source node 1:2'); + }); + + it('emits content and instanceOf rows in element styles and variant deltas', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { decorativeIcon: { content: 'info', styles: { width: 16 } } }, + }, + variants: [ + { + configuration: { appearance: 'success' }, + elements: { decorativeIcon: { content: 'check' } }, + }, + { + configuration: { size: 'l' }, + elements: { startVisual: { instanceOf: 'startVisualL' } }, + }, + ], + }, + ); + expect(out).toContain('| `content` | `info` |'); + expect(out).toContain('| `decorativeIcon` | `content` | `check` |'); + expect(out).toContain('| `startVisual` | `instanceOf` | `startVisualL` |'); + }); + + it('includes variant-scoped bindings with a When column', async () => { + const out = await run( + tmpDir, + { props: { value: { type: 'string' } } }, + { + default: { elements: {} }, + variants: [ + { + configuration: { focused: true }, + elements: { value: { content: { $binding: '#/props/value' } } }, + }, + ], + }, + ); + expect(out).toContain('| `value` | `content` | `value` | direct | `focused: true` |'); + expect(out).toContain('bound to `value.content` (when `focused: true`)'); + }); + + it('filters impossible boolean state values and adds unclassified enum rows', async () => { + const states: ProcessingStates = { + hover: { prop: 'state', value: 'hover' }, + indeterminate: { prop: 'selected', value: 'indeterminate' }, + }; + const out = await run( + tmpDir, + { + props: { + state: { type: 'string', default: 'Rest', enum: ['Rest', 'Hover', 'Focus'] }, + selected: { type: 'boolean', default: false }, + }, + }, + undefined, + states, + ); + // Boolean prop cannot be activated by a non-boolean value + expect(out).not.toContain('indeterminate'); + // Value casing follows the enum + expect(out).toContain('| `hover` | `state` | `Hover` |'); + // Unmapped non-default enum value surfaces as unclassified + expect(out).toContain('| — | `state` | `Focus` | — | unclassified — no concept mapping |'); + }); + + it('renders token-valued effects as a single token row', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { + root: { + styles: { + effects: { + $token: 'Shadow theme/Medium elevation', + $type: 'effects', + $extensions: { 'com.figma': { id: 'x', name: 'Medium elevation' } }, + }, + }, + }, + }, + }, + }, + ); + expect(out).toContain('| `effects` | `Shadow theme/Medium elevation` (effects) |'); + expect(out).not.toContain('$extensions'); + }); + + it('appends rawValue to token references when present', async () => { + const out = await run( + tmpDir, + {}, + { + default: { + elements: { + root: { + styles: { + strokes: { + $token: 'Color/Outline', + $type: 'color', + $extensions: { 'com.figma': { rawValue: { hex: '#818494' } } }, + }, + itemSpacing: { + $token: 'Constants/Spacing/1x', + $type: 'dimension', + $extensions: { 'com.figma': { rawValue: 4 } }, + }, + }, + }, + }, + }, + }, + ); + expect(out).toContain('`Color/Outline` (color, #818494)'); + expect(out).toContain('`Constants/Spacing/1x` (dimension, 4)'); + }); + + it('renders null style values as unset', async () => { + const out = await run( + tmpDir, + {}, + { + default: { elements: {} }, + variants: [ + { configuration: { size: 'm' }, elements: { root: { styles: { width: null } } } }, + ], + }, + ); + expect(out).toContain('| `root` | `width` | unset |'); + }); + + it('normalizes detectedIn to prop vocabulary', async () => { + const out = await run(tmpDir, { + props: { + appearance: { type: 'string', default: 'solid', enum: ['solid', 'outline'] }, + disabled: { type: 'boolean', default: false }, + }, + anatomy: { focusRing: { type: 'container', detectedIn: 'Appearance=Solid, Disabled=False' } }, + }); + expect(out).toContain('detected in `appearance: solid, disabled: false`'); + }); + + it('adds a Presence column to the anatomy table', async () => { + const out = await run( + tmpDir, + { + anatomy: { + root: { type: 'container' }, + icon: { type: 'instance' }, + leftIcon: { type: 'instance' }, + }, + }, + { + default: { layout: [{ root: ['icon'] }] }, + variants: [ + { configuration: { state: 'hover' }, layout: [{ root: ['leftIcon'] }] }, + { configuration: { state: 'active' }, layout: [{ root: ['leftIcon'] }] }, + ], + }, + ); + expect(out).toContain('| `root` | container | — | always |'); + expect(out).toContain('| `icon` | instance | — | default only |'); + expect(out).toContain('| `leftIcon` | instance | — | all variant layouts (not default) |'); + }); + + it('summarizes variant layout changes as diffs against the default', async () => { + const out = await run( + tmpDir, + {}, + { + default: { layout: [{ root: ['icon'] }] }, + variants: [ + { configuration: { state: 'focus' }, layout: [{ root: ['leftIcon', 'focusRing'] }] }, + ], + }, + ); + expect(out).toContain('_vs default: + leftIcon, focusRing · − icon_'); + expect(out).toContain('_Layout: + leftIcon, focusRing · − icon — see [Layout](#layout)._'); + }); + + it('cross-references hoisted color changes from variant deltas', async () => { + const out = await run( + tmpDir, + {}, + { + default: { elements: {} }, + variants: [ + { + configuration: { state: 'hover' }, + elements: { root: { styles: { backgroundColor: { $token: 'Color/Hover', $type: 'color' } } } }, + }, + ], + }, + ); + expect(out).toContain('_Color: `root.backgroundColor` — see [Color](#color)._'); + }); + + it('states the delta resolution rule', async () => { + const out = await run( + tmpDir, + {}, + { + default: { elements: {} }, + variants: [{ configuration: { size: 'l' }, elements: { root: { styles: { height: 42 } } } }], + }, + ); + expect(out).toContain('Deltas apply on top of Element styles (default), in the order listed; later values win per property.'); + expect(out).toContain('### 1. `size: l`'); + }); + + it('normalizes and subsumption-reduces invalid combinations', async () => { + const out = await run( + tmpDir, + { + props: { + disabled: { type: 'boolean', default: false }, + state: { type: 'string', default: 'Rest', enum: ['Rest', 'Hover'] }, + size: { type: 'string', default: 'M', enum: ['S', 'M'] }, + }, + }, + { + default: {}, + variants: [], + invalidVariantCombinations: [ + { disabled: 'True', state: 'hover' }, + { disabled: 'True', state: 'hover', size: 'S' }, + ], + }, + ); + expect(out).toContain('- `disabled: true, state: Hover`'); + expect(out).not.toContain('size: S'); + expect(out).toContain('_Reduced from 2 spec entries; these patterns cover exactly the same configurations._'); + }); + + it('collapses cross-product invalid expansions into a minimal pattern', async () => { + // elevated:true is invalid across ALL states — the spec enumerates each state. + const out = await run( + tmpDir, + { + props: { + state: { type: 'string', default: 'Rest', enum: ['Rest', 'Hover', 'Pressed'] }, + elevated: { type: 'boolean', default: false }, + }, + }, + { + default: {}, + variants: [], + invalidVariantCombinations: [ + { state: 'Rest', elevated: true }, + { state: 'Hover', elevated: true }, + { state: 'Pressed', elevated: true }, + ], + }, + ); + expect(out).toContain('- `elevated: true`'); + expect(out).not.toContain('state: Rest, elevated'); + expect(out).toContain('_Reduced from 3 spec entries; these patterns cover exactly the same configurations._'); + }); + + it('emits slot constraints and code-only markers in prop notes', async () => { + const out = await run(tmpDir, { + props: { + children: { type: 'slot', minChildren: 1, maxChildren: 2, anyOf: ['dsButton'] }, + a11yLabel: { + type: 'string', + examples: ['A11y label'], + $extensions: { 'com.figma': { type: 'TEXT', source: { kind: 'codeOnlyProp', layer: 'A11y label' } } }, + }, + }, + }); + expect(out).toContain('slot (min 1, max 2, accepts `dsButton`)'); + expect(out).toContain('code-only prop — not rendered in Figma'); + expect(out).toContain('e.g. `A11y label`'); + }); + + it('is deterministic — identical input produces identical output', async () => { + const apiYaml = { + title: 'DS Button', + props: { size: { type: 'string', default: 'm', enum: ['s', 'm'] } }, + }; + const first = await run(tmpDir, apiYaml); + const second = await run(tmpDir, apiYaml); + expect(second).toBe(first); + }); +}); diff --git a/site/src/content/docs/cli/commands/transform.md b/site/src/content/docs/cli/commands/transform.md index 93d3a2e..78c1b17 100644 --- a/site/src/content/docs/cli/commands/transform.md +++ b/site/src/content/docs/cli/commands/transform.md @@ -40,6 +40,7 @@ Show detailed output during transformation. | `contract` *(default)* | TypeScript Props interface and defaults per component | | `css` | CSS custom property rules per component | | `styling` | Token inventory per component | +| `component-md` | Single-file markdown reference per component | ## See Also diff --git a/site/src/content/docs/cli/transforms/component-md.md b/site/src/content/docs/cli/transforms/component-md.md new file mode 100644 index 0000000..9af9ea6 --- /dev/null +++ b/site/src/content/docs/cli/transforms/component-md.md @@ -0,0 +1,63 @@ +--- +title: "component-md" +description: "Emit a deterministic single-file markdown reference for each component" +--- + + + +Emits `component.md` — a single-file markdown reference for each component, projected deterministically from `api.yaml` and `variants.yaml`. Every value is verbatim from the spec: no inference, no LLM calls, byte-identical output for identical input. + +## Use When + +- You want a readable component reference for humans and coding agents without loading the full spec YAML. +- You want an agent-retrieval doc — props, anatomy, layout, tokens, states, and variant behavior in one file that fits a single tool call. +- You want documentation that regenerates in seconds when the design changes, instead of being re-derived by hand or by agent interpretation. + +## Invocation + +```bash +specs transform component-md +``` + +## Output + +Each component subfolder receives a `component.md`. When subcomponents are present, each also receives a `component.md` inside its own named subfolder, linked from the parent's **Subcomponents** section. + +## Sections + +| Section | Source | Contents | +|---------|--------|----------| +| Overview | `api.yaml` + `variants.yaml` | Counts, variant-axes one-liner with values and defaults (axes with no recorded style impact are flagged), and a contents manifest — delta count, layout trees, invalid combinations, configuration-space size | +| Props | `api.yaml` | Full prop table — type, default, enum values, nullability, slot constraints (`min`/`max`/`anyOf`), code-only markers, example values, binding notes, contract omissions | +| Bindings | `api.yaml` + `variants.yaml` | Element properties bound to props (`visible`, `content`, `instanceOf`, `children`), including conditional and variant-scoped bindings with a When column | +| States | `config.processing.states` | Semantic state classification — prop, activating value, web selector(s), contract inclusion; unmapped enum values surface as `unclassified` | +| Anatomy | `api.yaml` + `variants.yaml` | Element table with types, instance references, and a Presence column (default / all variants / specific configurations) | +| Layout | `variants.yaml` | Default layout tree plus variant layout changes, deduplicated by identical shape, each with a `+ added − removed` diff against the default | +| Element styles (default) | `variants.yaml` | Per-element default style tables including `content`, with verbatim token paths | +| Typography | `variants.yaml` | Text style properties per element with variant overrides | +| Color | `variants.yaml` | Element × property color map — default token plus every variant override | +| Variant deltas | `variants.yaml` | Numbered per-configuration changes — styles, `content`, `instanceOf` swaps, prop configurations — with layout diffs and cross-references to hoisted Color rows | +| Invalid combinations | `variants.yaml` | Minimal invalid patterns — spec entries are exact-cover minimized so cross-product expansions collapse to their rule | +| Subcomponents | `api.yaml` | Summary table linking each subcomponent's own `component.md` | +| Examples | `examples.yaml` | Instance examples with their non-default prop configurations; slot-content example names | +| Provenance | `api.yaml` metadata | Author, timestamp, generator, schema version, source node | + +Sections without data are omitted. + +## Resolution Rule + +The header states the delta composition contract: start from Element styles (default), then apply every variant delta whose configuration matches, in the order listed — later values win per property. `unset` removes a default value. + +## Token References + +Token references render verbatim — `` `DS Color/Action/Hover` (color) `` — never renamed or converted. When the spec carries resolved raw values (`TOKEN_FIGMA_EXTENSIONS` format), they are appended: `` `Color/Outline` (color, #818494) ``, `` `Constants/Spacing/1x` (dimension, 4) ``. For per-variant styling detail and anything not carried in the markdown, the spec YAML remains canonical. + +## States Classification + +When [`processing.states`](/specs/config/states/) is configured, the **States** section documents which variant props are browser-driven (omitted from the generated contract) versus consumer-controlled (bridged to HTML/ARIA attributes), with the canonical web selector for each. Concepts whose activating value cannot match the prop's enum values are excluded. + +## See Also + +- [`contract` transformer](/specs/cli/transforms/contract/) — TypeScript Props interface affected by the same states classification +- [`css` transformer](/specs/cli/transforms/css/) — stylesheet output using the same selector strategy +- [`states` config](/specs/config/states/) — the classification that drives the States section diff --git a/site/src/content/docs/cli/transforms/index.md b/site/src/content/docs/cli/transforms/index.md index daba2e5..c624494 100644 --- a/site/src/content/docs/cli/transforms/index.md +++ b/site/src/content/docs/cli/transforms/index.md @@ -60,6 +60,7 @@ Transformer names can be passed as positional arguments, configured in `specs.co |-------------|-------------|-----------------| | [`contract`](/specs/cli/transforms/contract/) | `contract.ts` | TypeScript Props interface and defaults constant | | [`css`](/specs/cli/transforms/css/) | `styles.css` | CSS rules per anatomy element, with token vars and variant selectors | +| [`component-md`](/specs/cli/transforms/component-md/) | `component.md` | Single-file markdown reference — props, anatomy, layout, tokens, states, variant deltas | ## Running All Transformers