diff --git a/CLAUDE.md b/CLAUDE.md index ac58cb7f..f240b4d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ | Package | Path | Description | |---------|------|-------------| -| `@directededges/specs-schema` | `packages/schema/` | TypeScript types and JSON schema definitions for component specifications. Exports are type-only except for `DEFAULT_CONFIG`. | +| `@directededges/specs-schema` | `packages/schema/` | TypeScript types and JSON schema definitions for component specifications. Exports are type-only except for `DEFAULT_CONVENTIONS`, `DEFAULT_SETTINGS`, and `DEFAULT_PIPELINE`. | | `@directededges/specs-cli` | `packages/cli/` | CLI for design system operations: generate, scan, and fetch component specs from the Figma REST API. | ## Dependency Flow @@ -32,7 +32,9 @@ packages/ │ ├── types/ # TypeScript type definitions (source of truth) │ │ ├── index.ts # Barrel export │ │ ├── Component.ts # Top-level component spec shape -│ │ ├── Config.ts # Config interface + DEFAULT_CONFIG +│ │ ├── Conventions.ts # Conventions interface + DEFAULT_CONVENTIONS +│ │ ├── Settings.ts # Settings interface + DEFAULT_SETTINGS +│ │ ├── Pipeline.ts # Pipeline interface + DEFAULT_PIPELINE │ │ └── ... # Anatomy, Props, Element, Styles, etc. │ ├── schema/ # JSON Schema definitions (for validation) │ │ ├── component.schema.json @@ -67,7 +69,7 @@ npm run build --workspace=packages/cli # Build CLI only - **Test framework**: Vitest with globals enabled - **Path alias**: `@` → `./src` (used in CLI package) - **Deterministic output**: Same input produces identical output. No side effects in the processing pipeline. -- **Config type** (from `@directededges/specs-schema`): Controls output shape — `DETAILS`, `FORMAT_KEYS`, `FORMAT_COLOR`, `DATA_LAYOUT`, `VARIANT_DEPTH`, etc. +- **Conventions / Settings / Pipeline types** (from `@directededges/specs-schema`): `Conventions` declares facts about the Figma library (`figma.naming`, `figma.glyphs`, `figma.states`, etc. — a wrong value produces incorrect output); `Settings` controls output shape (`spec.details`, `spec.keys`, `spec.color`, `spec.layout`, `spec.variantDepth`, etc. — a different value produces different output); `Pipeline` declares `transformers` and `analyses` ## Schema Governance @@ -88,7 +90,7 @@ Each step is a separate skill; run them in order. The ADR stays `DRAFT` until al The documentation site is built with Astro (port 4323) from `site/src/content/docs/`. Content sections: - `schema/` — one page per schema type (Component, Styles, Props, etc.) -- `config/` — one page per config option (color, keys, layout, tokens, etc.) +- `settings/` — one page per convention or setting (color, keys, layout, tokens, states, etc.) - `guides/` — how-to guides for specific features (slot constraints, variant depth, token format, etc.) - `cli/` — CLI overview, getting started, and per-command reference - `overview/` — product overview, licensing, releases diff --git a/ONBOARDING.md b/ONBOARDING.md index b4b68ba3..b9966e0d 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -13,7 +13,7 @@ ## Role -You are setting up Specs CLI for a user in the current working directory. Specs CLI generates component specifications from a Figma design system. Your job is to walk the user through the setup interactively, make every non-obvious decision *with* the user (not for them), and leave them with a working `specs.config.yaml`, a populated `.env`, and a generated spec file. +You are setting up Specs CLI for a user in the current working directory. Specs CLI generates component specifications from a Figma design system. Your job is to walk the user through the setup interactively, make every non-obvious decision *with* the user (not for them), and leave them with a working `config/` directory (`conventions.yaml`, `settings.yaml`, `pipeline.yaml`), a populated `.env`, and a generated spec file. You must follow this document **top to bottom**. Do not skip steps. Do not batch multiple steps before checking in. After each numbered step, verify the step succeeded before moving on. @@ -33,7 +33,13 @@ If they say no, ask them to `cd` and restart. Do not proceed. ### P2. Confirm the directory is empty or intended -Run `ls -la`. If the directory has any of: `specs.config.yaml`, `specs.config.json`, `.env`, `data/`, `specs/` — assume a previous setup exists: +Run `ls -la`. If the directory has a `specs.config.yaml` or `specs.config.json`, that's a **pre-split configuration** (ADR-071). The CLI refuses to read it — `specs init` will refuse too — so it must be converted first: + +> "This directory has a `specs.config.yaml` from an earlier Specs CLI. I can run `specs migrate config` to convert it — it writes `config/conventions.yaml`, `config/settings.yaml`, and `config/pipeline.yaml`, and renames the old file to `.migrated`. Or should I stop so you can pick a different directory?" + +If they choose migration, continue to Step 1 to install the CLI, then run `specs migrate config` in place of Step 2 and show them the three generated files. Resume from Step 6 (secrets) — the migrated files already carry their sources and settings, so confirm rather than re-ask. + +If the directory has any of: `config/`, `.env`, `data/`, `specs/` — assume a previous setup exists: > "This directory already has ``. Should I (a) overwrite, (b) merge/resume, or (c) stop so you can pick a different directory?" @@ -70,13 +76,21 @@ Record which choice was made and use it consistently for the rest of the session ## Step 2. Scaffold the config -Run `specs init` (or `npx @directededges/specs-cli init`). Verify `specs.config.yaml` appears in the current directory. +Run `specs init` (or `npx @directededges/specs-cli init`). Verify a `config/` directory appears holding three files: `conventions.yaml`, `settings.yaml`, and `pipeline.yaml`. + +Each file answers one question: + +- `config/conventions.yaml` — **facts about the Figma library** (naming patterns, state classification). A wrong value here produces *incorrect* output. +- `config/settings.yaml` — **choices about the run** (sources, format, file layout). A different value here produces merely *different* output. This is where most of the setup below goes. +- `config/pipeline.yaml` — **work to run over the specs** (transformers, analyses). Scaffolded fully commented; leave it alone during onboarding. Tell the user: -> "I've created `specs.config.yaml` with defaults. We'll fill in the sections together now." +> "I've created a `config/` directory with three files: `conventions.yaml` (facts about your Figma library), `settings.yaml` (choices about this run — most of our setup goes here), and `pipeline.yaml` (optional downstream work — we'll skip it today). We'll fill in the sections together now." + +If `specs init` refuses because it found a `specs.config.yaml`, you skipped P2 — go back and run `specs migrate config` first. -If the user is in VS Code, they can click to open it while you work. In terminal, they can `cat specs.config.yaml` at any time. +If the user is in VS Code, they can click to open the files while you work. In terminal, they can `cat config/settings.yaml` at any time. --- @@ -134,7 +148,7 @@ Ask (default to `library`, pre-filled if your UI supports it): > - `RDS UI Kit` → I'd save it as `rdsUiKit` > - `library` → stays `library` > -> Source names become keys in `specs.config.yaml`, so I'll camelCase whatever you give me to keep it YAML-safe." +> Source names become keys in `config/settings.yaml`, so I'll camelCase whatever you give me to keep it YAML-safe." Take whatever the user provides and convert to camelCase for the YAML key. Preserve spaces/original casing only in explanatory comments, not as the actual key. @@ -144,72 +158,88 @@ Ask: > "Do you have a separate tokens/foundations file to pull from? Many teams keep foundations (colors, spacing, type) in a file distinct from components." -Repeat 4a–4b for each additional source. Every source gets the full data array — we always fetch `file`, `variables`, and `styles`. It's marginally more bandwidth on files that don't need `file`, but it keeps the flow simple and nothing downstream complains. +Repeat 4a–4b for each additional source. Every source gets the full `fetch` list — we always fetch `file`, `variables`, and `styles`. It's marginally more bandwidth on files that don't need `file`, but it keeps the flow simple and nothing downstream complains. -Typical result: +Typical result (in `config/settings.yaml`): ```yaml -sources: - library: - key: - data: [file, variables, styles] - foundations: - key: - data: [file, variables, styles] +data: + directory: ./data + sources: + library: + key: + fetch: [file, variables, styles] + foundations: + key: + fetch: [file, variables, styles] ``` -Write the full `sources:` block to `specs.config.yaml` now. Show the user what you wrote. +Write the full `data.sources:` block to `config/settings.yaml` now, replacing the empty `sources: {}` stub `specs init` wrote. Show the user what you wrote. --- ## Checkpoint — full config reference -At this point, `specs.config.yaml` should contain everything below. Every knob is either set by the user (**SOURCE**), silently defaulted by `specs init` (**DEFAULT**), asked in Step 5 (**ASKED-5x** — tagged as Essentials or Complete), or intentionally absent because the absence itself disables the feature (**OMITTED**). +At this point, the `config/` directory should contain everything below. Every knob is either set by the user (**SOURCE**), silently defaulted by `specs init` (**DEFAULT**), asked in Step 5 (**ASKED-5x** — tagged as Essentials or Complete), or intentionally absent because the absence itself disables the feature (**OMITTED**). This is both a checklist (so the walkthrough's coverage can be audited) and an accurate snapshot of the file state before we tune anything. +**`config/conventions.yaml`** — facts about the Figma library. The onboarding flow doesn't ask about these: the defaults suit a first setup, and getting them right means knowing how the library is authored, which the user can tune later. + ```yaml -dataDirectory: ./data # DEFAULT -outputDirectory: ./specs # DEFAULT - -sources: # SOURCE — set in Step 4 - : - key: - data: [file, variables, styles] - -config: - processing: - subcomponents: # DEFAULT — subcomponent detection on with a common pattern - match: - - '{C} / _ / {S}' - # scope: NESTED # OMITTED — optional; defaults to NESTED when subcomponents block is present +figma: + subcomponents: # DEFAULT — subcomponent detection on with a common pattern + match: + - '{C} / _ / {S}' + # scope: NESTED # OMITTED — optional; defaults to NESTED when the block is present # exclude: ['...'] # OMITTED — optional - # glyphNamePattern: 'DS Icon /' # OMITTED — opt-in; absence = glyph detection off - # codeOnlyPropsPattern: '...' # OMITTED — opt-in; absence = code-only prop extraction off - # slotConstraints: false # OMITTED — opt-in advanced feature - variantDepth: 9999 # DEFAULT — unlimited - details: LAYERED # DEFAULT — compact diff-from-default output - # inferNumberProps: false # OMITTED — opt-in advanced feature - format: - output: JSON # ASKED-5a (Essentials) → YAML | JSON - keys: SAFE # ASKED-5b (Essentials) → SAFE | CAMEL | KEBAB | SNAKE | PASCAL | TRAIN - tokens: TOKEN # ASKED-5d (Complete) → TOKEN | TOKEN_NAME | FIGMA_NAME | TOKEN_FIGMA_EXTENSIONS | CUSTOM - layout: LAYOUT # ASKED-5e (Complete) → LAYOUT | PARENT_CHILDREN | BOTH - include: - invalidVariants: false # ASKED-5f (Complete) - invalidCombinations: true # ASKED-5f (Complete) - # emptyVariants: false # OMITTED — opt-in edge case - -output: # ASKED-5c (Essentials) — file layout on disk - splitComponents: false # ASKED-5c - splitConcerns: false # ASKED-5c - useSubfolders: false # ASKED-5c - # defaultFormat: yaml # OMITTED — stdout-only knob; the `--format` CLI flag overrides per command + # naming: NONE # OMITTED — opt-in; the file's naming convention (SENTENCE | TITLE) + # glyphs: # OMITTED — opt-in; absence = glyph detection off + # match: 'DS Icon Glyph / {i}' + # codeOnlyProps: # OMITTED — opt-in; absence = code-only prop extraction off + # match: 'Code only props' + # images: { ... } # OMITTED — opt-in; absence = image detection off + # instanceExamples: { ... } # OMITTED — opt-in (Pro) + slotConstraints: false # DEFAULT — opt-in advanced feature + # inferNumberProps: false # OMITTED — opt-in advanced feature + # states: { ... } # OMITTED — opt-in; classifies variant props as semantic states ``` -If the user later wants to enable anything marked **OMITTED**, point them at the [Configuration Reference](https://www.specsplugin.com/config/) — those features are opt-in because either (a) absence means the feature is off (`subcomponents`, `glyphNamePattern`, `codeOnlyPropsPattern`), or (b) they're advanced tuning knobs rarely needed in a first setup (`slotConstraints`, `inferNumberProps`, `emptyVariants`, `defaultFormat`). +**`config/settings.yaml`** — choices about the run: -**Note on `output:`**: `specs init` today does not write this section. If it's missing after Step 2, Claude will create it in sub-step 5c with the defaults shown. If a future `specs init` adds it, the checkpoint still matches. +```yaml +author: # SOURCE — replace the placeholder with the user's name + +data: + directory: ./data # DEFAULT + sources: # SOURCE — set in Step 4 + : + key: + fetch: [file, variables, styles] + +spec: + directory: ./specs # DEFAULT + format: JSON # ASKED-5a (Essentials) → YAML | JSON + keys: SAFE # ASKED-5b (Essentials) → SAFE | CAMEL | KEBAB | SNAKE | PASCAL | TRAIN + tokens: TOKEN # ASKED-5d (Complete) → TOKEN | TOKEN_NAME | FIGMA_NAME | TOKEN_FIGMA_EXTENSIONS | CUSTOM + layout: LAYOUT # ASKED-5e (Complete) → LAYOUT | PARENT_CHILDREN | BOTH + color: HEX # DEFAULT + variantDepth: 9999 # DEFAULT — unlimited + details: LAYERED # DEFAULT — compact diff-from-default output + # invalidVariants: false # ASKED-5f (Complete) + # invalidCombinations: true # ASKED-5f (Complete) + # emptyVariants: false # OMITTED — opt-in edge case + splitComponents: true # ASKED-5c (Essentials) — file layout on disk + splitConcerns: true # ASKED-5c + useSubfolders: true # ASKED-5c + +# assets: # OMITTED — opt-in shared-assets location +# directory: ./assets +``` + +**`config/pipeline.yaml`** — work to run over the specs. Scaffolded fully commented (**OMITTED** throughout); nothing in onboarding touches it. + +If the user later wants to enable anything marked **OMITTED**, point them at the [Settings Reference](https://www.specsplugin.com/settings/) — those features are opt-in because either (a) a convention that isn't declared isn't processed — absence *is* the off-switch (`glyphs`, `codeOnlyProps`, `images`, `instanceExamples`, `states`), or (b) they're advanced tuning knobs rarely needed in a first setup (`slotConstraints`, `inferNumberProps`, `emptyVariants`). --- @@ -220,15 +250,15 @@ Based on the path chosen in Step 3: - **Essentials** → ask **5a, 5b, 5c**, then skip to Step 6. - **Complete** → ask **5a through 5f**. -For every prompt below: ask, record the answer, write it to `specs.config.yaml`. Do **not** dump all of these at once. If the user says "just use defaults" on any individual question, accept that and move on. If an Essentials user asks about a Complete-only knob mid-flow, jump to it, apply it, and return. +For every prompt below: ask, record the answer, write it to `config/settings.yaml`. Do **not** dump all of these at once. If the user says "just use defaults" on any individual question, accept that and move on. If an Essentials user asks about a Complete-only knob mid-flow, jump to it, apply it, and return. -### 5a. `format.output` — JSON or YAML? +### 5a. `spec.format` — JSON or YAML? > "Should generated specs be in **YAML** (easier for humans to read and diff in PRs) or **JSON** (smaller, strict, better for programmatic consumption)?" > > Default: **JSON**. Recommend **YAML** if specs will be reviewed in PRs or eyeballed for debugging; stick with **JSON** if they'll only be consumed by code. -### 5b. `format.keys` — naming style for keys +### 5b. `spec.keys` — naming style for keys > "Your Figma component names, element names, and prop names often contain spaces (e.g., component `Text Input`, element `Form Label`, prop `Show Icon`). How should the generated specs name them? > @@ -243,9 +273,9 @@ For every prompt below: ask, record the answer, write it to `specs.config.yaml`. > > Default: **SAFE**. Recommended for most codegen pipelines: **CAMEL**." -### 5c. `output` modes — how files are split on disk +### 5c. `spec` split flags — how files are split on disk -> "How should generated specs be organized on disk? Three switches in the top-level `output:` section of `specs.config.yaml`: +> "How should generated specs be organized on disk? Three switches in the `spec:` section of `config/settings.yaml`: > > - **splitComponents** — one file per component (`button.yaml`, `card.yaml`) instead of one big `library.yaml`. Good for component-level PRs and review ownership. > - **splitConcerns** — separate the API (anatomy, props) from variants into distinct files (`api.yaml` + `variants.yaml`). Good for API-first development or backend/frontend team separation. @@ -254,23 +284,22 @@ For every prompt below: ask, record the answer, write it to `specs.config.yaml`. > Common presets: > > - **One file, done** → all three **false** (default). +> - **Everything downstream** (`transform`, `analyze`, `render`) → leave all three at their `true` default. > - **Component-level PRs** → `splitComponents: true`, others false. > - **Large library, namespaced** → `splitComponents: true` + `useSubfolders: true`. > - **API-first / backend-frontend split** → `splitComponents: true` + `splitConcerns: true`. > > Which preset (or custom combination)?" -Write an `output:` section to `specs.config.yaml` reflecting the answer: +Update the three flags in the `spec:` section of `config/settings.yaml` in place — `specs init` already wrote them as `false`: ```yaml -output: - splitComponents: - splitConcerns: - useSubfolders: +spec: + splitComponents: # default: true + splitConcerns: # default: true + useSubfolders: # default: true ``` -If `specs init` didn't already write an `output:` section, add it. If it did, update the existing keys in place. - --- **Essentials path stops here.** Skip to Step 6. @@ -279,7 +308,7 @@ If `specs init` didn't already write an `output:` section, add it. If it did, up --- -### 5d. `format.tokens` — how design tokens are referenced +### 5d. `spec.tokens` — how design tokens are referenced > "When a component uses a design token (e.g., `color.brand.primary`), how should the spec reference it? > @@ -291,7 +320,7 @@ If `specs init` didn't already write an `output:` section, add it. If it did, up > > Recommended: **TOKEN**." -### 5e. `format.layout` — layout representation +### 5e. `spec.layout` — layout representation > "Every spec has a flat `anatomy` map and a flat `elements` map. This setting controls **where the tree structure lives** — in a separate `layout` array, or attached to each element as `parent`/`children` fields. Here's a simple `Button` with two children (`icon`, `label`) shown three ways: > @@ -331,7 +360,7 @@ If `specs init` didn't already write an `output:` section, add it. If it did, up > > Recommended: **LAYOUT** for codegen (concise tree, easy to walk); **PARENT_CHILDREN** if downstream tooling works element-by-element and doesn't want to parse a tree." -### 5f. `include.invalidVariants` / `include.invalidCombinations` +### 5f. `spec.invalidVariants` / `spec.invalidCombinations` > "Your component set may have 'invalid' combinations — variants that aren't wired up, or prop combos that shouldn't exist. Two switches: > @@ -340,7 +369,9 @@ If `specs init` didn't already write an `output:` section, add it. If it did, up > > Use defaults unless you know you want otherwise." -After this section, show the user the full `config:` block you wrote and ask them to confirm before moving on. +Both are commented out in the scaffolded `config/settings.yaml`; uncomment and set them only when the answer differs from the default. + +After this section, show the user the full `config/settings.yaml` you wrote and ask them to confirm before moving on. --- @@ -351,7 +382,7 @@ After this section, show the user the full `config:` block you wrote and ask the 1. **Create `.gitignore` BEFORE creating `.env`.** Ensure `.gitignore` contains a line with `.env`. If `.gitignore` exists without that line, append it. If it doesn't exist, create it with `.env` as its first entry. 2. **Never echo the token back to the conversation.** If the user pastes their PAT inline, acknowledge receipt ("got it, writing now") without repeating the value, and write it directly to `.env`. 3. **Prefer letting the user paste the token into the file themselves.** Create `.env` with the key stubs (`FIGMA_TOKEN=` and optionally `SPECS_LICENSE_KEY=`), then instruct them to open `.env` and paste their values. This is the safer default. -4. **Never commit or stage `.env`.** If the user asks you to commit setup files, stage `specs.config.yaml` and `.gitignore` only; skip `.env`. +4. **Never commit or stage `.env`.** If the user asks you to commit setup files, stage the `config/` directory and `.gitignore` only; skip `.env`. 5. **Never log, cat, or grep `.env` contents back to the chat.** If you need to verify the file is populated, check the file exists and is non-empty, not its contents. ### 6a. Figma Personal Access Token @@ -389,7 +420,7 @@ Ask: Run `specs fetch`. Verify: 1. Command exits 0. -2. Files exist in `dataDirectory` (default `./data/`) matching `.file.json`, `.variables.json`, `.styles.json` for each source (only the data types they configured). +2. Files exist in `data.directory` (default `./data/`) matching `.file.json`, `.variables.json`, `.styles.json` for each source (only the fetch kinds they configured). 3. The files are non-empty. If `fetch` fails with an auth error, the token is wrong or missing scopes. Ask the user to regenerate it with all the scopes listed in 6a. @@ -412,7 +443,7 @@ For each additional source, use `--source `: specs scan --source ``` -No `-o` flag needed. `scan` writes the manifest to `data/.manifest.md` by default (`dataDirectory` from config). Let the default do its work — downstream commands and the checkpoint below both assume it. +No `-o` flag needed. `scan` writes the manifest to `data/.manifest.md` by default (`data.directory` from `config/settings.yaml`). Let the default do its work — downstream commands and the checkpoint below both assume it. Verify each manifest file exists at `data/.manifest.md` and has at least one component listed. Show the user the manifest contents (first ~20 lines is fine). @@ -442,12 +473,12 @@ Run: specs generate ``` -No arguments needed — `generate` reads the default manifest (`data/.manifest.md`, `library` alias preferred) and writes output to `outputDirectory` from config (default `./specs`). If the user has multiple sources with components, run it once per source by passing the explicit manifest path: `specs generate data/.manifest.md`. +No arguments needed — `generate` reads the default manifest (`data/.manifest.md`, `library` alias preferred) and writes output to `spec.directory` from `config/settings.yaml` (default `./specs`). If the user has multiple sources with components, run it once per source by passing the explicit manifest path: `specs generate data/.manifest.md`. Verify: 1. Command exits 0. -2. The output files exist in `./specs/` (or the configured `outputDirectory`) and are non-empty. +2. The output files exist in `./specs/` (or the configured `spec.directory`) and are non-empty. 3. Peek at the first ~30 lines of one file to sanity-check: it should start with `components:` and show at least one component's structure. --- @@ -456,7 +487,7 @@ Verify: Summarize what exists now: -- `specs.config.yaml` — project config, **safe to commit**. +- `config/` — `conventions.yaml`, `settings.yaml`, `pipeline.yaml` — project config, **safe to commit**. - `.gitignore` — contains `.env`. - `.env` — secrets, **never commit**. - `data/` — raw Figma payloads. Usually gitignored; ask the user if unsure. @@ -491,7 +522,7 @@ During this flow, never: This flow is UI-agnostic — it works whether the user is in terminal Claude Code or the VS Code panel. -- **VS Code panel users** can click file links you produce (e.g., `specs.config.yaml`) to open them in a tab. They can edit config or `.env` in place while you continue; just read the file again before your next tool call. +- **VS Code panel users** can click file links you produce (e.g., `config/settings.yaml`) to open them in a tab. They can edit config or `.env` in place while you continue; just read the file again before your next tool call. - **Terminal users** won't see files appear visually. After writing any config file, proactively offer to `cat` it so they can review without opening an editor. Regardless of UI, for the `.env` step, prefer the "create stub file, user pastes secrets themselves" pattern. It's the one behavior difference worth emphasizing. @@ -501,5 +532,5 @@ Regardless of UI, for the `.env` step, prefer the "create stub file, user pastes ## See also - [Getting Started](https://www.specsplugin.com/cli/getting-started/) — the manual walkthrough -- [Configuration Reference](https://www.specsplugin.com/config/) — full option docs +- [Settings Reference](https://www.specsplugin.com/settings/) — full convention and setting docs - [CLI Overview](https://www.specsplugin.com/cli/) — per-command flags and behavior diff --git a/README.md b/README.md index c27c692e..36135bd8 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ Command-line interface (CLI) for generating component specifications from Figma ```sh # 1. Install cli globally to run with the command `specs` npm install -g @directededges/specs-cli -# 2. Initialize a specs.config.yaml file +# 2. Initialize the config/ directory (conventions.yaml, settings.yaml, pipeline.yaml) specs init -# 3. Edit the config for your Figma file key and preferred settings +# 3. Edit config/settings.yaml for your Figma file key and preferred settings # 4. Set up an .env file with a Figma PAT and – if subscribed - license key # 5. Fetch raw Figma data (file, variables, styles) specs fetch @@ -65,7 +65,7 @@ specs generate Helpful documentation includes: - [Overview](https://www.specsplugin.com/cli/) - [Getting started](https://www.specsplugin.com/cli/getting-started/) -- [Configuration file](https://www.specsplugin.com/cli/configuration/) details +- [Configuration reference](https://www.specsplugin.com/settings/) for conventions and settings - Per [command](https://www.specsplugin.com/cli/commands/) instructions and flags @@ -80,8 +80,8 @@ npm install @directededges/specs-schema Exports include: - [JSON Schema](packages/schema/schema/root.schema.json) — the canonical schema for component spec output -- [TypeScript types](packages/schema/types/) — complete type definitions for all schema entities (`Component`, `Config`, `Styles`, `Element`, `AnyProp`, etc.) -- `DEFAULT_CONFIG` — a runtime configuration object controlling output shape (format, token resolution, variant depth, etc.) +- [TypeScript types](packages/schema/types/) — complete type definitions for all schema entities (`Component`, `Conventions`, `Settings`, `Pipeline`, `Styles`, `Element`, `AnyProp`, etc.) +- `DEFAULT_CONVENTIONS`, `DEFAULT_SETTINGS`, `DEFAULT_PIPELINE` — runtime default objects: a library declaring no conventions, run settings controlling output shape (format, token resolution, variant depth, etc.), and an empty pipeline Learn more in the [Schema docs](https://www.specsplugin.com/schema/), including details on each property including component, variants, styles, props and more. diff --git a/adr/030-subcomponent-refs.md b/adr/030-subcomponent-refs.md index 8cb9af0c..5924a122 100644 --- a/adr/030-subcomponent-refs.md +++ b/adr/030-subcomponent-refs.md @@ -11,7 +11,7 @@ ## Context -When a component contains subcomponents, anatomy items and elements that are instances of those subcomponents currently record `instanceOf` as a plain formatted string (e.g., `"egdsRadioButtonFormLabel"`). This is opaque — a consumer cannot distinguish a subcomponent reference from an arbitrary component name, nor can tooling follow the relationship programmatically. +When a component contains subcomponents, anatomy items and elements that are instances of those subcomponents currently record `instanceOf` as a plain formatted string (e.g., `"dsRadioButtonFormLabel"`). This is opaque — a consumer cannot distinguish a subcomponent reference from an arbitrary component name, nor can tooling follow the relationship programmatically. The `$ref` pattern already exists in this package: `ElementTypeRef` uses `{ $ref: string }` on `AnatomyElement.type` to express a machine-followable pointer to an external definition. The same pattern should apply to `instanceOf` when the target is a sibling subcomponent within the same spec. diff --git a/adr/071-config-conventions-split.md b/adr/071-config-conventions-split.md new file mode 100644 index 00000000..c0a549f4 --- /dev/null +++ b/adr/071-config-conventions-split.md @@ -0,0 +1,734 @@ +# ADR 071: Separate Library Conventions from Tooling Settings + +**Branch**: `071-config-conventions-split` +**Created**: 2026-08-18 +**Status**: ACCEPTED +**Summary**: `Conventions`, `Settings` and `Pipeline` replace `Config`, separating library facts from run choices and declared work. +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`Config` is one flat structure with four groups — `processing`, `format`, `include`, and `transformers` — and every member is a peer. In practice its members answer two unrelated questions. + +**What is true about this Figma library?** Get one of these wrong and the output is *incorrect*: + +```yaml +processing: + glyphNamePattern: "DS Icon Glyph / {i}" # icon assets go undetected if wrong + codeOnlyPropsPattern: "Code only props" # code-only props go unextracted if wrong + subcomponents: + match: + - "{C} / {S}" # subcomponents go undiscovered if wrong + states: + hover: + prop: state # unclassified, this prop emits as a data-* attribute + value: hover + active: + prop: state # two concepts share one prop + value: pressed # the concept name is not the Figma value + disabled: + prop: disabled # boolean prop — no value to match + readonly: + prop: readOnly # concept casing is not prop casing + invalid: + prop: validation # neither name resembles the other + value: invalid +format: + figmaKeys: SENTENCE # name reversal is undefined if wrong +``` + +**What do I want out of this run?** Get one of these "wrong" and the output is merely *different*: + +```yaml +processing: + variantDepth: 9999 + inferNumberProps: true +format: + output: YAML + color: HEXA +include: + emptyVariants: false +``` + +The two are indistinguishable in the type, and three consequences follow: + +- **The first kind cannot be shared.** Every consumer reading the same library — the CLI, the plugin, a second workspace, a CI job — re-declares the same facts. Drift between copies produces silently different specs from one Figma file +- **`ResolvedConfig` conflates two kinds of absence.** A missing member of the second kind means "use the default." A missing member of the first kind means "this library has no such convention" — a different claim with different consequences +- **A third kind exists in one consumer and has nowhere to live.** The plugin persists values that never reach a spec at all, mixed into the same map as the two above + +--- + +## Decision Drivers + +- **Naming governance (Constitution VI)**: no code-platform consensus exists for this concept, so rule 3 applies — terms are chosen for consumer clarity +- **No abbreviations**: full, unabbreviated words in the public contract +- **Minimal, stable, intentional public API (Constitution III)**: the split must express a genuine shared concept, not one consumer's arrangement +- **Type ↔ schema symmetry (Constitution I)**: regrouping applies to both artifacts in the same change +- **No logic (Constitution II)**: the schema may classify members; it may not resolve, merge, or load them +- **Absence must mean one thing per member** +- **Serviceable by every consumer equally**: the CLI, the plugin, and the generator each hold configuration differently + +--- + +## Options Considered + +Nine decisions, taken separately: the classification rule, the name of each side (independently), whether the library side is scoped to Figma, the structural shape, whether any block straddles the line, the shape of name-based conventions, what replaces block presence as the detection switch, what becomes of `Config`, and the workspace artifact layout. + +--- + +## Decision 1 — The classification rule + +### Option 1A: Substitution — what a different team would have to keep *(Selected)* + +**If a different team pointed this tool at the same Figma file, which values would they have to keep?** + +- Values they **must keep** are **conventions**. They describe the library, and changing one produces *incorrect* output +- Values they **may freely change** are **settings**. They describe the run, and changing one produces *different* output + +A residue falls outside both: values that never reach a spec at all, so two identical specs can be produced with each set differently. They belong to whichever consumer holds them and stay out of the shared contract (see Downstream Impact). + +**Pros**: + +- Mechanical and reproducible — every current member classifies without argument +- Explains the sharing consequence directly: conventions are the values worth declaring once per library +- Gives absence one meaning per side: a missing setting is defaulted, a missing convention is declared absent +- Distinguishes a violable side from an inviolable one — a setting cannot be wrong, a convention can + +**Cons / Trade-offs**: + +- Three blocks contain members from both sides (Decision 6) +- A member could in principle be a convention for one library and a preference for another; no current member is + +--- + +### Option 1B: Split by who authors it — designer versus engineer *(Rejected)* + +**Rejected because**: it encodes an org chart, not a property of the data. The same person often sets both, and the semantic classification of variant props is authored by whoever knows the library — which may be either. + +--- + +### Option 1C: Split by rate of change — stable versus per-run *(Rejected)* + +**Rejected because**: it describes a symptom rather than a cause. `variantDepth` and `format.output` are typically set once and never touched, yet nothing about them describes the library. + +--- + +## Decision 2 — What to call the library side + +| Candidate | For | Against | +|---|---|---| +| **`conventions`** *(Selected)* | Established term for a library-wide agreement about naming and meaning; already the word this package's own documentation uses for state classification and name patterns | Slightly formal; says nothing about *whose* conventions (see Decision 3) | +| `authoring` | Accurate — these describe how the library was authored | Names an activity, not a thing; reads oddly as a noun holding patterns | +| `library` | Short, familiar | Ambiguous in a schema whose root artifact already describes a component within a library | +| `source` | Accurate in the data-flow sense | Already load-bearing: `metadata.source` names a Figma node, `images.sourceProps` names props carrying image sources | +| `vocabulary` | Fits the state classification | Wrong for `match`/`exclude` patterns, which are not terms | +| `declarations` | Emphasizes that these are asserted, not derived | Verbose, and every configuration value is a declaration | +| `facts` | Precisely what they are | Unidiomatic in configuration; invites "whose facts?" | + +**Selected: `conventions`.** It is the only candidate that covers both halves of the contents — the naming patterns and the semantic classifications — without colliding with an existing term in the schema. + +`states` is the clearest member of the category. In a real catalog's declaration, five of ten entries could not be recovered by any rule: two concepts share one prop (`hover` and `active` both read `state`), a concept name differs from the Figma value that activates it (`active` ← `pressed`), a concept's casing differs from its prop's (`readonly` ← `readOnly`), and one pair resembles nothing (`invalid` ← `validation`). Every one of them is an agreement, and a different team reading the same file would have to keep all five. + +--- + +## Decision 3 — Whether the library side is scoped to Figma + +Every member classified as a convention today describes **Figma authoring**: layer-name patterns, component naming, variant props, the file's own naming convention. Nothing prevents code-side conventions from arriving later — how generated names are cased, what a platform calls its props. + +### Option 3A: `conventions.figma` — namespaced by source *(Selected)* + +```yaml +# conventions.yaml +figma: + naming: SENTENCE + glyphs: + match: "DS Icon Glyph / {i}" + codeOnlyProps: + match: "Code only props" + images: + backgroundImage: true + match: "DS Image" + sourceProps: + - Image + subcomponents: + scope: PAGE + match: + - "{C} / {S}" + - "{C} / _ / {S}" + exclude: + - "{C} / Examples / {S}" + instanceExamples: + scope: PAGE + match: + - "{C}*" + parentNames: + - Examples + slotConstraints: true + inferNumberProps: true + states: + hover: + prop: state + value: hover + active: + prop: state + value: pressed + focus-within: + prop: focused + disabled: + prop: disabled + readonly: + prop: readOnly + invalid: + prop: validation + value: invalid + selected: + prop: selected + indeterminate: + prop: selected + value: indeterminate +``` + +```yaml +# settings.yaml +author: Nathan Curtis +data: + directory: ./data + sources: + library: + key: + fetch: + - file + - variables + - styles + - icons +spec: + directory: ./specs + format: YAML + keys: CAMEL + layout: LAYOUT + tokens: TOKEN + color: HEXA + variantDepth: 9999 + details: LAYERED + collapsePrimitiveWrapper: true + invalidVariants: false + invalidCombinations: true + emptyVariants: false + defaultSlotContent: true + splitComponents: true + splitConcerns: true + useSubfolders: true +assets: + directory: ./assets +``` + +```yaml +# pipeline.yaml +transformers: + - name: react + - name: css + - name: contract +analyses: + - name: dependencies +``` + +**Pros**: + +- **Symmetric.** Both sides are nameable, referenceable, and independently validatable. A classification where only one side has a handle is half a classification +- Either side can be held, shared, published, validated, or diffed on its own, with nothing to unwrap first +- **No parent to imply they are one thing.** The ADR's whole claim is that these have different owners, lifecycles, sharing models, and resolution rules; a container immediately argues the opposite +- The existing group names keep their meaning + +**Cons / Trade-offs**: + +- Every member's path changes; every consumer breaks at compile time +- A consumer wanting both halves passes or holds two values instead of one + +--- + +### Option 5B: A `conventions` block beside the existing groups *(Rejected)* + +**Rejected because**: it is asymmetric. Conventions become addressable and settings do not, leaving "the settings" a category with no handle — unnameable, unvalidatable alone, and unable to be stored separately. + +--- + +### Option 5C: Classify in documentation only *(Rejected)* + +**Rejected because**: a classification a consumer cannot address is one it cannot act on. The sharing problem is unsolved, and nothing prevents the next member from landing on the wrong side. + +--- + +## Decision 6 — Members that look like settings but are not + +Several members read as run choices at first glance: `scope` (where to search), `backgroundImage` (whether to read container fills), `slotConstraints` (whether to consolidate slot constraints), and `inferNumberProps` (whether numeric-looking text props become number props). + +### Option 6A: Test each member; all four are conventions *(Selected)* + +| Member | If a different team set it differently | Verdict | +|---|---|---| +| `subcomponents.scope` | A library keeping subcomponents on the page yields none under `NESTED` | Convention | +| `instanceExamples.scope` | A library keeping examples on other pages yields none under `PAGE`; a `FILE` search of a single-page library can match foreign frames | Convention | +| `images.backgroundImage` | A library expressing images as container fills loses them when false | Convention | +| `slotConstraints` | A library authoring slot constraints as code-only props loses declared constraint data when false | Convention | +| `inferNumberProps` | A library authoring numeric props as Figma `TEXT` with numeric defaults gets `StringProp` for genuinely numeric props when false — worse typing, not different typing | Convention | + +Each states how the library is organized or authored — **where** its assets live, **how** it expresses images, **how** it declares constraints, **how** it types numbers. None can be chosen freely. + +**`collapsePrimitiveWrapper` is the one that stays a setting**, and the distinction is worth stating: that a wrapper around a lone text or glyph is meaningless *is* a library fact, but keeping it is faithful to Figma rather than wrong. Stripping it is a normalization choice, so both outputs are correct and the substitution test passes it to settings. + +**Pros**: + +- **No feature is split.** All five name-based features live wholly in `conventions.figma` and are read in one place +- Consistent with Decision 8: a declared convention declares the capability, and `scope` is part of the declaration rather than a dial on top of it +- Leaves `settings` holding only members that cannot be wrong +- Removes the awkward case where `scope` and `match`, authored together and meaningless apart, would sit in different artifacts + +**Cons / Trade-offs**: + +- `backgroundImage`, `slotConstraints`, and `inferNumberProps` read as toggles and are classified as declarations; their names would be clearer as statements about the library (renames deliberately not taken here) +- A team wanting a narrower search for speed cannot express it in configuration — and would get different output if they could, which is the point. Per-run narrowing belongs to the invocation, as in Decision 8 + +--- + +### Option 6B: Split the blocks — patterns to conventions, toggles to settings *(Rejected)* + +**Rejected because**: it fails the substitution test on every one of them. `scope` is determined by where the library puts its assets, and the wrong value produces missing or foreign matches rather than merely different output. Splitting also separates `scope` from `match`, which are authored together and meaningless apart. + +--- + +### Option 6C: Move whole blocks by majority, without testing each member *(Rejected)* + +**Rejected because**: it reaches the selected answer by accident rather than by rule, and would have carried `collapsePrimitiveWrapper` across with the others. + +--- + +## Decision 7 — The shape of name-based conventions + +Five conventions answer the same question — *which assets in the file are these?* — under three different key shapes: + +| Member | Key today | Value today | Placeholders | +|---|---|---|---| +| `glyphNamePattern` | scalar, suffixed | one pattern | `{i}` | +| `codeOnlyPropsPattern` | scalar, suffixed | one pattern | none — a literal layer name | +| `imageComponent` | scalar, prefixed | one component name | none | +| `subcomponents` | block | `match` list, `exclude` list | `{C}`, `{S}` | +| `instanceExamples` | block | `match`, `exclude`, `parentNames` lists | `{C}` | + +The suffix restates what the block already says, and the same idea is spelled three ways depending on when each member was added. + +### Option 7A: One block per named thing, keyed `match` — cardinality unchanged *(Selected)* + +Every name-based convention becomes a block named for the thing it finds, with its pattern under `match`. **What each member accepts does not change**: members that take one pattern still take one, members that take lists still take lists, and `exclude` appears only where it exists today. + +```yaml +conventions: + figma: + glyphs: + match: "DS Icon Glyph / {i}" + codeOnlyProps: + match: "Code only props" + images: + backgroundImage: true + match: "DS Image" + sourceProps: + - Image + subcomponents: + scope: PAGE + match: + - "{C} / {S}" + exclude: + - "{C} / Examples / {S}" + instanceExamples: + scope: PAGE + match: + - "{C}*" + parentNames: + - Examples +``` + +**Pros**: + +- **The suffix disappears.** Inside `conventions.figma` everything is a name pattern, so `glyphNamePattern` becomes `glyphs.match` — shorter, and it says more +- **One vocabulary.** `match` means the same thing in every member; a reader who knows `subcomponents` can read `glyphs` without checking +- **No implementation is demanded.** Nothing gains a capability, nothing needs new matching logic, and no member starts accepting input it did not accept before. The change is naming and nesting only +- **A later widening stays possible and stays a decision.** If a catalog ever needs two icon patterns, `glyphs.match` can widen — but that is a separate ADR with its own justification, not a consequence of this one + +**Cons / Trade-offs**: + +- `match` has two cardinalities across members, so the uniformity is in the key, not the type +- Five members change path on top of the regrouping +- Placeholder grammars still differ per member (`{i}`, `{C}`, `{S}`); a shared key name may imply a shared grammar, and documentation has to carry the distinction + +--- + +### Option 7B: Uniform *and* capability-complete — lists and `exclude` everywhere *(Rejected)* + +Give every member `match: string[]` and `exclude?: string[]`, so the five are interchangeable in shape and capability. + +**Rejected because**: it invents features to satisfy a symmetry. Nothing in the validation library needs a second glyph pattern, an excluded code-only-props frame, or a second image component, and each addition is matching logic to implement, test, and document. A schema that declares capability the transformers do not honor is worse than an asymmetric one that tells the truth. + +--- + +### Option 7C: Keep the scalars as they are *(Rejected)* + +**Rejected because**: it keeps a redundant suffix inside a block whose name already supplies it, and keeps one question spelled three ways. The rename costs nothing beyond the `MAJOR` already being taken, and this is the only moment it is free. + +--- + +### Option 7D: Group name-based members under a `names` sub-block *(Rejected)* + +**Rejected because**: it separates name-based conventions from semantic ones at the cost of a fifth nesting level, and the block-with-`match` shape already signals which kind a member is. + +--- + +## Decision 8 — What replaces block presence as the detection switch + +Five features are switched on today by the **presence of a block that also carries the convention**: + +```yaml +# Today — one block, two jobs +processing: + subcomponents: # present = detect subcomponents + match: # ...and here is how to find them + - "{C} / {S}" +``` + +Splitting the block separates the switch from the pattern, and the implicit "detect this" needs somewhere to go — or needs to be shown unnecessary. + +### Option 8A: Convention presence declares the capability; no switch exists *(Selected)* + +A declared convention means the library follows it, and a library that follows a convention wants it processed. There is no switch, in either half. + +```yaml +conventions: + figma: + subcomponents: + match: + - "{C} / {S}" + +settings: + processing: + subcomponents: + scope: PAGE # only genuine run choices remain +``` + +**Pros**: + +- **Nothing is invented.** No member is added, and no consumer writes a line it does not write today +- **Behavior is unchanged.** Declaring a convention detects; declaring none does not — the same two states the presence rule produces now +- **Decision 1 holds.** Convention presence does not *drive* the run, it *enables* it. What a run does with a declared capability is the run's business, and belongs to the invocation +- **Two of five features stay whole.** `glyphs` and `codeOnlyProps` have no genuine run choices, so they appear on the conventions side only — no settings block exists solely to hold a toggle +- **No half-states.** A switch would introduce "declared but off" and "on but undeclared"; neither is representable + +**Cons / Trade-offs**: + +- Skipping a feature for one run is not expressible in configuration. It belongs to the invocation — a CLI flag — and is out of scope here +- The plugin's five booleans gain no contract counterpart and remain application preferences, which is the correct home for a panel checkbox that skips work + +--- + +### Option 8B: An explicit `detect` setting per feature *(Rejected)* + +Add `detect?: boolean` to each feature's settings block, defaulting to `true`. + +**Rejected because**: it invents a capability to fill a hole the split created. Nobody following a convention wants it ignored, so `detect: false` answers a question no library asks — the same reasoning that rejects Option 7B, and it applies with equal force here. It also costs two settings blocks (`glyphs`, `codeOnlyProps`) that would exist only to hold a toggle, doubles the locations to consult per feature, and introduces two half-states that must then be diagnosed. + +--- + +### Option 8C: Presence of the settings block is the switch *(Rejected)* + +**Rejected because**: it preserves the ambiguity in a worse place. Every member of those settings blocks is defaulted, so the on-state is an empty block whose only meaning is that someone typed its name, and `scope` — which has a default — becomes load-bearing by proximity. + +--- + +## Decision 9 — What becomes of `Config` and `Config.ts` + +### Option 9A: Retire both; `Conventions.ts` and `Settings.ts` replace them *(Selected)* + +No container type is introduced. `Config.ts` splits into two files, and `Metadata` carries both halves as siblings: + +```yaml +metadata: + conventions: + figma: + keys: SENTENCE + settings: + format: + color: HEXA +``` + +**Pros**: + +- **Avoids a collision the container would create.** `types/` is flat and already holds `PropConfigurations.ts`; a `Configuration.ts` beside it invites a misread on every import +- **One concept per file**, matching the directory's existing pattern — `Anatomy.ts`, `Props.ts`, `Variant.ts` +- **Type files mirror artifacts.** `Conventions.ts` ↔ `conventions.yaml`, `Settings.ts` ↔ `settings.yaml` (Decision 10) +- **Retires the vague word entirely.** "Config" is the term whose ambiguity let two kinds of value share one structure; naming nothing that removes the temptation to put a third kind there +- Each half is separately diffable in `metadata` — the drift check the linter wants compares `metadata.conventions` directly, with nothing to unwrap + +**Cons / Trade-offs**: + +- `metadata` gains two keys where it had one, and nothing states that the two were resolved together for one run — beyond their appearing in the same `metadata` block for the same output +- A consumer that genuinely wants "the whole configuration" declares a local pair type +- `DEFAULT_CONFIG` becomes `DEFAULT_SETTINGS` in `Settings.ts`, joined by `DEFAULT_PIPELINE` and `DEFAULT_CONVENTIONS` — one defaults constant per artifact. `DEFAULT_CONVENTIONS` carries only the three members that have a default; no convention *block* is defaulted, because absence is the statement that a library declares none + +--- + +### Option 9B: A `Configuration` container replacing `Config` *(Rejected)* + +**Rejected because**: it collides in a flat `types/` directory with `PropConfigurations.ts`, and it re-implies the two halves are one thing at the exact moment the ADR separates them. It also preserves a container whose only remaining job — giving `metadata` a single key — is served just as well by two keys that can be compared independently. + +--- + +### Option 9C: Keep `Config` as the container *(Rejected)* + +**Rejected because**: it keeps both the collision risk and the vague word, and the constitution already lists `Config` as a grandfathered abbreviation that new work should avoid. + +--- + +## Decision 10 — Workspace artifact layout, and where paths live + +`workspace.schema.json` (ADR 054) describes `specs.config.yaml`, so the artifact's shape is governed by this package and is in scope here. Today that one file carries three unrelated things: `dataDirectory` and `outputDirectory` at the root, a `sources` block, an `output` block, and a `config` block holding everything else. + +### Option 10A: One artifact per question; work and place are separate *(Selected)* + +``` +config/ + conventions.yaml # what the library is + settings.yaml # how output behaves, and where it goes + pipeline.yaml # what to run +.env # unchanged, at the workspace root +``` + +**On the folder name.** Frontend tooling names config either for the tool (`.storybook/`, `.github/`) or as a root file (`vite.config.ts`), and a generic `config/` is more Rails idiom than JS. `.specs/` was considered and rejected: `conventions.yaml` is the one artifact meant to be authored, published, and handed to other teams, and hiding the least engineering-facing file behind a dotfolder is the wrong trade. A Specs workspace is a directory dedicated to Specs, so `config/` carries no ambiguity within it. + +**Settings groups by concern, not by kind.** Three blocks — `data`, `spec`, and `assets` — each hold their own members *and* their own `directory`. The former `processing` / `format` / `include` grouping is retired: it sorted members by what kind of knob they were, which is not a question anyone asks. `sources` folds into `data`, since declaring what to fetch and declaring where it lands are one concern. + +**`assets` is grouped by consumer, not producer.** Icons, images, generated CSS, and fonts arrive from fetch, from generate, from transform, and sometimes from a process outside this tool entirely. What unites them is that every code output points at them, whatever the platform. + +**Analysis output has no block.** It is analysis *of* spec data and is inseparable from it, so it stays derived beneath the spec directory as it is today. + +**Pipeline declares work; settings declares place.** Transformers and analyses name what runs and nothing else. They do not carry output paths, because they do not own locations — every transformer writes *into* the spec structure the CLI owns (`/generated/`, with `contract`, `css`, and `react` output side by side), and every analysis writes to one analysis location. A per-transformer directory would either fracture that structure or have several transformers name the same path and comingle. + +**Pros**: + +- **Each output kind is a concern with a home.** `spec`, `analysis`, and `foundations` are separately relocatable, and each block is where that kind's future settings land +- **No transformer name enters the schema** (Constitution III), because no transformer declares a path +- Each artifact answers one question, and `conventions.yaml` can be published by a library for every consuming workspace to adopt verbatim +- `.env` stays at the root, where dotenv-style discovery expects it + +**Cons / Trade-offs**: + +- **Per-target output locations are unresolved and deliberately out of scope.** Some transformers imply a destination (`react` produces a library, and a storybook alongside it); others do not (`contract` emits beside the spec). Modeling targets here would force every transformer to answer a question only some of them have, so where an assembled library and its storybook live is left to the transformer package that owns them +- `assets` begins as a block holding only a `directory` +- Three files where there was one, and existing workspaces need a read path for the old arrangement +- Naming the assets location makes configurable what is derived today — new capability, deliberately taken + +--- + +### Option 10B: An aggregated `directories` map *(Rejected)* + +```yaml +output: + directories: + specs: ./specs + analysis: ./analysis + react: ./src/react +``` + +**Rejected because**: it collects paths by virtue of being paths, which is the same "group by kind" mistake as `processing` / `format` / `include`. Every new output kind edits a central map rather than declaring itself, and listing transformer names — `react`, `storybook` — would put one consumer's vocabulary in the shared contract, which Constitution III forbids. + +--- + +### Option 10C: One artifact with three top-level blocks *(Rejected)* + +**Rejected because**: it leaves conventions un-shareable without extracting a fragment from a file that also carries settings and pipeline. The types would say the halves are separable while the artifact denies it. + +--- + +## Decision + +Retire `Config`. Publish two independent root types — `Conventions` (namespaced by source) and `Settings` — in their own files, and split the workspace artifact to match. + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Config.ts` | Removed; `Config` and `ResolvedConfig` retired | MAJOR | +| `Conventions.ts` | Added — `Conventions` / `ResolvedConventions`, with a `figma` namespace | MAJOR | +| `Settings.ts` | Added — `Settings` / `ResolvedSettings`, grouped by concern: `data` (absorbing `sources`), `spec`, `assets` | MAJOR | +| `Conventions.ts` | Holds `naming` (from `format.figmaKeys`), `states`, `slotConstraints`, `inferNumberProps`, and the five name-based features whole, including `scope` and `backgroundImage` per Decision 6 | MAJOR | +| `Conventions.ts` | Rekeyed name-based conventions to `.match`: `glyphNamePattern` → `glyphs.match`, `codeOnlyPropsPattern` → `codeOnlyProps.match`, `imageComponent` → `images.match`. Value types unchanged | MAJOR | +| `Settings.ts` | `processing` / `format` / `include` retired; their members fold into `spec`, and `format.output` becomes `spec.format` | MAJOR | + +| `Settings.ts` | Workspace members absorbed: `sources` → `data.sources` (its `data` list renamed `fetch`), `outputDirectory` → `spec.directory`, `splitComponents` / `splitConcerns` / `useSubfolders` → `spec`, `dataDirectory` → `data.directory` | MAJOR | +| `Settings.ts` | `transformers` removed — work to run moves to its own artifact per Decision 10 | MAJOR | +| `Settings.ts` | `author` absorbed from the workspace root | MAJOR | +| `Pipeline.ts` | Added — `Pipeline` / `ResolvedPipeline`, holding `transformers` and `analyses` | MAJOR | +| `Conventions.ts` | `VariantStateEntry` relocated here, beside `states` | MAJOR | +| `Settings.ts` | `ColorFormat` relocated here, beside `spec.color` | MAJOR | +| `Pipeline.ts` | `TransformEntry` relocated here, beside `transformers`; an `AnalysisEntry` of the same shape is added for `analyses` | MAJOR | +| `Settings.ts` | `DEFAULT_CONFIG` → `DEFAULT_SETTINGS`, typed `ResolvedSettings` | MAJOR | +| `Metadata.ts` | `config: Config` → `conventions: ResolvedConventions` and `settings: ResolvedSettings` | MAJOR | +| `index.ts` | Exports both types and their resolved forms; `Config` exports removed | MAJOR | + +**Example — new shape**: + +```yaml +# Before — types/Config.ts +Config: + processing: + glyphNamePattern?: string + variantDepth?: 1 | 2 | 3 | 9999 + inferNumberProps?: boolean + format: + output?: 'JSON' | 'YAML' + figmaKeys?: 'NONE' | 'SENTENCE' | 'TITLE' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + +# After — types/Conventions.ts +Conventions: + figma: + naming?: 'NONE' | 'SENTENCE' | 'TITLE' + inferNumberProps?: boolean + glyphs?: + match: string + +# After — types/Settings.ts +Settings: + spec: + directory?: string + format?: 'JSON' | 'YAML' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + variantDepth?: 1 | 2 | 3 | 9999 +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Removed `#/definitions/Config` | MAJOR | +| `component.schema.json` | Added `#/definitions/Conventions` and `#/definitions/Settings` | MAJOR | +| `pipeline.schema.json` | Added — validates `config/pipeline.yaml` | MAJOR | +| `component.schema.json` | `metadata.config` → `metadata.conventions` + `metadata.settings` | MAJOR | +| `workspace.schema.json` | Removed — one schema per artifact replaces it | MAJOR | +| `conventions.schema.json` | Added — validates `config/conventions.yaml` | MAJOR | +| `settings.schema.json` | Added — validates `config/settings.yaml`, absorbing the former top-level workspace members | MAJOR | + +**Example — new shape** (`schema/component.schema.json`): + +```yaml +Conventions: + type: object + description: "Facts about the library a spec was generated from, namespaced by source. Every consumer reading the same library declares the same values; differing values produce incorrect output, not merely different output." + properties: + figma: + type: object + properties: + keys: + enum: + - NONE + - SENTENCE + - TITLE + glyphs: + type: object + properties: + match: + type: string + subcomponents: + type: object + properties: + match: + type: array + exclude: + type: array + states: + type: object + # not in required[] at any level — a library may declare no conventions +``` + +### Notes + +- **Both halves need a resolved form, for different reasons.** `ResolvedSettings` guarantees every defaulted member is present. `ResolvedConventions` guarantees the members *inside* a present convention block are present — `subcomponents.scope`, `instanceExamples.scope`, `images.backgroundImage` and `sourceProps` all default within a declared block, exactly as `ResolvedConfig` does today. What conventions lack is a default for the *block itself*: absence means the library declares no such convention, and nothing can supply that. `DEFAULT_CONVENTIONS` therefore carries the three defaultable members and no blocks +- **`data.directory` holds artifacts of four different lifecycles**, and the block is named `data` rather than `cache` for that reason. It carries fetched downloads (`library..json`, one per entry in `sources..data`), computed caches (`cache/*.yaml`), extracted assets (`icons/*.svg`), an authored input that injects into a fetched artifact (`token-mappings.json`), and a manifest that is fetched and then authored in place. Naming it `cache` would invite a deletion that destroys authored work. Separating the authored inputs from the regenerable artifacts is a real follow-up and is **not** taken here +- **`sources` folds into `data`, and its `data` list is renamed `fetch`.** Nested under the block it now belongs to, `data.sources..data` would stutter; `fetch` names what the list actually is — the artifacts to download for that source +- **Work to run is not a setting.** `transformers` names *work* rather than how work behaves, and it is joined by `analyses`, which no artifact declares today. Both move to `pipeline.yaml` (Decision 10), which removes the odd member from `Settings` and gives analyses a declared home for the first time +- **Application preferences are deliberately absent from the contract.** Values that never reach a spec — canvas column count, which sections a consumer renders, whether prior output is replaced — belong to the consumer that holds them. Naming the category is the contribution; owning it is not +- **`VariantStateEntry` and `TransformEntry` are unchanged**, referenced from their new locations +- **One canonical definition per shape, referenced rather than copied.** This package validates two different things: the configuration a person authors in a workspace, and the spec output the tool generates, whose `metadata` embeds the resolved configuration as provenance. The same shapes therefore appear on both sides — and today `workspace.schema.json` carries its own copy of `VariantStateEntry` alongside `component.schema.json`'s. The split multiplies that risk — `metadata` embeds conventions and settings, while the workspace artifacts validate the same shapes — so each shape is defined once and referenced by `$ref` from the other schema files. Constitution IV requires the schemas to remain internally consistent, and duplication is the mechanism by which they drift +- **No migration logic is added to this package** (Constitution II). Reading a pre-split artifact is a consumer concern + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: `Conventions` ↔ `#/definitions/Conventions`; `Settings` ↔ `#/definitions/Settings`; each moved member's schema property moves with its type field; `ResolvedSettings` is required-with-defaults while `ResolvedConventions` stays optional throughout; `metadata.conventions` and `metadata.settings` ↔ the matching `#/definitions/Metadata/properties` entries; `workspace.schema.json`'s two artifacts reference the same two definitions + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Reads convention and setting members from new paths under renamed types | Update configuration access and type imports; no behavior change | +| `specs-cli` | Workspace artifact splits into `config/conventions.yaml` and `config/settings.yaml`; `metadata` carries two keys where it carried one | Update configuration access and imports; provide a read path for the pre-split artifact; surface which half a validation error came from | +| `specs-plugin-2` | Holds configuration independently of any workspace, in one flat persisted map that mixes all three categories. Its own exported `Settings` type collides with the schema's and is renamed `PanelSettings` | Update configuration access and imports; separate persisted preferences from the shared halves; the convention set becomes comparable against a workspace's, making parity checkable rather than assumed | + +**On the plugin's flat map.** Its persisted settings interleave conventions, settings, and a third group that never reaches a spec: + +```ts +// specs-plugin-2 — persisted values with no effect on any spec +OUTPUT_COLUMNS: 1 | 2 | 3 | 4 // canvas layout of rendered output +OUTPUT_DATA: boolean // which sections to draw on canvas +OUTPUT_STYLING: boolean +OUTPUT_ANATOMY: boolean +OUTPUT_LAYOUT: boolean +OUTPUT_PROPS: boolean +OUTPUT_MODES: boolean +OUTPUT_REPLACE: boolean // overwrite prior canvas output +ANATOMY_CONTENT: 'CANVAS' | 'DEV_MODE' // which surface anatomy is read from +``` + +These are **application preferences** (Decision 4). They are the plugin's to keep, and this ADR neither types them nor moves them — it names the category so they have somewhere to belong that is not the shared contract. + +Two further representational differences are worth naming because they are the plugin's, not the contract's: + +- **Feature on-switches are booleans** (`SUBCOMPONENTS`, `GLYPHS`, `IMAGES`, `CODE_ONLY_PROPS`, `INSTANCE_EXAMPLES`) where the contract has no counterpart at all. Per Decision 8 they stay application preferences — a panel checkbox that skips work is a UI affordance, not a statement about the library. The plugin keeps them; the contract does not gain them +- **Pattern members are single strings** (`SUBCOMPONENT_MATCH`) where the contract uses arrays + +Neither is changed by this ADR. Both become easier to reconcile once the halves are addressable, because the plugin can map its map onto two named types rather than onto one undifferentiated one. + +--- + +## Semver Decision + +**Version bump**: MAJOR + +**Justification**: A published type is removed and replaced by two, fields move between paths, a generated `metadata` key is replaced by two, and the workspace artifact splits. Per the constitution, any change to a type signature, field name, field presence, or schema structure is `MAJOR`. Every consumer reading the old paths breaks at compile time, which is the desired failure mode. + +--- + +## Consequences + +- The contract states which configuration values describe the library and which describe the run, and a consumer can tell them apart without knowing the ecosystem's history +- Conventions can be published once per library and adopted verbatim by every workspace and consumer that reads it +- Absence means one thing per side: a missing setting is defaulted; a missing convention is declared absent +- Every name-based feature is read in one place — no feature is assembled from both halves — and `settings.processing` is left holding only members that cannot be wrong +- A convention mismatch between two consumers of one library becomes a diffable difference in `metadata.configuration` rather than an unexplained difference in output +- The plugin's isolated configuration and a workspace's become comparable on the half where agreement actually matters +- `conventions.figma` leaves room for conventions of other sources without a further breaking change +- Every name-based convention is keyed the same way, so `match` means one thing across the block — without any member gaining a capability it does not have today +- Widening a single-pattern member to a list remains available as its own decision, with its own justification +- A third category — application preferences — is named and deliberately excluded, so the next consumer-local value has an obvious home outside the contract +- Declaring a convention is what enables its processing, so no consumer writes a switch and no configuration gains a member to preserve today's behavior +- Skipping a feature for a single run is an invocation concern rather than a configuration one, and is deliberately left to the CLI surface +- New configuration members must be classified on arrival: a small ongoing tax, and the mechanism that keeps the split honest +- Every consumer updates imports and configuration access in the same release, and pre-split workspaces need a read path provided outside this package +- The `Config` abbreviation exception can be removed from the constitution's exceptions list, since no type carries the name +- `metadata.conventions` is comparable across specs and consumers on its own, which is what makes convention drift detectable rather than merely diffable +- Conventions and `data.sources` land on opposite sides, which names a new error class worth linting: a conventions file paired with a source it was not written for +- Shared assets have one declared home that every code output can point at, whatever the platform +- Where an assembled library and its storybook live remains open, scoped to the package that produces them rather than to the shared contract diff --git a/adr/072-numeric-variant-enum.md b/adr/072-numeric-variant-enum.md new file mode 100644 index 00000000..7ba0b7ec --- /dev/null +++ b/adr/072-numeric-variant-enum.md @@ -0,0 +1,182 @@ +# ADR: Numeric Enum on NumberProp + +**Branch**: `072-numeric-variant-enum` +**Created**: 2026-08-24 +**Status**: DRAFT +**Summary**: *(written at implementation — see `/specs.adr.implement`)* +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +Figma has no numeric variant. A designer who wants a component to carry a count — how many dots a pager shows, which one is selected — authors a VARIANT property whose options are the strings `"1"`, `"2"`, `"3"`, and so on. The transformer reads that faithfully and emits an `EnumProp`: `type: string` with a `string[]` enum. + +Consumers then receive a string where the domain has a number. A React scaffold types the prop `"1" | "2" | "3"`, arithmetic on it needs a cast, and a Storybook control renders a select of quoted digits. The same value, authored as a code-only TEXT prop instead, already becomes a `NumberProp` — ADR-029 added that inference behind `inferNumberProps`. Only the variant path is left behind. + +**Precedent, twice over.** ADR-029 established that a numeric string from Figma may be re-typed to `number` when a guard makes the inference unambiguous. And the VARIANT path *already* re-types: a property whose options are exactly `"true"`/`"false"` is emitted as `BooleanProp`, not a two-value string enum. Numeric variant inference is the third instance of a rule the schema already lives by — Figma's string is a transport, not the type. + +**Current state of `NumberProp`**: +```yaml +NumberProp: + type: 'number' + default?: number + nullable?: boolean + examples?: number[] +``` + +There is no way to say "a number, drawn from this closed set". `EnumProp` carries a closed set but fixes `type: 'string'`; `NumberProp` carries the numeric type but leaves the range open. A numeric variant needs both at once, and neither type provides it. + +**Gap**: the option set is not incidental — it is the authored content of the variant axis. Dropping it to gain the numeric type would lose what the designer enumerated, and the reverse direction (`figma-from-specs`) rebuilds the variant axis *from* that enum. Losing it would make the round-trip lossy. + +--- + +## Decision Drivers + +- **Type/schema parity (Principle I)**: any field added to `NumberProp` lands in `types/Props.ts` and `schema/component.schema.json` together. +- **No logic in this package (Principle II)**: the all-options-numeric test and the existing `isNumericValue` guard live in `specs-from-figma`. This ADR adds a field, not an inference. +- **Lossless round-trip**: the emitted prop must carry enough for `figma-from-specs` to rebuild the same variant axis, in the same order. +- **Additive only**: the field must be optional so every existing `NumberProp` stays valid and the release stays MINOR. +- **Reuse the existing gate**: numeric variant inference is governed by `conventions.figma.inferNumberProps`, the flag ADR-029 introduced. No new configuration surface. + +--- + +## Options Considered + +### Option A: Add optional `enum?: number[]` to `NumberProp` *(Selected)* + +A numeric variant is emitted as `type: number` with a numeric `default`, a numeric `enum` preserving the authored option order, and an explicit `nullable: false`. + +```yaml +selection: + type: number + default: 1 + enum: [1, 2, 3, 4, 5, 6, 7, 8] + nullable: false +``` + +**Pros**: +- Carries the numeric type and the closed set at once — the two facts a numeric variant holds. +- Round-trip is lossless: the enum is the variant axis, in order, and stringifies back to Figma's option names. +- Purely additive; `NumberProp` without `enum` keeps its present meaning of an open numeric range. +- Mirrors `EnumProp.enum` in name and shape, so the discriminated union stays readable. + +**Cons / Trade-offs**: +- `nullable`'s documented default (`true`, open set) now reads oddly for an enumerated number prop. Addressed by emitting `nullable: false` explicitly rather than by changing the default, which would be a breaking reinterpretation of existing specs. +- Two types can now express a closed set, so consumers must check `type` before assuming the enum's element type. + +--- + +### Option B: Widen `EnumProp.enum` to `(string | number)[]` *(Rejected)* + +Keep the prop `type: 'string'` and allow numbers among its enum values. + +**Rejected because**: it violates the discriminated union — `type: 'string'` would no longer describe the values. A consumer switching on `type` gets `string` and is wrong. It also leaves the prop's `default` a string while its options are numbers. + +--- + +### Option C: Emit `type: number` and drop the option set *(Rejected)* + +Re-type to `NumberProp` with `examples`, discarding the enumeration. + +**Rejected because**: it violates the lossless round-trip driver. `figma-from-specs` builds the variant axis from the enum; without it the rendered component loses every variant beyond the default, and the designer's authored option set is unrecoverable from the spec. + +--- + +### Option D: A distinct `NumberEnumProp` type *(Rejected)* + +Add a fifth prop type alongside `EnumProp` for the numeric case. + +**Rejected because**: it fails the minimal-API driver. The discriminant is `type`, and a `NumberEnumProp` would also carry `type: 'number'` — indistinguishable from `NumberProp` without inspecting `enum` anyway. An optional field on the existing type says the same thing with less surface. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Props.ts` | Added optional field `enum?: number[]` to `NumberProp` | MINOR | +| `Props.ts` | Widened the `NumberProp` doc comment to name the VARIANT source | PATCH | + +**Example — new shape** (`types/Props.ts`): +```yaml +# Before +NumberProp: + type: 'number' + default?: number + nullable?: boolean + examples?: number[] + +# After +NumberProp: + type: 'number' + default?: number + enum?: number[] # optional — MINOR + nullable?: boolean + examples?: number[] +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Added property `enum` (array of number) to `#/definitions/NumberProp` | MINOR | +| `component.schema.json` | Widened the `NumberProp` description to name the VARIANT source | PATCH | + +**Example — new shape** (`schema/component.schema.json`): +```yaml +# New property under #/definitions/NumberProp/properties +enum: + type: array + items: + type: number + description: "The closed set of accepted values, when the source enumerates them rather than leaving the range open. Absent means the prop accepts any number." + # not in required[] — optional field +``` + +### Notes + +**Why `nullable` is emitted explicitly.** `NumberProp.nullable` defaults to `true` because an open numeric range admits absence. An enumerated number prop is the opposite case — the enum lists every accepted value — so the reader writes `nullable: false` rather than leaning on the default. The default is left alone: changing it would reinterpret every `NumberProp` already written. + +**Why the enum order matters.** The array is the variant axis in the order the designer authored it. `figma-from-specs` reads it positionally when rebuilding the component set, and the first entry is the default variant. Sorting it would silently reorder the designer's panel. + +**What does not become a number.** The guard is all-or-nothing across the option set. A single non-numeric option leaves the whole prop a `StringProp` enum — `["2","3","4","5","6","7","8 or more"]` stays a string enum, because `"8 or more"` is a value the domain genuinely holds and no numeric type can represent it. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: `NumberProp.enum?: number[]` ↔ `#/definitions/NumberProp/properties/enum` (`type: array`, `items.type: number`), absent from `required[]` in both. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Emits the new shape | Detect an all-numeric VARIANT option set behind `conventions.figma.inferNumberProps`; emit numeric `default`, `enum`, and `nullable: false`. Cast the matching variant configuration values to numbers | +| `figma-from-specs` | Reads the new shape | Treat `type: number` with an `enum` as a variant axis; stringify enum values when naming variants | +| `specs-cli` | Recompile | None beyond picking up the schema version | +| `specs-plugin-2` | Recompile | None — it compiles engine source | +| transformers (`react-from-specs`, `webcomponents-from-specs`) | Richer types available | May narrow a numeric union instead of a string union. Not required by this ADR | + +--- + +## Semver Decision + +**Version bump**: `0.31.0` (`MINOR`) + +**Justification**: one new optional field on an existing type, with a matching optional schema property. No existing document becomes invalid and no field changes meaning — additive optional field → MINOR per constitution III. The change lands inside the in-flight `0.31.0` release cycle, which is already a MINOR bump. + +--- + +## Consequences + +- A numeric variant axis reaches consumers as numbers with a closed set, so a generated union is `1 | 2 | 3` rather than `"1" | "2" | "3"`. +- Two prop types can now carry an `enum`. Consumers must branch on `type` before assuming the element type of the array. +- A mixed option set is unchanged, by design — one non-numeric option keeps the prop a string enum, which is what `itemCount` with `"8 or more"` needs. +- The inference is gated by `conventions.figma.inferNumberProps`, so a library that leaves the flag off sees no change at all. diff --git a/adr/080-null-prop-configuration.md b/adr/080-null-prop-configuration.md new file mode 100644 index 00000000..8096d954 --- /dev/null +++ b/adr/080-null-prop-configuration.md @@ -0,0 +1,233 @@ +# ADR: `null` as a Prop Configuration Value + +**Branch**: `080-null-prop-configuration` +**Created**: 2026-08-31 +**Status**: ACCEPTED +**Summary**: A `null` prop configuration value joins the scalars, `PropBinding`, `SlotContentRef` and `ImageBinding` a configuration already carries. +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +A nullable prop can be unset. `SlotProp`, `StringProp`, `NumberProp`, and `ImageProp` +default to `nullable: true`, and a slot prop paired with a visibility boolean is +explicitly marked nullable with `default: null` — the pairing is what makes "no header" +expressible at all. + +`PropConfigurations` cannot say it. `PropConfigurationValue` admits a scalar, a +`PropBinding`, a `SlotContentRef`, or an `ImageBinding` — but not `null`: + +```yaml +# types/PropConfigurations.ts — current +PropConfigurationValue: + - string + - number + - boolean + - PropBinding + - SlotContentRef + - ImageBinding +``` + +So a configuration can express *which* content fills a slot and never that the slot is +empty. Producers that have to record an unset nullable prop are pushed into expressing +it some other way — most commonly by keeping the paired visibility boolean alongside the +content value: + +```yaml +# A configuration that shows no header, as it can be expressed today +propConfigurations: + header: + $slotContent: "#/components/card/slotContentExamples/cardHeader" + headerVisible: false +``` + +Two things are wrong with that. The boolean is not a prop of the component being +configured — `PropPairings` folds it into the content prop and removes it from `props`, +so the configuration names a prop that the API does not have. And the content value and +the boolean disagree: the slot is bound and hidden at the same time, leaving every +consumer to decide which half to believe. A consumer reading only the props it knows +sees a bound slot, and renders content the design does not show. + +The gap is narrow and structural: the prop is nullable, the configuration type is not. + +--- + +## Decision Drivers + +- **A configuration says what a prop is set to** — including `null`, when the prop is + nullable. Nothing outside the value should have to be consulted to know that. +- **One value carries the whole meaning** — a configuration must not need a second, + paired key to be interpreted, and must never name a prop absent from `props`. +- **No platform-specific reads** — a consumer must not resolve a configuration by + reading `$extensions`. Producer metadata explains provenance; it never carries meaning + a consumer needs. +- **Layering must survive the round trip** — configurations layer, so an unset must be + expressible as a value that overrides an inherited one. Absence already means "inherit"; + it cannot also mean "unset". +- **Additive only** — no existing document may become invalid. +- **Type ↔ schema parity** — every type arm has a schema arm. + +--- + +## Options Considered + +*(Pre-decided — no alternatives evaluated.)* + +The decision follows from the nullable prop itself: a prop typed `nullable: true` with +`default: null` has `null` in its value domain, and a configuration that selects a value +for that prop must be able to select that one. No alternative was weighed, because any +other encoding reintroduces the second key this ADR exists to remove. + +--- + +## Decision + +Widen `PropConfigurationValue` to admit `null`. + +`null` under a prop key means the prop is **unset** in this configuration. It is a value, +not an absence: an absent key inherits, a `null` key overrides an inherited value with +"no value". + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `PropConfigurations.ts` | Added `null` arm to `PropConfigurationValue` | MINOR | +| `InstanceExample.ts` | Added `null` arm to the inline value union on `propConfigurations` | MINOR | + +**Example — new shape** (`types/PropConfigurations.ts`): + +```yaml +# Before +PropConfigurationValue: + - string + - number + - boolean + - PropBinding + - SlotContentRef + - ImageBinding + +# After +PropConfigurationValue: + - string + - number + - boolean + - "null" # the prop is unset in this configuration + - PropBinding + - SlotContentRef + - ImageBinding +``` + +The configuration above becomes a single key, with no boolean beside it: + +```yaml +# A configuration that shows no header +propConfigurations: + header: null + +# A configuration that shows one +propConfigurations: + header: + $slotContent: "#/components/card/slotContentExamples/cardHeader" +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Added `{ "type": "null" }` arm to `#/definitions/PropConfigurationValue` | MINOR | +| `component.schema.json` | Added `{ "type": "null" }` arm to `#/definitions/InstanceExample/properties/propConfigurations/additionalProperties` | MINOR | + +**Example — new shape** (`schema/component.schema.json`): + +```yaml +# #/definitions/PropConfigurationValue +oneOf: + - type: string + - type: number + - type: boolean + - type: "null" # new arm — the prop is unset in this configuration + - $ref: "#/definitions/PropBinding" + - $ref: "#/definitions/SlotContentRef" + - $ref: "#/definitions/ImageBinding" +``` + +### Notes + +**Where the value may appear.** `null` is admissible under any prop key whose prop is +nullable, in every position a prop configuration occupies: + +- `Element.propConfigurations` — a nested instance +- `Variant.configuration` — a variant's own configuration +- `NestedPropConfiguration` — a path-addressed descendant (`$nested`) +- `InstanceExample.propConfigurations` — a whole-component example + +The first three reference `PropConfigurations`, and inherit the widened value type. +`InstanceExample.propConfigurations` does **not**: it declares its own inline union so it +can exclude `PropBinding` (ADR-048 — an example is a documented configuration, not a live +binding). That union is widened separately and by hand. It gains `null` and keeps its +exclusion of `PropBinding`. + +**Layering.** Configurations layer, and `null` layers as a value. A base that binds a +slot and a layer that sets it `null` resolves to unset; the reverse resolves to bound. A +layer that omits the key inherits whatever the base carried. This is the property that +makes an unset expressible per-variant, and it is why absence and `null` must stay +distinct. + +**Validity.** `null` under a non-nullable prop is not meaningful. This ADR does not add +cross-field validation to express that — the schema types the value domain, and prop +nullability continues to be described by the prop itself. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: The `null` arm of the `PropConfigurationValue` union in + `types/PropConfigurations.ts` maps to the `{ "type": "null" }` member of the `oneOf` at + `#/definitions/PropConfigurationValue` in `schema/component.schema.json`. No other type + or schema definition changes; `PropConfigurations` and `NestedPropConfiguration` + reference the widened value type and inherit the new arm. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Can emit an unset nullable prop as a value, and stop emitting a paired visibility boolean alongside a content value | Consolidate the paired boolean into the content prop before emitting; emit `null` when unset | +| `specs-cli` | Reads configurations when producing code artifacts | Treat a `null` configuration as "prop unset" — no content — rather than skipping the key | +| `specs-plugin-2` | Reads and renders configurations | Recompile; treat `null` as unset when applying a configuration | + +Consumers that layer configurations MUST distinguish an absent key from a `null` one: +absent inherits, `null` overrides with unset. + +--- + +## Semver Decision + +**Version**: `0.31.0` (release branch `release/schema-0.31.0+cli-0.28.0`) — **MINOR**. + +**Justification**: The change adds an arm to a union type and a member to a schema +`oneOf`. Every document valid before remains valid, and no field is renamed, removed, or +changed in presence — additive per the constitution's versioning rule ("`MINOR` for +additive types or new optional fields"). + +--- + +## Consequences + +- A configuration can state that a nullable prop is unset, in every position + `PropConfigurations` appears. +- A content prop paired with a visibility boolean is expressible as one key. Producers + can stop emitting the boolean, which was never a prop of the component being configured. +- Consumers no longer need `$extensions` to interpret a configuration — the value is the + whole meaning. +- Absence and `null` are now semantically distinct in layered configurations: absent + inherits, `null` overrides with unset. Consumers that treated a missing key and an + empty value alike must separate them. +- Documents produced before this ADR remain valid, and may still carry a paired boolean + beside a content value. Producers are expected to stop writing that shape; readers + that encounter it should prefer the content value. diff --git a/adr/082-component-description.md b/adr/082-component-description.md new file mode 100644 index 00000000..b7b9f6f2 --- /dev/null +++ b/adr/082-component-description.md @@ -0,0 +1,375 @@ +# ADR: `Component.description` — The Authored Figma Description, Plain Text and Opt-In + +**Branch**: `082-component-description` +**Created**: 2026-09-01 +**Status**: DRAFT +**Summary**: *(written at implementation — see `/specs.adr.implement`)* +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +A Figma `COMPONENT` and `COMPONENT_SET` each carry an author-entered description. It is the one piece of +component documentation that already exists in the library, is maintained by the people who maintain the +component, and travels with the published asset. Today it is dropped: `Component` has no `description` +property, and `#/definitions/Component` is `additionalProperties: false`, so a consumer cannot add it and +stay conformant. + +An external request (`DirectedEdges/specs#374`) asks for the field. A follow-up comment on that issue +establishes empirically that opening the object alone changes nothing: `specs generate` never reads +`component.schema.json`, and flipping `additionalProperties` produced byte-identical output. The schema +governs only whether such a value would be *allowed to survive validation*. That is the right reading — +this ADR therefore declares a property rather than opening the object, and the generator mapping is +downstream work that this contract enables. + +### What the field actually holds + +The reservation is not about mechanism, it is about content. The description is a free-text box, and +libraries use it for materially different things: + +- **Usage guidance** — when to reach for this component and when not to. High value, and the closest thing + to what a spec consumer wants. +- **Anatomy or prop notes** — prose restating structure the spec already models with precision. Redundant at + best, and drifts from the spec as the component changes. +- **Process and housekeeping** — status markers, deprecation notes, owning team, ticket links, "do not use, + see v2", changelog fragments. Meaningful to the library's maintainers, meaningless to a generator. +- **Nothing at all** — a large share of components in most libraries carry an empty description. + +Its internal structure is equally uneven. Some libraries write one disciplined sentence; others write a +heading-and-bullets document with links; others paste a changelog. There is enough implicit structure to +tempt a parser and never enough to justify one. This contract therefore treats the value as opaque text: it +is carried, not interpreted, and nothing downstream may key on a convention inside it. + +### The readability cost + +The Specs output is read and diffed by humans. `title` is one line; a description can be several hundred +words of prose sitting directly above the props of every component in a library. In split-concern output it +lands at the top of `api.yaml`, the file a reader opens first. Multi-line YAML block scalars also make the +noisiest possible diffs — an editorial reword in Figma reads as a change to the component's API file. + +That cost is real but it is a *default* problem, not a *contract* problem. The value is genuinely useful to +documentation pipelines and genuinely noisy to code generators, and both are legitimate consumers. So the +property is declared, and whether it is emitted is a setting the run makes — consistent with how +`defaultSlotContent` handles the same tension. + +### Runtime parity + +The two producing runtimes do not expose the same thing. The Plugin API offers both +[`description`](https://developers.figma.com/docs/plugins/api/properties/nodes-description/) (plain text) +and [`descriptionMarkdown`](https://developers.figma.com/docs/plugins/api/properties/nodes-descriptionmarkdown/) +(rich text) on `ComponentNode` and `ComponentSetNode`. The REST file response exposes only the plain +`description` on `file.components[nodeId]` and `file.componentSets[nodeId]`. Any contract that depends on +the markdown form is satisfiable in the plugin and not in the CLI. + +--- + +## Decision Drivers + +- **Runtime parity is non-negotiable**: the CLI and the plugin must produce identical output for the same + component. A field only one runtime can populate is not a schema field. +- **The closed `Component` stays closed**: `additionalProperties: false` is load-bearing — `Subcomponent` + composes `Component` via `allOf` under draft-07, which has no `unevaluatedProperties`. Opening it makes a + drifted key indistinguishable from an intended one. +- **Carry, do not interpret**: the schema may hold unstructured text, but must not imply a grammar inside it. +- **Readability of generated output**: a spec a human will not read is a spec that stops being reviewed. +- **Additive only**: existing documents stay valid and existing consumers may ignore the field (Constitution III). +- **Type ↔ schema symmetry** (Constitution I) and **no logic in this package** (Constitution II). +- **Naming — code platforms first** (Constitution VI, rule 1): `description` is the agreed term across JSON + Schema, OpenAPI, TSDoc, and DTCG, and is already the name used on `Composition`. + +--- + +## Options Considered — Decision 1: Where the description lives + +### Option 1A: A first-class optional `Component.description` *(Selected)* + +Declare `description?: string` on `Component`, sibling to `title`. + +**Pros**: +- The value is component documentation, and `title` — its nearest neighbour — is already a root field. +- Inherited by `Subcomponent` for free: it is `Omit`, so a + subcomponent's own Figma description has a home with no second declaration. +- Keeps `#/definitions/Component` closed; a declared property is the only way to admit a value without + surrendering that guarantee. +- Lands in the api concern alongside `title` under `splitConcerns`, with no new concern-routing rule. +- Matches `Composition.description`, which already means "purpose and usage notes for documentation tooling". + +**Cons / Trade-offs**: +- Puts unbounded free text at the top of the most-read file. Mitigated by Decision 3, not by placement. +- The api concern is where a root field falls by default, not where this value was argued to belong. A + documentation concern would be the better home; see *Concern placement* under the Decision. + +--- + +### Option 1B: `metadata.source.description` *(Rejected)* + +Treat the description as provenance and hang it off the Figma source record that already identifies the node. + +**Rejected because**: `metadata.source` answers *where this spec came from* — `pageId`, `nodeId`, `nodeType`. +A human-authored sentence about when to use the component is not provenance, and burying documentation in a +metadata block makes it invisible to the documentation tools that want it. It would also fail to reach +`Subcomponent`, which drops `metadata` entirely. + +--- + +### Option 1C: `$extensions['com.figma'].description` *(Rejected)* + +Carry it as a Figma platform extension, in the DTCG §5.2.3 slot used for extraction provenance elsewhere. + +**Rejected because**: `$extensions['com.figma']` is for values that are meaningful *only* in Figma's model +and that no code platform expresses — the preserved original layer name, for instance. A component +description is platform-neutral documentation that every code platform has a place for. Constitution VI +rule 1 puts it in the shared contract, not behind a vendor namespace. + +--- + +### Option 1D: Open `Component` with `additionalProperties: true` *(Rejected)* + +Let consumers attach `description` — and anything else — without a declaration. + +**Rejected because**: it surrenders the closed contract that `Subcomponent`'s `allOf` composition depends on, +and it validates typos. It also would not produce the field: the generator, not the schema, emits output. + +--- + +## Options Considered — Decision 2: Plain text or markdown + +### Option 2A: Plain text, from `description` *(Selected)* + +`description` is an opaque string carrying the plain-text annotation. Leading and trailing whitespace is +trimmed; internal line breaks and Unicode are preserved; a whitespace-only source value is omitted rather +than emitted as `""`. + +**Pros**: +- Populatable identically from the REST file response and from the Plugin API, so the two runtimes agree. +- Consistent with "carry, do not interpret" — a consumer that wants to render markdown may attempt it, but + nothing in the contract promises the text is markdown. + +**Cons / Trade-offs**: +- Formatting an author applied in Figma is flattened. + +--- + +### Option 2B: `descriptionMarkdown`, or a `{ text, markdown }` pair *(Rejected)* + +Carry the rich-text form, or both forms. + +**Rejected because**: the REST runtime cannot supply it. The CLI would emit one shape and the plugin another +for the same component, which breaks the parity guarantee the ecosystem's own parity tests exist to enforce. +A pair also doubles the readability cost for a formatting gain, and it invites a grammar into a field this +ADR is deliberately keeping opaque. Revisitable if and when REST exposes the markdown form. + +--- + +## Options Considered — Decision 3: Whether emission is gated + +### Option 3A: A `spec.description` setting, default `false` *(Selected)* + +Add `description?: boolean` to `Settings.spec` (required on `ResolvedSettings.spec`, `false` in +`DEFAULT_SETTINGS`). When false the property is not emitted, whatever the source carries. + +**Pros**: +- Every existing run produces byte-identical output after this change — no diff churn across a library from + an upgrade alone. +- Lets the two legitimate consumers diverge: documentation pipelines turn it on, code generators leave it off. +- Exactly the precedent set by `defaultSlotContent` (ADR-050) — optional, potentially bulky content, off by + default, recorded in `metadata.settings` so the output is self-describing. + +**Cons / Trade-offs**: +- One more setting on an already broad surface, and the requester must opt in to get the field. + +--- + +### Option 3B: Always emit when present *(Rejected)* + +No setting; the property appears whenever the Figma node has a non-empty description. + +**Rejected because**: it forces the readability and diff cost on every existing consumer to serve a subset, +and it does so silently on upgrade. The content is exactly the kind of high-variance, often-low-value prose +that a run should have to ask for. + +--- + +### Option 3C: Emit with a length cap or truncation *(Rejected)* + +Bound the field — a `maxLength`, or truncation with an ellipsis. + +**Rejected because**: it is lossy in the schema itself, it introduces a processing rule into a package that +must contain none (Constitution II), and there is no defensible number. A run either wants the author's +words or it does not. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Component.ts` | Added optional field `description?: string` | MINOR | +| `Settings.ts` | Added optional field `spec.description?: boolean` to `Settings` | MINOR | +| `Settings.ts` | Added required field `spec.description: boolean` to `ResolvedSettings` | MINOR | +| `Settings.ts` | Added `spec.description: false` to `DEFAULT_SETTINGS` | MINOR | + +`Subcomponent` requires no change — it is `Omit` and inherits the +new field, giving a subcomponent's own Figma description a home. + +**Example — new shape** (`types/Component.ts`): +```yaml +# Before +Component: + title: string + anatomy: Anatomy + default: Variant + +# After +Component: + title: string + description?: string # optional — opaque plain text — MINOR + anatomy: Anatomy + default: Variant +``` + +**Example — generated `api.yaml` with `spec.description: true`**: +```yaml +title: Radio +description: >- + Radio Buttons allow users to select a single option from a set of multiple + choices. Once selected, an option cannot be deselected without choosing + another. +props: + selected: + type: boolean +``` + +**Example — the same component with `spec.description: false` (the default)**: +```yaml +title: Radio +props: + selected: + type: boolean +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Added property `description` under `#/definitions/Component/properties` | MINOR | +| `settings.schema.json` | Added property `description` under the `spec` object | MINOR | + +`#/definitions/Component` keeps `additionalProperties: false`, and `description` is not added to `required`. + +**Example — new property** (`schema/component.schema.json`): +```yaml +# #/definitions/Component/properties +description: + type: string + minLength: 1 + description: >- + The component description authored in Figma. Opaque plain text — carried, + not interpreted. Emitted only when spec.description is true; omitted when + the source value is absent or whitespace-only. + examples: + - "Radio Buttons allow users to select a single option from a set of multiple choices." +``` + +**Example — new setting** (`schema/settings.schema.json`): +```yaml +# spec.properties +description: + type: boolean + default: false + description: >- + Emit the component description authored in Figma. Optional; defaults to false. +``` + +### Notes + +- `minLength: 1` is the schema's expression of "never emit an empty string". Whitespace trimming and + omission are producer behaviour, stated here as the contract the producers implement — no logic is added + to this package. +- The field is opaque. No consumer may parse headings, bullets, links, or any other implicit structure out + of it, and no future ADR should add such a grammar without superseding this one. +- Under `splitConcerns`, `description` belongs to the api concern with `title`. No concern-routing change is + needed: it is a `Component` root field, and the api concern file carries the root fields. That placement is + a consequence of the current concern set, not an argued position — see below. + +### Concern placement, and a documentation concern this ADR does not open + +`description` lands in the api concern because it is a root field and the api concern carries root fields. +Nothing about the value argues for that file. It is unbounded, editorially churny, and addressed to a +different reader than the rest of the api concern — which is the same observation that motivates the +`spec.description` gate in Decision 3. The gate answers it crudely: emitted or not. A concern file would +answer it precisely: emitted, and somewhere a code generator can ignore. + +The mechanism for that already exists. The concern split is not a physical partition of one object — the +variants and examples concerns are separate files that refer back to the api concern's anatomy keys, which +are the canonical names. A documentation concern would work the same way: a fourth file keyed to the same +anatomy, carrying content addressed to documentation tooling. `title` is a fair thing to question in that +light — it is identity rather than API surface — but it stays where it is regardless, because it is one +required line every consumer needs, and moving it would force two file reads to learn what a file describes. + +This is also the reason the case cannot be built on `description` alone. Figma's node annotations are the +natural second occupant, and being node-scoped is not an obstacle: they would key to anatomy paths exactly +as the variants and examples concerns already do. But annotations are not modelled yet, and a concern file +designed around a single field is a partition invented for one value. + +So the ADR records the question rather than answering it. If a documentation concern is introduced, this +field moves into it — a routing change, not a contract change, needing no supersession of this ADR. The +gate and the concern are complementary, not competing: one decides whether the value is produced, the other +where it lands. Nothing here forecloses either. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: + - `Component.description` ↔ `#/definitions/Component/properties/description` + - `Settings.spec.description` and `ResolvedSettings.spec.description` ↔ `settings.schema.json` `spec.description` + - `Subcomponent` inherits `description` in the types via `Omit`, and in the schema via the existing `allOf` + composition of the closed `Component` definition — no separate declaration in either artifact. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | Additive. The value must be read from the selected source node and assigned, in both runtimes: the REST file response's component and component-set records, and the Plugin API's node property. | Map the source description onto the new field, trim, omit when empty, and honour the new setting. | +| `specs-cli` | Additive. Recompile against the new schema version; expose the new setting through settings resolution and document it. | Recompile; surface the setting; cover it in generate documentation. | +| `specs-plugin-2` | Additive. Recompile; the plugin runtime must use the plain-text property, not the markdown one, to stay identical to the CLI. | Recompile; map the plain-text property. | + +--- + +## Semver Decision + +**Version**: `0.31.0` — `MINOR` + +**Justification**: every change is an additive optional field on a published type plus its schema +counterpart. Existing documents remain valid, existing consumers may ignore the new property, and the +default setting leaves generated output unchanged. This is `MINOR` per the constitution's versioning rule +("`MINOR` for additive types or new optional fields"). The new required member on `ResolvedSettings` is +additive to a type this package itself resolves and always populates from `DEFAULT_SETTINGS`. + +--- + +## Consequences + +- A component's authored Figma description can be represented in the spec for the first time, and consumers + maintaining a companion documentation artifact keyed on `metadata.source.nodeId` can retire it. +- Subcomponents get the same field with no further contract work. +- Nothing changes for any existing run: the setting is off by default, and a library that turns it on has + opted into the readability and diff cost knowingly. +- `#/definitions/Component` remains closed, so the `Subcomponent` `allOf` composition and its typo-detection + are preserved. +- The field is fixed as opaque text. Structured component documentation — usage do's and don'ts, status, + ownership — remains unmodelled, and if it is ever wanted it must be modelled as its own typed structure + rather than parsed back out of this string. +- The plain-text choice is a parity constraint, not a preference. If the REST API ever exposes the rich-text + form, a successor ADR may revisit it. +- The field's home in the api concern is provisional. Introducing a documentation concern later moves it + there as a routing change, without superseding this ADR — and this is the first field whose reader differs + from the rest of the api concern, so it is the first evidence for that file. diff --git a/adr/INDEX.md b/adr/INDEX.md index 31015db1..060caff0 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,15 @@ | # | Title | Highlights | |---|-------|------------| +| 082 | `Component.description` — The Authored Figma Description, Plain Text and Opt-In | | +| 079 | `metadata.conventions` Carries Only the Producing Platform | (reserved, draft in PR #363) | +| 078 | One Conventions File per Platform, in `config/conventions/` | (reserved, draft in PR #363) | +| 077 | The Image Component's Code Name, and the Encoding / Vocabulary Boundary | (reserved, draft in PR #363) | +| 076 | Direction-Keyed Container Bindings, and a Platform-Level `stylesProp` | (reserved, draft in PR #363) | +| 075 | `props` — a Closed, Concept-Keyed Map onto a Component's Props | (reserved, draft in PR #363) | +| 074 | Primitives Resolve to Components at Emit Time, Not in the Spec | (reserved, draft in PR #363) | +| 073 | `conventions.platforms`, with Figma as One Platform Among Them | (reserved, draft in PR #363) | +| 072 | Numeric Enum on `NumberProp` | Adds optional `enum?: number[]` so a VARIANT whose options are all numbers emits as a number with its closed option set preserved | | 070 | Explicit `position: ABSOLUTE` for Children of Non-Auto-Layout Parents | | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | @@ -15,6 +24,8 @@ | # | Title | Highlights | |---|-------|------------| +| 080 | `null` as a Prop Configuration Value | Adds a `null` arm to `PropConfigurationValue` and to `InstanceExample.propConfigurations`; absent inherits, `null` overrides with unset | +| 071 | Separate Library Conventions from Tooling Settings | `Conventions`, `Settings` and `Pipeline` replace `Config`, separating library facts from run choices and declared work | | 069 | Rename `clipContent` to `clipsContent` | Renames the clip flag to the key the data carries, so container clipping and CSS `overflow` resolve for the first time | | 066 | Lossless Key Formatting — Safe Key Grammar and Figma Name Preservation | Adds opt-in `format.figmaKeys` (`NONE` default), a safe key grammar, and `com.figma.name` on anatomy and props; renames `originalName` | | 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 73fff7a7..0f1bdc07 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,102 @@ 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.28.0] - Unreleased + +### Breaking + +**`specs.config.yaml` → a `config/` directory for conventions, settings and pipeline**, and +`specs migrate config` switches an existing workspace over. + +The single file mixed two kinds of statement: facts about your Figma library that every +consumer of it must agree on, and choices this particular run makes. A wrong fact produces +incorrect output, while a different choice just produces different output — so they are +now three files that can be read, validated and reviewed apart. Every command that loads +configuration is affected, and a workspace that has not migrated gets an error naming the +file and the command that converts it rather than a silent fall back to defaults, because +the shape of your output depends on this file and ignoring it quietly would change what +you emit without saying so. + +### Added + +- **`specs migrate ` — versioned workspace migrations.** `specs migrate config` + reads `specs.config.yaml` (or `.json`) and writes `config/conventions.yaml`, + `config/settings.yaml` and `config/pipeline.yaml`, then renames the source to + `.migrated` so discovery stops finding it. `conventions.yaml` and `pipeline.yaml` are + written only if the source configured them; `settings.yaml` is always written, because + the layout flags have to be carried across explicitly (see below). + - `--dry-run` reports what would change without writing. + - `--source ` converts a file discovery would not find — a custom name, or one you + pass to other commands via `--config`. + - `--from ` pins the source version; `--list` shows what is registered. + - The command is a registry, not a synonym for this one conversion: later migrations + register alongside `config v1 → v2` rather than replacing it. + - It writes to your workspace, which is why it is a command you run rather than + something the loader does for you. Configuration is loaded by read-only commands and + in CI, and a read path must not mutate a checkout. + +### Changed + +- **Configuration is discovered in `./config/`.** A directory is used when it holds at + least one of `conventions`, `settings` or `pipeline` (`.yaml` or `.json`). `--config` + now accepts that directory as well as a file path. +- **`specs init` scaffolds the three files** and refuses to run in a workspace that still + has `specs.config.yaml`, pointing at `specs migrate config`. Scaffolding fresh defaults + over a configured workspace would replace what it declares, and `migrate` would then + refuse because `config/` exists. +- **The vocabulary changed with the split** — `format.figmaKeys` is now + `conventions.figma.naming`, `processing.glyphNamePattern` is `conventions.figma.glyphs.match`, + `format.output` is `settings.spec.format`, and the root `sources`, `dataDirectory`, + `outputDirectory` and `output` blocks fold into `settings`. `specs migrate config` + applies all of it; the [schema changelog](../schema/CHANGELOG.md) lists the mapping in full. +- **Spec metadata records conventions and settings separately**, so either half can be + compared across specs on its own. Regenerating rewrites the `metadata` block of every + spec file. +- **The split layout is now the default.** `specs generate` writes one folder per component + holding `api`, `variants` and (when there are examples) `examples` files, without any + flags. Everything downstream — `transform`, `analyze`, `render` — reads that layout, so + it was the shape almost every workspace had to opt into by hand. +- **Layout flags are named for what they do.** `--split-components`, `--split-concerns` and + `--use-subfolders` are replaced by `--combine-as-library`, `--combine-concerns` and + `--no-subfolders`. Each turns a split off; an absent flag defers to the configured value + rather than overriding it. +- **`specs migrate config` preserves the layout a workspace emits today.** The three flags + defaulted to `false` in `specs.config.yaml` and default to `true` now, so migration + writes all three out explicitly — including from a source that configured no `output` + block. Deleting those three lines from `config/settings.yaml` is how a migrated + workspace adopts the new default. +- **`specs init` no longer writes the three flags.** The generated `config/settings.yaml` + shows them commented out at their defaults. +- **`transform`'s prerequisite tip** no longer names flags that no longer exist; running + `specs generate` is now sufficient. + +### Removed + +- **`specs.config.yaml` and `specs.config.json` are no longer read.** They are still + detected, but only to refuse with the command that converts them. +- **`~/.specs/config.yaml` — user-level configuration — has no equivalent.** Conventions + describe a specific Figma library and settings describe a specific workspace; neither is + a personal preference that should follow you between projects. Move what it declares + into the workspace's `config/` directory and delete it. +- `--split-components`, `--split-concerns` and `--use-subfolders` on `specs generate`, + replaced by the negative flags above. + +### Fixed + +- **`scan` records the dev status Figma actually set, including `COMPLETED`** — Every status + on a node is carried through to the manifest's Dev Status column verbatim instead of being + collapsed to `READY_FOR_DEV` or `NONE`, so a component marked complete is no longer + indistinguishable from one nobody has touched. Selection is unchanged: `scan` still checks + only `READY_FOR_DEV` rows by default, and `generate` still builds only what is checked. +- **A manifest row with an unfamiliar dev status is kept and left unselected, not dropped** — + The parser previously accepted only two status values and silently discarded any row it + could not match, so the component vanished from the run while the summary line still read + as healthy. Such a row is now parsed, reported with a warning, and left unchecked. +- **`scan` and `generate` warn about manifest rows they cannot read** — Any line that looks + like a component row but fails to parse now prints a warning naming the row instead of + disappearing. + + ## [0.27.0] - 2026-08-17 Specs can now go back into Figma. `specs render` takes a spec you already have and builds diff --git a/packages/cli/package.json b/packages/cli/package.json index a5dd491b..dd261217 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.27.0", + "version": "0.28.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/Config/ConfigLoader.ts b/packages/cli/src/Config/ConfigLoader.ts index 7c14c7c3..c3109eb1 100644 --- a/packages/cli/src/Config/ConfigLoader.ts +++ b/packages/cli/src/Config/ConfigLoader.ts @@ -1,356 +1,437 @@ /** * Configuration File Loader - * - * Loads and validates specs.config.yaml or specs.config.json files - * with sensible defaults and priority order: CLI flags > file > defaults + * + * Loads and validates the workspace's split configuration (ADR-071): + * a `config/` directory holding `conventions.yaml`, `settings.yaml`, and + * `pipeline.yaml` (each optional, `.json` also accepted). + * + * A pre-split `specs.config.yaml` is refused, not read: `specs migrate config` + * converts it. Loading runs inside read-only commands and in CI, so it never + * writes to a workspace, and it never falls back to defaults when it finds a + * configuration it cannot use. + * + * Priority order: CLI flags > file > defaults. */ import fs from 'fs-extra'; import path from 'path'; import yaml from 'yaml'; -import { DEFAULT_CONFIG, type Config, type ResolvedConfig } from '@directededges/specs-schema'; +import { + DEFAULT_CONVENTIONS, + DEFAULT_SETTINGS, + DEFAULT_PIPELINE, + type ResolvedConventions, + type ResolvedSettings, + type ResolvedPipeline, + type Settings, + type SourceEntry, +} from '@directededges/specs-schema'; import { CONFIG_DEFAULTS } from './ConfigDefaults.js'; import type { CLIConfig } from '../Types/CLIConfig.js'; -import type { OutputConfig, OutputFormat } from '../Types/OutputConfig.js'; + +/** Base names of the three split-configuration files inside `config/`. */ +const CONFIG_DIR_FILES = ['conventions', 'settings', 'pipeline'] as const; + +/** Extensions accepted for each split-configuration file, in priority order. */ +const CONFIG_FILE_EXTENSIONS = ['yaml', 'json'] as const; + +type ConfigSource = + | { kind: 'directory'; dir: string } + | { kind: 'legacy'; file: string }; + +/** Pre-split workspace files that `specs migrate config` can convert. */ +const CONFIG_V1_BASENAMES = ['specs.config.yaml', 'specs.config.json']; export class ConfigLoader { constructor() { // Config loader with runtime type checking instead of schema validation } - + /** - * Load configuration from file or use defaults - * - * @param configPath - Optional explicit config file path + * Load configuration from the split `config/` directory, a legacy file, + * or defaults. + * + * @param configPath - Optional explicit path: either a `config/` directory + * holding split files or a legacy `specs.config.yaml`/`.json` file * @returns Complete CLI configuration */ public load(configPath?: string): CLIConfig { - const filePath = configPath || this.findConfigFile(); - - if (!filePath || !fs.existsSync(filePath)) { - // No config file, use defaults + const source = configPath + ? this.classifyExplicitPath(configPath) + : this.findConfigSource(); + + if (!source) { return this.getDefaultConfig(); } - + + // A pre-split file is a hard stop, not a load failure: falling back to + // defaults would generate successfully and silently wrong, missing + // everything the workspace's conventions declare. + if (source.kind === 'legacy') { + this.refuseLegacyFile(source.file); + } + try { - const raw = fs.readFileSync(filePath, 'utf-8'); - const parsed = filePath.endsWith('.json') - ? JSON.parse(raw) - : yaml.parse(raw); - - // Validate and merge with defaults - const config = this.validateAndMerge(parsed); - - // Resolve dataDirectory and outputDirectory relative to config file location - const configDir = path.dirname(filePath); - if (config.dataDirectory && !path.isAbsolute(config.dataDirectory)) { - config.dataDirectory = path.resolve(configDir, config.dataDirectory); - } - if (config.outputDirectory && !path.isAbsolute(config.outputDirectory)) { - config.outputDirectory = path.resolve(configDir, config.outputDirectory); - } - - return config; + return this.loadFromDirectory(source.dir); } catch (error) { - console.error(`Error loading config from ${filePath}:`, error); + console.error(`Error loading config from ${source.dir}:`, error); console.error('Falling back to default configuration'); return this.getDefaultConfig(); } } - + /** - * Find config file in standard locations + * Classify an explicit `configPath` argument as either a split-config + * directory or a legacy file. + */ + private classifyExplicitPath(configPath: string): ConfigSource | null { + if (!fs.existsSync(configPath)) { + return null; + } + if (fs.statSync(configPath).isDirectory()) { + return { kind: 'directory', dir: path.resolve(configPath) }; + } + return { kind: 'legacy', file: path.resolve(configPath) }; + } + + /** + * Find configuration in standard locations. * * Checks in order: - * 1. ./specs.config.yaml - * 2. ./specs.config.json - * 3. ~/.specs/config.yaml - * - * @returns Path to config file or null if not found + * 1. ./config/ containing any of conventions|settings|pipeline .yaml/.json + * 2. ./specs.config.yaml + * 3. ./specs.config.json + * 4. ~/.specs/config.yaml */ - private findConfigFile(): string | null { - const locations = [ + private findConfigSource(): ConfigSource | null { + const configDir = path.join(process.cwd(), 'config'); + if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) { + const hasSplitFile = CONFIG_DIR_FILES.some(base => + CONFIG_FILE_EXTENSIONS.some(ext => fs.existsSync(path.join(configDir, `${base}.${ext}`))) + ); + if (hasSplitFile) { + return { kind: 'directory', dir: configDir }; + } + } + + const legacyLocations = [ path.join(process.cwd(), 'specs.config.yaml'), path.join(process.cwd(), 'specs.config.json'), path.join(process.env.HOME || '~', '.specs', 'config.yaml'), ]; - - for (const location of locations) { + for (const location of legacyLocations) { if (fs.existsSync(location)) { - return location; + return { kind: 'legacy', file: location }; } } - + return null; } - + /** - * Validate and merge config file with defaults + * Load the split configuration from a `config/` directory. Missing files + * are fine — each half defaults independently. + * + * Relative directories resolve against the directory that *contains* + * `config/` (the workspace root), matching where a legacy root-level + * `specs.config.yaml` resolved them. */ - private validateAndMerge(parsed: unknown): CLIConfig { - // Support deprecated 'sourceDirectory' with warning - const raw = parsed as any; // eslint-disable-line @typescript-eslint/no-explicit-any - let dataDirectory = raw?.dataDirectory; - if (!dataDirectory && raw?.sourceDirectory) { - console.warn("Warning: 'sourceDirectory' is deprecated, use 'dataDirectory' instead"); - dataDirectory = raw.sourceDirectory; - } - - const config: CLIConfig = { - dataDirectory, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - outputDirectory: (parsed as any)?.outputDirectory, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - author: (parsed as any)?.author, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config: this.mergeConfig((parsed as any)?.config), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - output: this.mergeOutputConfig((parsed as any)?.output), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sources: (parsed as any)?.sources, - }; - - return config; + private loadFromDirectory(dir: string): CLIConfig { + const conventions = this.resolveConventions(this.readPart(dir, 'conventions')); + const settings = this.resolveSettings(this.readPart(dir, 'settings')); + const pipeline = this.resolvePipeline(this.readPart(dir, 'pipeline')); + + const configDir = path.dirname(dir); + this.resolveSettingsDirectories(settings, configDir); + + return { conventions, settings, pipeline, configDir }; } - + /** - * Merge model config from file with defaults + * Refuse a pre-split configuration file. + * + * ADR-071 made this a breaking change deliberately. Reading the old shape + * would mean supporting two layouts indefinitely; ignoring it would be worse + * — the run would fall through to defaults and generate specs missing + * everything the conventions declare, without ever failing. */ - private mergeConfig(fileModel: unknown): ResolvedConfig { - if (!fileModel) { - return DEFAULT_CONFIG; - } - - // Deep merge user config over defaults - const merged = this.deepMerge(DEFAULT_CONFIG, fileModel as Partial); - - // Validate and correct merged config, replacing invalid values with defaults - return this.validateAndCorrectConfig(merged); + private refuseLegacyFile(file: string): never { + // Name the full path: a workspace file and a home-directory one are refused + // for the same reason but have different remedies, and `config.yaml` alone + // does not tell the reader which file is being rejected. + const home = process.env.HOME || '~'; + const isUserLevel = file.startsWith(path.join(home, '.specs')); + const isDiscovered = CONFIG_V1_BASENAMES.includes(path.basename(file)); + + const remedy = isUserLevel + ? " A user-level configuration has no equivalent in the split layout. Move what it declares into this workspace's config/ directory, then delete it." + : isDiscovered + ? ' Run `specs migrate config` to write config/conventions.yaml, config/settings.yaml and config/pipeline.yaml from it.' + : ` Run \`specs migrate config --source ${path.basename(file)}\` to write config/conventions.yaml, config/settings.yaml and config/pipeline.yaml from it.`; + + throw new Error( + `${file} is no longer read (ADR-071).\n` + + `${remedy}\n` + + ` Docs: https://specs.directededges.com/settings/` + ); } /** - * Validate and correct model config, replacing invalid values with defaults + * Read one split-configuration file (`.yaml` or `.json`) from + * the config directory. Returns undefined when the file is absent. */ - private validateAndCorrectConfig(config: Config): ResolvedConfig { - const corrected = JSON.parse(JSON.stringify(config)) as ResolvedConfig; - - // Guard against null/undefined nested config objects from YAML parsing - // (YAML parsing of keys with only comments produces null instead of empty object) - if (!corrected.processing || typeof corrected.processing !== 'object') { - corrected.processing = DEFAULT_CONFIG.processing; - } - if (!corrected.format || typeof corrected.format !== 'object') { - corrected.format = DEFAULT_CONFIG.format; - } - if (!corrected.include || typeof corrected.include !== 'object') { - corrected.include = DEFAULT_CONFIG.include; + private readPart(dir: string, base: string): unknown { + for (const ext of CONFIG_FILE_EXTENSIONS) { + const file = path.join(dir, `${base}.${ext}`); + if (fs.existsSync(file)) { + return this.parseFile(file); + } } + return undefined; + } - // Valid processing options - const validVariantDepths = [1, 2, 3, 9999]; - const validDetails = ['FULL', 'LAYERED']; + private parseFile(file: string): unknown { + const raw = fs.readFileSync(file, 'utf-8'); + return file.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw); + } - // Validate processing - if (!validVariantDepths.includes(corrected.processing.variantDepth)) { - corrected.processing.variantDepth = DEFAULT_CONFIG.processing.variantDepth; - } - if (!validDetails.includes(corrected.processing.details)) { - corrected.processing.details = DEFAULT_CONFIG.processing.details; - } - if (corrected.processing.glyphNamePattern !== undefined - && (typeof corrected.processing.glyphNamePattern !== 'string' - || corrected.processing.glyphNamePattern.trim() === '')) { - delete corrected.processing.glyphNamePattern; - } - if (corrected.processing.codeOnlyPropsPattern !== undefined - && (typeof corrected.processing.codeOnlyPropsPattern !== 'string' - || corrected.processing.codeOnlyPropsPattern.trim() === '')) { - delete corrected.processing.codeOnlyPropsPattern; - } - if (typeof corrected.processing.slotConstraints !== 'boolean') { - corrected.processing.slotConstraints = false; + /** + * Resolve conventions: apply the three top-level defaults, and the inner + * defaults of any declared block, validating members as the pre-split + * loader did. Absence of a block means the library declares no such + * convention — no default can supply it. + */ + private resolveConventions(parsed: unknown): ResolvedConventions { + const rawRoot = (parsed && typeof parsed === 'object' ? parsed : {}) as Record; + const raw = (rawRoot.figma && typeof rawRoot.figma === 'object' ? rawRoot.figma : {}) as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + + const figma: ResolvedConventions['figma'] = { + ...DEFAULT_CONVENTIONS.figma, + slotConstraints: raw.slotConstraints === true, + inferNumberProps: raw.inferNumberProps === true, + }; + + // naming + const validNaming = ['NONE', 'SENTENCE', 'TITLE']; + const naming = typeof raw.naming === 'string' ? raw.naming.toUpperCase() : undefined; + if (naming && validNaming.includes(naming)) { + figma.naming = naming as ResolvedConventions['figma']['naming']; } - if (typeof corrected.processing.inferNumberProps !== 'boolean') { - corrected.processing.inferNumberProps = false; + + // glyphs — a non-empty match string, else the block is dropped + if (raw.glyphs !== undefined && raw.glyphs !== null) { + const match = typeof raw.glyphs === 'object' ? raw.glyphs.match : undefined; + if (typeof match === 'string' && match.trim() !== '') { + figma.glyphs = { match }; + } } - if (typeof corrected.processing.collapsePrimitiveWrapper !== 'boolean') { - corrected.processing.collapsePrimitiveWrapper = false; + + // codeOnlyProps — a non-empty match string, else the block is dropped + if (raw.codeOnlyProps !== undefined && raw.codeOnlyProps !== null) { + const match = typeof raw.codeOnlyProps === 'object' ? raw.codeOnlyProps.match : undefined; + if (typeof match === 'string' && match.trim() !== '') { + figma.codeOnlyProps = { match }; + } } - // Validate processing.subcomponents - if (corrected.processing.subcomponents !== undefined) { - const subs = corrected.processing.subcomponents; + // subcomponents + if (raw.subcomponents !== undefined && raw.subcomponents !== null && typeof raw.subcomponents === 'object') { + const subs = raw.subcomponents; const validScopes = ['NESTED', 'PAGE']; - if (subs.scope !== undefined && !validScopes.includes(subs.scope)) { - subs.scope = 'NESTED'; - } if (!Array.isArray(subs.match) || subs.match.length === 0) { - console.warn('Invalid processing.subcomponents.match: must be a non-empty array of strings. Removing subcomponents config.'); - delete corrected.processing.subcomponents; - } else if (subs.exclude !== undefined && !Array.isArray(subs.exclude)) { - delete subs.exclude; + console.warn('Invalid conventions.figma.subcomponents.match: must be a non-empty array of strings. Removing subcomponents convention.'); + } else { + figma.subcomponents = { + scope: validScopes.includes(subs.scope) ? subs.scope : 'NESTED', + match: subs.match, + ...(Array.isArray(subs.exclude) && { exclude: subs.exclude }), + }; + } + } + + // instanceExamples (ADR-050). The on-switch is the presence of the block; + // match is an OPTIONAL name filter — when invalid, drop just the filter. + if (raw.instanceExamples !== undefined && raw.instanceExamples !== null && typeof raw.instanceExamples === 'object') { + const ie = raw.instanceExamples; + const validIeScopes = ['PAGE', 'FILE']; + let match = ie.match; + if (match !== undefined && (!Array.isArray(match) || match.length === 0)) { + console.warn('Invalid conventions.figma.instanceExamples.match: when provided, must be a non-empty array of strings. Ignoring match filter.'); + match = undefined; } + figma.instanceExamples = { + scope: validIeScopes.includes(ie.scope) ? ie.scope : 'PAGE', + ...(match !== undefined && { match }), + ...(Array.isArray(ie.exclude) && { exclude: ie.exclude }), + ...(Array.isArray(ie.parentNames) && { parentNames: ie.parentNames }), + }; } - // Validate processing.images (ADR-063). Presence of the block is the - // on-switch; each member is an independent representation trigger. - if (corrected.processing.images !== undefined) { - const img = corrected.processing.images as unknown as Record; + // images (ADR-063). Presence of the block is the on-switch; each member + // is an independent representation trigger. + if (raw.images !== undefined) { + const img = raw.images as Record; if (img === null || typeof img !== 'object') { - console.warn('Invalid processing.images: expected an object. Removing images config.'); - delete corrected.processing.images; + console.warn('Invalid conventions.figma.images: expected an object. Removing images convention.'); } else { if (img.backgroundImage !== undefined && typeof img.backgroundImage !== 'boolean') { - console.warn(`Invalid processing.images.backgroundImage: expected boolean, got ${typeof img.backgroundImage}. Using default: false`); + console.warn(`Invalid conventions.figma.images.backgroundImage: expected boolean, got ${typeof img.backgroundImage}. Using default: false`); } const sourceProps = Array.isArray(img.sourceProps) ? (img.sourceProps as unknown[]).filter((p): p is string => typeof p === 'string' && p.trim() !== '').map(p => p.trim()) : []; if (img.sourceProps !== undefined && (!Array.isArray(img.sourceProps) || sourceProps.length === 0)) { - console.warn('Invalid processing.images.sourceProps: expected a non-empty array of strings. Ignoring sourceProps.'); + console.warn('Invalid conventions.figma.images.sourceProps: expected a non-empty array of strings. Ignoring sourceProps.'); } - let imageComponent = typeof img.imageComponent === 'string' && (img.imageComponent as string).trim() !== '' - ? (img.imageComponent as string).trim() + let match = typeof img.match === 'string' && (img.match as string).trim() !== '' + ? (img.match as string).trim() : undefined; - if (img.imageComponent !== undefined && !imageComponent) { - console.warn('Invalid processing.images.imageComponent: expected a non-empty string. Ignoring imageComponent.'); + if (img.match !== undefined && !match) { + console.warn('Invalid conventions.figma.images.match: expected a non-empty string. Ignoring match.'); } // The designated component needs a forwarding target: sourceProps[0]. - if (imageComponent && sourceProps.length === 0) { - console.warn('processing.images.imageComponent requires a non-empty sourceProps (sourceProps[0] is its source prop). Ignoring imageComponent.'); - imageComponent = undefined; + if (match && sourceProps.length === 0) { + console.warn('conventions.figma.images.match requires a non-empty sourceProps (sourceProps[0] is its source prop). Ignoring match.'); + match = undefined; } - corrected.processing.images = { + figma.images = { backgroundImage: img.backgroundImage === true, - ...(imageComponent && { imageComponent }), + ...(match && { match }), sourceProps, }; } } - // Validate processing.instanceExamples (ADR-050) - if (corrected.processing.instanceExamples !== undefined) { - const ie = corrected.processing.instanceExamples; - const validIeScopes = ['PAGE', 'FILE']; - if (ie.scope !== undefined && !validIeScopes.includes(ie.scope)) { - ie.scope = 'PAGE'; - } - // match is an OPTIONAL name filter (ADR-050). The on-switch is the presence - // of the instanceExamples block; the primary relevance test is structural - // identity. When match is omitted, every in-scope instance qualifies - // (subject to exclude/parentNames). When provided, it must be a non-empty - // array of strings — otherwise drop just the match filter, not the block. - if (ie.match !== undefined && (!Array.isArray(ie.match) || ie.match.length === 0)) { - console.warn('Invalid processing.instanceExamples.match: when provided, must be a non-empty array of strings. Ignoring match filter.'); - delete ie.match; - } - if (ie.exclude !== undefined && !Array.isArray(ie.exclude)) { - delete ie.exclude; - } - if (ie.parentNames !== undefined && !Array.isArray(ie.parentNames)) { - delete ie.parentNames; - } + // states — concept-keyed map, passed through when it is an object + if (raw.states !== undefined && raw.states !== null && typeof raw.states === 'object' && !Array.isArray(raw.states)) { + figma.states = raw.states; } - // Valid format options + return { figma }; + } + + /** + * Resolve settings: deep-merge over DEFAULT_SETTINGS, then validate and + * correct, replacing invalid enum values with defaults. + */ + private resolveSettings(parsed: unknown): ResolvedSettings { + const merged = parsed + ? this.deepMerge(DEFAULT_SETTINGS, parsed as Partial) + : DEFAULT_SETTINGS; + const corrected = JSON.parse(JSON.stringify(merged)) as ResolvedSettings; + + // Guard against null/undefined nested objects from YAML parsing + // (YAML parsing of keys with only comments produces null instead of empty object) + if (!corrected.spec || typeof corrected.spec !== 'object') { + corrected.spec = JSON.parse(JSON.stringify(DEFAULT_SETTINGS.spec)); + } + if (corrected.data !== undefined && (corrected.data === null || typeof corrected.data !== 'object')) { + delete corrected.data; + } + if (corrected.assets !== undefined && (corrected.assets === null || typeof corrected.assets !== 'object')) { + delete corrected.assets; + } + + const spec = corrected.spec; + + // Valid enum options (authoritative lists in schema Settings.ts) const validKeys = ['SAFE', 'CAMEL', 'SNAKE', 'KEBAB', 'PASCAL', 'TRAIN']; - const validOutputs = ['JSON', 'YAML']; + const validFormats = ['JSON', 'YAML']; const validLayouts = ['LAYOUT', 'PARENT_CHILDREN', 'BOTH']; const validTokens = ['TOKEN', 'TOKEN_NAME', 'TOKEN_FIGMA_EXTENSIONS', 'FIGMA_NAME', 'CUSTOM', 'FIGMA_SYNTAX_WEB', 'FIGMA_SYNTAX_IOS', 'FIGMA_SYNTAX_ANDROID']; const validColors = ['HEX', 'HEXA', 'RGB', 'RGBA', 'HSLA', 'HSB', 'OKLCH', 'OKLAB', 'OBJECT']; + const validVariantDepths = [1, 2, 3, 9999]; + const validDetails = ['FULL', 'LAYERED']; - // Normalize format values to uppercase before validation + // Normalize serialization values to uppercase before validation // (YAML configs commonly use lowercase; schema constants are uppercase) - corrected.format.keys = corrected.format.keys?.toUpperCase() ?? ''; - corrected.format.output = corrected.format.output?.toUpperCase() ?? ''; - corrected.format.layout = corrected.format.layout?.toUpperCase() ?? ''; - if (corrected.format.tokens) { - corrected.format.tokens = corrected.format.tokens.toUpperCase(); + spec.keys = (spec.keys?.toUpperCase() ?? '') as typeof spec.keys; + spec.format = (spec.format?.toUpperCase() ?? '') as typeof spec.format; + spec.layout = (spec.layout?.toUpperCase() ?? '') as typeof spec.layout; + if (spec.tokens) { + spec.tokens = spec.tokens.toUpperCase() as typeof spec.tokens; } - if (corrected.format.color) { - corrected.format.color = corrected.format.color.toUpperCase(); + if (spec.color) { + spec.color = spec.color.toUpperCase() as typeof spec.color; } - // Validate format - if (!validKeys.includes(corrected.format.keys)) { - corrected.format.keys = DEFAULT_CONFIG.format.keys; + if (!validKeys.includes(spec.keys)) { + spec.keys = DEFAULT_SETTINGS.spec.keys; } - if (!validOutputs.includes(corrected.format.output)) { - corrected.format.output = DEFAULT_CONFIG.format.output; + if (!validFormats.includes(spec.format)) { + spec.format = DEFAULT_SETTINGS.spec.format; } - if (!validLayouts.includes(corrected.format.layout)) { - corrected.format.layout = DEFAULT_CONFIG.format.layout; + if (!validLayouts.includes(spec.layout)) { + spec.layout = DEFAULT_SETTINGS.spec.layout; } - if (corrected.format.tokens && !validTokens.includes(corrected.format.tokens)) { - corrected.format.tokens = DEFAULT_CONFIG.format.tokens; + if (spec.tokens && !validTokens.includes(spec.tokens)) { + spec.tokens = DEFAULT_SETTINGS.spec.tokens; } - if (!validColors.includes(corrected.format.color)) { - corrected.format.color = DEFAULT_CONFIG.format.color; + if (!validColors.includes(spec.color)) { + spec.color = DEFAULT_SETTINGS.spec.color; } - - // Strip EOLed include properties — subcomponent inclusion is controlled by - // the presence of processing.subcomponents, and instanceExamples likewise by - // the presence of processing.instanceExamples (so include.instanceExamples is - // not a valid key). - const validIncludeKeys = new Set(['invalidVariants', 'invalidCombinations', 'emptyVariants', 'defaultSlotContent']); - for (const key of Object.keys(corrected.include)) { - if (!validIncludeKeys.has(key)) { - delete (corrected.include as Record)[key]; - } + if (!validVariantDepths.includes(spec.variantDepth)) { + spec.variantDepth = DEFAULT_SETTINGS.spec.variantDepth; + } + if (!validDetails.includes(spec.details)) { + spec.details = DEFAULT_SETTINGS.spec.details; + } + if (typeof spec.collapsePrimitiveWrapper !== 'boolean') { + spec.collapsePrimitiveWrapper = false; } // defaultSlotContent activates only on a literal boolean `true`. Any other // value (e.g. the string "yes", a number, or undefined) is treated as off. - const dsc = (corrected.include as Record).defaultSlotContent; + const dsc = (spec as Record).defaultSlotContent; if (dsc !== undefined && typeof dsc !== 'boolean') { - console.warn(`Invalid include.defaultSlotContent: expected boolean, got ${typeof dsc}. Using default: false`); + console.warn(`Invalid settings.spec.defaultSlotContent: expected boolean, got ${typeof dsc}. Using default: false`); } if (dsc !== true) { - corrected.include.defaultSlotContent = false; + spec.defaultSlotContent = false; + } + + // Split flags: booleans only. These are required on ResolvedSettings, so an + // invalid value is replaced with the schema default rather than removed. + for (const flag of ['splitComponents', 'splitConcerns', 'useSubfolders'] as const) { + const value = (spec as Record)[flag]; + if (typeof value !== 'boolean') { + if (value !== undefined) { + console.warn( + `Invalid settings.spec.${flag}: expected boolean, got ${typeof value}. Using default: ${DEFAULT_SETTINGS.spec[flag]}` + ); + } + spec[flag] = DEFAULT_SETTINGS.spec[flag]; + } } return corrected; } /** - * Merge output config from file with defaults + * Resolve pipeline: both lists guaranteed present; an empty list means + * no work of that kind runs. + */ + private resolvePipeline(parsed: unknown): ResolvedPipeline { + const raw = (parsed && typeof parsed === 'object' ? parsed : {}) as Record; + return { + transformers: Array.isArray(raw.transformers) ? raw.transformers : [...DEFAULT_PIPELINE.transformers], + analyses: Array.isArray(raw.analyses) ? raw.analyses : [...DEFAULT_PIPELINE.analyses], + }; + } + + /** + * Resolve relative data/spec directories against the directory the + * configuration was loaded from. */ - private mergeOutputConfig(fileOutput: unknown): OutputConfig { - if (!fileOutput) { - return CONFIG_DEFAULTS.output; - } - - // Validate types and provide warnings for invalid values - const output = fileOutput as Record; - const result = { ...CONFIG_DEFAULTS.output }; - - if (typeof output.splitComponents === 'boolean') { - result.splitComponents = output.splitComponents; - } else if (output.splitComponents !== undefined) { - console.warn(`Invalid output.splitComponents: expected boolean, got ${typeof output.splitComponents}. Using default: false`); - } - - if (typeof output.splitConcerns === 'boolean') { - result.splitConcerns = output.splitConcerns; - } else if (output.splitConcerns !== undefined) { - console.warn(`Invalid output.splitConcerns: expected boolean, got ${typeof output.splitConcerns}. Using default: false`); - } - - if (typeof output.useSubfolders === 'boolean') { - result.useSubfolders = output.useSubfolders; - } else if (output.useSubfolders !== undefined) { - console.warn(`Invalid output.useSubfolders: expected boolean, got ${typeof output.useSubfolders}. Using default: false`); - } - - if (output.defaultFormat === 'yaml' || output.defaultFormat === 'json') { - result.defaultFormat = output.defaultFormat as OutputFormat; - } else if (output.defaultFormat !== undefined) { - console.warn(`Invalid output.defaultFormat: expected 'yaml' or 'json', got ${output.defaultFormat}. Using default: 'yaml'`); - } - - return result; + private resolveSettingsDirectories(settings: ResolvedSettings, configDir: string): void { + if (settings.data?.directory && !path.isAbsolute(settings.data.directory)) { + settings.data.directory = path.resolve(configDir, settings.data.directory); + } + if (settings.spec.directory && !path.isAbsolute(settings.spec.directory)) { + settings.spec.directory = path.resolve(configDir, settings.spec.directory); + } } - + /** * Deep merge two objects, with source overriding target * Null values from source are ignored to prevent YAML parsing issues @@ -376,15 +457,22 @@ export class ConfigLoader { return result as T; } - + /** * Get default configuration with convention-based defaults */ private getDefaultConfig(): CLIConfig { + const settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as ResolvedSettings; + settings.data = { directory: path.resolve(CONFIG_DEFAULTS.dataDirectory) }; + settings.spec.directory = path.resolve(CONFIG_DEFAULTS.outputDirectory); + return { - dataDirectory: path.resolve(CONFIG_DEFAULTS.dataDirectory), - outputDirectory: path.resolve(CONFIG_DEFAULTS.outputDirectory), - config: DEFAULT_CONFIG, + conventions: this.resolveConventions(undefined), + settings, + pipeline: { + transformers: [...DEFAULT_PIPELINE.transformers], + analyses: [...DEFAULT_PIPELINE.analyses], + }, }; } } diff --git a/packages/cli/src/Config/ConfigTemplates.ts b/packages/cli/src/Config/ConfigTemplates.ts index 7fea335d..99729e05 100644 --- a/packages/cli/src/Config/ConfigTemplates.ts +++ b/packages/cli/src/Config/ConfigTemplates.ts @@ -1,198 +1,232 @@ /** * Configuration Templates - * - * Generates configuration templates with production-ready defaults - * and inline documentation for the init command. + * + * Generates the split-configuration templates (ADR-071) with + * production-ready defaults and inline documentation for the init command: + * `config/conventions.yaml`, `config/settings.yaml`, `config/pipeline.yaml`. */ import { CONFIG_DEFAULTS } from './ConfigDefaults.js'; /** - * Generate a YAML configuration template with inline comments - * Uses production-ready defaults. + * Generate the `config/conventions.yaml` template with inline comments. */ -export function generateConfigTemplate(): string { - return `# Specs CLI Configuration -# -# This file configures how Specs fetches and processes Figma component data. +export function generateConventionsTemplate(): string { + return `# Facts about the Figma library — every consumer of that library declares the same values. +# A wrong value here produces incorrect output, not merely different output. +# Absence of a block means the library has no such convention. # See: https://www.specsplugin.com/settings/ -# ─── 'fetch'ed Sources (CLI only) ───────────────────────────────────────────── -# Where Figma data is fetched from and stored locally. - -# Where fetch writes payloads, and where generate/scan read from. -dataDirectory: ${CONFIG_DEFAULTS.dataDirectory} - -# Figma file sources to fetch and process. -# Example: -# sources: -# library: -# key: YOUR_FIGMA_FILE_KEY -# data: [file, variables, styles] -sources: {} - -# ─── 'generated' Output (CLI only) ────────────────────────────────────────────── -# Where spec files are written and how they are organized on disk. +figma: + # Naming convention your Figma file uses for layer and property names, and the + # target a spec reverses into when rendered back to Figma: NONE, SENTENCE, or TITLE. + # NONE declares no convention — names are not checked and none are preserved. + # Declaring one records the Figma name wherever a key cannot reconstruct it. + # See: https://www.specsplugin.com/settings/figma-keys/ + # naming: NONE + + # Naming pattern identifying icon glyph instances. Use {i} as the placeholder + # for the glyph name (e.g. 'DS Icon Glyph / {i}' matches + # 'DS Icon Glyph / arrow-down' and extracts 'arrow-down'). + # glyphs: + # match: 'DS Icon Glyph / {i}' + + # Literal layer name of the code-only props container layer. + # Presence enables code-only prop extraction from matching layers. + # codeOnlyProps: + # match: 'Code only props' + + # Subcomponent organization and naming. + # Presence of this block enables subcomponent detection; remove to disable. + # See: https://www.specsplugin.com/guides/subcomponent-scoping/ + subcomponents: + # Where the library keeps subcomponents: NESTED (component anatomy only) + # or PAGE (also search the Figma page) + # scope: NESTED + + # Template patterns defining which assets are subcomponents. + # Uses {C} (component name) and {S} (subcomponent name) placeholders. + match: + - '{C} / _ / {S}' + + # Template patterns to exclude from matches (optional). + # exclude: + # - '{C} / Examples / {S}' + + # Instance example organization and naming (Pro). Presence of this block is + # the on-switch. + # See: https://www.specsplugin.com/guides/instance-examples/ + # instanceExamples: + # # Where the library keeps instance examples: PAGE or FILE (default: PAGE) + # scope: PAGE + # # Optional name filter; {C} = component name. Omit to match every + # # in-scope instance of the component. + # match: + # - '{C} Example' + # # A candidate's immediate parent frame or section must match one of these. + # parentNames: + # - Examples + + # How the library expresses images: presence of this block is the on-switch; + # each member is an independent representation trigger. + # See: https://www.specsplugin.com/guides/images/ + # images: + # # The library expresses images as container fills, emitted as + # # backgroundImage; also the fallback for fills outside the designated + # # component. + # backgroundImage: true + # # The library's designated image component; instances route their image + # # through the first source prop below. Requires sourceProps. + # match: DS Image + # # Code-only prop names (exact Figma names) typed as images; the first + # # is the image component's own source prop. + # sourceProps: + # - imageSource + + # The library authors slot constraints (anyOf, minItems, maxItems) as + # code-only props, consolidated into the slot property. + # Requires codeOnlyProps to be set. (default: false) + slotConstraints: false + + # The library authors numeric props as TEXT code-only props whose values + # parse as valid numbers, emitted as NumberProp instead of StringProp. + # (default: false) + # inferNumberProps: false + + # Semantic states: classify Figma variant props as state concepts, keyed by + # concept name. Absence means all variant props emit as data-* selectors. + # See: https://www.specsplugin.com/settings/states/ + # states: + # hover: + # prop: state + # value: hover + # disabled: + # prop: disabled # boolean prop — value defaults to "true" +`; +} -# Default location for generated spec files (can override with -o flag). -outputDirectory: ${CONFIG_DEFAULTS.outputDirectory} +/** + * Generate the `config/settings.yaml` template with inline comments. + */ +export function generateSettingsTemplate(): string { + return `# Choices about this run — sources, spec output, assets. +# A different team reading the same library could set any of these differently +# and still be correct. +# See: https://www.specsplugin.com/settings/ # Author name for generated specs (optional, defaults to "Unknown"). author: -# File structure options. -output: - # Write one file per component instead of a single library file (default: false) - splitComponents: false - # Split output into separate api, variants, and examples files (default: false) - splitConcerns: false - # When splitComponents is true, nest each file in a subfolder (default: false) - useSubfolders: false - -# ─── Configuration options (same as the Figma plugin) ────────────────────────────── -# See: https://www.specsplugin.com/settings/ +# Source acquisition: what to fetch, and where fetched artifacts land. +data: + # Where fetch writes payloads, and where generate/scan read from. + directory: ${CONFIG_DEFAULTS.dataDirectory} + + # Figma file sources to fetch and process, keyed by source name. + # Example: + # sources: + # library: + # key: YOUR_FIGMA_FILE_KEY + # fetch: [file, variables, styles] + sources: {} + +# The generated spec: where it lands, how it is split, what it contains, +# and how values are serialized. +spec: + # Default location for generated spec files (can override with -o flag). + directory: ${CONFIG_DEFAULTS.outputDirectory} + + # Serialization format: JSON or YAML + format: JSON + + # Key name transformation: SAFE, CAMEL, SNAKE, KEBAB, PASCAL, TRAIN + # See: https://www.specsplugin.com/guides/key-formatting/ + keys: SAFE + + # Layout representation: LAYOUT, PARENT_CHILDREN, or BOTH + # See: https://www.specsplugin.com/guides/data-layout/ + layout: LAYOUT + + # Token reference format: TOKEN, TOKEN_NAME, TOKEN_FIGMA_EXTENSIONS, FIGMA_NAME, CUSTOM, + # FIGMA_SYNTAX_WEB, FIGMA_SYNTAX_IOS, or FIGMA_SYNTAX_ANDROID + # See: https://www.specsplugin.com/settings/tokens/ + # Requires a license key to resolve token references in output. + tokens: TOKEN + + # Color value format: HEX, HEXA, RGB, RGBA, HSLA, HSB, OKLCH, OKLAB, or OBJECT + # See: https://www.specsplugin.com/settings/color/ + color: HEX + + # Maximum variant property depth to process: 1, 2, 3, or 9999 (unlimited) + # See: https://www.specsplugin.com/guides/variant-depth/ + variantDepth: 9999 + + # Detail level for variant data: FULL or LAYERED + # See: https://www.specsplugin.com/guides/variant-layering/ + details: LAYERED + + # Collapse primitive wrappers: when true, a component whose root is a plain + # container wrapping a single text or glyph element (no meaningful container + # styles, no slot bindings) is collapsed — the wrapper is stripped and the + # leaf becomes the spec root. All-or-nothing across variants. (default: false) + # collapsePrimitiveWrapper: false + + # Include invalid variant data in output (default: false) + # invalidVariants: false + + # Calculate and include invalid property combinations (default: true) + # Requires a license key to compute combinations in output. + # invalidCombinations: true + + # Include layered variants that contain no elements (default: false) + # emptyVariants: false + + # Emit the component's default slot content as examples (Pro; default: false) + # See: https://www.specsplugin.com/guides/default-slot-content/ + # defaultSlotContent: false + + # Write one file per component instead of a single library file (default: true) + # splitComponents: true + + # Split output into separate api, variants, and examples files (default: true) + # splitConcerns: true + + # When splitComponents is true, nest each file in a subfolder (default: true) + # useSubfolders: true + +# Shared resources every code output points at: icons, images, generated CSS, fonts. +# assets: +# directory: ./assets +`; +} -config: - - format: - # Output format: JSON or YAML - output: JSON - - # Key name transformation: SAFE, CAMEL, SNAKE, KEBAB, PASCAL, TRAIN - # See: https://www.specsplugin.com/guides/key-formatting/ - keys: SAFE - - # Naming convention your Figma file uses for layer and property names, and the - # target a spec reverses into when rendered back to Figma: NONE, SENTENCE, or TITLE. - # NONE declares no convention — names are not checked and none are preserved. - # Declaring one records the Figma name wherever a key cannot reconstruct it. - # See: https://www.specsplugin.com/settings/figma-keys/ - # figmaKeys: NONE - - # Layout representation: LAYOUT, PARENT_CHILDREN, or BOTH - # See: https://www.specsplugin.com/guides/data-layout/ - layout: LAYOUT - - # Token reference format: TOKEN, TOKEN_NAME, TOKEN_FIGMA_EXTENSIONS, FIGMA_NAME, CUSTOM, - # FIGMA_SYNTAX_WEB, FIGMA_SYNTAX_IOS, or FIGMA_SYNTAX_ANDROID - # See: https://www.specsplugin.com/settings/tokens/ - # Requires a license key to resolve token references in output. - tokens: TOKEN - - # Color value format: HEX, HEXA, RGB, RGBA, HSLA, HSB, OKLCH, OKLAB, or OBJECT - # See: https://www.specsplugin.com/settings/color/ - color: HEX - - processing: - # Subcomponent discovery configuration. - # Presence of this block enables subcomponent detection; remove to disable. - # See: https://www.specsplugin.com/guides/subcomponent-scoping/ - subcomponents: - # Where to search: NESTED (component anatomy only) or PAGE (also search Figma page) - # scope: NESTED - - # Template patterns defining which assets are subcomponents. - # Uses {C} (component name) and {S} (subcomponent name) placeholders. - match: - - '{C} / _ / {S}' - - # Template patterns to exclude from matches (optional). - # exclude: - # - '{C} / Examples / {S}' - - # Naming pattern to detect icon glyph instances. Use {i} as the placeholder - # for the glyph name (e.g. 'DS Icon Glyph / {i}' matches - # 'DS Icon Glyph / arrow-down' and extracts 'arrow-down'). - # glyphNamePattern: 'DS Icon Glyph / {i}' - - # Naming pattern for the code-only props container layer (e.g. 'Code only props') - # Presence enables code-only prop extraction from matching layers. - # codeOnlyPropsPattern: 'Code only props' - - # Consolidate slot constraints (anyOf, minItems, maxItems) from code-only props - # into the slot property. Requires codeOnlyPropsPattern to be set. (default: false) - slotConstraints: false - - # Maximum variant property depth to process: 1, 2, 3, or 9999 (unlimited) - # See: https://www.specsplugin.com/guides/variant-depth/ - variantDepth: 9999 - - # Detail level for variant data: FULL or LAYERED - # See: https://www.specsplugin.com/guides/variant-layering/ - details: LAYERED - - # Infer number props: when true, TEXT code-only props whose values parse as - # valid numbers are emitted as NumberProp instead of StringProp. (default: false) - # inferNumberProps: false - - # Collapse primitive wrappers: when true, a component whose root is a plain - # container wrapping a single text or glyph element (no meaningful container - # styles, no slot bindings) is collapsed — the wrapper is stripped and the - # leaf becomes the spec root. All-or-nothing across variants. (default: false) - # collapsePrimitiveWrapper: false - - # Instance example detection (Pro). Presence of this block is the on-switch. - # See: https://www.specsplugin.com/guides/instance-examples/ - # instanceExamples: - # # Where to search for candidate instances: PAGE or FILE (default: PAGE) - # scope: PAGE - # # Optional name filter; {C} = component name. Omit to match every - # # in-scope instance of the component. - # match: - # - '{C} Example' - # # A candidate's immediate parent frame or section must match one of these. - # parentNames: - # - Examples - - # Semantic states: classify Figma variant props as state concepts, keyed by - # concept name. Absence means all variant props emit as data-* selectors. - # See: https://www.specsplugin.com/settings/states/ - # states: - # hover: - # prop: state - # value: hover - # disabled: - # prop: disabled # boolean prop — value defaults to "true" - - # Image processing: presence of this block is the on-switch; each member - # is an independent representation trigger. - # See: https://www.specsplugin.com/guides/images/ - # images: - # # Detect image fills on containers as backgroundImage; also the - # # fallback for fills outside the designated component. - # backgroundImage: true - # # Designated image component; instances route their image through the - # # first source prop below. Requires sourceProps. - # imageComponent: DS Image - # # Code-only prop names (exact Figma names) typed as images; the first - # # is the image component's own source prop. - # sourceProps: - # - imageSource - - include: - # Subcomponent inclusion is controlled by processing.subcomponents above. - # If the subcomponents block is present, subcomponents are included. - - # Include invalid variant data in output (default: false) - # invalidVariants: false - - # Calculate and include invalid property combinations (default: true) - # Requires a license key to compute combinations in output. - # invalidCombinations: true - - # Include layered variants that contain no elements (default: false) - # emptyVariants: false - - # Emit the component's default slot content as examples (Pro; default: false) - # See: https://www.specsplugin.com/guides/default-slot-content/ - # defaultSlotContent: false - - - # ─── Transform configuration ───────────────────────────────────────────────── - # Transformers to run with \`specs transform\`. Absence means CLI defaults apply (contract). - # transformers: - # - name: contract - # - name: css - # - name: styling +/** + * Generate the `config/pipeline.yaml` template with inline comments. + */ +export function generatePipelineTemplate(): string { + return `# Work this workspace runs: transformers and analyses. +# Absence means CLI defaults apply. + +# Transformers to run with \`specs transform\` (default: contract). +# transformers: +# - name: contract +# - name: css +# - name: react + +# Analyses to run with \`specs analyze\`. +# analyses: +# - name: dependencies `; } + +/** + * The three split-configuration templates, keyed by their file path relative + * to the workspace root. + */ +export function generateConfigTemplates(): Record { + return { + 'config/conventions.yaml': generateConventionsTemplate(), + 'config/settings.yaml': generateSettingsTemplate(), + 'config/pipeline.yaml': generatePipelineTemplate(), + }; +} diff --git a/packages/cli/src/Config/migrations/configV1.ts b/packages/cli/src/Config/migrations/configV1.ts new file mode 100644 index 00000000..8a5709b5 --- /dev/null +++ b/packages/cli/src/Config/migrations/configV1.ts @@ -0,0 +1,135 @@ +/** + * config v1 → v2 (ADR-071) + * + * v1 is the single `specs.config.yaml` with `dataDirectory`, `outputDirectory`, + * `author`, `sources`, `output` and a `config` block. v2 is the three files + * under `config/`: conventions, settings, pipeline. + * + * This runs from `specs migrate config`, never from the loader. Config loading + * happens inside read-only commands and in CI, and a read path must not write + * to a workspace. + */ + +import type { SourceEntry } from '@directededges/specs-schema'; + +/** The three split shapes, as authored — unresolved, with absent members omitted. */ +export interface MigratedConfig { + conventions: unknown; + settings: unknown; + pipeline: unknown; +} + +/** + * Map a pre-split config file into the three split shapes, in memory. + */ +export function migrateConfigV1(parsed: unknown): MigratedConfig { + const raw = (parsed ?? {}) as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const figma: Record = {}; + const spec: Record = {}; + const data: Record = {}; + const settings: Record = {}; + const pipeline: Record = {}; + /* eslint-enable @typescript-eslint/no-explicit-any */ + + // Support deprecated 'sourceDirectory' with warning (predates the split) + let dataDirectory = raw.dataDirectory; + if (!dataDirectory && raw.sourceDirectory) { + console.warn("Warning: 'sourceDirectory' is deprecated, use 'dataDirectory' instead"); + dataDirectory = raw.sourceDirectory; + } + if (dataDirectory !== undefined) { + data.directory = dataDirectory; + } + if (raw.outputDirectory !== undefined) { + spec.directory = raw.outputDirectory; + } + if (raw.author !== undefined) { + settings.author = raw.author; + } + + // sources -> settings.data.sources, renaming each source's `data` to `fetch` + if (raw.sources && typeof raw.sources === 'object') { + const sources: Record = {}; + for (const [name, entry] of Object.entries(raw.sources as Record)) { + if (!entry || typeof entry !== 'object') continue; + const src = entry as Record; + const fetch = Array.isArray(src.data) ? src.data : (Array.isArray(src.fetch) ? src.fetch : undefined); + sources[name] = { + key: src.key as string, + ...(fetch && { fetch: fetch as string[] }), + }; + } + data.sources = sources; + } + + // output.{splitComponents,splitConcerns,useSubfolders} -> settings.spec + // + // These defaulted to false in v1 and default to true in v2, so an omitted + // flag meant "off" in the source and would mean "on" in the target. A + // migration changes where configuration lives, not what a workspace emits, + // so an omitted flag is written out explicitly as false. Deleting those + // three lines is how a migrated workspace opts in to the new default. + const v1Output = (raw.output && typeof raw.output === 'object' ? raw.output : {}) as Record; + for (const flag of ['splitComponents', 'splitConcerns', 'useSubfolders'] as const) { + spec[flag] = v1Output[flag] !== undefined ? v1Output[flag] : false; + } + + const cfg = (raw.config && typeof raw.config === 'object' ? raw.config : {}) as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + + // config.format -> settings.spec (output->format) + conventions.figma.naming + if (cfg.format && typeof cfg.format === 'object') { + if (cfg.format.output !== undefined) spec.format = cfg.format.output; + for (const key of ['keys', 'layout', 'tokens', 'color'] as const) { + if (cfg.format[key] !== undefined) spec[key] = cfg.format[key]; + } + if (cfg.format.figmaKeys !== undefined) figma.naming = cfg.format.figmaKeys; + } + + // config.processing -> settings.spec + conventions.figma + if (cfg.processing && typeof cfg.processing === 'object') { + const proc = cfg.processing; + for (const key of ['variantDepth', 'details', 'collapsePrimitiveWrapper'] as const) { + if (proc[key] !== undefined) spec[key] = proc[key]; + } + for (const key of ['subcomponents', 'instanceExamples', 'states', 'slotConstraints', 'inferNumberProps'] as const) { + if (proc[key] !== undefined) figma[key] = proc[key]; + } + if (proc.glyphNamePattern !== undefined) { + figma.glyphs = { match: proc.glyphNamePattern }; + } + if (proc.codeOnlyPropsPattern !== undefined) { + figma.codeOnlyProps = { match: proc.codeOnlyPropsPattern }; + } + if (proc.images && typeof proc.images === 'object') { + figma.images = { + ...(proc.images.imageComponent !== undefined && { match: proc.images.imageComponent }), + ...(proc.images.backgroundImage !== undefined && { backgroundImage: proc.images.backgroundImage }), + ...(proc.images.sourceProps !== undefined && { sourceProps: proc.images.sourceProps }), + }; + } + } + + // config.include -> settings.spec + if (cfg.include && typeof cfg.include === 'object') { + for (const key of ['invalidVariants', 'invalidCombinations', 'emptyVariants', 'defaultSlotContent'] as const) { + if (cfg.include[key] !== undefined) spec[key] = cfg.include[key]; + } + } + + // config.transformers -> pipeline.transformers + if (cfg.transformers !== undefined) { + pipeline.transformers = cfg.transformers; + } + + if (Object.keys(data).length > 0) settings.data = data; + if (Object.keys(spec).length > 0) settings.spec = spec; + + return { + conventions: Object.keys(figma).length > 0 ? { figma } : undefined, + settings: Object.keys(settings).length > 0 ? settings : undefined, + pipeline: Object.keys(pipeline).length > 0 ? pipeline : undefined, + }; +} + diff --git a/packages/cli/src/Types/CLIConfig.ts b/packages/cli/src/Types/CLIConfig.ts index a60bbc4d..e2b69339 100644 --- a/packages/cli/src/Types/CLIConfig.ts +++ b/packages/cli/src/Types/CLIConfig.ts @@ -1,22 +1,28 @@ /** * CLI configuration structure + * + * The three artifacts a workspace authors (ADR-071), fully resolved: + * `config/conventions.yaml`, `config/settings.yaml`, `config/pipeline.yaml`. + * + * A pre-split `specs.config.yaml` still loads: `ConfigLoader` migrates it into + * this shape in memory and warns once. */ -import type { ResolvedConfig } from '@directededges/specs-schema'; -import type { OutputConfig } from './OutputConfig.js'; +import type { + ResolvedConventions, + ResolvedSettings, + ResolvedPipeline, +} from '@directededges/specs-schema'; export type CliSourceDataKind = 'file' | 'variables' | 'styles'; -export interface CliSourceConfig { - key: string; - data: CliSourceDataKind[]; -} - export interface CLIConfig { - dataDirectory?: string; - outputDirectory?: string; - author?: string; - config: ResolvedConfig; - output?: OutputConfig; - sources?: Record; + /** Facts about the Figma library — every consumer of that library declares the same values. */ + conventions: ResolvedConventions; + /** Choices about this run — sources, spec output, assets. */ + settings: ResolvedSettings; + /** Work this workspace runs: transformers and analyses. */ + pipeline: ResolvedPipeline; + /** Absolute path of the directory the configuration was loaded from, for resolving relative paths. */ + configDir?: string; } diff --git a/packages/cli/src/Types/OutputConfig.ts b/packages/cli/src/Types/OutputConfig.ts index 92f2829f..f3b081ca 100644 --- a/packages/cli/src/Types/OutputConfig.ts +++ b/packages/cli/src/Types/OutputConfig.ts @@ -12,8 +12,8 @@ export interface OutputConfig { } export const DEFAULT_OUTPUT_CONFIG: OutputConfig = { - splitComponents: false, - splitConcerns: false, - useSubfolders: false, + splitComponents: true, + splitConcerns: true, + useSubfolders: true, defaultFormat: 'json' }; diff --git a/packages/cli/src/Types/Transformer.ts b/packages/cli/src/Types/Transformer.ts index 6e06c2c2..56e11ec3 100644 --- a/packages/cli/src/Types/Transformer.ts +++ b/packages/cli/src/Types/Transformer.ts @@ -3,7 +3,7 @@ import type { ProcessingStates } from '../transforms/states.js'; export interface TransformerContext { /** Absolute path to the component's output subfolder. */ outputDir: string; - /** camelCase component folder name (e.g. `egdsButton`). */ + /** camelCase component folder name (e.g. `dsButton`). */ componentKey: string; /** Token format from config.format.tokens. Drives CSS variable resolution. */ tokensFormat: string; diff --git a/packages/cli/src/Types/index.ts b/packages/cli/src/Types/index.ts index f50cafab..62627870 100644 --- a/packages/cli/src/Types/index.ts +++ b/packages/cli/src/Types/index.ts @@ -1,3 +1,3 @@ -export type { CLIConfig, CliSourceConfig, CliSourceDataKind } from './CLIConfig.js'; +export type { CLIConfig, CliSourceDataKind } from './CLIConfig.js'; export type { OutputConfig, OutputFormat } from './OutputConfig.js'; export { DEFAULT_OUTPUT_CONFIG } from './OutputConfig.js'; diff --git a/packages/cli/src/Writers/FileManifest.ts b/packages/cli/src/Writers/FileManifest.ts index d32f0821..816509f4 100644 --- a/packages/cli/src/Writers/FileManifest.ts +++ b/packages/cli/src/Writers/FileManifest.ts @@ -75,12 +75,10 @@ export class FileManifest { const sortedComponents = sortComponentsByName(components); // Determine output mode and build appropriate manifest - const splitComponents = config.splitComponents || false; - const splitConcerns = config.splitConcerns || false; - const useSubfolders = config.useSubfolders || false; - + const { splitComponents, splitConcerns, useSubfolders } = config; + if (!splitComponents && !splitConcerns) { - // Mode 1: Single file (default) + // Mode 1: Single library file this.buildSingleFileManifest(sortedComponents); } else if (splitComponents && !splitConcerns) { // Mode 2 & 3: Per-component (flat or subfolders) @@ -89,7 +87,8 @@ export class FileManifest { // Mode 4: Per-concern this.buildPerConcernManifest(sortedComponents); } else { - // Mode 5: Combined (per-component + per-concern) + // Mode 5: Combined (per-component + per-concern) — the default. Component + // folders are structural here, so useSubfolders does not apply. this.buildCombinedManifest(sortedComponents); } } diff --git a/packages/cli/src/analyzers/Keys.ts b/packages/cli/src/analyzers/Keys.ts index b33c7fbd..70d0b3f4 100644 --- a/packages/cli/src/analyzers/Keys.ts +++ b/packages/cli/src/analyzers/Keys.ts @@ -97,7 +97,7 @@ export class KeysAnalyzer implements Transformer { private readonly _components = new Set(); private _totalNames = 0; private _outputFormat: 'JSON' | 'YAML' = 'JSON'; - /** Read from each spec's own `metadata.config`; undefined means no convention declared. */ + /** Read from each spec's own metadata; undefined means no convention declared. */ private _convention: DeclaredConvention | undefined; async run(apiYaml: Record, context: TransformerContext): Promise { @@ -261,11 +261,17 @@ function causeOf(name: string): Cause { return 'casing'; } -/** The convention the spec was generated under, from its own `metadata.config`. */ +/** The convention the spec was generated under, from its own metadata. */ function declaredConvention(apiYaml: Record): DeclaredConvention | undefined { const metadata = apiYaml.metadata as Record | undefined; - const config = metadata?.config as Record | undefined; - const format = config?.format as Record | undefined; - const value = format?.figmaKeys; + // A spec generated since the conventions/settings split (ADR-071) records the Figma + // naming convention at `metadata.conventions.figma.naming`; older specs carry + // `metadata.config.format.figmaKeys`. Read the new key first and fall back so + // pre-split specs still analyze. + const conventions = metadata?.conventions as Record | undefined; + const figma = conventions?.figma as Record | undefined; + const legacyConfig = metadata?.config as Record | undefined; + const legacyFormat = legacyConfig?.format as Record | undefined; + const value = figma?.naming ?? legacyFormat?.figmaKeys; return value === 'SENTENCE' || value === 'TITLE' ? value : undefined; } diff --git a/packages/cli/src/bridge/client.ts b/packages/cli/src/bridge/client.ts index 24804b69..f0afcd6e 100644 --- a/packages/cli/src/bridge/client.ts +++ b/packages/cli/src/bridge/client.ts @@ -1,6 +1,6 @@ // Thin HTTP client for talking to a running bridge server (see bridge/server.ts). -import type { ResolvedConfig } from '@directededges/specs-schema'; +import type { ResolvedConventions, ResolvedSettings } from '@directededges/specs-schema'; import { HTTP_PORT } from './config.js'; export interface BridgeConnection { @@ -35,11 +35,13 @@ export interface RenderRequestBody { /** Delete a same-titled page node before rendering, instead of erroring on the collision. */ overwrite?: boolean; /** - * The workspace config, as a fallback for a spec that carries no `metadata.config` — - * a hand-authored one, say. The spec's own config still wins: it records how the spec - * was produced, which is what render has to reverse. + * The workspace conventions and settings, as a fallback for a spec that carries no + * `metadata.conventions`/`metadata.settings` — a hand-authored one, say. The spec's + * own record still wins: it captures how the spec was produced, which is what render + * has to reverse. */ - config?: ResolvedConfig; + conventions?: ResolvedConventions; + settings?: ResolvedSettings; } // Render reports success/failure only — the round-trip spec read is a second, @@ -71,13 +73,14 @@ export interface GenerateFromSelectionRequestBody { /** Generate from this node instead of the current selection (the plugin selects it first). */ nodeId?: string; /** - * The config to process the node under. Without it the plugin builds the spec under its - * own UI settings, so the same node in the same file yields different specs depending on - * how someone last left the panel — and a round trip compares a baseline made under one - * config against a read made under another. Applies to this request only; it must never - * be stored as the plugin's settings. + * The conventions and settings to process the node under. Without them the plugin builds + * the spec under its own UI settings, so the same node in the same file yields different + * specs depending on how someone last left the panel — and a round trip compares a + * baseline made under one configuration against a read made under another. Applies to + * this request only; it must never be stored as the plugin's settings. */ - config?: ResolvedConfig; + conventions?: ResolvedConventions; + settings?: ResolvedSettings; /** * Testing utility: delete the node once its spec has been read. A catalogue sweep that * renders and reads every component otherwise leaves all of them on the page. diff --git a/packages/cli/src/bridge/server.ts b/packages/cli/src/bridge/server.ts index b427c1b9..c04980aa 100644 --- a/packages/cli/src/bridge/server.ts +++ b/packages/cli/src/bridge/server.ts @@ -42,7 +42,7 @@ // back on the matching result message, so responses route to the right // caller even with multiple connections and requests in flight. -import type { ResolvedConfig } from '@directededges/specs-schema'; +import type { ResolvedConventions, ResolvedSettings } from '@directededges/specs-schema'; import { WebSocketServer, type WebSocket } from 'ws'; import { createServer } from 'http'; import { readFileSync, readdirSync, statSync } from 'fs'; @@ -119,7 +119,7 @@ function resolveDirs(fromPath: string): { specsDir: string; dataDir: string; ali const specsDir = envSpecsDir ?? pathResolve(workspaceDir as string, 'specs'); const dataDir = envDataDir ?? pathResolve(workspaceDir as string, 'data'); // Data files are named {sourceAlias}.manifest.md, {sourceAlias}.file.json, etc. - // The source alias comes from the first key under `sources` in specs.config.yaml. + // The source alias comes from the first key under `data.sources` in the workspace settings. const { aliases, glyphNamePattern } = resolveSources(workspaceDir as string); return { specsDir, dataDir, aliases, glyphNamePattern }; } @@ -231,14 +231,14 @@ const http = createServer((req, res) => { let genBody = ''; req.on('data', (chunk) => { genBody += chunk; }); req.on('end', () => { - let params: { fileKey?: string; nodeId?: string; config?: ResolvedConfig; remove?: boolean }; + let params: { fileKey?: string; nodeId?: string; conventions?: ResolvedConventions; settings?: ResolvedSettings; remove?: boolean }; try { params = genBody ? JSON.parse(genBody) : {}; } catch { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid JSON body.' })); return; } - sendGenerateFromSelection(params.fileKey, params.nodeId, params.config) + sendGenerateFromSelection(params.fileKey, params.nodeId, params.conventions, params.settings) // The spec is in hand before the node goes, so a failed removal cannot cost the read. .then(async (result) => { if (params.remove && result.success && result.nodeId) { @@ -267,14 +267,14 @@ const http = createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - let params: { specPath?: string; spec?: Record; pageId?: string | null; fileKey?: string; overwrite?: boolean; config?: ResolvedConfig }; + let params: { specPath?: string; spec?: Record; pageId?: string | null; fileKey?: string; overwrite?: boolean; conventions?: ResolvedConventions; settings?: ResolvedSettings }; try { params = JSON.parse(body); } catch { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid JSON body.' })); return; } - const { specPath: specArg, spec: preParsedSpec, pageId = null, fileKey, overwrite, config } = params; + const { specPath: specArg, spec: preParsedSpec, pageId = null, fileKey, overwrite, conventions, settings } = params; if (!specArg) { res.writeHead(400); @@ -289,7 +289,7 @@ const http = createServer((req, res) => { return; } - sendRender(specArg, pageId, fileKey, preParsedSpec, overwrite, config) + sendRender(specArg, pageId, fileKey, preParsedSpec, overwrite, conventions, settings) .then((result) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); @@ -524,18 +524,35 @@ function toCamelCase(str: string): string { // ── Workspace config resolution ─────────────────────────────────────────────── -/** - * Read specs.config.yaml from the workspace root and return the first source alias. - * Data files are named {alias}.manifest.md, {alias}.file.json, etc. — matching what - * `specs scan` produces (line: baseName = path.basename(alias + '.file.json', '.file.json')). - * Falls back to the workspace directory name if the config is absent or has no sources. - */ /** * Every source alias declared in the workspace config, in declaration order, plus the * glyph naming pattern. Render resolves against all of them: a component can instance a * component, bind a token, or place a glyph from any library the workspace declares. + * + * The workspace authors config/settings.yaml and config/conventions.yaml (ADR-071); + * a pre-split workspace still carries a single specs.config.yaml, so that shape is + * read as a fallback to keep older workspaces rendering. */ function resolveSources(workspaceDir: string): { aliases: string[]; glyphNamePattern?: string } { + try { + const settings = parse(readFileSync(pathResolve(workspaceDir, 'config', 'settings.yaml'), 'utf8')) as { + data?: { sources?: Record }; + }; + const sources = settings?.data?.sources; + const aliases = sources && typeof sources === 'object' ? Object.keys(sources) : []; + let glyphNamePattern: string | undefined; + try { + const conventions = parse(readFileSync(pathResolve(workspaceDir, 'config', 'conventions.yaml'), 'utf8')) as { + figma?: { glyphs?: { match?: string } }; + }; + glyphNamePattern = conventions?.figma?.glyphs?.match; + } catch { + // No conventions file — no glyph pattern. + } + return { aliases, glyphNamePattern }; + } catch { + // No split config — fall through to the legacy single-file shape. + } const configPath = pathResolve(workspaceDir, 'specs.config.yaml'); try { const config = parse(readFileSync(configPath, 'utf8')) as { @@ -574,19 +591,19 @@ async function sendRemoveNode(nodeId: string, fileKey?: string): Promise<{ succe return promise as Promise<{ success: boolean; error?: string }>; } -async function sendGenerateFromSelection(fileKey?: string, nodeId?: string, config?: ResolvedConfig): Promise { +async function sendGenerateFromSelection(fileKey?: string, nodeId?: string, conventions?: ResolvedConventions, settings?: ResolvedSettings): Promise { const conn = registry.resolve(fileKey); const { requestId, promise } = requests.create(60000, 'Timed out waiting for generateFromSelection-result.'); - // `config` travels with the request and governs how the plugin builds this one spec. - // The plugin must not adopt it as its own settings. - conn.ws.send(JSON.stringify({ type: 'generateFromSelection', requestId, nodeId, config })); + // `conventions`/`settings` travel with the request and govern how the plugin builds + // this one spec. The plugin must not adopt them as its own settings. + conn.ws.send(JSON.stringify({ type: 'generateFromSelection', requestId, nodeId, conventions, settings })); return promise as Promise; } /** * Send a single renderComponent message over the WebSocket and wait for the result. */ -async function sendRender(specPath: string, rawPageId: string | null, fileKey?: string, preParsedSpec?: Record, overwrite?: boolean, config?: ResolvedConfig): Promise { +async function sendRender(specPath: string, rawPageId: string | null, fileKey?: string, preParsedSpec?: Record, overwrite?: boolean, conventions?: ResolvedConventions, settings?: ResolvedSettings): Promise { const conn = registry.resolve(fileKey); let spec: Record; @@ -616,10 +633,14 @@ async function sendRender(specPath: string, rawPageId: string | null, fileKey?: try { return fn(); } finally { bridgeTimings.push({ label, ms: Date.now() - start }); } }; - const keyFormat = (spec as { metadata?: { config?: { format?: { keys?: string } } } })?.metadata?.config?.format?.keys - ?? (spec as { components?: Record }) - ?.components?.[Object.keys((spec as { components?: Record }).components ?? {})[0]] - ?.metadata?.config?.format?.keys; + // A spec generated since the conventions/settings split (ADR-071) records its key + // format at `metadata.settings.spec.keys`; older specs carry `metadata.config.format.keys`. + // Read the new key first and fall back so pre-split specs still render. + type SpecMeta = { settings?: { spec?: { keys?: string } }; config?: { format?: { keys?: string } } }; + const keysOf = (m?: SpecMeta): string | undefined => m?.settings?.spec?.keys ?? m?.config?.format?.keys; + const componentsOf = (spec as { components?: Record })?.components; + const keyFormat = keysOf((spec as { metadata?: SpecMeta })?.metadata) + ?? keysOf(componentsOf?.[Object.keys(componentsOf ?? {})[0]]?.metadata); const manifest = timed('instance manifest', () => buildManifest(spec, specsDir, dataDir, keyFormat)); const glyphIdManifest = timed('glyph manifest', () => buildGlyphManifest(dataDir)); const stylesManifest = timed('styles manifest', () => buildStylesManifest(dataDir)); @@ -644,7 +665,7 @@ async function sendRender(specPath: string, rawPageId: string | null, fileKey?: // A large component set against a big library can take minutes today; a timeout // shorter than the render discards a result the plugin actually produced. const { requestId, promise } = requests.create(300000, 'Timed out waiting for renderComponent-result.'); - const payload = JSON.stringify({ type: 'renderComponent', requestId, spec, pageId, instanceIdManifest: manifest, glyphIdManifest, stylesManifest, variablesManifest, overwrite, config }); + const payload = JSON.stringify({ type: 'renderComponent', requestId, spec, pageId, instanceIdManifest: manifest, glyphIdManifest, stylesManifest, variablesManifest, overwrite, conventions, settings }); const sentAt = Date.now(); conn.ws.send(payload); diff --git a/packages/cli/src/commands/AnalyzeCommand.ts b/packages/cli/src/commands/AnalyzeCommand.ts index f11d77b6..6ff3a914 100644 --- a/packages/cli/src/commands/AnalyzeCommand.ts +++ b/packages/cli/src/commands/AnalyzeCommand.ts @@ -22,7 +22,7 @@ export const Analyze = new Command('analyze') .argument('[analyzers...]', 'Analyzer names to run (props, styling, dependencies, keys)') .option('-o, --output ', 'Path to the specs directory (input)') .option('--analysis ', 'Path to write analysis output (default: /_analysis)') - .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('--verbose', 'Enable detailed logging', false) .action(async (analyzerNames: string[], options: AnalyzeOptions) => { try { @@ -31,8 +31,8 @@ export const Analyze = new Command('analyze') const outputPath = options.output ? path.resolve(options.output) - : config.outputDirectory - ? path.resolve(config.outputDirectory) + : config.settings.spec.directory + ? path.resolve(config.settings.spec.directory) : path.resolve(process.cwd()); if (!fs.existsSync(outputPath)) { @@ -90,9 +90,9 @@ export const Analyze = new Command('analyze') const context: TransformerContext = { outputDir: componentDir, componentKey, - tokensFormat: config.config.format.tokens, - outputFormat: config.config.format.output, - processingStates: config.config.processing?.states as ProcessingStates | undefined, + tokensFormat: config.settings.spec.tokens, + outputFormat: config.settings.spec.format, + processingStates: config.conventions.figma.states as ProcessingStates | undefined, }; await analyzer.run(apiYaml, context); } @@ -110,10 +110,11 @@ export const Analyze = new Command('analyze') // Load the full token universe (variables, styles) from fetched data // files so analyzers can report tokens never referenced by any spec. - const dataDir = config.dataDirectory ? path.resolve(config.dataDirectory) : path.resolve(process.cwd()); + const dataDirectory = config.settings.data?.directory; + const dataDir = dataDirectory ? path.resolve(dataDirectory) : path.resolve(process.cwd()); const foundationsPathsFor = (kind: 'variables' | 'styles'): string[] => - Object.entries(config.sources || {}) - .filter(([, s]) => Array.isArray(s.data) && s.data.includes(kind)) + Object.entries(config.settings.data?.sources ?? {}) + .filter(([, s]) => Array.isArray(s.fetch) && s.fetch.includes(kind)) .map(([alias]) => path.join(dataDir, `${alias}.${kind}.json`)) .filter(p => fs.existsSync(p)); diff --git a/packages/cli/src/commands/ApplyCustomTokensCommand.ts b/packages/cli/src/commands/ApplyCustomTokensCommand.ts index cc9b1e8c..b8d728df 100644 --- a/packages/cli/src/commands/ApplyCustomTokensCommand.ts +++ b/packages/cli/src/commands/ApplyCustomTokensCommand.ts @@ -4,16 +4,17 @@ * Purpose: * - Read a mapping JSON file containing { figmaId: { $custom: {...}, ...otherProps } } * - Inject the $custom objects into variables and styles JSON files - * - Config-aware: auto-discovers data files from specs.config.yaml + * - Config-aware: auto-discovers data files from the workspace settings * - Supports -v/-s flags to override file paths */ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; -import yaml from 'yaml'; import { refreshCache } from '../Cache/Cache.js'; import { reportCache } from './CacheCommand.js'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; +import type { CLIConfig } from '../Types/CLIConfig.js'; const ERROR_CODES = { SUCCESS: 0, @@ -22,48 +23,6 @@ const ERROR_CODES = { FILE_ERROR: 3, }; -type MinimalConfig = { - dataDirectory?: string; - sourceDirectory?: string; // deprecated alias - sources?: Record; - config?: { processing?: { glyphNamePattern?: string } }; -}; - -function findConfigFile(cwd: string): string | null { - const locations = [ - path.join(cwd, 'specs.config.yaml'), - path.join(cwd, 'specs.config.json'), - path.join(process.env.HOME || '~', '.specs', 'config.yaml'), - ]; - - for (const location of locations) { - if (fs.existsSync(location)) return location; - } - - return null; -} - -function loadConfig(configPath?: string): { configPath: string | null; config: MinimalConfig; configDir: string } { - const resolvedPath = configPath ? path.resolve(configPath) : findConfigFile(process.cwd()); - - if (!resolvedPath) { - return { configPath: null, config: {}, configDir: process.cwd() }; - } - - if (!fs.existsSync(resolvedPath)) { - throw new Error(`Config file not found: ${resolvedPath}`); - } - - const raw = fs.readFileSync(resolvedPath, 'utf-8'); - const parsed = resolvedPath.endsWith('.json') ? JSON.parse(raw) : (yaml.parse(raw) as unknown); - - return { - configPath: resolvedPath, - config: (parsed as MinimalConfig) || {}, - configDir: path.dirname(resolvedPath), - }; -} - type MappingEntry = Record; type MappingFile = Record; @@ -97,7 +56,7 @@ function loadAndValidateMapping(mappingPath: string): MappingFile { function discoverDataFiles( configDir: string, - config: MinimalConfig, + config: CLIConfig, variablesFlag?: string, stylesFlag?: string, ): { variablesPaths: string[]; stylesPaths: string[] } { @@ -108,17 +67,18 @@ function discoverDataFiles( }; } - const sourceDir = path.resolve(configDir, config.dataDirectory || config.sourceDirectory || 'data'); + const sourceDir = path.resolve(configDir, config.settings.data?.directory || 'data'); const variablesPaths: string[] = []; const stylesPaths: string[] = []; - if (config.sources) { - for (const [alias, source] of Object.entries(config.sources)) { - if (source.data.includes('variables')) { + const sources = config.settings.data?.sources; + if (sources) { + for (const [alias, source] of Object.entries(sources)) { + if ((source.fetch ?? []).includes('variables')) { const vp = path.join(sourceDir, `${alias}.variables.json`); if (fs.existsSync(vp)) variablesPaths.push(vp); } - if (source.data.includes('styles')) { + if ((source.fetch ?? []).includes('styles')) { const sp = path.join(sourceDir, `${alias}.styles.json`); if (fs.existsSync(sp)) stylesPaths.push(sp); } @@ -202,7 +162,7 @@ export interface ApplyCustomTokensOptions { export const ApplyCustomTokens = new Command('applyCustomTokens') .description('Inject $custom token objects from a mapping file into fetched variables/styles JSON') .argument('', 'Path to the JSON mapping file') - .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('-v, --variables ', 'Path to variables JSON file (overrides config discovery)') .option('-s, --styles ', 'Path to styles JSON file (overrides config discovery)') .action(async (mappingArg: string, options: ApplyCustomTokensOptions) => { @@ -221,11 +181,12 @@ export const ApplyCustomTokens = new Command('applyCustomTokens') } // Load config and discover files - const { config, configDir } = loadConfig(options.config); + const config = new ConfigLoader().load(options.config); + const configDir = config.configDir ?? process.cwd(); const { variablesPaths, stylesPaths } = discoverDataFiles(configDir, config, options.variables, options.styles); if (variablesPaths.length === 0 && stylesPaths.length === 0) { - console.error('Error: No variables or styles files found. Use -v/-s flags or configure sources in specs.config.yaml'); + console.error('Error: No variables or styles files found. Use -v/-s flags or configure sources in the workspace settings'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -290,11 +251,12 @@ export const ApplyCustomTokens = new Command('applyCustomTokens') // The variables payload was just rewritten in place, so the render cache built from // it now describes pre-custom-token data. Rebuild before anyone renders against it. - if (config.dataDirectory) { + const dataDirectory = config.settings.data?.directory; + if (dataDirectory) { const report = refreshCache({ - dataDir: path.resolve(configDir, config.dataDirectory), - aliases: Object.keys(config.sources ?? {}), - glyphNamePattern: config.config?.processing?.glyphNamePattern, + dataDir: path.resolve(configDir, dataDirectory), + aliases: Object.keys(config.settings.data?.sources ?? {}), + glyphNamePattern: config.conventions.figma.glyphs?.match, }); reportCache(report); } diff --git a/packages/cli/src/commands/CacheCommand.ts b/packages/cli/src/commands/CacheCommand.ts index 1ce77972..34c0b1d2 100644 --- a/packages/cli/src/commands/CacheCommand.ts +++ b/packages/cli/src/commands/CacheCommand.ts @@ -1,7 +1,7 @@ /** * Cache Command * - * Builds the render lookup caches under `{dataDirectory}/cache/` from the fetched + * Builds the render lookup caches under `{data.directory}/cache/` from the fetched * Figma payloads. `fetch` and `apply-custom-tokens` run this themselves, so it is * needed by hand only when the caches are missing or something outside those * commands changed the data. See src/Cache/Cache.ts for what the files contain. @@ -32,28 +32,29 @@ export function reportCache(report: CacheReport): void { export const Cache = new Command('cache') .description('Build the render lookup caches from fetched Figma data') - .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('--force', 'Rebuild every source, even those whose cached data still matches') .action((options: { config?: string; force?: boolean }) => { try { const config = new ConfigLoader().load(options.config); - if (!config.dataDirectory) { - console.error('Error: dataDirectory is not set in specs.config.yaml.'); + const dataDirectory = config.settings.data?.directory; + if (!dataDirectory) { + console.error('Error: data.directory is not set in the workspace settings.'); process.exit(ERROR_CODES.INVALID_ARGS); } - const aliases = Object.keys(config.sources ?? {}); + const aliases = Object.keys(config.settings.data?.sources ?? {}); if (aliases.length === 0) { - console.error('Error: no sources are configured in specs.config.yaml.'); - console.error('Tip: the cache is built per source — add one under `sources`, then run `specs fetch`.'); + console.error('Error: no sources are configured in the workspace settings.'); + console.error('Tip: the cache is built per source — add one under `data.sources`, then run `specs fetch`.'); process.exit(ERROR_CODES.INVALID_ARGS); } const report = refreshCache({ - dataDir: config.dataDirectory, + dataDir: dataDirectory, aliases, - glyphNamePattern: config.config?.processing?.glyphNamePattern, + glyphNamePattern: config.conventions.figma.glyphs?.match, force: options.force, }); diff --git a/packages/cli/src/commands/FetchCommand.ts b/packages/cli/src/commands/FetchCommand.ts index 871c1614..9864090c 100644 --- a/packages/cli/src/commands/FetchCommand.ts +++ b/packages/cli/src/commands/FetchCommand.ts @@ -10,12 +10,12 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; -import yaml from 'yaml'; import readline from 'readline'; import { collectGlyphComponents } from '../utilities/glyphComponents.js'; import { startSpinner, clearInlineStatus, renderInlineStatus, isInteractive, formatElapsed } from '../utilities/spinner.js'; import { refreshCache } from '../Cache/Cache.js'; import { reportCache } from './CacheCommand.js'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; const ERROR_CODES = { SUCCESS: 0, @@ -27,15 +27,9 @@ const ERROR_CODES = { RATE_LIMIT: 6 }; -type FetchKind = 'file' | 'variables' | 'styles' | 'icons'; +import type { SourceEntry } from '@directededges/specs-schema'; -type MinimalConfig = { - dataDirectory?: string; - sourceDirectory?: string; // deprecated alias - outputDirectory?: string; // icons land beside the specs they serve - sources?: Record; - config?: { processing?: { glyphNamePattern?: string } }; -}; +type FetchKind = 'file' | 'variables' | 'styles' | 'icons'; export { collectGlyphComponents }; @@ -51,40 +45,6 @@ async function streamToString(stream: ReadableStream | null): Promis return Buffer.concat(chunks).toString('utf-8'); } -function findConfigFile(cwd: string): string | null { - const locations = [ - path.join(cwd, 'specs.config.yaml'), - path.join(cwd, 'specs.config.json'), - path.join(process.env.HOME || '~', '.specs', 'config.yaml') - ]; - - for (const location of locations) { - if (fs.existsSync(location)) return location; - } - - return null; -} - -function loadConfig(configPath?: string): { configPath: string | null; config: MinimalConfig } { - const resolvedPath = configPath ? path.resolve(configPath) : findConfigFile(process.cwd()); - - if (!resolvedPath) { - return { configPath: null, config: {} }; - } - - if (!fs.existsSync(resolvedPath)) { - throw new Error(`Config file not found: ${resolvedPath}`); - } - - const raw = fs.readFileSync(resolvedPath, 'utf-8'); - const parsed = resolvedPath.endsWith('.json') ? JSON.parse(raw) : (yaml.parse(raw) as unknown); - - return { - configPath: resolvedPath, - config: (parsed as MinimalConfig) || {} - }; -} - const FETCH_KINDS: readonly FetchKind[] = ['file', 'variables', 'styles', 'icons']; function isFetchKind(value: string): value is FetchKind { @@ -99,13 +59,13 @@ function splitOnly(value?: string): string[] { .filter(Boolean); } -function normalizeSources(sources?: MinimalConfig['sources']): Array<{ alias: string; key: string; fetch: FetchKind[] }> { +function normalizeSources(sources?: Record): Array<{ alias: string; key: string; fetch: FetchKind[] }> { if (!sources) return []; return Object.entries(sources).map(([alias, entry]) => ({ alias, key: entry.key, - fetch: entry.data + fetch: (entry.fetch ?? []).filter(isFetchKind) })); } @@ -180,7 +140,7 @@ function classifyHttpStatus(status: number): 'ok' | 'auth' | 'rate' | 'error' { } function configReference(configPath: string | null): string { - return configPath || 'specs.config.yaml'; + return configPath || 'the workspace settings (config/settings.yaml)'; } export function formatNotFoundError(alias: string, kind: string, configPath: string | null): string { @@ -190,7 +150,7 @@ export function formatNotFoundError(alias: string, kind: string, configPath: str ' This usually means the key in your config is stale or out of reach:', ' • The file was moved, deleted, or recreated (keys change on duplicate/recreate)', ' • Your FIGMA_TOKEN account cannot open this file', - ` Check: sources.${alias}.key in ${configReference(configPath)}` + ` Check: data.sources.${alias}.key in ${configReference(configPath)}` ].join('\n'); } @@ -200,14 +160,14 @@ export function formatAuthError(status: number, alias: string, kind: string, con return [ `Error: Access denied (403) while fetching ${alias}.${kind}`, ' Your FIGMA_TOKEN is valid but cannot access this file.', - ` • Confirm your Figma account can open the file for sources.${alias}.key`, + ` • Confirm your Figma account can open the file for data.sources.${alias}.key`, ' • Personal access tokens only reach files your account can view', ' • If your org enforces SAML/SSO, personal access tokens are blocked', ' Use an OAuth token or ask your admin to allow PATs', ' See: https://www.figma.com/developers/api#oauth2', ' • The file may be in personal drafts or a restricted team (403 = exists but no access)', ...(keyHint ? [keyHint] : []), - ` Check: sources.${alias}.key in ${configReference(configPath)}` + ` Check: data.sources.${alias}.key in ${configReference(configPath)}` ].join('\n'); } @@ -231,8 +191,8 @@ export interface FetchOptions { export const Fetch = new Command('fetch') .description('Fetch raw REST payloads (file, variables, styles) for configured Figma files') - .option('--config ', 'Path to config file (specs.config.yaml)') - .option('--data-dir ', 'Override data directory (default: config dataDirectory or ./data)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') + .option('--data-dir ', 'Override data directory (default: data.directory from the workspace settings, or ./data)') .option('--outDir ', 'Deprecated: use --data-dir') .option('--only ', 'Fetch only these — a file alias from sources, a data kind (file, variables, styles, icons), or both') .option('--no-geometry', 'Omit geometry data (fillGeometry, strokeGeometry, size, relativeTransform) from file payloads') @@ -246,24 +206,26 @@ export const Fetch = new Command('fetch') process.exit(ERROR_CODES.INVALID_ARGS); } - const { configPath, config } = loadConfig(options.config); - const configDir = configPath ? path.dirname(configPath) : process.cwd(); + const configPath = options.config ? path.resolve(options.config) : null; + const config = new ConfigLoader().load(options.config); + const configDir = config.configDir ?? process.cwd(); if (options.outDir && !options.dataDir) { console.error('Warning: --outDir is deprecated, use --data-dir instead'); } - const outDirValue = options.dataDir || options.outDir || config.dataDirectory || config.sourceDirectory || 'data'; + const outDirValue = options.dataDir || options.outDir || config.settings.data?.directory || 'data'; const outDir = path.resolve(configDir, outDirValue); - const fileEntries = normalizeSources(config.sources); + const fileEntries = normalizeSources(config.settings.data?.sources); if (fileEntries.length === 0) { console.error('Error: No sources configured'); - console.error('Add to specs.config.yaml:'); - console.error(' dataDirectory: data'); - console.error(' sources:'); - console.error(' library:'); - console.error(' key: ""'); - console.error(' data: ["file","variables","styles"]'); + console.error('Add to config/settings.yaml:'); + console.error(' data:'); + console.error(' directory: data'); + console.error(' sources:'); + console.error(' library:'); + console.error(' key: ""'); + console.error(' fetch: ["file","variables","styles"]'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -394,15 +356,16 @@ export const Fetch = new Command('fetch') // the saved file payload, so `file` must be present (fetched this run // or a previous one) before icons can resolve. if (entry.fetch.includes('icons') && wants('icons')) { - const pattern = config.config?.processing?.glyphNamePattern; + const pattern = config.conventions.figma.glyphs?.match; if (!pattern) { - console.error(`Error: sources.${entry.alias}.data includes "icons" but config.processing.glyphNamePattern is not set`); + console.error(`Error: data.sources.${entry.alias}.fetch includes "icons" but conventions.figma.glyphs.match is not set`); process.exit(ERROR_CODES.INVALID_ARGS); } // Icons are consumed by generated component output, so they live in // the durable spec workspace (beside _images/), not the data cache. - if (!config.outputDirectory) { - console.error(`Error: sources.${entry.alias}.data includes "icons" but outputDirectory is not set in config`); + const specDirectory = config.settings.spec.directory; + if (!specDirectory) { + console.error(`Error: data.sources.${entry.alias}.fetch includes "icons" but spec.directory is not set in the workspace settings`); process.exit(ERROR_CODES.INVALID_ARGS); } const filePath = path.join(outDir, `${entry.alias}.file.json`); @@ -414,7 +377,7 @@ export const Fetch = new Command('fetch') const stopSpinner = startSpinner(`Downloading: ${entry.alias} icons`); const fileJson = JSON.parse(await fs.readFile(filePath, 'utf-8')) as { document?: unknown }; const glyphs = collectGlyphComponents(fileJson.document, pattern); - const iconsDir = path.join(path.resolve(configDir, config.outputDirectory), '_icons'); + const iconsDir = path.join(path.resolve(configDir, specDirectory), '_icons'); await fs.ensureDir(iconsDir); let downloaded = 0; @@ -463,12 +426,13 @@ export const Fetch = new Command('fetch') // Refresh the render caches from everything now on disk — the sources fetched this // run, plus any fetched previously. A source with no payload yet is skipped: not // having fetched it is a normal state, and only render treats it as an error. - if (config.dataDirectory) { - const dataDir = path.resolve(configDir, config.dataDirectory); + const dataDirectory = config.settings.data?.directory; + if (dataDirectory) { + const dataDir = path.resolve(configDir, dataDirectory); const report = refreshCache({ dataDir, - aliases: Object.keys(config.sources ?? {}), - glyphNamePattern: config.config?.processing?.glyphNamePattern, + aliases: Object.keys(config.settings.data?.sources ?? {}), + glyphNamePattern: config.conventions.figma.glyphs?.match, }); reportCache(report); } diff --git a/packages/cli/src/commands/GenerateCommand.ts b/packages/cli/src/commands/GenerateCommand.ts index 2c21a480..6187760f 100644 --- a/packages/cli/src/commands/GenerateCommand.ts +++ b/packages/cli/src/commands/GenerateCommand.ts @@ -14,6 +14,7 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; import yaml from 'yaml'; +import type { SourceEntry } from '@directededges/specs-schema'; import { Components } from '@directededges/specs-from-figma'; import type { ProgressEvent, RestLicenseInput } from '@directededges/specs-from-figma'; import { ConfigLoader } from '../Config/ConfigLoader.js'; @@ -69,9 +70,10 @@ interface GenerateOptions { styles?: string; verbose: boolean; config?: string; - splitComponents?: boolean; - splitConcerns?: boolean; - useSubfolders?: boolean; + combineAsLibrary?: boolean; + combineConcerns?: boolean; + /** Commander sets this false only when --no-subfolders is passed. */ + subfolders?: boolean; getImages?: boolean; fromBridge?: boolean; file?: string; @@ -81,14 +83,14 @@ interface GenerateOptions { /** * Resolve the config source alias that carries the component file: `library` - * when configured with `data: [file]`, else the first source that is. + * when configured with `fetch: [file]`, else the first source that is. * Single source of truth for the default-manifest path, the manifest-mode * component file, and the --get-images file key. */ -function resolveFileSourceAlias(sources: NonNullable['sources']> | undefined): string | null { +function resolveFileSourceAlias(sources: Record | undefined): string | null { const entries = sources ?? {}; - if (entries.library && Array.isArray(entries.library.data) && entries.library.data.includes('file')) return 'library'; - const candidate = Object.entries(entries).find(([, s]) => Array.isArray(s.data) && s.data.includes('file')); + if (entries.library && Array.isArray(entries.library.fetch) && entries.library.fetch.includes('file')) return 'library'; + const candidate = Object.entries(entries).find(([, s]) => Array.isArray(s.fetch) && s.fetch.includes('file')); return candidate ? candidate[0] : null; } @@ -102,21 +104,20 @@ async function writeGeneratedOutput( errors: Array<{ component: string; error: string }>, isManifest: boolean, options: GenerateOptions, - config: CLIConfig, - modelConfig: CLIConfig['config'] + config: CLIConfig ): Promise { // ------------------------------------------------------------------- // File mode stdout (no -o) // ------------------------------------------------------------------- - if (!isManifest && !options.output && !config.outputDirectory) { + if (!isManifest && !options.output && !config.settings.spec.directory) { if (options.getImages) { - console.error('Error: --get-images requires an output directory (set outputDirectory in config or pass -o) so image files have somewhere to be written'); + console.error('Error: --get-images requires an output directory (set spec.directory in the workspace settings or pass -o) so image files have somewhere to be written'); process.exit(ERROR_CODES.INVALID_ARGS); } const componentData = processedComponents[0].spec; const outputFormat = options.format ? options.format.toLowerCase() - : modelConfig.format.output.toLowerCase(); + : config.settings.spec.format.toLowerCase(); const formattedOutput = outputFormat === 'yaml' ? yaml.stringify(componentData) @@ -132,21 +133,23 @@ async function writeGeneratedOutput( // ------------------------------------------------------------------- const resolvedFormat: OutputFormat = options.format ? options.format.toLowerCase() as OutputFormat - : modelConfig.format.output.toLowerCase() as OutputFormat; + : config.settings.spec.format.toLowerCase() as OutputFormat; + // The split layout is the default (ADR-071). Each flag only ever turns a + // split off, so an absent flag falls through to the configured value rather + // than overriding it. const outputConfig = { - ...config.output, - splitComponents: options.splitComponents ?? config.output?.splitComponents ?? false, - splitConcerns: options.splitConcerns ?? config.output?.splitConcerns ?? false, - useSubfolders: options.useSubfolders ?? config.output?.useSubfolders ?? false, + splitComponents: options.combineAsLibrary ? false : config.settings.spec.splitComponents, + splitConcerns: options.combineConcerns ? false : config.settings.spec.splitConcerns, + useSubfolders: options.subfolders === false ? false : config.settings.spec.useSubfolders, defaultFormat: resolvedFormat }; let outputPath: string; if (options.output) { outputPath = path.resolve(options.output); - } else if (config.outputDirectory) { - outputPath = path.resolve(config.outputDirectory); + } else if (config.settings.spec.directory) { + outputPath = path.resolve(config.settings.spec.directory); } else { // Should not reach here — handled above for file mode stdout process.exit(ERROR_CODES.INVALID_ARGS); @@ -177,9 +180,9 @@ async function writeGeneratedOutput( if (options.getImages) { const hashes = ImageFillsResolver.collectUnresolvedHashes(processedComponents); if (hashes.size === 0) { - console.log(modelConfig.processing.images + console.log(config.conventions.figma.images ? 'Note: --get-images found no unresolved image placeholders' - : 'Note: --get-images has no effect — processing.images is not configured'); + : 'Note: --get-images has no effect — conventions.figma.images is not configured'); } else { // Reuse hash-named files already present in _images/ — only the // remainder needs the token, the API call, and downloads. @@ -192,10 +195,10 @@ async function writeGeneratedOutput( console.error('Error: --get-images requires the FIGMA_TOKEN environment variable (same token as `specs fetch`)'); process.exit(ERROR_CODES.INVALID_ARGS); } - const fileSourceAlias = resolveFileSourceAlias(config.sources); - const fileKey = fileSourceAlias ? config.sources?.[fileSourceAlias]?.key : undefined; + const fileSourceAlias = resolveFileSourceAlias(config.settings.data?.sources); + const fileKey = fileSourceAlias ? config.settings.data?.sources?.[fileSourceAlias]?.key : undefined; if (!fileKey) { - console.error('Error: --get-images requires a configured source file key (sources..key in specs.config.yaml)'); + console.error('Error: --get-images requires a configured source file key (data.sources..key in the workspace settings)'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -254,7 +257,7 @@ async function writeGeneratedOutput( export const Generate = new Command('generate') .description('Generate component specifications from Figma data or manifest') - .argument('[source]', 'Path to Figma JSON file or markdown manifest (default: {dataDirectory}/{alias}.manifest.md from config)') + .argument('[source]', 'Path to Figma JSON file or markdown manifest (default: {data.directory}/{alias}.manifest.md from config)') .option('-c, --component ', 'Component name or ID (required for file mode)') .option('-l, --license ', 'License key for premium features (or set SPECS_LICENSE_KEY)') .option('-f, --format ', 'Output format (yaml or json) - overrides config') @@ -262,10 +265,10 @@ export const Generate = new Command('generate') .option('-v, --variables ', 'External variables JSON file') .option('-s, --styles ', 'External styles JSON file') .option('--data-dir ', 'Override data directory for loading source files') - .option('--config ', 'Path to config file (specs.config.yaml)') - .option('--split-components', 'Create separate file per component') - .option('--split-concerns', 'Separate API, variants, and examples into different files') - .option('--use-subfolders', 'Organize component files in subdirectories (requires --split-components)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') + .option('--combine-as-library', 'Write every component into one library file instead of a file per component') + .option('--combine-concerns', 'Write API, variants, and examples into one file per component instead of separate files') + .option('--no-subfolders', 'Write component files side by side instead of nesting each in its own subfolder') .option('--get-images', 'Resolve unresolved registry images into files under _images/ (requires processing.images in config and FIGMA_TOKEN)') .option('--from-bridge', 'Generate from the current selection in a connected Figma file via the CLI bridge (no REST fetch)') .option('--file ', 'Target a specific connected Figma file with --from-bridge (prompts to choose if more than one is connected in an interactive terminal; required otherwise)') @@ -277,7 +280,6 @@ export const Generate = new Command('generate') // Load configuration (needed to resolve default source path) const configLoader = new ConfigLoader(); const config = configLoader.load(options.config); - const modelConfig = config.config; if (options.verbose && options.config) { console.log(`[CLI] Using config from: ${options.config}`); @@ -297,11 +299,11 @@ export const Generate = new Command('generate') let result; try { const fileKey = await resolveFileKey(options.file); - // The config shapes the spec the plugin builds, not merely where it is written. - result = await postGenerateFromSelection({ fileKey, nodeId: options.node, config: modelConfig, remove: options.remove }); + // The conventions and settings shape the spec the plugin builds, not merely where it is written. + result = await postGenerateFromSelection({ fileKey, nodeId: options.node, conventions: config.conventions, settings: config.settings, remove: options.remove }); } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + const err = e as NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }; + if (err.cause && err.cause.code === 'ECONNREFUSED') { console.error('Error: bridge is not running.'); console.error(' Start it with: specs bridge start'); } else { @@ -324,25 +326,25 @@ export const Generate = new Command('generate') console.log(`✓ Generated from selection: ${result.name ?? result.nodeId}`); const processedComponents = [{ name: result.name ?? String(result.nodeId), spec: result.specData as Record }]; - await writeGeneratedOutput(processedComponents, [], false, options, config, modelConfig); + await writeGeneratedOutput(processedComponents, [], false, options, config); return; } - // Use dataDirectory for loading data files (flag > config > default) + // Use data.directory for loading data files (flag > config > default) const sourceDir = options.dataDir ? path.resolve(options.dataDir) - : config.dataDirectory - ? path.resolve(config.dataDirectory) + : config.settings.data?.directory + ? path.resolve(config.settings.data.directory) : path.join(process.cwd(), 'data'); - // Resolve default source path: {dataDirectory}/{alias}.manifest.md - // Alias preference: `library` if configured with `data: [file]`, else first source with `data: [file]`. + // Resolve default source path: {data.directory}/{alias}.manifest.md + // Alias preference: `library` if configured with `fetch: [file]`, else first source with `fetch: [file]`. if (!source) { - const defaultAlias = resolveFileSourceAlias(config.sources); + const defaultAlias = resolveFileSourceAlias(config.settings.data?.sources); if (!defaultAlias) { console.error('Error: No source argument provided and no default manifest could be resolved'); - console.error('Tip: run `specs scan` to generate a manifest, or configure a source with `data: [file]` in specs.config.yaml'); + console.error('Tip: run `specs scan` to generate a manifest, or configure a source with `fetch: [file]` in the workspace settings'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -397,14 +399,19 @@ export const Generate = new Command('generate') if (isManifest) { // MANIFEST MODE - if (!options.output && !config.outputDirectory) { - console.error('Error: Specify --output or set outputDirectory in config'); + if (!options.output && !config.settings.spec.directory) { + console.error('Error: Specify --output or set spec.directory in the workspace settings'); process.exit(ERROR_CODES.INVALID_ARGS); } - const { components, metadata } = isV2Manifest + const parsed = isV2Manifest ? ManifestParserV2.parse(sourceContent) : ManifestParser.parse(sourceContent); + const { components, metadata } = parsed; + + for (const warning of ('warnings' in parsed ? parsed.warnings : [])) { + console.warn(`⚠ ${warning}`); + } if (components.length === 0) { console.error('Error: No components found in manifest'); @@ -421,13 +428,13 @@ export const Generate = new Command('generate') console.log(`✓ Loaded manifest: ${components.length} components (${selectedComponents.length} selected)`); // Determine source file - const componentSourceAlias = resolveFileSourceAlias(config.sources); + const componentSourceAlias = resolveFileSourceAlias(config.settings.data?.sources); const sourceFile = metadata.file || (componentSourceAlias ? path.join(sourceDir, `${componentSourceAlias}.file.json`) : undefined); if (!sourceFile) { console.error('Error: No component source file specified'); - console.error('Include **File:** in the manifest header (from `specs audit`) or configure a source alias with `data: [file]` in specs.config.yaml'); + console.error('Include **File:** in the manifest header (from `specs audit`) or configure a source alias with `fetch: [file]` in the workspace settings'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -451,11 +458,11 @@ export const Generate = new Command('generate') if (options.component) { const wanted = options.component; chosen = selectedComponents.filter(c => - c.id === wanted || c.name === wanted || formatKey(c.name, modelConfig.format.keys) === wanted); + c.id === wanted || c.name === wanted || formatKey(c.name, config.settings.spec.keys) === wanted); if (chosen.length === 0) { console.error(`Error: no component named "${wanted}" in the manifest.`); const near = selectedComponents - .map(c => formatKey(c.name, modelConfig.format.keys)) + .map(c => formatKey(c.name, config.settings.spec.keys)) .filter(k => k.toLowerCase().includes(wanted.toLowerCase())) .slice(0, 5); if (near.length > 0) { @@ -505,15 +512,15 @@ export const Generate = new Command('generate') const variablesPaths = options.variables ? [path.resolve(options.variables)] - : Object.entries(config.sources || {}) - .filter(([, s]) => Array.isArray(s.data) && s.data.includes('variables')) + : Object.entries(config.settings.data?.sources ?? {}) + .filter(([, s]) => Array.isArray(s.fetch) && s.fetch.includes('variables')) .map(([alias]) => path.join(sourceDir, `${alias}.variables.json`)) .filter(p => p.length > 0); const stylesPaths = options.styles ? [path.resolve(options.styles)] - : Object.entries(config.sources || {}) - .filter(([, s]) => Array.isArray(s.data) && s.data.includes('styles')) + : Object.entries(config.settings.data?.sources ?? {}) + .filter(([, s]) => Array.isArray(s.fetch) && s.fetch.includes('styles')) .map(([alias]) => path.join(sourceDir, `${alias}.styles.json`)) .filter(p => p.length > 0); @@ -564,8 +571,9 @@ export const Generate = new Command('generate') const results = await Components.fromRestApi( componentIds, libraryJson, - modelConfig, - { styles, variables, collections, author: config.author, generator: CLI_GENERATOR }, + config.conventions, + config.settings, + { styles, variables, collections, author: config.settings.author, generator: CLI_GENERATOR }, (event: ProgressEvent) => { if (!isManifest) { // File mode: quiet progress (verbose only) @@ -662,7 +670,7 @@ export const Generate = new Command('generate') process.exit(ERROR_CODES.GENERAL_ERROR); } - await writeGeneratedOutput(processedComponents, errors, isManifest, options, config, modelConfig); + await writeGeneratedOutput(processedComponents, errors, isManifest, options, config); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/InitCommand.ts b/packages/cli/src/commands/InitCommand.ts index 4ee949f0..d4fdbd49 100644 --- a/packages/cli/src/commands/InitCommand.ts +++ b/packages/cli/src/commands/InitCommand.ts @@ -1,33 +1,50 @@ /** * Init Command * - * Scaffolds a specs.config.yaml file with production-ready defaults - * and inline documentation for getting started with Specs CLI. + * Scaffolds the config/ directory — conventions.yaml, settings.yaml, and + * pipeline.yaml — with production-ready defaults and inline documentation + * for getting started with Specs CLI. */ import { Command } from 'commander'; import fs from 'fs-extra'; +import path from 'path'; import readline from 'readline'; -import { generateConfigTemplate } from '../Config/ConfigTemplates.js'; +import { generateConfigTemplates } from '../Config/ConfigTemplates.js'; export const Init = new Command('init') - .description('Initialize a specs.config.yaml file with production defaults') + .description('Initialize config/conventions.yaml, config/settings.yaml, and config/pipeline.yaml with production defaults') .option('--force', 'Overwrite existing config without prompting') - .option('-c, --config ', 'Custom path for config file (default: specs.config.yaml)') + .option('-c, --config ', 'Custom directory to write the config files into (default: current directory)') .action(async (options) => { await initCommand(options as { force?: boolean; config?: string }); }); /** - * Initialize config file + * Initialize config files */ async function initCommand(options: { force?: boolean; config?: string }): Promise { - const configPath = options.config || 'specs.config.yaml'; - const configExists = fs.existsSync(configPath); + const baseDir = options.config || '.'; - if (configExists && !options.force) { + // A pre-split workspace must not be scaffolded over. `init` writes defaults, + // so it would quietly replace whatever the workspace actually declared — and + // `specs migrate config` would then refuse, because config/ exists. + const legacy = ['specs.config.yaml', 'specs.config.json'] + .find(name => fs.existsSync(path.join(baseDir, name))); + if (legacy) { + console.error(`Error: found ${legacy} — a pre-split configuration (ADR-071).`); + console.error(' Run `specs migrate config` to convert it, keeping what this workspace declares.'); + console.error(` To scaffold fresh defaults instead, remove ${legacy} first.`); + process.exitCode = 2; + return; + } + + const templates = generateConfigTemplates(); + const existing = Object.keys(templates).filter(rel => fs.existsSync(path.join(baseDir, rel))); + + if (existing.length > 0 && !options.force) { // Prompt before overwriting - const shouldOverwrite = await promptBeforeOverwrite(configPath); + const shouldOverwrite = await promptBeforeOverwrite(existing.join(', ')); if (!shouldOverwrite) { console.log('Config initialization cancelled.'); return; @@ -35,16 +52,19 @@ async function initCommand(options: { force?: boolean; config?: string }): Promi } try { - const template = generateConfigTemplate(); - fs.writeFileSync(configPath, template, 'utf-8'); - console.log(`✓ Created ${configPath}`); + for (const [rel, template] of Object.entries(templates)) { + const filePath = path.join(baseDir, rel); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, template, 'utf-8'); + console.log(`✓ Created ${filePath}`); + } console.log('📚 Next steps:'); console.log(' 1. Edit the config file to add your Figma file keys'); console.log(' 2. Run: specs fetch'); console.log(' 3. Run: specs scan'); console.log(' 4. Run: specs generate'); console.log(''); - console.log('📖 Documentation: docs/cli/configuration.md'); + console.log('📖 Documentation: https://specs.directededges.com/settings/'); } catch (error) { if (error instanceof Error) { console.error(`Error creating config file: ${error.message}`); @@ -66,7 +86,7 @@ async function promptBeforeOverwrite(configPath: string): Promise { }); rl.question( - `Config file exists at ${configPath}. Overwrite? (y/N) `, + `Config file(s) exist: ${configPath}. Overwrite? (y/N) `, (answer) => { rl.close(); resolve(answer.toLowerCase() === 'y'); diff --git a/packages/cli/src/commands/MigrateCommand.ts b/packages/cli/src/commands/MigrateCommand.ts new file mode 100644 index 00000000..e49c023e --- /dev/null +++ b/packages/cli/src/commands/MigrateCommand.ts @@ -0,0 +1,170 @@ +/** + * Migrate Command + * + * Runs a named, versioned migration over a workspace. Migrations are registered + * per subject and source version so this command never becomes synonymous with + * whichever migration happened to be first. + * + * Migrations write to the workspace, which is why they are a command a person + * runs deliberately rather than something a loader does on their behalf: config + * loading happens inside read-only commands and in CI, and a read path must not + * mutate a checkout. + */ + +import { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import { migrateConfigV1 } from '../Config/migrations/configV1.js'; +const ERROR_CODES = { + INVALID_ARGS: 2, +}; + +interface MigrationResult { + /** Files written, relative to the workspace root. */ + written: string[]; + /** The source file, renamed so discovery no longer finds it. */ + renamed?: { from: string; to: string }; +} + +interface Migration { + subject: string; + from: string; + to: string; + summary: string; + /** Detect whether this migration applies in `dir`, returning the source path. */ + detect(dir: string, source?: string): string | null; + /** Refuse with a reason, or return null when the migration can proceed. */ + blocked(dir: string): string | null; + run(dir: string, source: string, dryRun: boolean): MigrationResult; +} + +const CONFIG_V1_SOURCES = ['specs.config.yaml', 'specs.config.json']; + +const configV1: Migration = { + subject: 'config', + from: 'v1', + to: 'v2', + summary: 'single specs.config.yaml → config/{conventions,settings,pipeline}.yaml (ADR-071)', + + detect(dir, source) { + // An explicit source lets a workspace convert a file that discovery would + // not find — a custom name, or one passed to another command via --config. + if (source) { + const explicit = path.isAbsolute(source) ? source : path.join(dir, source); + return fs.existsSync(explicit) ? explicit : null; + } + for (const name of CONFIG_V1_SOURCES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + return null; + }, + + blocked(dir) { + const configDir = path.join(dir, 'config'); + if (!fs.existsSync(configDir)) return null; + const present = ['conventions', 'settings', 'pipeline'] + .flatMap(base => ['yaml', 'json'].map(ext => `${base}.${ext}`)) + .filter(file => fs.existsSync(path.join(configDir, file))); + if (present.length === 0) return null; + return `config/ already contains ${present.join(', ')} — migrating would overwrite authored files. Move or delete them first.`; + }, + + run(dir, source, dryRun) { + const raw = fs.readFileSync(source, 'utf-8'); + const parsed = source.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw); + const migrated = migrateConfigV1(parsed); + + const files: Record = { + 'config/conventions.yaml': migrated.conventions, + 'config/settings.yaml': migrated.settings, + 'config/pipeline.yaml': migrated.pipeline, + }; + + const written: string[] = []; + for (const [rel, value] of Object.entries(files)) { + if (value === undefined) continue; // nothing of that kind was configured + written.push(rel); + if (dryRun) continue; + const target = path.join(dir, rel); + fs.ensureDirSync(path.dirname(target)); + fs.writeFileSync(target, yaml.stringify(value), 'utf-8'); + } + + // Rename rather than delete: the original is the only record of what the + // workspace declared, and discovery must stop finding it or every command + // keeps refusing. + const renamedTo = `${path.basename(source)}.migrated`; + if (!dryRun) fs.renameSync(source, path.join(dir, renamedTo)); + + return { written, renamed: { from: path.basename(source), to: renamedTo } }; + }, +}; + +const MIGRATIONS: Migration[] = [configV1]; + +export const Migrate = new Command('migrate') + .description('Run a versioned migration over this workspace') + .argument('[subject]', 'What to migrate (e.g. config)') + .option('--from ', 'Source version to migrate from (default: the detected one)') + .option('--source ', 'Convert this file instead of the one discovery finds') + .option('--dry-run', 'Report what would change without writing') + .option('--list', 'List available migrations') + .action((subject: string | undefined, options: { from?: string; source?: string; dryRun?: boolean; list?: boolean }) => { + if (options.list || !subject) { + console.log('Available migrations:\n'); + for (const m of MIGRATIONS) { + console.log(` specs migrate ${m.subject} --from ${m.from}`); + console.log(` ${m.from} → ${m.to}: ${m.summary}\n`); + } + if (!subject) process.exit(ERROR_CODES.INVALID_ARGS); + return; + } + + const candidates = MIGRATIONS.filter(m => m.subject === subject); + if (candidates.length === 0) { + console.error(`Error: no migrations registered for '${subject}'. Run \`specs migrate --list\`.`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const migration = options.from + ? candidates.find(m => m.from === options.from) + : candidates.find(m => m.detect(process.cwd(), options.source) !== null) ?? candidates[0]; + + if (!migration) { + console.error(`Error: no '${subject}' migration from '${options.from}'. Run \`specs migrate --list\`.`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const dir = process.cwd(); + const source = migration.detect(dir, options.source); + if (!source) { + console.log( + options.source + ? `Nothing to migrate — ${options.source} does not exist.` + : `Nothing to migrate — no ${migration.from} ${migration.subject} found in this directory.` + ); + return; + } + + const blocked = migration.blocked(dir); + if (blocked) { + console.error(`Error: ${blocked}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const result = migration.run(dir, source, !!options.dryRun); + const verb = options.dryRun ? 'Would write' : 'Wrote'; + + console.log(`${migration.subject} ${migration.from} → ${migration.to}`); + for (const file of result.written) console.log(` ${verb}: ${file}`); + if (result.renamed) { + console.log( + options.dryRun + ? ` Would rename: ${result.renamed.from} → ${result.renamed.to}` + : ` Renamed: ${result.renamed.from} → ${result.renamed.to} (safe to delete once you have reviewed the new files)` + ); + } + if (!options.dryRun) console.log('\nReview the generated files before committing.'); + }); diff --git a/packages/cli/src/commands/RenderCommand.ts b/packages/cli/src/commands/RenderCommand.ts index 3eb39770..da9a9a4b 100644 --- a/packages/cli/src/commands/RenderCommand.ts +++ b/packages/cli/src/commands/RenderCommand.ts @@ -6,7 +6,7 @@ * Figma. See `specs bridge` to start/stop the bridge. */ -import type { ResolvedConfig } from '@directededges/specs-schema'; +import type { ResolvedConventions, ResolvedSettings } from '@directededges/specs-schema'; import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; @@ -27,8 +27,8 @@ const ERROR_CODES = { export const Render = new Command('render') .description('Render a spec into Figma via the local CLI bridge') - .argument('[specPath]', 'Path to a spec YAML file, a component folder, or a directory of component folders (default: {outputDirectory} from config)') - .option('--config ', 'Path to config file (specs.config.yaml)') + .argument('[specPath]', 'Path to a spec YAML file, a component folder, or a directory of component folders (default: {spec.directory} from the workspace settings)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('--file ', 'Target a specific connected Figma file (prompts to choose if more than one is connected in an interactive terminal; required otherwise)') .option('--page ', 'Render onto this page id instead of the plugin\'s current page (recommended for scripted runs — immune to page drift)') .option('--overwrite', 'Delete any existing page component with the same title before rendering (without this, a title collision is an error)') @@ -41,14 +41,15 @@ export const Render = new Command('render') // is the flag that fixes it without a separate command. if (options.refreshCache) { const config = new ConfigLoader().load(options.config); - if (!config.dataDirectory) { - console.error('Error: --refresh-cache needs dataDirectory set in specs.config.yaml.'); + const dataDirectory = config.settings.data?.directory; + if (!dataDirectory) { + console.error('Error: --refresh-cache needs data.directory set in the workspace settings.'); process.exit(ERROR_CODES.INVALID_ARGS); } reportCache(refreshCache({ - dataDir: config.dataDirectory, - aliases: Object.keys(config.sources ?? {}), - glyphNamePattern: config.config?.processing?.glyphNamePattern, + dataDir: dataDirectory, + aliases: Object.keys(config.settings.data?.sources ?? {}), + glyphNamePattern: config.conventions.figma.glyphs?.match, })); } if (options.watch) { @@ -60,18 +61,19 @@ export const Render = new Command('render') return; } - // Zero-arg resolution: the configured outputDirectory, as a batch of + // Zero-arg resolution: the configured spec directory, as a batch of // component folders. if (!specPath) { const configLoader = new ConfigLoader(); const config = configLoader.load(options.config); - if (config.outputDirectory && fs.existsSync(path.resolve(config.outputDirectory))) { - specPath = path.resolve(config.outputDirectory); + const specDirectory = config.settings.spec.directory; + if (specDirectory && fs.existsSync(path.resolve(specDirectory))) { + specPath = path.resolve(specDirectory); console.log(`Using output directory: ${path.relative(process.cwd(), specPath) || '.'}`); } else { console.error('Error: provide a spec path.'); - console.error('Tip: no outputDirectory to fall back to — set one in specs.config.yaml.'); + console.error('Tip: no spec.directory to fall back to — set one in the workspace settings.'); process.exit(ERROR_CODES.INVALID_ARGS); } } @@ -83,17 +85,21 @@ export const Render = new Command('render') process.exit(ERROR_CODES.INVALID_ARGS); } - // A spec records the config it was produced under, and render reverses that - // record — so the spec's own `metadata.config` governs. This is the fallback for - // a spec carrying none, such as a hand-authored one. A workspace without a config - // file is fine: the spec is then the only source there is. - let workspaceConfig: ResolvedConfig | undefined; + // A spec records the conventions and settings it was produced under, and render + // reverses that record — so the spec's own `metadata.conventions`/`metadata.settings` + // govern. This is the fallback for a spec carrying none, such as a hand-authored one. + // A workspace without a config file is fine: the spec is then the only source there is. + let workspaceConventions: ResolvedConventions | undefined; + let workspaceSettings: ResolvedSettings | undefined; try { - workspaceConfig = new ConfigLoader().load(options.config).config; + const workspace = new ConfigLoader().load(options.config); + workspaceConventions = workspace.conventions; + workspaceSettings = workspace.settings; } catch { - workspaceConfig = undefined; + workspaceConventions = undefined; + workspaceSettings = undefined; } - const withConfig = { ...options, workspaceConfig }; + const withConfig = { ...options, workspaceConventions, workspaceSettings }; const isBatchDir = fs.statSync(absSpecPath).isDirectory() && !isComponentFolder(absSpecPath); if (isBatchDir) { @@ -102,8 +108,8 @@ export const Render = new Command('render') await renderSpecPath(absSpecPath, withConfig); } } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + const err = e as NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }; + if (err.cause && err.cause.code === 'ECONNREFUSED') { console.error('Error: bridge is not running.'); console.error(' Start it with: specs bridge start'); } else { @@ -118,7 +124,7 @@ export const Render = new Command('render') // logged and retried on the next change (watch). async function renderSpecPath( specPath: string, - options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConfig?: ResolvedConfig } + options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConventions?: ResolvedConventions; workspaceSettings?: ResolvedSettings } ): Promise { const { spec, resolvePath } = loadSpec(specPath); // The component, not the path it came from: the full path is noise on every line of a @@ -134,7 +140,7 @@ async function renderSpecPath( const startedAt = Date.now(); let result: RenderResponse; try { - result = await postRender({ specPath: resolvePath, spec, fileKey, pageId: options.page, overwrite: options.overwrite, config: options.workspaceConfig }); + result = await postRender({ specPath: resolvePath, spec, fileKey, pageId: options.page, overwrite: options.overwrite, conventions: options.workspaceConventions, settings: options.workspaceSettings }); } finally { stopSpinner(); } @@ -235,7 +241,7 @@ function confirm(question: string): Promise { */ async function renderBatchDirectory( absDir: string, - options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConfig?: ResolvedConfig }, + options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConventions?: ResolvedConventions; workspaceSettings?: ResolvedSettings }, // In watch mode a batch is re-run on every change: don't re-confirm, and // don't exit the process on a failure the next save might fix. { watch = false }: { watch?: boolean } = {} @@ -294,7 +300,7 @@ const WATCH_DEBOUNCE_MS = 300; async function watchAndRender( specPath: string, - options: { file?: string; workspaceConfig?: ResolvedConfig } + options: { file?: string; workspaceConventions?: ResolvedConventions; workspaceSettings?: ResolvedSettings } ): Promise { const absSpecPath = path.resolve(specPath); if (!fs.existsSync(absSpecPath)) { diff --git a/packages/cli/src/commands/ScanCommand.ts b/packages/cli/src/commands/ScanCommand.ts index 6d184d8e..625a3c6a 100644 --- a/packages/cli/src/commands/ScanCommand.ts +++ b/packages/cli/src/commands/ScanCommand.ts @@ -7,11 +7,11 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; -import yaml from 'yaml'; import { ComponentDiscovery, type ComponentInfo, type DevStatus } from '../utilities/ComponentDiscovery.js'; import { ManifestParserV2, type ManifestRowV2 } from '../utilities/ManifestParserV2.js'; import { isV1Manifest, migrateV1ToV2 } from '../utilities/ManifestMigrationV1ToV2.js'; import { glyphPatternMatch } from '../utilities/glyphPatternMatch.js'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; const SCAN_FORMAT_VERSION = 2; @@ -34,38 +34,6 @@ interface ScanOptions { verbose: boolean; } -type MinimalConfig = { - dataDirectory?: string; - sourceDirectory?: string; // deprecated alias - sources?: Record; - config?: { processing?: { glyphNamePattern?: string } }; -}; - -function findConfigFile(cwd: string): string | null { - const locations = [ - path.join(cwd, 'specs.config.yaml'), - path.join(cwd, 'specs.config.json'), - path.join(process.env.HOME || '~', '.specs', 'config.yaml') - ]; - for (const location of locations) { - if (fs.existsSync(location)) return location; - } - return null; -} - -function loadConfig(configPath?: string): { configDir: string; config: MinimalConfig } { - const resolvedPath = configPath ? path.resolve(configPath) : findConfigFile(process.cwd()); - if (!resolvedPath || !fs.existsSync(resolvedPath)) { - return { configDir: process.cwd(), config: {} }; - } - const raw = fs.readFileSync(resolvedPath, 'utf-8'); - const parsed = resolvedPath.endsWith('.json') ? JSON.parse(raw) : (yaml.parse(raw) as unknown); - return { - configDir: path.dirname(resolvedPath), - config: (parsed as MinimalConfig) || {} - }; -} - /** * Default inclusion when no prior manifest exists (or --reset-checks). * @@ -177,7 +145,9 @@ function readPriorManifest(outputPath: string): ManifestRowV2[] | null { } if (ManifestParserV2.isV2(content)) { - return ManifestParserV2.parse(content).components; + const { components, warnings } = ManifestParserV2.parse(content); + for (const warning of warnings) console.warn(`⚠ ${warning}`); + return components; } return null; } @@ -187,7 +157,7 @@ function escapeCell(value: string): string { } /** - * Partition components by glyphNamePattern. When pattern is falsy, all + * Partition components by the glyph naming convention. When the pattern is falsy, all * components stay in the components list and glyphs is empty. */ export function partitionByGlyphPattern( @@ -240,7 +210,7 @@ function generateManifestV2( lines.push(''); lines.push('## Glyphs'); lines.push(''); - lines.push('_Detected via `glyphNamePattern`. Excluded from `specs generate`._'); + lines.push('_Detected via `conventions.figma.glyphs.match`. Excluded from `specs generate`._'); lines.push(''); lines.push('| Name | ID | Type |'); lines.push('|------|------|------|'); @@ -254,11 +224,11 @@ function generateManifestV2( export const Scan = new Command('scan') .description('Scan Figma file and generate component manifest for curation') - .argument('[file]', 'Path to Figma JSON file (default: resolved from configured source in specs.config.yaml)') + .argument('[file]', 'Path to Figma JSON file (default: resolved from a configured source in the workspace settings)') .option('--source ', 'Configured source alias to scan (required when multiple sources exist)') - .option('-o, --output ', 'Output manifest file path (default: {dataDirectory}/{alias}.manifest.md)') + .option('-o, --output ', 'Output manifest file path (default: {data.directory}/{alias}.manifest.md)') .option('--data-dir ', 'Override data directory for default manifest output path') - .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('--include-all', 'Include all components (overrides devStatus and heuristics)', false) .option('--keep-checks', 'Preserve prior checkbox state for existing rows; ignore devStatus changes', false) .option('--reset-checks', 'Ignore prior manifest and re-derive checks from devStatus / heuristics', false) @@ -271,8 +241,9 @@ export const Scan = new Command('scan') process.exit(ERROR_CODES.INVALID_ARGS); } - const { configDir, config } = loadConfig(options.config); - const dataDir = options.dataDir || config.dataDirectory || config.sourceDirectory; + const config = new ConfigLoader().load(options.config); + const configDir = config.configDir ?? process.cwd(); + const dataDir = options.dataDir || config.settings.data?.directory; const resolvedDir = path.resolve(configDir, dataDir || '.'); let file: string; @@ -283,12 +254,12 @@ export const Scan = new Command('scan') } file = fileArg; } else { - const fileSources = Object.entries(config.sources || {}).filter( - ([, entry]) => Array.isArray(entry.data) && entry.data.includes('file') + const fileSources = Object.entries(config.settings.data?.sources ?? {}).filter( + ([, entry]) => Array.isArray(entry.fetch) && entry.fetch.includes('file') ); if (fileSources.length === 0) { - console.error('Error: No argument provided and no sources configured in specs.config.yaml'); + console.error('Error: No argument provided and no sources configured in the workspace settings'); console.error('Tip: run `specs fetch` first, or pass a file path explicitly (e.g., `specs scan data/library.file.json`)'); process.exit(ERROR_CODES.INVALID_ARGS); } @@ -356,7 +327,7 @@ export const Scan = new Command('scan') // Sort by name for stable diffs componentInfoList.sort((a, b) => a.name.localeCompare(b.name)); - const glyphPattern = config.config?.processing?.glyphNamePattern; + const glyphPattern = config.conventions.figma.glyphs?.match; const { components: componentList, glyphs: glyphList } = partitionByGlyphPattern( componentInfoList, glyphPattern diff --git a/packages/cli/src/commands/TransformCommand.ts b/packages/cli/src/commands/TransformCommand.ts index acdf4d84..72141560 100644 --- a/packages/cli/src/commands/TransformCommand.ts +++ b/packages/cli/src/commands/TransformCommand.ts @@ -20,7 +20,7 @@ export const Transform = new Command('transform') .description('Project component contracts into derived files (contract, css, tokens, …)') .argument('[transformers...]', 'Transformer names to run (default: contract)') .option('-o, --output ', 'Path to the specs directory (input and output)') - .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--config ', 'Path to a config/ directory or legacy specs.config.yaml') .option('--components ', 'Only transform these component folders (default: all)') .option('--verbose', 'Enable detailed logging', false) .action(async (transformerNames: string[], options: TransformOptions) => { @@ -31,18 +31,18 @@ export const Transform = new Command('transform') // Resolve output/input directory: flag → config → cwd const outputPath = options.output ? path.resolve(options.output) - : config.outputDirectory - ? path.resolve(config.outputDirectory) + : config.settings.spec.directory + ? path.resolve(config.settings.spec.directory) : path.resolve(process.cwd()); if (!fs.existsSync(outputPath)) { console.error(`Error: specs directory not found: ${outputPath}`); - console.error('Tip: run `specs generate --split-components --split-concerns` first'); + console.error('Tip: run `specs generate` first — it writes this layout by default'); process.exit(ERROR_CODES.INVALID_ARGS); } - // Resolve transformer names: positionals → config.transformers → defaults - const configTransformerEntries = (config.config?.transformers ?? []) as Array>; + // Resolve transformer names: positionals → pipeline.transformers → defaults + const configTransformerEntries = config.pipeline.transformers as Array>; const transformerOptionsMap = new Map>( configTransformerEntries.map(e => { const { name, ...rest } = e; @@ -85,7 +85,7 @@ export const Transform = new Command('transform') if (componentDirs.length === 0) { console.error(`Error: no component directories with api.yaml found in ${outputPath}`); - console.error('Tip: run `specs generate --split-components --split-concerns` first'); + console.error('Tip: run `specs generate` first — it writes this layout by default'); process.exit(ERROR_CODES.FILE_ERROR); } @@ -107,8 +107,9 @@ export const Transform = new Command('transform') const context: TransformerContext = { outputDir: componentDir, componentKey, - tokensFormat: config.config.format.tokens, - processingStates: config.config.processing?.states as ProcessingStates | undefined, + tokensFormat: config.settings.spec.tokens, + outputFormat: config.settings.spec.format, + processingStates: config.conventions.figma.states as ProcessingStates | undefined, transformerOptions: transformerOptionsMap.get(transformer.name), }; await transformer.run(apiYaml, context); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c55c66fb..c158b9f4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -26,21 +26,23 @@ import { Scan } from './commands/ScanCommand.js'; import { Fetch } from './commands/FetchCommand.js'; import { Cache } from './commands/CacheCommand.js'; import { Init } from './commands/InitCommand.js'; +import { Migrate } from './commands/MigrateCommand.js'; import { Analyze } from './commands/AnalyzeCommand.js'; import { ApplyCustomTokens } from './commands/ApplyCustomTokensCommand.js'; import { Transform } from './commands/TransformCommand.js'; import { Render } from './commands/RenderCommand.js'; import { Bridge } from './commands/BridgeCommand.js'; -declare const __SPECS_CLI_VERSION__; +declare const __SPECS_CLI_VERSION__: string; // Backward compatibility: export Scan also as Audit export const Audit = Scan; -export { Generate, Scan, Fetch, Init, ApplyCustomTokens, Transform, Analyze, Render, Bridge }; +export { Generate, Scan, Fetch, Init, Migrate, ApplyCustomTokens, Transform, Analyze, Render, Bridge }; export const commands = { Init, + Migrate, Generate, Scan, Fetch, @@ -60,6 +62,7 @@ export function createProgram(): Command { .version(__SPECS_CLI_VERSION__); program.addCommand(Init); + program.addCommand(Migrate); program.addCommand(Generate); program.addCommand(Scan); program.addCommand(Fetch); diff --git a/packages/cli/src/transforms/Css.mapping.md b/packages/cli/src/transforms/Css.mapping.md index 5ecae94f..669ff6b5 100644 --- a/packages/cli/src/transforms/Css.mapping.md +++ b/packages/cli/src/transforms/Css.mapping.md @@ -15,7 +15,7 @@ Each key in `default.elements` becomes a CSS class: | any other | `.{component}__{element-in-kebab}` | Component and element keys are converted from camelCase to kebab-case: -`egdsButton` → `.egds-button`, `startVisualAndLabel` → `.egds-button__start-visual-and-label`. +`dsButton` → `.ds-button`, `startVisualAndLabel` → `.ds-button__start-visual-and-label`. --- @@ -26,7 +26,7 @@ on the root class: ``` configuration: { appearance: outline, state: hover } - → .egds-button[data-appearance="outline"][data-state="hover"] + → .ds-button[data-appearance="outline"][data-state="hover"] ``` Variants are emitted in **schema order** — this is intentional. `variants.yaml` orders @@ -36,7 +36,7 @@ CSS cascade matching the variant-layering algorithm. Do not reorder. Child element overrides under a variant use a descendant selector: ```css -.egds-button[data-appearance="outline"] .egds-button__label { ... } +.ds-button[data-appearance="outline"] .ds-button__label { ... } ``` --- diff --git a/packages/cli/src/transforms/Css.states-setup.md b/packages/cli/src/transforms/Css.states-setup.md index d64fc0b7..14e0ee9c 100644 --- a/packages/cli/src/transforms/Css.states-setup.md +++ b/packages/cli/src/transforms/Css.states-setup.md @@ -1,7 +1,7 @@ # CSS States Setup — Library Analysis Skill Scans a generated specs output directory, identifies variant configuration props that are -candidates for `processing.states` concepts, and proposes a `states:` block for `specs.config.yaml`. +candidates for `figma.states` concepts, and proposes a `states:` block for `config/conventions.yaml`. --- @@ -87,7 +87,7 @@ Given a specs output directory path: ## Output format ```yaml -# Proposed states config — paste into specs.config.yaml under config.processing +# Proposed states config — paste into config/conventions.yaml under figma processing: states: hover: diff --git a/packages/cli/src/transforms/css/token-resolution.md b/packages/cli/src/transforms/css/token-resolution.md index c770a432..83f6a491 100644 --- a/packages/cli/src/transforms/css/token-resolution.md +++ b/packages/cli/src/transforms/css/token-resolution.md @@ -10,7 +10,7 @@ The spec file contains token references in whatever format the consumer chose at ### Token reference shapes in the spec (by format) -| `format.tokens` config | Shape in spec | Example | +| `spec.tokens` setting | Shape in spec | Example | |---|---|---| | `TOKEN` *(default)* | `{ $token, $type }` object | `{ $token: "DS Color/Alert/Info/Background", $type: "color" }` | | `TOKEN_FIGMA_EXTENSIONS` | `{ $token, $type, $extensions }` object | same + `$extensions.com.figma.id` | @@ -25,7 +25,7 @@ The spec file contains token references in whatever format the consumer chose at ## Resolution Logic -Resolution is determined by `config.format.tokens`. Three branches: +Resolution is determined by `settings.spec.tokens`. Three branches: --- @@ -125,7 +125,7 @@ Transform steps, in order: ## Resolution Table -| `format.tokens` | Step 1 | Step 2 | Step 3 | +| `spec.tokens` | Step 1 | Step 2 | Step 3 | |---|---|---|---| | `FIGMA_SYNTAX_WEB` | string starts with `--` → use directly | `{ $token }` fallback → path derivation | — | | `CUSTOM` | `$custom.$cssVar` → use directly | variables file reverse lookup → `codeSyntax.WEB` | path derivation | @@ -142,9 +142,9 @@ Transform steps, in order: For Branch 2 step 2, the transformer needs the fetched variables file. This is supplied via: - `--variables ` flag on `specs transform` -- `config.dataDirectory` / `config.sources` (same resolution as `generate`) +- `settings.data.directory` / `settings.data.sources` (same resolution as `generate`) -The file is only loaded when `format.tokens: CUSTOM` and `$cssVar` is absent from the spec value. It is never required for other branches. +The file is only loaded when `spec.tokens: CUSTOM` and `$cssVar` is absent from the spec value. It is never required for other branches. --- diff --git a/packages/cli/src/utilities/ComponentDiscovery.ts b/packages/cli/src/utilities/ComponentDiscovery.ts index 8e91925c..6b44e1b1 100644 --- a/packages/cli/src/utilities/ComponentDiscovery.ts +++ b/packages/cli/src/utilities/ComponentDiscovery.ts @@ -26,7 +26,26 @@ interface RestApiNode { devStatus?: { type?: string; description?: string }; } -export type DevStatus = 'READY_FOR_DEV' | 'NONE'; +/** Dev statuses Figma itself sets, plus 'NONE' for a node with no status. */ +export type KnownDevStatus = 'READY_FOR_DEV' | 'COMPLETED' | 'NONE'; + +export const KNOWN_DEV_STATUSES: readonly KnownDevStatus[] = ['READY_FOR_DEV', 'COMPLETED', 'NONE']; + +/** + * A dev status as carried from Figma. Values outside `KnownDevStatus` are passed + * through verbatim rather than collapsed, so a status this CLI predates stays visible. + */ +export type DevStatus = KnownDevStatus | (string & {}); + +export function isKnownDevStatus(value: string): value is KnownDevStatus { + return (KNOWN_DEV_STATUSES as readonly string[]).includes(value); +} + +/** Normalize a node's devStatus, carrying unknown values through untouched. */ +function readDevStatus(node: RestApiNode): DevStatus { + const type = node.devStatus?.type?.trim(); + return type ? type.toUpperCase() : 'NONE'; +} /** * Minimal structure for REST API file data @@ -49,7 +68,7 @@ export interface ComponentInfo { name: string; /** Node type (COMPONENT or COMPONENT_SET) */ type: string; - /** Dev-ready status from Figma. 'NONE' when the property is absent on the node. */ + /** Dev status from Figma, verbatim. 'NONE' when the property is absent on the node. */ devStatus: DevStatus; } @@ -125,7 +144,7 @@ export class ComponentDiscovery { id: node.id, name: node.name, type: node.type, - devStatus: node.devStatus?.type === 'READY_FOR_DEV' ? 'READY_FOR_DEV' : 'NONE' + devStatus: readDevStatus(node) }); continue; } @@ -145,7 +164,7 @@ export class ComponentDiscovery { id: node.id, name: node.name, type: node.type, - devStatus: node.devStatus?.type === 'READY_FOR_DEV' ? 'READY_FOR_DEV' : 'NONE' + devStatus: readDevStatus(node) }); } } diff --git a/packages/cli/src/utilities/ManifestParserV2.ts b/packages/cli/src/utilities/ManifestParserV2.ts index 871ea42b..c365097f 100644 --- a/packages/cli/src/utilities/ManifestParserV2.ts +++ b/packages/cli/src/utilities/ManifestParserV2.ts @@ -11,7 +11,7 @@ * | [ ] | Card | 1:45 | COMPONENT | NONE | */ -import type { DevStatus } from './ComponentDiscovery.js'; +import { isKnownDevStatus, type DevStatus } from './ComponentDiscovery.js'; export interface ManifestRowV2 { id: string; @@ -31,6 +31,8 @@ export interface ManifestMetadataV2 { export interface ManifestResultV2 { components: ManifestRowV2[]; metadata: ManifestMetadataV2; + /** One entry per row that could not be parsed or carried an unrecognized status. */ + warnings: string[]; } const SCAN_FORMAT_VERSION_REGEX = /\*\*Scan format version:\*\*\s+(\d+)/m; @@ -39,8 +41,13 @@ const VARIABLES_HEADER_REGEX = /\*\*Variables:\*\*\s+(.+?)$/m; const FILE_LAST_MODIFIED_REGEX = /\*\*File last modified:\*\*\s+(.+?)$/m; // | [x] | Name | id | TYPE | DEV_STATUS | +// The status column is open: any token is accepted so a status this CLI does not +// know is still parsed into a row (unselected) rather than dropping the row. const ROW_REGEX = - /^\|\s*\[([x ])\]\s*\|\s*(.+?)\s*\|\s*(\d+:\d+)\s*\|\s*(COMPONENT_SET|COMPONENT)\s*\|\s*(READY_FOR_DEV|NONE)\s*\|/i; + /^\|\s*\[([x ])\]\s*\|\s*(.+?)\s*\|\s*(\d+:\d+)\s*\|\s*(COMPONENT_SET|COMPONENT)\s*\|\s*([A-Za-z0-9_]+)\s*\|/i; + +// Any line whose first cell is a checkbox is meant to be a component row. +const CHECKBOX_ROW_REGEX = /^\|\s*\[[^\]]*\]\s*\|/; export class ManifestParserV2 { /** Returns true when the content declares scan format version 2 (or higher). */ @@ -66,6 +73,7 @@ export class ManifestParserV2 { if (lastModifiedMatch) metadata.fileLastModified = lastModifiedMatch[1].trim(); const components: ManifestRowV2[] = []; + const warnings: string[] = []; let inComponentsSection = false; for (const line of content.split('\n')) { const heading = line.match(/^##\s+(.+?)\s*$/); @@ -75,19 +83,31 @@ export class ManifestParserV2 { } if (!inComponentsSection) continue; const match = line.match(ROW_REGEX); - if (!match) continue; - const [, checkbox, name, id, type, devStatus] = match; + if (!match) { + if (CHECKBOX_ROW_REGEX.test(line)) { + warnings.push(`Skipped unparseable manifest row: ${line.trim()}`); + } + continue; + } + const [, checkbox, name, id, type, rawStatus] = match; + const devStatus: DevStatus = rawStatus.toUpperCase(); + const known = isKnownDevStatus(devStatus); + if (!known) { + warnings.push( + `Unrecognized dev status "${devStatus}" on row ${id} — treating the component as unselected.` + ); + } components.push({ id, // `specs scan` escapes pipes in cell values (`escapeCell`: | → \|). // Undo that here so names round-trip back to their literal form. name: name.trim().replace(/\\\|/g, '|'), type: type.toUpperCase() as 'COMPONENT' | 'COMPONENT_SET', - included: checkbox.toLowerCase() === 'x', - devStatus: devStatus.toUpperCase() as DevStatus + included: known && checkbox.toLowerCase() === 'x', + devStatus }); } - return { components, metadata }; + return { components, metadata, warnings }; } } diff --git a/packages/cli/src/utilities/loadFoundations.ts b/packages/cli/src/utilities/loadFoundations.ts index 5a364aeb..bde90506 100644 --- a/packages/cli/src/utilities/loadFoundations.ts +++ b/packages/cli/src/utilities/loadFoundations.ts @@ -49,7 +49,7 @@ export interface FoundationsData { * libraryJson * ); * - * const component = Component.fromRestApi(libraryJson, componentId, config, { + * const component = Component.fromRestApi(libraryJson, componentId, conventions, settings, { * coordinator, * styles, * variables, diff --git a/packages/cli/tests/unit/analyzers/Props.test.ts b/packages/cli/tests/unit/analyzers/Props.test.ts index 0f81b24f..5653e432 100644 --- a/packages/cli/tests/unit/analyzers/Props.test.ts +++ b/packages/cli/tests/unit/analyzers/Props.test.ts @@ -32,7 +32,7 @@ describe('PropsAnalyzer', () => { for (const [componentKey, apiYaml] of Object.entries(components)) { const compDir = path.join(outputDir, componentKey); await fs.ensureDir(compDir); - await a.run(apiYaml, { outputDir: compDir, componentKey, outputFormat }); + await a.run(apiYaml, { outputDir: compDir, componentKey, tokensFormat: 'TOKEN', outputFormat }); } await a.finalize!(outputDir, analysisDir); if (outputFormat === 'JSON') { @@ -61,7 +61,7 @@ describe('PropsAnalyzer', () => { const compDir = path.join(outputDir, 'compA'); await fs.ensureDir(compDir); const a = new PropsAnalyzer(); - await a.run({ props: { label: { type: 'string' } } }, { outputDir: compDir, componentKey: 'compA', outputFormat: 'YAML' }); + await a.run({ props: { label: { type: 'string' } } }, { outputDir: compDir, componentKey: 'compA', tokensFormat: 'TOKEN', outputFormat: 'YAML' }); expect(fs.existsSync(path.join(compDir, 'props.yaml'))).toBe(false); }); @@ -231,7 +231,7 @@ describe('PropsAnalyzer', () => { const a2 = new PropsAnalyzer(); const compDir2 = path.join(outputDir2, 'compA'); await fs.ensureDir(compDir2); - await a2.run({ props: { size: { type: 'string', enum: ['sm', 'md'] }, disabled: { type: 'boolean' } } }, { outputDir: compDir2, componentKey: 'compA' }); + await a2.run({ props: { size: { type: 'string', enum: ['sm', 'md'] }, disabled: { type: 'boolean' } } }, { outputDir: compDir2, componentKey: 'compA', tokensFormat: 'TOKEN', outputFormat: 'YAML' as const }); const analysisDir2 = path.join(outputDir2, '_analysis'); await a2.finalize!(outputDir2, analysisDir2); const raw2 = await fs.readFile(path.join(analysisDir2, 'props.yaml'), 'utf-8'); @@ -247,7 +247,7 @@ describe('PropsAnalyzer', () => { const a = new PropsAnalyzer(); const compDir = path.join(outputDir, 'compA'); await fs.ensureDir(compDir); - await a.run({ props: { label: { type: 'string' } } }, { outputDir: compDir, componentKey: 'compA' }); + await a.run({ props: { label: { type: 'string' } } }, { outputDir: compDir, componentKey: 'compA', tokensFormat: 'TOKEN', outputFormat: 'YAML' as const }); await a.finalize!(outputDir, customDir); expect(fs.existsSync(path.join(customDir, 'props.yaml'))).toBe(true); expect(fs.existsSync(path.join(outputDir, '_analysis', 'props.yaml'))).toBe(false); diff --git a/packages/cli/tests/unit/analyzers/Styling.test.ts b/packages/cli/tests/unit/analyzers/Styling.test.ts index 6d545847..7b77c8b2 100644 --- a/packages/cli/tests/unit/analyzers/Styling.test.ts +++ b/packages/cli/tests/unit/analyzers/Styling.test.ts @@ -8,7 +8,7 @@ import { StylingAnalyzer } from '../../../src/analyzers/Styling.js'; const transformer = new StylingAnalyzer(); function makeContext(dir: string, componentKey = 'dsButton', outputFormat: 'JSON' | 'YAML' = 'JSON') { - return { outputDir: dir, componentKey, outputFormat }; + return { outputDir: dir, componentKey, tokensFormat: 'TOKEN', outputFormat }; } async function run(dir: string, apiYaml: Record, componentKey = 'dsButton') { @@ -310,7 +310,7 @@ describe('StylingAnalyzer', () => { try { await t.run( { anatomy: { root: { type: 'container' } }, default: { elements: { root: { styles: { backgroundColor: { $token: 'DS Color.Primary', $type: 'color' } } } } } }, - { outputDir: dir, componentKey: 'dsButton', outputFormat: 'YAML' }, + { outputDir: dir, componentKey: 'dsButton', tokensFormat: 'TOKEN', outputFormat: 'YAML' }, ); expect(fs.existsSync(path.join(dir, 'styling.yaml'))).toBe(true); expect(fs.existsSync(path.join(dir, 'styling.json'))).toBe(false); @@ -380,8 +380,8 @@ describe('StylingAnalyzer.finalize', () => { async function runFinalize(outputFormat: 'JSON' | 'YAML' = 'JSON') { const t = new StylingAnalyzer(); - await t.run(COMP_A, { outputDir: compDirA, componentKey: 'compA', outputFormat }); - await t.run(COMP_B, { outputDir: compDirB, componentKey: 'compB', outputFormat }); + await t.run(COMP_A, { outputDir: compDirA, componentKey: 'compA', tokensFormat: 'TOKEN', outputFormat }); + await t.run(COMP_B, { outputDir: compDirB, componentKey: 'compB', tokensFormat: 'TOKEN', outputFormat }); await t.finalize!(outputDir); const ext = outputFormat === 'YAML' ? 'yaml' : 'json'; const byCompRaw = await fs.readFile(path.join(outputDir, '_analysis', `styling.byComponent.${ext}`), 'utf-8'); @@ -423,7 +423,7 @@ describe('StylingAnalyzer.finalize', () => { }; const compDir = path.join(outputDir, 'compRaw'); await fs.ensureDir(compDir); - await t.run(withRaw, { outputDir: compDir, componentKey: 'compRaw', outputFormat: 'JSON' }); + await t.run(withRaw, { outputDir: compDir, componentKey: 'compRaw', tokensFormat: 'TOKEN', outputFormat: 'JSON' }); await t.finalize!(outputDir); const byComp = JSON.parse(await fs.readFile(path.join(outputDir, '_analysis', 'styling.byComponent.json'), 'utf-8')); expect('rawValue' in byComp.compRaw.variables[0]).toBe(false); @@ -464,7 +464,7 @@ describe('StylingAnalyzer.finalize', () => { }; const compDir = path.join(outputDir, 'compSub'); await fs.ensureDir(compDir); - await t.run(withSub, { outputDir: compDir, componentKey: 'compSub', outputFormat: 'JSON' }); + await t.run(withSub, { outputDir: compDir, componentKey: 'compSub', tokensFormat: 'TOKEN', outputFormat: 'JSON' }); await t.finalize!(outputDir); const byToken = JSON.parse(await fs.readFile(path.join(outputDir, '_analysis', 'styling.byToken.json'), 'utf-8')); const entry = byToken.variables['Color/On surface'][0]; @@ -526,7 +526,7 @@ describe('StylingAnalyzer unused tokens', () => { const t = new StylingAnalyzer(); const compDir = path.join(outputDir, 'comp'); await fs.ensureDir(compDir); - await t.run(COMPONENT, { outputDir: compDir, componentKey: 'comp', outputFormat }); + await t.run(COMPONENT, { outputDir: compDir, componentKey: 'comp', tokensFormat: 'TOKEN', outputFormat }); await t.finalize!(outputDir, undefined, foundations); const ext = outputFormat === 'YAML' ? 'yaml' : 'json'; const unusedPath = path.join(outputDir, '_analysis', `styling.unused.${ext}`); diff --git a/packages/cli/tests/unit/commands/FetchCommand.test.ts b/packages/cli/tests/unit/commands/FetchCommand.test.ts index 9824fc63..8f4fc74e 100644 --- a/packages/cli/tests/unit/commands/FetchCommand.test.ts +++ b/packages/cli/tests/unit/commands/FetchCommand.test.ts @@ -135,14 +135,14 @@ describe('formatNotFoundError', () => { ' This usually means the key in your config is stale or out of reach:', ' • The file was moved, deleted, or recreated (keys change on duplicate/recreate)', ' • Your FIGMA_TOKEN account cannot open this file', - ' Check: sources.library.key in /proj/specs.config.yaml' + ' Check: data.sources.library.key in /proj/specs.config.yaml' ].join('\n')); }); it('falls back to a default config name when path is null', () => { const result = formatNotFoundError('kds', 'variables', null); - expect(result).toContain('Check: sources.kds.key in specs.config.yaml'); + expect(result).toContain('Check: data.sources.kds.key in the workspace settings (config/settings.yaml)'); }); }); @@ -164,13 +164,13 @@ describe('formatAuthError', () => { expect(result).toBe([ 'Error: Access denied (403) while fetching library.styles', ' Your FIGMA_TOKEN is valid but cannot access this file.', - ' • Confirm your Figma account can open the file for sources.library.key', + ' • Confirm your Figma account can open the file for data.sources.library.key', ' • Personal access tokens only reach files your account can view', ' • If your org enforces SAML/SSO, personal access tokens are blocked', ' Use an OAuth token or ask your admin to allow PATs', ' See: https://www.figma.com/developers/api#oauth2', ' • The file may be in personal drafts or a restricted team (403 = exists but no access)', - ' Check: sources.library.key in /proj/specs.config.yaml' + ' Check: data.sources.library.key in /proj/specs.config.yaml' ].join('\n')); }); }); diff --git a/packages/cli/tests/unit/commands/GenerateCommand.test.ts b/packages/cli/tests/unit/commands/GenerateCommand.test.ts index b482f31e..9129bd0e 100644 --- a/packages/cli/tests/unit/commands/GenerateCommand.test.ts +++ b/packages/cli/tests/unit/commands/GenerateCommand.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { Command } from 'commander'; import { Generate } from '../../../src/commands/GenerateCommand.js'; import { ManifestParser } from '../../../src/utilities/ManifestParser.js'; import { ManifestParserV2 } from '../../../src/utilities/ManifestParserV2.js'; @@ -42,9 +43,9 @@ describe('GenerateCommand', () => { expect(options).toContain('--styles'); expect(options).toContain('--data-dir'); expect(options).toContain('--config'); - expect(options).toContain('--split-components'); - expect(options).toContain('--split-concerns'); - expect(options).toContain('--use-subfolders'); + expect(options).toContain('--combine-as-library'); + expect(options).toContain('--combine-concerns'); + expect(options).toContain('--no-subfolders'); expect(options).toContain('--from-bridge'); expect(options).toContain('--file'); expect(options).toContain('--verbose'); @@ -70,19 +71,37 @@ describe('GenerateCommand', () => { expect(shorts).toContain('-s'); }); - it('split-components has no Commander default (defers to config)', () => { - const opt = Generate.options.find(o => o.long === '--split-components'); + // The split layout is the default, so each flag turns a split OFF. Absent + // means "defer to config", which is why neither combine flag carries a + // Commander default that would mask a configured value. + it('combine-as-library has no Commander default (defers to config)', () => { + const opt = Generate.options.find(o => o.long === '--combine-as-library'); expect(opt!.defaultValue).toBeUndefined(); }); - it('split-concerns has no Commander default (defers to config)', () => { - const opt = Generate.options.find(o => o.long === '--split-concerns'); + it('combine-concerns has no Commander default (defers to config)', () => { + const opt = Generate.options.find(o => o.long === '--combine-concerns'); expect(opt!.defaultValue).toBeUndefined(); }); - it('use-subfolders has no Commander default (defers to config)', () => { - const opt = Generate.options.find(o => o.long === '--use-subfolders'); - expect(opt!.defaultValue).toBeUndefined(); + // Commander resolves a negatable flag at parse time rather than through + // Option.defaultValue: absent yields true, passed yields false. Generate's + // resolution keys off that explicit `false`, so absence still defers to + // the configured value. + it('no-subfolders parses to true when absent and false when passed', () => { + const parse = (argv: string[]) => { + const probe = new Command('probe') + .option('--combine-as-library', 'x') + .option('--combine-concerns', 'x') + .option('--no-subfolders', 'x'); + probe.parse(argv, { from: 'user' }); + return probe.opts(); + }; + + expect(parse([]).subfolders).toBe(true); + expect(parse(['--no-subfolders']).subfolders).toBe(false); + expect(parse([]).combineAsLibrary).toBeUndefined(); + expect(parse(['--combine-as-library']).combineAsLibrary).toBe(true); }); it('verbose defaults to false', () => { diff --git a/packages/cli/tests/unit/commands/InitCommand.test.ts b/packages/cli/tests/unit/commands/InitCommand.test.ts index f785f9ba..511fbbf6 100644 --- a/packages/cli/tests/unit/commands/InitCommand.test.ts +++ b/packages/cli/tests/unit/commands/InitCommand.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs-extra'; import path from 'path'; import { fileURLToPath } from 'url'; -import { generateConfigTemplate } from '../../../src/Config/ConfigTemplates.js'; +import { generateConfigTemplates } from '../../../src/Config/ConfigTemplates.js'; import { Init } from '../../../src/commands/InitCommand.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -23,41 +23,48 @@ describe('InitCommand', () => { }); describe('template generation', () => { - it('should generate config template with dataDirectory', () => { - const template = generateConfigTemplate(); - expect(template).toContain('dataDirectory: ./data'); + it('generates the three split-config files under config/', () => { + const templates = generateConfigTemplates(); + expect(Object.keys(templates).sort()).toEqual([ + 'config/conventions.yaml', + 'config/pipeline.yaml', + 'config/settings.yaml', + ]); }); - it('should generate config template with outputDirectory', () => { - const template = generateConfigTemplate(); - expect(template).toContain('outputDirectory: ./specs'); + it('settings template carries data and spec directories with defaults', () => { + const settings = generateConfigTemplates()['config/settings.yaml']; + expect(settings).toContain('directory: ./data'); + expect(settings).toContain('directory: ./specs'); + expect(settings).toContain('sources: {}'); }); - it('should generate template with proper structure', () => { - const template = generateConfigTemplate(); - expect(template).toContain('sources: {}'); - expect(template).toContain('config:'); - expect(template).toContain('processing:'); - expect(template).toContain('format:'); + it('conventions template carries the figma conventions structure', () => { + const conventions = generateConfigTemplates()['config/conventions.yaml']; + expect(conventions).toContain('figma:'); + expect(conventions).toContain('subcomponents:'); + expect(conventions).toContain('match:'); }); it('should include inline documentation', () => { - const template = generateConfigTemplate(); - expect(template).toContain('# Specs CLI Configuration'); - expect(template).toContain('www.specsplugin.com/settings/'); + for (const template of Object.values(generateConfigTemplates())) { + expect(template).toContain('#'); + } + const settings = generateConfigTemplates()['config/settings.yaml']; + expect(settings).toContain('www.specsplugin.com/settings/'); }); it('should mention defaults in comments', () => { - const template = generateConfigTemplate(); - expect(template).toContain('Default'); + const settings = generateConfigTemplates()['config/settings.yaml']; + expect(settings).toContain('Default'); }); it('should have consistent YAML indentation', () => { - const template = generateConfigTemplate(); - const lines = template.split('\n'); + const settings = generateConfigTemplates()['config/settings.yaml']; + const lines = settings.split('\n'); const seenIndents = new Set(); - lines.forEach(line => { + lines.forEach((line: string) => { if (line.trim() && !line.trim().startsWith('#')) { const leadingSpaces = line.match(/^ */)?.[0].length || 0; seenIndents.add(leadingSpaces); @@ -70,27 +77,32 @@ describe('InitCommand', () => { }); describe('file operations', () => { - it('should create config file if it does not exist', async () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - const template = generateConfigTemplate(); - - fs.writeFileSync(configPath, template, 'utf-8'); - expect(fs.existsSync(configPath)).toBe(true); + it('should create the three config files if they do not exist', async () => { + const templates = generateConfigTemplates(); + for (const [rel, template] of Object.entries(templates)) { + const filePath = path.join(testDir, rel); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, template, 'utf-8'); + } + expect(fs.existsSync(path.join(testDir, 'config', 'conventions.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'config', 'settings.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'config', 'pipeline.yaml'))).toBe(true); }); it('should write template content exactly', async () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - const template = generateConfigTemplate(); - - fs.writeFileSync(configPath, template, 'utf-8'); - const content = fs.readFileSync(configPath, 'utf-8'); - - expect(content).toBe(template); + const templates = generateConfigTemplates(); + for (const [rel, template] of Object.entries(templates)) { + const filePath = path.join(testDir, rel); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, template, 'utf-8'); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(template); + } }); - it('should preserve template across read/write cycles', async () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - const template = generateConfigTemplate(); + it('should preserve templates across read/write cycles', async () => { + const template = generateConfigTemplates()['config/settings.yaml']; + const configPath = path.join(testDir, 'config', 'settings.yaml'); + fs.ensureDirSync(path.dirname(configPath)); // First write fs.writeFileSync(configPath, template, 'utf-8'); @@ -104,30 +116,24 @@ describe('InitCommand', () => { expect(secondRead).toBe(template); }); - it('should support custom config paths', async () => { - const customPath = path.join(testDir, 'custom-config.yaml'); - const template = generateConfigTemplate(); - - fs.writeFileSync(customPath, template, 'utf-8'); - expect(fs.existsSync(customPath)).toBe(true); - }); - - it('should handle nested directory paths', async () => { - const nestedPath = path.join(testDir, 'nested', 'dirs', 'specs.config.yaml'); - const template = generateConfigTemplate(); - - fs.ensureDirSync(path.dirname(nestedPath)); - fs.writeFileSync(nestedPath, template, 'utf-8'); - - expect(fs.existsSync(nestedPath)).toBe(true); + it('should support a custom base directory', async () => { + const customBase = path.join(testDir, 'workspace'); + const templates = generateConfigTemplates(); + for (const [rel, template] of Object.entries(templates)) { + const filePath = path.join(customBase, rel); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, template, 'utf-8'); + } + expect(fs.existsSync(path.join(customBase, 'config', 'settings.yaml'))).toBe(true); }); it('should allow overwriting existing config', async () => { - const configPath = path.join(testDir, 'specs.config.yaml'); + const configPath = path.join(testDir, 'config', 'settings.yaml'); const oldContent = '# Old config\nold: value'; - const newTemplate = generateConfigTemplate(); + const newTemplate = generateConfigTemplates()['config/settings.yaml']; // Write old content + fs.ensureDirSync(path.dirname(configPath)); fs.writeFileSync(configPath, oldContent, 'utf-8'); expect(fs.readFileSync(configPath, 'utf-8')).toBe(oldContent); @@ -138,80 +144,78 @@ describe('InitCommand', () => { }); describe('config content validation', () => { - it('should have valid structure with top-level keys', () => { - const template = generateConfigTemplate(); - const yamlLines = template.split('\n'); + it('settings template has the expected top-level keys', () => { + const settings = generateConfigTemplates()['config/settings.yaml']; + const yamlLines = settings.split('\n'); const topLevelKeys = yamlLines - .filter(line => !line.startsWith(' ') && !line.startsWith('#') && line.trim()) - .map(line => line.split(':')[0]); + .filter((line: string) => !line.startsWith(' ') && !line.startsWith('#') && line.trim()) + .map((line: string) => line.split(':')[0]); - expect(topLevelKeys).toContain('dataDirectory'); - expect(topLevelKeys).toContain('outputDirectory'); - expect(topLevelKeys).toContain('sources'); - expect(topLevelKeys).toContain('config'); + expect(topLevelKeys).toContain('author'); + expect(topLevelKeys).toContain('data'); + expect(topLevelKeys).toContain('spec'); - // format, processing, and include are nested under config, not top-level - expect(template).toContain('format:'); - expect(template).toContain('processing:'); - expect(template).toContain('include:'); + // directories and sources are nested under data/spec, not top-level + expect(settings).toContain('directory:'); + expect(settings).toContain('sources:'); }); it('should have all required documentation URLs', () => { - const template = generateConfigTemplate(); + const settings = generateConfigTemplates()['config/settings.yaml']; const requiredUrls = [ 'www.specsplugin.com/settings/', 'www.specsplugin.com/', ]; requiredUrls.forEach(url => { - expect(template).toContain(url); + expect(settings).toContain(url); }); }); - it('should have model processing configuration', () => { - const template = generateConfigTemplate(); + it('conventions template has processing conventions', () => { + const conventions = generateConfigTemplates()['config/conventions.yaml']; - expect(template).toContain('subcomponents:'); - expect(template).toContain('variantDepth'); - expect(template).toContain('details'); + expect(conventions).toContain('subcomponents:'); + expect(conventions).toContain('slotConstraints'); }); - it('should have format configuration', () => { - const template = generateConfigTemplate(); + it('settings template has serialization settings', () => { + const settings = generateConfigTemplates()['config/settings.yaml']; - expect(template).toContain('keys:'); - expect(template).toContain('output:'); - expect(template).toContain('tokens:'); - expect(template).toContain('layout:'); + expect(settings).toContain('keys:'); + expect(settings).toContain('format:'); + expect(settings).toContain('tokens:'); + expect(settings).toContain('layout:'); + expect(settings).toContain('variantDepth'); + expect(settings).toContain('details'); }); it('should not contain any credential placeholders', () => { - const template = generateConfigTemplate(); - - // Should not have explicit credential markers - expect(template).not.toContain('YOUR_API_KEY'); - expect(template).not.toContain('YOUR_SECRET'); - expect(template).not.toContain('ACTUAL_TOKEN'); + for (const template of Object.values(generateConfigTemplates())) { + expect(template).not.toContain('YOUR_API_KEY'); + expect(template).not.toContain('YOUR_SECRET'); + expect(template).not.toContain('ACTUAL_TOKEN'); + } }); it('should use example keys for Figma sources', () => { - const template = generateConfigTemplate(); + const settings = generateConfigTemplates()['config/settings.yaml']; // Should show the pattern with example key - expect(template).toMatch(/key:\s*YOUR_FIGMA_FILE_KEY|FIGMA_FILE_KEY/); + expect(settings).toMatch(/key:\s*YOUR_FIGMA_FILE_KEY|FIGMA_FILE_KEY/); }); it('should have sensible default values', () => { - const template = generateConfigTemplate(); + const settings = generateConfigTemplates()['config/settings.yaml']; // Default data directory - expect(template).toContain('dataDirectory: ./data'); + expect(settings).toContain('directory: ./data'); - // Default output directory - expect(template).toContain('outputDirectory: ./specs'); + // Default spec directory + expect(settings).toContain('directory: ./specs'); // Empty sources initially - expect(template).toContain('sources: {}'); + expect(settings).toContain('sources: {}'); }); }); }); diff --git a/packages/cli/tests/unit/commands/MigrateCommand.test.ts b/packages/cli/tests/unit/commands/MigrateCommand.test.ts new file mode 100644 index 00000000..9266dece --- /dev/null +++ b/packages/cli/tests/unit/commands/MigrateCommand.test.ts @@ -0,0 +1,494 @@ +/** + * MigrateCommand unit tests + * + * Covers `specs migrate config` (v1 → v2, ADR-071) and the underlying + * `migrateConfigV1` mapping: every member of the pre-split + * `specs.config.yaml` shape lands in the right split file — conventions, + * settings, or pipeline. These mappings used to be exercised through the + * loader's in-memory migration; the loader now refuses legacy files + * (ConfigLoader.test.ts) and the mapping lives here. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import { Migrate } from '../../../src/commands/MigrateCommand.js'; +import { migrateConfigV1 } from '../../../src/Config/migrations/configV1.js'; +import { ConfigLoader } from '../../../src/Config/ConfigLoader.js'; + +/** + * v1 defaulted the three layout flags to false and v2 defaults them to true, so + * every migration writes them out explicitly to preserve what the workspace + * currently emits. They appear in every settings result, whatever else the + * source configured. + */ +const PRESERVED_LAYOUT = { splitComponents: false, splitConcerns: false, useSubfolders: false }; + +describe('migrateConfigV1 (config v1 → v2 mapping)', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns nothing for an empty source (no section configured, no file written)', () => { + const result = migrateConfigV1({}); + expect(result.conventions).toBeUndefined(); + // Not "nothing": the layout flags must be preserved even from an empty source. + expect(result.settings).toEqual({ spec: { ...PRESERVED_LAYOUT } }); + expect(result.pipeline).toBeUndefined(); + }); + + it('maps dataDirectory to settings.data.directory', () => { + const result = migrateConfigV1({ dataDirectory: './data-in' }); + expect(result.settings).toEqual({ data: { directory: './data-in' }, spec: { ...PRESERVED_LAYOUT } }); + }); + + it('maps outputDirectory to settings.spec.directory', () => { + const result = migrateConfigV1({ outputDirectory: './specs-out' }); + expect(result.settings).toEqual({ spec: { directory: './specs-out', ...PRESERVED_LAYOUT } }); + }); + + it('maps author to settings.author', () => { + const result = migrateConfigV1({ author: 'Test Author' }); + expect(result.settings).toEqual({ author: 'Test Author', spec: { ...PRESERVED_LAYOUT } }); + }); + + it('supports deprecated sourceDirectory as dataDirectory (with warning)', () => { + const warn = vi.mocked(console.warn); + const result = migrateConfigV1({ sourceDirectory: './old-data' }); + expect(result.settings).toEqual({ data: { directory: './old-data' }, spec: { ...PRESERVED_LAYOUT } }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("'sourceDirectory' is deprecated")); + }); + + it('prefers dataDirectory over sourceDirectory when both are present', () => { + const warn = vi.mocked(console.warn); + const result = migrateConfigV1({ dataDirectory: './new', sourceDirectory: './old' }); + expect(result.settings).toEqual({ data: { directory: './new' }, spec: { ...PRESERVED_LAYOUT } }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("maps sources, renaming each source's data array to fetch", () => { + const result = migrateConfigV1({ + sources: { + library: { key: 'ABC123', data: ['file', 'variables', 'styles'] }, + icons: { key: 'DEF456' }, + }, + }); + expect(result.settings).toEqual({ + data: { + sources: { + library: { key: 'ABC123', fetch: ['file', 'variables', 'styles'] }, + icons: { key: 'DEF456' }, + }, + }, + spec: { ...PRESERVED_LAYOUT }, + }); + }); + + it('maps output split flags into settings.spec', () => { + const result = migrateConfigV1({ + output: { splitComponents: true, splitConcerns: true, useSubfolders: true }, + }); + expect(result.settings).toEqual({ + spec: { splitComponents: true, splitConcerns: true, useSubfolders: true }, + }); + }); + + it('maps config.format.output to settings.spec.format and passes keys/layout/tokens/color through', () => { + const result = migrateConfigV1({ + config: { + format: { output: 'YAML', keys: 'SNAKE', layout: 'BOTH', tokens: 'TOKEN_NAME', color: 'HEXA' }, + }, + }); + expect(result.settings).toEqual({ + spec: { format: 'YAML', keys: 'SNAKE', layout: 'BOTH', tokens: 'TOKEN_NAME', color: 'HEXA', ...PRESERVED_LAYOUT }, + }); + }); + + it('maps config.format.figmaKeys to conventions.figma.naming', () => { + const result = migrateConfigV1({ config: { format: { figmaKeys: 'SENTENCE' } } }); + expect(result.conventions).toEqual({ figma: { naming: 'SENTENCE' } }); + }); + + it('maps processing.glyphNamePattern to conventions.figma.glyphs.match', () => { + const result = migrateConfigV1({ config: { processing: { glyphNamePattern: 'DS Icon Glyph /' } } }); + expect(result.conventions).toEqual({ figma: { glyphs: { match: 'DS Icon Glyph /' } } }); + }); + + it('maps processing.codeOnlyPropsPattern to conventions.figma.codeOnlyProps.match', () => { + const result = migrateConfigV1({ config: { processing: { codeOnlyPropsPattern: '^_' } } }); + expect(result.conventions).toEqual({ figma: { codeOnlyProps: { match: '^_' } } }); + }); + + it('maps processing.images.imageComponent to conventions.figma.images.match', () => { + const result = migrateConfigV1({ + config: { + processing: { + images: { imageComponent: 'DS Image', backgroundImage: true, sourceProps: ['imageSource'] }, + }, + }, + }); + expect(result.conventions).toEqual({ + figma: { images: { match: 'DS Image', backgroundImage: true, sourceProps: ['imageSource'] } }, + }); + }); + + it('maps processing states/subcomponents/instanceExamples/slotConstraints/inferNumberProps to conventions.figma', () => { + const states = { interaction: ['hover', 'pressed'] }; + const subcomponents = { match: ['{C} / {S}'] }; + const instanceExamples = { scope: 'FILE', match: ['{C} / Examples / {S}'] }; + const result = migrateConfigV1({ + config: { + processing: { + states, + subcomponents, + instanceExamples, + slotConstraints: true, + inferNumberProps: true, + }, + }, + }); + expect(result.conventions).toEqual({ + figma: { states, subcomponents, instanceExamples, slotConstraints: true, inferNumberProps: true }, + }); + }); + + it('maps processing variantDepth/details/collapsePrimitiveWrapper to settings.spec', () => { + const result = migrateConfigV1({ + config: { processing: { variantDepth: 2, details: 'FULL', collapsePrimitiveWrapper: true } }, + }); + expect(result.settings).toEqual({ + spec: { variantDepth: 2, details: 'FULL', collapsePrimitiveWrapper: true, ...PRESERVED_LAYOUT }, + }); + }); + + it('maps the config.include allowlist to settings.spec', () => { + const result = migrateConfigV1({ + config: { + include: { + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: true, + }, + }, + }); + expect(result.settings).toEqual({ + spec: { + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: true, + ...PRESERVED_LAYOUT, + }, + }); + }); + + it('strips unknown include keys (EOLed allowlist: imageData, instanceExamples)', () => { + const result = migrateConfigV1({ + config: { include: { defaultSlotContent: true, imageData: true, instanceExamples: true } }, + }); + expect(result.settings).toEqual({ spec: { defaultSlotContent: true, ...PRESERVED_LAYOUT } }); + expect(result.conventions).toBeUndefined(); + }); + + it('maps config.transformers to pipeline.transformers', () => { + const result = migrateConfigV1({ + config: { transformers: [{ name: 'contract' }, { name: 'css', rules: ['layout'] }] }, + }); + expect(result.pipeline).toEqual({ + transformers: [{ name: 'contract' }, { name: 'css', rules: ['layout'] }], + }); + }); + + it('omits pipeline when the source declares no transformers', () => { + const result = migrateConfigV1({ author: 'Test Author' }); + expect(result.pipeline).toBeUndefined(); + }); +}); + +describe('MigrateCommand (specs migrate)', () => { + let testDir: string; + + beforeEach(() => { + testDir = path.join(process.cwd(), 'tests', 'tmp', `test-migrate-${Date.now()}`); + fs.ensureDirSync(testDir); + + vi.spyOn(process, 'cwd').mockReturnValue(testDir); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code?: number | string | null) => { + throw new Error(`process.exit:${code}`); + }); + + // Commander accumulates option state across parses; reset between tests. + Migrate.setOptionValue('from', undefined); + Migrate.setOptionValue('dryRun', undefined); + Migrate.setOptionValue('list', undefined); + Migrate.setOptionValue('source', undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) { + fs.removeSync(testDir); + } + }); + + /** Run `specs migrate ` against the mocked cwd. */ + async function runMigrate(...args: string[]) { + await Migrate.parseAsync(['node', 'specs', ...args]); + } + + /** All console.log output, joined. */ + function logged(): string { + return vi.mocked(console.log).mock.calls.map(args => args.join(' ')).join('\n'); + } + + const LEGACY_FULL = ` +dataDirectory: ./data-in +outputDirectory: ./specs-out +author: Test Author +config: + format: + figmaKeys: SENTENCE + processing: + variantDepth: 2 + transformers: + - name: contract +`; + + it('writes the three files and renames the source to specs.config.yaml.migrated', async () => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), LEGACY_FULL); + + await runMigrate('config'); + + const conventions = yaml.parse(fs.readFileSync(path.join(testDir, 'config', 'conventions.yaml'), 'utf-8')); + const settings = yaml.parse(fs.readFileSync(path.join(testDir, 'config', 'settings.yaml'), 'utf-8')); + const pipeline = yaml.parse(fs.readFileSync(path.join(testDir, 'config', 'pipeline.yaml'), 'utf-8')); + + expect(conventions).toEqual({ figma: { naming: 'SENTENCE' } }); + expect(settings).toEqual({ + author: 'Test Author', + data: { directory: './data-in' }, + spec: { directory: './specs-out', variantDepth: 2, ...PRESERVED_LAYOUT }, + }); + expect(pipeline).toEqual({ transformers: [{ name: 'contract' }] }); + + // Discovery must stop finding the source: renamed, not left in place. + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml'))).toBe(false); + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml.migrated'))).toBe(true); + expect(fs.readFileSync(path.join(testDir, 'specs.config.yaml.migrated'), 'utf-8')).toBe(LEGACY_FULL); + }); + + it('renames a JSON source to specs.config.json.migrated', async () => { + fs.writeFileSync( + path.join(testDir, 'specs.config.json'), + JSON.stringify({ author: 'Test Author' }) + ); + + await runMigrate('config'); + + expect(fs.existsSync(path.join(testDir, 'specs.config.json'))).toBe(false); + expect(fs.existsSync(path.join(testDir, 'specs.config.json.migrated'))).toBe(true); + const settings = yaml.parse(fs.readFileSync(path.join(testDir, 'config', 'settings.yaml'), 'utf-8')); + expect(settings).toEqual({ author: 'Test Author', spec: { ...PRESERVED_LAYOUT } }); + }); + + it('--dry-run writes nothing and renames nothing', async () => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), LEGACY_FULL); + + await runMigrate('config', '--dry-run'); + + expect(fs.existsSync(path.join(testDir, 'config'))).toBe(false); + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml.migrated'))).toBe(false); + expect(logged()).toContain('Would write: config/conventions.yaml'); + expect(logged()).toContain('Would write: config/settings.yaml'); + expect(logged()).toContain('Would write: config/pipeline.yaml'); + expect(logged()).toContain('Would rename: specs.config.yaml → specs.config.yaml.migrated'); + }); + + it.each(['conventions.yaml', 'settings.yaml', 'pipeline.yaml'])( + 'refuses (and writes nothing) when config/%s already exists', + async existing => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), LEGACY_FULL); + fs.ensureDirSync(path.join(testDir, 'config')); + fs.writeFileSync(path.join(testDir, 'config', existing), '# authored\n'); + + await expect(runMigrate('config')).rejects.toThrow('process.exit:2'); + + // Nothing written, nothing renamed + expect(fs.readdirSync(path.join(testDir, 'config'))).toEqual([existing]); + expect(fs.readFileSync(path.join(testDir, 'config', existing), 'utf-8')).toBe('# authored\n'); + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'specs.config.yaml.migrated'))).toBe(false); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('already contains')); + } + ); + + it('writes no file for a section absent from the source (no transformers → no pipeline.yaml)', async () => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), 'author: Test Author\n'); + + await runMigrate('config'); + + expect(fs.existsSync(path.join(testDir, 'config', 'settings.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'config', 'conventions.yaml'))).toBe(false); + expect(fs.existsSync(path.join(testDir, 'config', 'pipeline.yaml'))).toBe(false); + }); + + it('reports nothing to migrate when no legacy file exists', async () => { + await runMigrate('config'); + expect(logged()).toContain('Nothing to migrate'); + expect(fs.existsSync(path.join(testDir, 'config'))).toBe(false); + }); + + it('exits for an unknown subject', async () => { + await expect(runMigrate('nonsense')).rejects.toThrow('process.exit:2'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("no migrations registered for 'nonsense'") + ); + }); + + it('exits for an unregistered source version', async () => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), LEGACY_FULL); + await expect(runMigrate('config', '--from', 'v9')).rejects.toThrow('process.exit:2'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("no 'config' migration from 'v9'") + ); + }); + + it('round-trip: migrated output loads to the same resolved config the legacy file meant', async () => { + fs.writeFileSync(path.join(testDir, 'specs.config.yaml'), ` +dataDirectory: ./data-in +outputDirectory: ./specs-out +author: Test Author +sources: + library: + key: ABC123 + data: [file, variables, styles] +output: + splitComponents: true + useSubfolders: true +config: + format: + output: YAML + keys: SNAKE + figmaKeys: SENTENCE + processing: + variantDepth: 2 + details: FULL + glyphNamePattern: 'DS Icon Glyph /' + codeOnlyPropsPattern: '^_' + slotConstraints: true + subcomponents: + match: + - "{C} / {S}" + instanceExamples: + scope: FILE + match: + - "{C} / Examples / {S}" + images: + imageComponent: DS Image + backgroundImage: true + sourceProps: [imageSource] + include: + invalidVariants: false + defaultSlotContent: true + transformers: + - name: contract +`); + + await runMigrate('config'); + + const config = new ConfigLoader().load(); + + // settings — directories resolve against the workspace root, exactly as + // they did when the legacy file sat there + expect(config.settings.author).toBe('Test Author'); + expect(config.settings.data?.directory).toBe(path.resolve(testDir, 'data-in')); + expect(config.settings.spec.directory).toBe(path.resolve(testDir, 'specs-out')); + expect(config.settings.data?.sources).toEqual({ + library: { key: 'ABC123', fetch: ['file', 'variables', 'styles'] }, + }); + expect(config.settings.spec.splitComponents).toBe(true); + expect(config.settings.spec.useSubfolders).toBe(true); + expect(config.settings.spec.format).toBe('YAML'); + expect(config.settings.spec.keys).toBe('SNAKE'); + expect(config.settings.spec.variantDepth).toBe(2); + expect(config.settings.spec.details).toBe('FULL'); + expect(config.settings.spec.invalidVariants).toBe(false); + expect(config.settings.spec.defaultSlotContent).toBe(true); + expect(config.configDir).toBe(testDir); + + // conventions + expect(config.conventions.figma.naming).toBe('SENTENCE'); + expect(config.conventions.figma.glyphs).toEqual({ match: 'DS Icon Glyph /' }); + expect(config.conventions.figma.codeOnlyProps).toEqual({ match: '^_' }); + expect(config.conventions.figma.slotConstraints).toBe(true); + expect(config.conventions.figma.subcomponents).toEqual({ scope: 'NESTED', match: ['{C} / {S}'] }); + expect(config.conventions.figma.instanceExamples).toEqual({ + scope: 'FILE', + match: ['{C} / Examples / {S}'], + }); + expect(config.conventions.figma.images).toEqual({ + backgroundImage: true, + match: 'DS Image', + sourceProps: ['imageSource'], + }); + + // pipeline + expect(config.pipeline.transformers).toEqual([{ name: 'contract' }]); + expect(config.pipeline.analyses).toEqual([]); + }); +}); + +describe('MigrateCommand — --source (a file discovery would not find)', () => { + let testDir: string; + + beforeEach(() => { + testDir = path.join(process.cwd(), 'tests', 'tmp', `test-migrate-source-${Date.now()}`); + fs.ensureDirSync(testDir); + vi.spyOn(process, 'cwd').mockReturnValue(testDir); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code?: number | string | null) => { + throw new Error(`process.exit:${code}`); + }); + Migrate.setOptionValue('from', undefined); + Migrate.setOptionValue('dryRun', undefined); + Migrate.setOptionValue('list', undefined); + Migrate.setOptionValue('source', undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) fs.removeSync(testDir); + }); + + it('converts a custom-named file and renames it', async () => { + fs.writeFileSync( + path.join(testDir, 'custom-config.yaml'), + 'dataDirectory: ./data\nconfig:\n format:\n keys: CAMEL\n' + ); + + await Migrate.parseAsync(['node', 'specs', 'config', '--source', 'custom-config.yaml']); + + expect(fs.existsSync(path.join(testDir, 'config', 'settings.yaml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'custom-config.yaml.migrated'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'custom-config.yaml'))).toBe(false); + }); + + it('reports nothing to migrate when the named file does not exist', async () => { + await Migrate.parseAsync(['node', 'specs', 'config', '--source', 'absent.yaml']); + + expect(fs.existsSync(path.join(testDir, 'config'))).toBe(false); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('absent.yaml does not exist')); + }); +}); + diff --git a/packages/cli/tests/unit/config/ConfigLoader.test.ts b/packages/cli/tests/unit/config/ConfigLoader.test.ts index f7c6568c..f09a7056 100644 --- a/packages/cli/tests/unit/config/ConfigLoader.test.ts +++ b/packages/cli/tests/unit/config/ConfigLoader.test.ts @@ -1,12 +1,18 @@ /** * ConfigLoader unit tests + * + * Covers the split `config/` directory (ADR-071): conventions.yaml, + * settings.yaml, and pipeline.yaml, each optional and independently + * defaulted. A pre-split `specs.config.yaml`/`.json` is refused with a + * pointer to `specs migrate config` — the legacy mapping itself is + * exercised in tests/unit/commands/MigrateCommand.test.ts. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs-extra'; import path from 'path'; import { ConfigLoader } from '../../../src/Config/ConfigLoader.js'; -import { DEFAULT_CONFIG as DEFAULT_CONFIG } from '@directededges/specs-schema'; +import { DEFAULT_SETTINGS } from '@directededges/specs-schema'; describe('ConfigLoader', () => { let configLoader: ConfigLoader; @@ -24,6 +30,10 @@ describe('ConfigLoader', () => { // Mock process.cwd() to return test directory vi.spyOn(process, 'cwd').mockReturnValue(testDir); + // Silence validation warnings by default; individual tests spy on + // console.warn where the warning itself is under test. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { @@ -37,551 +47,578 @@ describe('ConfigLoader', () => { } }); - describe('Config file discovery (findConfigFile)', () => { - it('should find specs.config.yaml in current directory', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n variantDepth: 2'); + /** Write one file of a split `config/` directory in the test workspace. */ + function writeSplitFile(name: string, content: string) { + const dir = path.join(testDir, 'config'); + fs.ensureDirSync(dir); + fs.writeFileSync(path.join(dir, name), content); + } + + /** Write a pre-split (v1) config file in the test workspace root. */ + function writeLegacyFile(content: string, name = 'specs.config.yaml') { + fs.writeFileSync(path.join(testDir, name), content); + } + + describe('split config/ directory (ADR-071)', () => { + it('loads conventions, settings, and pipeline from config/', () => { + writeSplitFile('conventions.yaml', ` +figma: + naming: SENTENCE + glyphs: + match: 'DS Icon Glyph / {i}' + slotConstraints: true +`); + writeSplitFile('settings.yaml', ` +author: Test Author +spec: + format: YAML + variantDepth: 2 +`); + writeSplitFile('pipeline.yaml', ` +transformers: + - name: contract + - name: css +analyses: + - name: dependencies +`); const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(2); + expect(config.conventions.figma.naming).toBe('SENTENCE'); + expect(config.conventions.figma.glyphs).toEqual({ match: 'DS Icon Glyph / {i}' }); + expect(config.conventions.figma.slotConstraints).toBe(true); + expect(config.settings.author).toBe('Test Author'); + expect(config.settings.spec.format).toBe('YAML'); + expect(config.settings.spec.variantDepth).toBe(2); + expect(config.pipeline.transformers).toEqual([{ name: 'contract' }, { name: 'css' }]); + expect(config.pipeline.analyses).toEqual([{ name: 'dependencies' }]); }); - it('should find specs.config.json in current directory', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { variantDepth: 3 } } - })); + it('defaults each missing split file independently', () => { + writeSplitFile('settings.yaml', 'spec:\n variantDepth: 3'); const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(3); + // conventions.yaml absent — resolved conventions defaults + expect(config.conventions).toEqual({ + figma: { naming: 'NONE', slotConstraints: false, inferNumberProps: false }, + }); + // pipeline.yaml absent — empty lists + expect(config.pipeline).toEqual({ transformers: [], analyses: [] }); + // settings.yaml present — merged over DEFAULT_SETTINGS + expect(config.settings.spec.variantDepth).toBe(3); + expect(config.settings.spec.format).toBe(DEFAULT_SETTINGS.spec.format); }); - it('should prefer specs.config.yaml over .json', () => { - const yamlPath = path.join(testDir, 'specs.config.yaml'); - const jsonPath = path.join(testDir, 'specs.config.json'); - - fs.writeFileSync(yamlPath, 'config:\n processing:\n variantDepth: 1'); - fs.writeFileSync(jsonPath, JSON.stringify({ - config: { processing: { variantDepth: 2 } } - })); + it('accepts .json split files', () => { + writeSplitFile('conventions.json', JSON.stringify({ figma: { naming: 'TITLE' } })); const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(1); // YAML wins + expect(config.conventions.figma.naming).toBe('TITLE'); + }); + + it('loads normally (no throw) when config/ is present alongside a legacy specs.config.yaml', () => { + writeSplitFile('settings.yaml', 'spec:\n variantDepth: 2'); + writeLegacyFile('config:\n processing:\n variantDepth: 1'); + + let config; + expect(() => { + config = configLoader.load(); + }).not.toThrow(); + expect(config!.settings.spec.variantDepth).toBe(2); // config/ wins }); - it('should return defaults when no config file exists', () => { + it('loads an explicit directory path', () => { + const dir = path.join(testDir, 'elsewhere', 'config'); + fs.ensureDirSync(dir); + fs.writeFileSync(path.join(dir, 'settings.yaml'), 'spec:\n format: YAML'); + + const config = configLoader.load(dir); + expect(config.settings.spec.format).toBe('YAML'); + }); + + it('resolves relative directories against the workspace root (parent of config/)', () => { + writeSplitFile('settings.yaml', 'data:\n directory: ./my-data\nspec:\n directory: ./my-specs'); + const config = configLoader.load(); - expect(config.config).toEqual(DEFAULT_CONFIG); + expect(config.settings.data?.directory).toBe(path.resolve(testDir, 'my-data')); + expect(config.settings.spec.directory).toBe(path.resolve(testDir, 'my-specs')); + expect(config.configDir).toBe(testDir); }); + }); - it('should use explicit config path when provided', () => { + describe('legacy file refusal (ADR-071)', () => { + it('refuses a discovered specs.config.yaml with a pointer to specs migrate config', () => { + writeLegacyFile('config:\n processing:\n variantDepth: 2'); + + expect(() => configLoader.load()).toThrow(/specs\.config\.yaml is no longer read \(ADR-071\)/); + expect(() => configLoader.load()).toThrow(/specs migrate config/); + expect(() => configLoader.load()).toThrow(/config\/conventions\.yaml, config\/settings\.yaml and config\/pipeline\.yaml/); + expect(() => configLoader.load()).toThrow(/https:\/\/specs\.directededges\.com\/settings\//); + }); + + it('refuses a discovered specs.config.json', () => { + writeLegacyFile( + JSON.stringify({ config: { processing: { variantDepth: 3 } } }), + 'specs.config.json' + ); + + expect(() => configLoader.load()).toThrow(/specs\.config\.json is no longer read \(ADR-071\)/); + }); + + it('refuses a discovered ~/.specs/config.yaml', () => { + const home = path.join(testDir, 'home'); + fs.ensureDirSync(path.join(home, '.specs')); + fs.writeFileSync(path.join(home, '.specs', 'config.yaml'), 'author: Home Author'); + process.env.HOME = home; + + expect(() => configLoader.load()).toThrow(/config\.yaml is no longer read \(ADR-071\)/); + }); + + it('names a working remedy for a file discovery would not find', () => { const customPath = path.join(testDir, 'custom-config.yaml'); fs.writeFileSync(customPath, 'config:\n processing:\n variantDepth: 2'); - const config = configLoader.load(customPath); - expect(config.config.processing.variantDepth).toBe(2); + // `specs migrate config` alone would report "nothing to migrate" here, so + // the refusal has to name the source explicitly. + expect(() => configLoader.load(customPath)).toThrow( + /specs migrate config --source custom-config\.yaml/ + ); }); - it('should return defaults when explicit path does not exist', () => { - const nonExistentPath = path.join(testDir, 'does-not-exist.yaml'); - const config = configLoader.load(nonExistentPath); - expect(config.config).toEqual(DEFAULT_CONFIG); + it('refuses an explicit --config path pointing at a legacy file', () => { + const customPath = path.join(testDir, 'custom-config.yaml'); + fs.writeFileSync(customPath, 'config:\n processing:\n variantDepth: 2'); + + expect(() => configLoader.load(customPath)).toThrow(/custom-config\.yaml is no longer read \(ADR-071\)/); }); - }); - describe('YAML parsing', () => { - it('should parse valid YAML config', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - const yamlContent = ` -config: - processing: - variantDepth: 2 - details: FULL - format: - keys: CAMEL -`; - fs.writeFileSync(configPath, yamlContent); - - const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(2); - expect(config.config.processing.details).toBe('FULL'); - expect(config.config.format.keys).toBe('CAMEL'); - }); - - it('should handle complex YAML config', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - const yamlContent = ` -config: - processing: - subcomponents: - match: - - "{C} / {S}" - variantDepth: 3 - details: FULL - format: - output: YAML - keys: SNAKE - layout: BOTH - tokens: TOKEN_NAME - include: - invalidVariants: false - invalidCombinations: false -sources: - variables: ./variables.json - styles: ./styles.json -`; - fs.writeFileSync(configPath, yamlContent); - - const config = configLoader.load(); - expect(config.config.processing.subcomponents).toEqual({ match: ['{C} / {S}'] }); - expect(config.config.processing.variantDepth).toBe(3); - expect(config.config.format.output).toBe('YAML'); - expect(config.config.format.keys).toBe('SNAKE'); - expect(config.sources).toEqual({ - variables: './variables.json', - styles: './styles.json' - }); + it('is a hard stop — never falls back to defaults', () => { + const error = vi.mocked(console.error); + writeLegacyFile('config:\n processing:\n variantDepth: 2'); + + expect(() => configLoader.load()).toThrow(); + // The refusal throws before the try/catch that degrades load failures + // into defaults, so the fallback path must never run. + expect(error).not.toHaveBeenCalledWith('Falling back to default configuration'); }); }); - describe('JSON parsing', () => { - it('should parse valid JSON config', () => { - const configPath = path.join(testDir, 'specs.config.json'); - const jsonContent = { - config: { - processing: { - variantDepth: 1, - details: 'LAYERED' - }, - format: { - keys: 'KEBAB' - } - } - }; - fs.writeFileSync(configPath, JSON.stringify(jsonContent, null, 2)); - + describe('defaults (no configuration found)', () => { + it('returns defaults when no config exists', () => { const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(1); - expect(config.config.processing.details).toBe('LAYERED'); - expect(config.config.format.keys).toBe('KEBAB'); + const { directory, ...spec } = config.settings.spec; + expect(spec).toEqual(DEFAULT_SETTINGS.spec); + expect(directory).toBeTruthy(); + expect(config.conventions).toEqual({ + figma: { naming: 'NONE', slotConstraints: false, inferNumberProps: false }, + }); + expect(config.pipeline).toEqual({ transformers: [], analyses: [] }); + }); + + it('returns defaults when an explicit path does not exist', () => { + const nonExistentPath = path.join(testDir, 'does-not-exist.yaml'); + const config = configLoader.load(nonExistentPath); + const { directory, ...spec } = config.settings.spec; + expect(spec).toEqual(DEFAULT_SETTINGS.spec); }); }); - describe('Config validation', () => { + describe('settings validation (config/settings.yaml)', () => { it('should validate variantDepth and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n variantDepth: 999'); // Invalid + writeSplitFile('settings.yaml', 'spec:\n variantDepth: 999'); // Invalid const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(9999); // Default + expect(config.settings.spec.variantDepth).toBe(9999); // Default }); it('should validate details and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n details: INVALID'); + writeSplitFile('settings.yaml', 'spec:\n details: INVALID'); const config = configLoader.load(); - expect(config.config.processing.details).toBe('LAYERED'); // Default + expect(config.settings.spec.details).toBe('LAYERED'); // Default }); - it('should validate format.keys and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n keys: INVALID'); + it('should validate spec.keys and use default for invalid values', () => { + writeSplitFile('settings.yaml', 'spec:\n keys: INVALID'); const config = configLoader.load(); - expect(config.config.format.keys).toBe('SAFE'); // Default + expect(config.settings.spec.keys).toBe('SAFE'); // Default }); - it('should validate format.output and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n output: XML'); + it('should validate spec.format and use default for invalid values', () => { + writeSplitFile('settings.yaml', 'spec:\n format: XML'); const config = configLoader.load(); - expect(config.config.format.output).toBe('JSON'); // Default + expect(config.settings.spec.format).toBe('JSON'); // Default }); - it('should validate format.layout and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n layout: INVALID'); + it('should validate spec.layout and use default for invalid values', () => { + writeSplitFile('settings.yaml', 'spec:\n layout: INVALID'); const config = configLoader.load(); - expect(config.config.format.layout).toBe('LAYOUT'); // Default + expect(config.settings.spec.layout).toBe('LAYOUT'); // Default }); - it('should validate format.tokens and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n tokens: INVALID'); + it('should validate spec.tokens and use default for invalid values', () => { + writeSplitFile('settings.yaml', 'spec:\n tokens: INVALID'); const config = configLoader.load(); - expect(config.config.format.tokens).toBe('TOKEN'); // Default + expect(config.settings.spec.tokens).toBe('TOKEN'); // Default }); - it('should accept all valid format.tokens values', () => { + it('should accept all valid spec.tokens values', () => { const validValues = ['TOKEN', 'TOKEN_NAME', 'TOKEN_FIGMA_EXTENSIONS', 'FIGMA_NAME', 'CUSTOM', 'FIGMA_SYNTAX_WEB', 'FIGMA_SYNTAX_IOS', 'FIGMA_SYNTAX_ANDROID']; validValues.forEach(value => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, `config:\n format:\n tokens: ${value}`); + writeSplitFile('settings.yaml', `spec:\n tokens: ${value}`); const config = configLoader.load(); - expect(config.config.format.tokens).toBe(value); + expect(config.settings.spec.tokens).toBe(value); }); }); - it('should normalize lowercase format.tokens to uppercase', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n tokens: figma_syntax_ios'); + it('should normalize lowercase spec.tokens to uppercase', () => { + writeSplitFile('settings.yaml', 'spec:\n tokens: figma_syntax_ios'); const config = configLoader.load(); - expect(config.config.format.tokens).toBe('FIGMA_SYNTAX_IOS'); + expect(config.settings.spec.tokens).toBe('FIGMA_SYNTAX_IOS'); }); - it('should validate format.color and use default for invalid values', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n color: INVALID'); + it('should validate spec.color and use default for invalid values', () => { + writeSplitFile('settings.yaml', 'spec:\n color: INVALID'); const config = configLoader.load(); - expect(config.config.format.color).toBe('HEX'); // Default + expect(config.settings.spec.color).toBe('HEX'); // Default }); - it('should accept all valid format.color values', () => { + it('should accept all valid spec.color values', () => { const validValues = ['HEX', 'HEXA', 'RGB', 'RGBA', 'HSLA', 'HSB', 'OKLCH', 'OKLAB', 'OBJECT']; validValues.forEach(value => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, `config:\n format:\n color: ${value}`); + writeSplitFile('settings.yaml', `spec:\n color: ${value}`); const config = configLoader.load(); - expect(config.config.format.color).toBe(value); + expect(config.settings.spec.color).toBe(value); }); }); - it('should normalize lowercase format.color to uppercase', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n format:\n color: oklch'); + it('should normalize lowercase spec.color to uppercase', () => { + writeSplitFile('settings.yaml', 'spec:\n color: oklch'); const config = configLoader.load(); - expect(config.config.format.color).toBe('OKLCH'); + expect(config.settings.spec.color).toBe('OKLCH'); + }); + + it('should accept all valid variantDepth values', () => { + const validValues = [1, 2, 3, 9999]; + + validValues.forEach(value => { + writeSplitFile('settings.yaml', `spec:\n variantDepth: ${value}`); + + const config = configLoader.load(); + expect(config.settings.spec.variantDepth).toBe(value); + }); + }); + + it('should accept all valid spec.keys values', () => { + const validValues = ['SAFE', 'CAMEL', 'SNAKE', 'KEBAB', 'PASCAL', 'TRAIN']; + + validValues.forEach(value => { + writeSplitFile('settings.yaml', `spec:\n keys: ${value}`); + + const config = configLoader.load(); + expect(config.settings.spec.keys).toBe(value); + }); }); - it('should preserve valid glyphNamePattern string', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n glyphNamePattern: "DS Icon Glyph /"'); + it('should default the split layout on when settings.yaml omits the flags', () => { + writeSplitFile('settings.yaml', 'spec:\n format: YAML'); const config = configLoader.load(); - expect(config.config.processing.glyphNamePattern).toBe('DS Icon Glyph /'); + expect(config.settings.spec.splitComponents).toBe(true); + expect(config.settings.spec.splitConcerns).toBe(true); + expect(config.settings.spec.useSubfolders).toBe(true); }); - it('should strip invalid glyphNamePattern (non-string)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n glyphNamePattern: 123'); + it('should let settings.yaml turn the split layout off', () => { + writeSplitFile( + 'settings.yaml', + 'spec:\n splitComponents: false\n splitConcerns: false\n useSubfolders: false' + ); const config = configLoader.load(); - expect(config.config.processing.glyphNamePattern).toBeUndefined(); + expect(config.settings.spec.splitComponents).toBe(false); + expect(config.settings.spec.splitConcerns).toBe(false); + expect(config.settings.spec.useSubfolders).toBe(false); }); - it('should strip empty glyphNamePattern', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, "config:\n processing:\n glyphNamePattern: ' '"); + it('should validate split flags: a non-boolean warns and falls back to the default', () => { + const warn = vi.mocked(console.warn); + writeSplitFile('settings.yaml', 'spec:\n splitComponents: "yes"'); const config = configLoader.load(); - expect(config.config.processing.glyphNamePattern).toBeUndefined(); + expect(config.settings.spec.splitComponents).toBe(true); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid settings.spec.splitComponents') + ); }); + }); - it('should accept all valid variantDepth values', () => { - const validValues = [1, 2, 3, 9999]; + describe('spec.defaultSlotContent validation', () => { + it('preserves a valid boolean (true)', () => { + writeSplitFile('settings.yaml', 'spec:\n defaultSlotContent: true'); - validValues.forEach(value => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, `config:\n processing:\n variantDepth: ${value}`); + const config = configLoader.load(); + expect(config.settings.spec.defaultSlotContent).toBe(true); + }); - const config = configLoader.load(); - expect(config.config.processing.variantDepth).toBe(value); - }); + it('preserves a valid boolean (false)', () => { + writeSplitFile('settings.yaml', 'spec:\n defaultSlotContent: false'); + + const config = configLoader.load(); + expect(config.settings.spec.defaultSlotContent).toBe(false); }); - it('should accept all valid format.keys values', () => { - const validValues = ['SAFE', 'CAMEL', 'SNAKE', 'KEBAB', 'PASCAL', 'TRAIN']; + // defaultSlotContent activates only on a literal boolean `true`; any other + // value is coerced to false (ConfigLoader.ts resolveSettings). + it('coerces a non-boolean defaultSlotContent value to false', () => { + writeSplitFile('settings.json', JSON.stringify({ spec: { defaultSlotContent: 'yes' } })); - validValues.forEach(value => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, `config:\n format:\n keys: ${value}`); + const config = configLoader.load(); + expect(config.settings.spec.defaultSlotContent).toBe(false); + }); - const config = configLoader.load(); - expect(config.config.format.keys).toBe(value); - }); + it('coerces a truthy-but-not-true value (e.g. 1) to false', () => { + writeSplitFile('settings.json', JSON.stringify({ spec: { defaultSlotContent: 1 } })); + + const config = configLoader.load(); + expect(config.settings.spec.defaultSlotContent).toBe(false); }); }); - describe('processing.instanceExamples validation (ADR-050)', () => { + describe('conventions validation (config/conventions.yaml)', () => { + it('should preserve a valid glyphs.match', () => { + writeSplitFile('conventions.yaml', 'figma:\n glyphs:\n match: "DS Icon Glyph /"'); + + const config = configLoader.load(); + expect(config.conventions.figma.glyphs).toEqual({ match: 'DS Icon Glyph /' }); + }); + + it('should strip an invalid glyphs.match (non-string)', () => { + writeSplitFile('conventions.yaml', 'figma:\n glyphs:\n match: 123'); + + const config = configLoader.load(); + expect(config.conventions.figma.glyphs).toBeUndefined(); + }); + + it('should strip an empty glyphs.match', () => { + writeSplitFile('conventions.yaml', "figma:\n glyphs:\n match: ' '"); + + const config = configLoader.load(); + expect(config.conventions.figma.glyphs).toBeUndefined(); + }); + + it('should default subcomponents.scope to NESTED when a valid match is given', () => { + writeSplitFile('conventions.yaml', ` +figma: + subcomponents: + match: + - "{C} / {S}" +`); + + const config = configLoader.load(); + expect(config.conventions.figma.subcomponents).toEqual({ scope: 'NESTED', match: ['{C} / {S}'] }); + }); + + it('should remove subcomponents (and warn) when match is empty', () => { + const warn = vi.mocked(console.warn); + writeSplitFile('conventions.json', JSON.stringify({ figma: { subcomponents: { match: [] } } })); + + const config = configLoader.load(); + expect(config.conventions.figma.subcomponents).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid conventions.figma.subcomponents.match') + ); + }); + }); + + describe('conventions.figma.instanceExamples validation (ADR-050)', () => { it('defaults an invalid scope to PAGE while keeping a valid match', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, ` -config: - processing: - instanceExamples: - scope: SIDEWAYS - match: - - "{C} / Examples / {S}" + writeSplitFile('conventions.yaml', ` +figma: + instanceExamples: + scope: SIDEWAYS + match: + - "{C} / Examples / {S}" `); const config = configLoader.load(); - expect(config.config.processing.instanceExamples).toEqual({ + expect(config.conventions.figma.instanceExamples).toEqual({ scope: 'PAGE', match: ['{C} / Examples / {S}'], }); }); it('preserves a valid scope (FILE)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, ` -config: - processing: - instanceExamples: - scope: FILE - match: - - "{C} / Examples / {S}" + writeSplitFile('conventions.yaml', ` +figma: + instanceExamples: + scope: FILE + match: + - "{C} / Examples / {S}" `); const config = configLoader.load(); - expect(config.config.processing.instanceExamples?.scope).toBe('FILE'); + expect(config.conventions.figma.instanceExamples?.scope).toBe('FILE'); }); it('keeps the block when match is omitted (match is optional — ADR-050)', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, ` -config: - processing: - instanceExamples: - scope: PAGE - parentNames: - - Ready-made examples + const warn = vi.mocked(console.warn); + writeSplitFile('conventions.yaml', ` +figma: + instanceExamples: + scope: PAGE + parentNames: + - Ready-made examples `); const config = configLoader.load(); // Presence of the block is the on-switch; no match means every in-scope // instance qualifies, narrowed here by parentNames. - expect(config.config.processing.instanceExamples).toEqual({ + expect(config.conventions.figma.instanceExamples).toEqual({ scope: 'PAGE', parentNames: ['Ready-made examples'], }); - expect(warn).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Invalid')); }); it('keeps the block but ignores match (and warns) when match is an empty array', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { instanceExamples: { scope: 'PAGE', match: [] } } }, + const warn = vi.mocked(console.warn); + writeSplitFile('conventions.json', JSON.stringify({ + figma: { instanceExamples: { scope: 'PAGE', match: [] } }, })); const config = configLoader.load(); - const ie = config.config.processing.instanceExamples as Record; + const ie = config.conventions.figma.instanceExamples as Record; expect(ie).toEqual({ scope: 'PAGE' }); expect(ie).not.toHaveProperty('match'); expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid processing.instanceExamples.match') + expect.stringContaining('Invalid conventions.figma.instanceExamples.match') ); }); it('strips a non-array exclude while keeping the rest of the block', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { - processing: { - instanceExamples: { match: ['{C} / Examples / {S}'], exclude: 'nope' }, - }, + writeSplitFile('conventions.json', JSON.stringify({ + figma: { + instanceExamples: { match: ['{C} / Examples / {S}'], exclude: 'nope' }, }, })); const config = configLoader.load(); - const ie = config.config.processing.instanceExamples as Record; + const ie = config.conventions.figma.instanceExamples as Record; expect(ie.match).toEqual(['{C} / Examples / {S}']); expect(ie.exclude).toBeUndefined(); }); it('strips a non-array parentNames while keeping the rest of the block', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { - processing: { - instanceExamples: { match: ['{C} / Examples / {S}'], parentNames: 123 }, - }, + writeSplitFile('conventions.json', JSON.stringify({ + figma: { + instanceExamples: { match: ['{C} / Examples / {S}'], parentNames: 123 }, }, })); const config = configLoader.load(); - const ie = config.config.processing.instanceExamples as Record; + const ie = config.conventions.figma.instanceExamples as Record; expect(ie.match).toEqual(['{C} / Examples / {S}']); expect(ie.parentNames).toBeUndefined(); }); it('passes a fully valid block through unchanged', () => { - const configPath = path.join(testDir, 'specs.config.json'); const block = { scope: 'PAGE', match: ['{C} / Examples / {S}'], exclude: ['{C} / Examples / Internal / {S}'], parentNames: ['Examples'], }; - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { instanceExamples: block } }, + writeSplitFile('conventions.json', JSON.stringify({ + figma: { instanceExamples: block }, })); const config = configLoader.load(); - expect(config.config.processing.instanceExamples).toEqual(block); + expect(config.conventions.figma.instanceExamples).toEqual(block); }); }); - describe('include.defaultSlotContent validation', () => { - it('preserves a valid boolean (true)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n include:\n defaultSlotContent: true'); - - const config = configLoader.load(); - expect(config.config.include.defaultSlotContent).toBe(true); - }); - - it('preserves a valid boolean (false)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n include:\n defaultSlotContent: false'); - - const config = configLoader.load(); - expect(config.config.include.defaultSlotContent).toBe(false); - }); - - it('keeps defaultSlotContent in the include allowlist (does not strip the key)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n include:\n defaultSlotContent: true'); - - const config = configLoader.load(); - expect(Object.keys(config.config.include)).toContain('defaultSlotContent'); - }); - - it('strips an unknown include key (EOLed allowlist) but keeps defaultSlotContent', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { include: { defaultSlotContent: true, instanceExamples: true } }, - })); - - const config = configLoader.load(); - const include = config.config.include as Record; - // instanceExamples is not a valid include key — must be stripped. - expect(include.instanceExamples).toBeUndefined(); - expect(include.defaultSlotContent).toBe(true); - }); - - // defaultSlotContent activates only on a literal boolean `true`; any other - // value is coerced to false (ConfigLoader.ts validateAndCorrectConfig). - it('coerces a non-boolean defaultSlotContent value to false', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { include: { defaultSlotContent: 'yes' } }, - })); - - const config = configLoader.load(); - expect(config.config.include.defaultSlotContent).toBe(false); - }); - - it('coerces a truthy-but-not-true value (e.g. 1) to false', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { include: { defaultSlotContent: 1 } }, - })); - - const config = configLoader.load(); - expect(config.config.include.defaultSlotContent).toBe(false); - }); - }); - - describe('processing.images validation (ADR-063)', () => { - it('resolves a full block: backgroundImage, trimmed imageComponent, trimmed sourceProps', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { images: { backgroundImage: true, imageComponent: ' DS Image ', sourceProps: [' imageSource ', 'src'] } } }, + describe('conventions.figma.images validation (ADR-063)', () => { + it('resolves a full block: backgroundImage, trimmed match, trimmed sourceProps', () => { + writeSplitFile('conventions.json', JSON.stringify({ + figma: { images: { backgroundImage: true, match: ' DS Image ', sourceProps: [' imageSource ', 'src'] } }, })); const config = configLoader.load(); - expect(config.config.processing.images).toEqual({ + expect(config.conventions.figma.images).toEqual({ backgroundImage: true, - imageComponent: 'DS Image', + match: 'DS Image', sourceProps: ['imageSource', 'src'], }); }); it('fills-only: backgroundImage alone resolves with defaults', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n images:\n backgroundImage: true'); + writeSplitFile('conventions.yaml', 'figma:\n images:\n backgroundImage: true'); const config = configLoader.load(); - expect(config.config.processing.images).toEqual({ backgroundImage: true, sourceProps: [] }); + expect(config.conventions.figma.images).toEqual({ backgroundImage: true, sourceProps: [] }); }); it('sourceProps-only: re-typing without fills or component', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { images: { sourceProps: ['Image'] } } }, + writeSplitFile('conventions.json', JSON.stringify({ + figma: { images: { sourceProps: ['Image'] } }, })); const config = configLoader.load(); - expect(config.config.processing.images).toEqual({ backgroundImage: false, sourceProps: ['Image'] }); + expect(config.conventions.figma.images).toEqual({ backgroundImage: false, sourceProps: ['Image'] }); }); - it('imageComponent without sourceProps is dropped (needs a forwarding target)', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { images: { backgroundImage: true, imageComponent: 'DS Image' } } }, + it('match without sourceProps is dropped (needs a forwarding target)', () => { + writeSplitFile('conventions.json', JSON.stringify({ + figma: { images: { backgroundImage: true, match: 'DS Image' } }, })); const config = configLoader.load(); - expect(config.config.processing.images).toEqual({ backgroundImage: true, sourceProps: [] }); - expect(config.config.processing.images).not.toHaveProperty('imageComponent'); + expect(config.conventions.figma.images).toEqual({ backgroundImage: true, sourceProps: [] }); + expect(config.conventions.figma.images).not.toHaveProperty('match'); }); it('coerces a non-boolean backgroundImage to false', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { processing: { images: { backgroundImage: 'yes' } } }, + writeSplitFile('conventions.json', JSON.stringify({ + figma: { images: { backgroundImage: 'yes' } }, })); const config = configLoader.load(); - expect(config.config.processing.images?.backgroundImage).toBe(false); + expect(config.conventions.figma.images?.backgroundImage).toBe(false); }); it('is absent by default (presence is the on-switch)', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n variantDepth: 2'); - - const config = configLoader.load(); - expect(config.config.processing.images).toBeUndefined(); - }); - - it('strips the retired include.imageData key', () => { - const configPath = path.join(testDir, 'specs.config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - config: { include: { imageData: true } }, - })); + writeSplitFile('conventions.yaml', 'figma:\n naming: NONE'); const config = configLoader.load(); - expect((config.config.include as Record).imageData).toBeUndefined(); + expect(config.conventions.figma.images).toBeUndefined(); }); }); describe('Merging with defaults', () => { - it('should merge partial config with defaults', () => { - const configPath = path.join(testDir, 'specs.config.yaml'); - fs.writeFileSync(configPath, 'config:\n processing:\n variantDepth: 2'); + it('should merge partial settings with defaults', () => { + writeSplitFile('settings.yaml', 'spec:\n variantDepth: 2'); const config = configLoader.load(); // Overridden value - expect(config.config.processing.variantDepth).toBe(2); + expect(config.settings.spec.variantDepth).toBe(2); // Default values preserved - expect(config.config.processing.details).toBe(DEFAULT_CONFIG.processing.details); - expect(config.config.processing.subcomponents).toEqual(DEFAULT_CONFIG.processing.subcomponents); - expect(config.config.format.keys).toBe(DEFAULT_CONFIG.format.keys); + expect(config.settings.spec.details).toBe(DEFAULT_SETTINGS.spec.details); + expect(config.settings.spec.keys).toBe(DEFAULT_SETTINGS.spec.keys); + // No subcomponents convention declared — no default can supply one + expect(config.conventions.figma.subcomponents).toBeUndefined(); }); }); }); diff --git a/packages/cli/tests/unit/config/ConfigTemplates.test.ts b/packages/cli/tests/unit/config/ConfigTemplates.test.ts index 39dae8b3..6d610906 100644 --- a/packages/cli/tests/unit/config/ConfigTemplates.test.ts +++ b/packages/cli/tests/unit/config/ConfigTemplates.test.ts @@ -1,20 +1,42 @@ import { describe, it, expect } from 'vitest'; import yaml from 'yaml'; -import { generateConfigTemplate } from '../../../src/Config/ConfigTemplates.js'; +import { + generateConventionsTemplate, + generateSettingsTemplate, + generatePipelineTemplate, + generateConfigTemplates, +} from '../../../src/Config/ConfigTemplates.js'; describe('ConfigTemplates', () => { - describe('generateConfigTemplate', () => { - it('should generate a valid YAML template', () => { - const template = generateConfigTemplate(); - expect(template).toBeTruthy(); - expect(typeof template).toBe('string'); - // Must parse as YAML — commented example blocks included. - expect(() => yaml.parse(template)).not.toThrow(); + describe('generateConfigTemplates', () => { + it('returns the three split-config files keyed by path under config/', () => { + const templates = generateConfigTemplates(); + expect(Object.keys(templates).sort()).toEqual([ + 'config/conventions.yaml', + 'config/pipeline.yaml', + 'config/settings.yaml', + ]); + }); + + it('every template parses as valid YAML — commented example blocks included', () => { + for (const template of Object.values(generateConfigTemplates())) { + expect(template).toBeTruthy(); + expect(typeof template).toBe('string'); + expect(() => yaml.parse(template)).not.toThrow(); + } }); + it('templates have consistent line endings', () => { + for (const template of Object.values(generateConfigTemplates())) { + expect(template.includes('\r\n')).toBe(false); + } + }); + }); + + describe('generateConventionsTemplate', () => { it('documents every feature-toggle block (commented) with a doc link', () => { - const template = generateConfigTemplate(); - for (const block of ['instanceExamples:', 'states:', 'images:', 'imageComponent:', 'sourceProps:', 'defaultSlotContent:']) { + const template = generateConventionsTemplate(); + for (const block of ['instanceExamples:', 'states:', 'images:', 'sourceProps:']) { expect(template).toContain(block); } expect(template).toContain('www.specsplugin.com/guides/images/'); @@ -22,113 +44,100 @@ describe('ConfigTemplates', () => { expect(template).toContain('www.specsplugin.com/settings/states/'); }); - it('should include dataDirectory with default value', () => { - const template = generateConfigTemplate(); - expect(template).toContain('dataDirectory: ./data'); + it('should include commented glyphs block for icon glyph naming', () => { + const template = generateConventionsTemplate(); + expect(template).toContain('glyphs:'); + expect(template).toContain('glyph'); + }); + + it('should include figma conventions structure', () => { + const template = generateConventionsTemplate(); + expect(template).toContain('figma:'); + expect(template).toContain('naming:'); + expect(template).toContain('subcomponents:'); + expect(template).toContain('match:'); + expect(template).toContain('slotConstraints:'); + expect(template).toContain('codeOnlyProps:'); + }); + + it('figma is the only top-level key', () => { + const parsed = yaml.parse(generateConventionsTemplate()); + expect(Object.keys(parsed)).toEqual(['figma']); + }); + }); + + describe('generateSettingsTemplate', () => { + it('should include data.directory with default value', () => { + const template = generateSettingsTemplate(); + expect(template).toContain('directory: ./data'); expect(template).toContain('Where fetch writes payloads'); }); - it('should include outputDirectory with default value', () => { - const template = generateConfigTemplate(); - expect(template).toContain('outputDirectory: ./specs'); + it('should include spec.directory with default value', () => { + const template = generateSettingsTemplate(); + expect(template).toContain('directory: ./specs'); expect(template).toContain('Default location for generated spec files'); }); it('should include inline documentation comments', () => { - const template = generateConfigTemplate(); + const template = generateSettingsTemplate(); expect(template).toContain('#'); - expect(template).toContain('Specs CLI Configuration'); expect(template).toContain('www.specsplugin.com/settings/'); }); - it('should include doc URL references', () => { - const template = generateConfigTemplate(); - expect(template).toContain('www.specsplugin.com/settings/'); - expect(template).toContain('www.specsplugin.com/'); - }); - it('should include Figma sources section', () => { - const template = generateConfigTemplate(); + const template = generateSettingsTemplate(); expect(template).toContain('sources:'); expect(template).toContain('Figma file sources'); expect(template).toContain('FIGMA_FILE_KEY'); }); - it('should include commented glyphNamePattern option', () => { - const template = generateConfigTemplate(); - expect(template).toContain('glyphNamePattern'); - expect(template).toContain('icon glyph'); - }); - - it('should include config processing configuration', () => { - const template = generateConfigTemplate(); - expect(template).toContain('config:'); - expect(template).toContain('processing:'); - expect(template).toContain('subcomponents:'); - expect(template).toContain('match:'); - expect(template).toContain('variantDepth'); - expect(template).toContain('details'); - expect(template).toContain('collapsePrimitiveWrapper'); - }); - - it('should include format configuration section', () => { - const template = generateConfigTemplate(); - expect(template).toContain('format:'); + it('should include spec serialization settings', () => { + const template = generateSettingsTemplate(); + expect(template).toContain('spec:'); expect(template).toContain('keys:'); - expect(template).toContain('output:'); + expect(template).toContain('format:'); expect(template).toContain('tokens:'); expect(template).toContain('layout:'); + expect(template).toContain('color:'); + expect(template).toContain('variantDepth'); + expect(template).toContain('details'); + expect(template).toContain('collapsePrimitiveWrapper'); + expect(template).toContain('defaultSlotContent'); }); - it('should have consistent line endings', () => { - const template = generateConfigTemplate(); - // Should use consistent line endings (not mixed \n and \r\n) - const hasWindows = template.includes('\r\n'); - const hasUnix = template.includes('\n'); - if (hasWindows && hasUnix) { - expect(false).toBe(true); // Fail if mixed line endings - } - }); - - it('should be valid YAML structure', () => { - const template = generateConfigTemplate(); - // Check basic YAML structure: top-level keys, nested indentation - const lines = template.split('\n'); - const nonCommentLines = lines.filter(line => !line.trim().startsWith('#') && line.trim()); - - // Should have top-level keys - const topLevelKeys = nonCommentLines.filter(line => !line.startsWith(' ')); - expect(topLevelKeys.length).toBeGreaterThan(0); - expect(topLevelKeys.some(line => line.includes('dataDirectory'))).toBe(true); - expect(topLevelKeys.some(line => line.includes('outputDirectory'))).toBe(true); - expect(topLevelKeys.some(line => line.includes('sources'))).toBe(true); - expect(topLevelKeys.some(line => line.includes('config'))).toBe(true); + it('should have valid structure with top-level keys', () => { + const parsed = yaml.parse(generateSettingsTemplate()); + expect(Object.keys(parsed)).toContain('author'); + expect(Object.keys(parsed)).toContain('data'); + expect(Object.keys(parsed)).toContain('spec'); + expect(parsed.data.sources).toEqual({}); }); it('should mention defaults in comments', () => { - const template = generateConfigTemplate(); + const template = generateSettingsTemplate(); expect(template).toContain('Default'); expect(template).toContain('default'); }); + }); - it('includes a commented-out transformers: block under config:', () => { - const template = generateConfigTemplate(); + describe('generatePipelineTemplate', () => { + it('includes a commented-out transformers: block', () => { + const template = generatePipelineTemplate(); expect(template).toContain('# transformers:'); }); - it('includes commented-out transformer entries for contract, css, styling', () => { - const template = generateConfigTemplate(); + it('includes commented-out transformer entries for contract, css, react', () => { + const template = generatePipelineTemplate(); expect(template).toContain('# - name: contract'); expect(template).toContain('# - name: css'); - expect(template).toContain('# - name: styling'); + expect(template).toContain('# - name: react'); }); - it('transformers block appears after the include: section', () => { - const template = generateConfigTemplate(); - const includeIdx = template.indexOf('include:'); - const transformIdx = template.indexOf('# transformers:'); - expect(includeIdx).toBeGreaterThanOrEqual(0); - expect(transformIdx).toBeGreaterThan(includeIdx); + it('includes a commented-out analyses: block', () => { + const template = generatePipelineTemplate(); + expect(template).toContain('# analyses:'); + expect(template).toContain('# - name: dependencies'); }); }); }); diff --git a/packages/cli/tests/unit/transforms/Contract.test.ts b/packages/cli/tests/unit/transforms/Contract.test.ts index 98d1f2ab..3582cb4d 100644 --- a/packages/cli/tests/unit/transforms/Contract.test.ts +++ b/packages/cli/tests/unit/transforms/Contract.test.ts @@ -8,7 +8,7 @@ import type { ProcessingStates } from '../../../src/transforms/states.js'; const transformer = new ContractTransformer(); function makeContext(dir: string, componentKey = 'dsButton', processingStates?: ProcessingStates) { - return { outputDir: dir, componentKey, tokensFormat: 'TOKEN', processingStates }; + return { outputDir: dir, componentKey, tokensFormat: 'TOKEN', outputFormat: 'JSON' as const, processingStates }; } function toPascalCase(str: string): string { diff --git a/packages/cli/tests/unit/transforms/Css.test.ts b/packages/cli/tests/unit/transforms/Css.test.ts index fc3f66bd..5673b998 100644 --- a/packages/cli/tests/unit/transforms/Css.test.ts +++ b/packages/cli/tests/unit/transforms/Css.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs-extra'; import path from 'path'; import os from 'os'; @@ -10,7 +10,7 @@ const transformer = new CssTransformer(); import type { ProcessingStates } from '../../../src/transforms/states.js'; function makeContext(dir: string, componentKey = 'dsButton', tokensFormat = 'TOKEN', processingStates?: ProcessingStates) { - return { outputDir: dir, componentKey, tokensFormat, processingStates }; + return { outputDir: dir, componentKey, tokensFormat, outputFormat: 'JSON' as const, processingStates }; } async function writeVariants(dir: string, data: Record) { diff --git a/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts b/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts index 93362043..677bad91 100644 --- a/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts +++ b/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts @@ -200,6 +200,49 @@ describe('ComponentDiscovery', () => { expect(discovery.getFileLastModified()).toBe('2026-05-08T17:48:26Z'); }); + it('should carry non-READY_FOR_DEV statuses through verbatim', async () => { + const filePath = path.join(testDir, 'library.json'); + const data = { + name: 'Test Library', + document: { + id: '0:0', + name: 'Document', + type: 'DOCUMENT', + children: [ + { + id: '1:1', + name: 'Page', + type: 'CANVAS', + children: [ + { + id: '9313:18494', + name: 'Text Field', + type: 'COMPONENT_SET', + devStatus: { type: 'COMPLETED', description: '' }, + children: [] + }, + { + id: '9313:18495', + name: 'Future Set', + type: 'COMPONENT_SET', + devStatus: { type: 'in_review' }, + children: [] + } + ] + } + ] + } + }; + + fs.writeJSONSync(filePath, data); + + const discovery = await ComponentDiscovery.fromFile(filePath); + const components = discovery.findAllComponents(); + + expect(components.find(c => c.id === '9313:18494')?.devStatus).toBe('COMPLETED'); + expect(components.find(c => c.id === '9313:18495')?.devStatus).toBe('IN_REVIEW'); + }); + it('should exclude variant children from results', async () => { const filePath = path.join(testDir, 'library.json'); const data = { diff --git a/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts index b1bba7a4..2ce48c15 100644 --- a/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts +++ b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts @@ -81,4 +81,69 @@ describe('ManifestParserV2', () => { expect(components[0].name).toBe('Toggle | On/Off'); expect(components[0].id).toBe('1:23'); }); + it('parses COMPLETED rows without warning and preserves the checkbox', () => { + const fixture = [ + '**Scan format version:** 2', + '', + '## Components', + '', + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | Text Field | 9313:18494 | COMPONENT_SET | COMPLETED |', + '' + ].join('\n'); + + const { components, warnings } = ManifestParserV2.parse(fixture); + expect(components).toEqual([ + { + id: '9313:18494', + name: 'Text Field', + type: 'COMPONENT_SET', + included: true, + devStatus: 'COMPLETED' + } + ]); + expect(warnings).toEqual([]); + }); + + it('keeps a row with an unrecognized status, unselected, and warns', () => { + const fixture = [ + '**Scan format version:** 2', + '', + '## Components', + '', + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | Text Field | 9313:18494 | COMPONENT_SET | IN_REVIEW |', + '' + ].join('\n'); + + const { components, warnings } = ManifestParserV2.parse(fixture); + expect(components).toHaveLength(1); + expect(components[0]).toMatchObject({ id: '9313:18494', devStatus: 'IN_REVIEW', included: false }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('IN_REVIEW'); + }); + + it('warns instead of silently dropping a malformed checkbox row', () => { + const fixture = [ + '**Scan format version:** 2', + '', + '## Components', + '', + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | Broken | not-an-id | COMPONENT_SET | NONE |', + '' + ].join('\n'); + + const { components, warnings } = ManifestParserV2.parse(fixture); + expect(components).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Broken'); + }); + + it('reports no warnings for a clean manifest', () => { + expect(ManifestParserV2.parse(V2_FIXTURE).warnings).toEqual([]); + }); }); diff --git a/packages/cli/tests/unit/writers/DataTransformers.test.ts b/packages/cli/tests/unit/writers/DataTransformers.test.ts index 871bcf38..ae14e32e 100644 --- a/packages/cli/tests/unit/writers/DataTransformers.test.ts +++ b/packages/cli/tests/unit/writers/DataTransformers.test.ts @@ -9,7 +9,7 @@ import { describe('splitComponentByConcern', () => { it('defaults missing props to {} (regression #84)', () => { const { api } = splitComponentByConcern({ - title: 'egdsDivider', + title: 'dsDivider', anatomy: {}, metadata: {}, }); @@ -82,17 +82,17 @@ describe('splitComponentByConcern — examples concern', () => { }); describe('splitComponentByConcern — images registry (ADR-063)', () => { - it('routes images into examples and counts as example data — the egdsAvatar case', () => { + it('routes images into examples and counts as example data — the dsAvatar case', () => { const { api, variants, examples } = splitComponentByConcern({ - title: 'egdsAvatar', + title: 'dsAvatar', anatomy: {}, default: {}, - images: { egdsAvatar__image: { src: '_images/d54334d2.png' } }, + images: { dsAvatar__image: { src: '_images/d54334d2.png' } }, metadata: {}, }); - expect(examples.images).toEqual({ egdsAvatar__image: { src: '_images/d54334d2.png' } }); - expect((api as Record).images).toBeUndefined(); - expect((variants as Record).images).toBeUndefined(); + expect(examples.images).toEqual({ dsAvatar__image: { src: '_images/d54334d2.png' } }); + expect(api).not.toHaveProperty('images'); + expect(variants).not.toHaveProperty('images'); // Images alone must be enough for examples.yaml to be written. expect(hasExampleData(examples)).toBe(true); }); diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index d7fd8de3..e81dcad8 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,46 @@ 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.31.0] - Unreleased + +Configuration now separates what is true about a Figma library from what a run chooses to do with it. A convention — a naming pattern, a state classification, where subcomponents live — is a fact every consumer of that library must share, and getting one wrong produces incorrect output. A setting is a free choice that produces different output. They were peers in one `Config`; they are now two published types, each addressable, each validated on its own, with the work a workspace runs declared separately again. + +### Added + +- `PropConfigurationValue` — a `null` arm; a configuration states a nullable prop is unset, and an absent key inherits while `null` overrides (ADR-080) +- `InstanceExample.propConfigurations` — accepts `null`, so an example can leave a prop unset; still refuses `PropBinding` +- `NumberProp.enum` — the closed set of values a numeric prop accepts, so a Figma VARIANT whose options are all numbers carries its numeric type and its authored option order at once (ADR-072) +- `Conventions` — facts about the Figma library, namespaced by source under `figma`; blocks are optional and their absence means the library declares no such convention +- `Settings` — run choices grouped by concern (`data`, `spec`, `assets`), each carrying its own `directory` +- `Pipeline` — `transformers` and `analyses` a workspace runs, with `AnalysisEntry` alongside `TransformEntry` +- `SourceEntry` — a source's Figma file `key` and the artifacts to `fetch` +- `DEFAULT_SETTINGS`, `DEFAULT_PIPELINE` and `DEFAULT_CONVENTIONS` — one defaults constant per configuration artifact. `DEFAULT_CONVENTIONS` carries only the three members that have a meaningful default; convention blocks are absent by design, because absence is the statement that a library declares none +- `conventions.schema.json`, `settings.schema.json`, `pipeline.schema.json` — one schema per authored artifact + +### Changed + +- **`Settings.spec.splitComponents`, `splitConcerns` and `useSubfolders` now default to `true`** and are required on `ResolvedSettings`. The split layout — one folder per component, one file per concern — is what `transform`, `analyze` and `render` read, so the shape of generated output is not a per-consumer choice. They previously carried no default, leaving each consumer to pick its own; both consumers picked `false`, which is the layout nothing downstream can use. Recorded spec `metadata.settings.spec` now carries all three on every spec. + +- `Metadata.config` → `Metadata.conventions` and `Metadata.settings` — each half comparable across specs on its own +- `Config.format.figmaKeys` → `Conventions.figma.naming` — no longer shares the word `keys` with the emitted casing +- `Config.processing.glyphNamePattern` → `Conventions.figma.glyphs.match`; `codeOnlyPropsPattern` → `codeOnlyProps.match`; `images.imageComponent` → `images.match` +- `Config.processing.states`, `subcomponents`, `instanceExamples`, `images`, `slotConstraints`, `inferNumberProps` → `Conventions.figma`, whole, including `scope` and `backgroundImage` +- `Config.format.output` → `Settings.spec.format`; the rest of `format` and all of `include` fold into `Settings.spec` +- Workspace-level `sources`, `dataDirectory`, `outputDirectory`, and the `output` block absorbed into `Settings` + +### Removed + +- `Config`, `ResolvedConfig`, and `DEFAULT_CONFIG` — replaced by the types above +- `workspace.schema.json` — one schema per artifact replaces it +- `Config.transformers` — work to run now lives in `Pipeline` + +### Migration + +- `Config` → `Conventions` + `Settings`: read library facts from `conventions`, run choices from `settings`. A member describes the library if a different team pointing at the same Figma file would have to keep your value. +- `DEFAULT_CONFIG` → `DEFAULT_SETTINGS`: conventions have no defaults to supply, so nothing replaces the conventions half. +- `Metadata.config` → `Metadata.conventions` / `Metadata.settings`: read whichever half you compared before, and prefer `conventions` when checking that two consumers agree about a library. + + ## [0.30.0] - 2026-08-17 A spec can now declare the naming convention its Figma file follows, so a formatted property key can be turned back into the name a designer sees on the canvas. When a key can't reconstruct that name on its own — because the original contained characters formatting doesn't survive — the spec records the Figma name alongside it, on every property type. Together these make round-tripping between a formatted spec and its source file possible without a side-channel lookup of the file's own naming. diff --git a/packages/schema/CONSTITUTION.md b/packages/schema/CONSTITUTION.md index ab3d0a33..e3749046 100644 --- a/packages/schema/CONSTITUTION.md +++ b/packages/schema/CONSTITUTION.md @@ -13,7 +13,7 @@ Rationale: Every consumer (`specs-from-figma`, `specs-cli`, `specs-plugin-2`) re ### II. No Logic — Types and Schema Only This package MUST NOT contain transformation logic, processing algorithms, or runtime behavior beyond: -- `DEFAULT_CONFIG` constant exported from `Config.ts` +- `DEFAULT_SETTINGS` exported from `Settings.ts`, `DEFAULT_PIPELINE` from `Pipeline.ts`, and `DEFAULT_CONVENTIONS` from `Conventions.ts` All other exports MUST be pure type declarations (`type`, `interface`) or static schema files. No classes, no functions, no async operations. @@ -25,7 +25,7 @@ The exports from `types/index.ts` are the full public API. Every exported type i - New types require explicit justification: they MUST represent a genuine, shared concept in the Specs schema — not an internal implementation detail of any one package. - Removing or renaming an exported type or a named field within a type is a breaking change and MUST follow semantic versioning. - Schema exports (via `exports` in `package.json`) are equally contractual — adding, removing, or restructuring a schema file is a breaking change. -- `DEFAULT_CONFIG` is the only permitted runtime export; adding more requires constitutional amendment. +- The permitted runtime exports are `DEFAULT_SETTINGS`, `DEFAULT_PIPELINE`, and `DEFAULT_CONVENTIONS` — one defaults constant per configuration artifact (ADR-071). Adding a runtime export for anything else requires constitutional amendment. - **ADRs and spec decisions MUST be justified by the shared contract's own coherence and the needs of all consumers equally. No single downstream package's internal model, class structure, or implementation detail may be cited as a decision driver or rationale. ADRs inform downstream packages; they are not driven by them.** Rationale: All downstream packages compile against these types. Any change has a multiplied impact across `specs-from-figma`, `specs-cli`, and `specs-plugin-2` simultaneously. Allowing one package's internals to steer the spec contract creates an implicit ownership hierarchy that undermines the shared-language role of this package. diff --git a/packages/schema/package.json b/packages/schema/package.json index 3d81f760..9dc7510c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.30.0", + "version": "0.31.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", @@ -18,7 +18,9 @@ "default": "./dist/index.js" }, "./schema/component": "./schema/component.schema.json", - "./schema/workspace": "./schema/workspace.schema.json" + "./schema/conventions": "./schema/conventions.schema.json", + "./schema/settings": "./schema/settings.schema.json", + "./schema/pipeline": "./schema/pipeline.schema.json" }, "files": [ "dist", diff --git a/packages/schema/schema/component.schema.json b/packages/schema/schema/component.schema.json index a157a9ea..33d2fd04 100644 --- a/packages/schema/schema/component.schema.json +++ b/packages/schema/schema/component.schema.json @@ -91,7 +91,7 @@ }, "propConfigurations": { "type": "object", - "description": "Prop values for this example. Scalar types for scalar props; SlotContentRef for slot props. PropBinding is not permitted here.", + "description": "Prop values for this example. Scalar types for scalar props; SlotContentRef for slot props; null when the prop is unset in this example (ADR-080). PropBinding is not permitted here.", "additionalProperties": { "oneOf": [ { @@ -103,6 +103,9 @@ { "type": "boolean" }, + { + "type": "null" + }, { "$ref": "#/definitions/SlotContentRef" } @@ -138,7 +141,7 @@ "SafeKeyTitle": { "type": "string", "pattern": "^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$", - "description": "As SafeKeySentence, but for format.figmaKeys TITLE — every letter word capitalized." + "description": "As SafeKeySentence, but for format.figmaKeys TITLE \u2014 every letter word capitalized." }, "ElementType": { "type": "string", @@ -225,7 +228,7 @@ "properties": { "name": { "type": "string", - "description": "The element's name in Figma. Recorded on any of three triggers: primitive-wrapper collapse promoted this element to root (ADR-058), always and independent of format.figmaKeys; the name fell outside the safe key grammar so format.keys could not represent it losslessly (ADR-066); or the name was already written in the destination format and passed through unformatted, making reversal identity rather than re-derivation (ADR-066). The latter two apply only when format.figmaKeys is not NONE — under NONE, format divergence is not evaluated and this field is absent however the key was derived." + "description": "The element's name in Figma. Recorded on any of three triggers: primitive-wrapper collapse promoted this element to root (ADR-058), always and independent of format.figmaKeys; the name fell outside the safe key grammar so format.keys could not represent it losslessly (ADR-066); or the name was already written in the destination format and passed through unformatted, making reversal identity rather than re-derivation (ADR-066). The latter two apply only when format.figmaKeys is not NONE \u2014 under NONE, format divergence is not evaluated and this field is absent however the key was derived." } }, "additionalProperties": false @@ -315,7 +318,7 @@ }, "name": { "type": "string", - "description": "The Figma component-property name (ADR-066). Recorded when the name fell outside the safe key grammar, or when it was already written in the destination format and passed through unformatted — in which case reversal is identity, not re-derivation. Both triggers require format.figmaKeys to be other than NONE; under NONE this field is absent however the key was derived." + "description": "The Figma component-property name (ADR-066). Recorded when the name fell outside the safe key grammar, or when it was already written in the destination format and passed through unformatted \u2014 in which case reversal is identity, not re-derivation. Both triggers require format.figmaKeys to be other than NONE; under NONE this field is absent however the key was derived." } }, "additionalProperties": true @@ -373,7 +376,7 @@ "nullable": { "type": "boolean", "default": true, - "description": "Whether this prop accepts a null value. Absent means true — a string prop has an open value set, so null is accepted unless false explicitly asserts otherwise." + "description": "Whether this prop accepts a null value. Absent means true \u2014 a string prop has an open value set, so null is accepted unless false explicitly asserts otherwise." }, "examples": { "type": "array", @@ -415,7 +418,7 @@ "nullable": { "type": "boolean", "default": false, - "description": "Whether this prop accepts a null value. Absent means false — enum enumerates every accepted value, and null is not among them unless true explicitly admits it." + "description": "Whether this prop accepts a null value. Absent means false \u2014 enum enumerates every accepted value, and null is not among them unless true explicitly admits it." }, "$extensions": { "$ref": "#/definitions/PropExtensions" @@ -441,7 +444,7 @@ "nullable": { "type": "boolean", "default": true, - "description": "Whether this slot prop accepts a null value. Absent means true — a slot has an open content set and may be empty, unless false explicitly asserts otherwise." + "description": "Whether this slot prop accepts a null value. Absent means true \u2014 a slot has an open content set and may be empty, unless false explicitly asserts otherwise." }, "minChildren": { "type": "integer", @@ -528,7 +531,7 @@ ] }, "PropConfigurationValue": { - "description": "The value of a single prop configuration entry (ADR-049): a scalar, a PropBinding for pass-through to a parent prop, a SlotContentRef under a slot-prop key for a named slot-content fill, or an ImageBinding under an image-prop key forwarding an image into a nested image instance (ADR-063).", + "description": "The value of a single prop configuration entry (ADR-049): a scalar, a PropBinding for pass-through to a parent prop, a SlotContentRef under a slot-prop key for a named slot-content fill, or an ImageBinding under an image-prop key forwarding an image into a nested image instance (ADR-063), or null when the prop is unset in this configuration \u2014 a value, not an absence: an absent key inherits, a null key overrides an inherited value with no value (ADR-080).", "oneOf": [ { "type": "string" @@ -539,6 +542,9 @@ { "type": "boolean" }, + { + "type": "null" + }, { "$ref": "#/definitions/PropBinding" }, @@ -978,8 +984,13 @@ ], "additionalProperties": false }, - "config": { - "$ref": "workspace.schema.json#/definitions/Config" + "conventions": { + "$ref": "conventions.schema.json#/definitions/Conventions", + "description": "Facts about the Figma library this spec was generated from." + }, + "settings": { + "$ref": "settings.schema.json#/definitions/Settings", + "description": "Choices about the run that generated this spec." } }, "required": [ @@ -988,7 +999,8 @@ "generator", "schema", "source", - "config" + "conventions", + "settings" ], "additionalProperties": false }, @@ -1007,10 +1019,18 @@ "nodeType": { "type": "string", "description": "The Figma node type.", - "enum": ["COMPONENT", "COMPONENT_SET", "FRAME"] + "enum": [ + "COMPONENT", + "COMPONENT_SET", + "FRAME" + ] } }, - "required": ["pageId", "nodeId", "nodeType"], + "required": [ + "pageId", + "nodeId", + "nodeType" + ], "additionalProperties": false }, "Subcomponent": { @@ -1042,7 +1062,7 @@ }, "NumberProp": { "type": "object", - "description": "Number property definition for numeric-valued props inferred from TEXT code-only props.", + "description": "Number property definition for numeric-valued props, whether inferred from a TEXT code-only prop or from a VARIANT whose options are all numbers.", "examples": [], "properties": { "type": { @@ -1052,10 +1072,17 @@ "default": { "type": "number" }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "description": "The closed set of accepted values, when the source enumerates them rather than leaving the range open. Absent means the prop accepts any number." + }, "nullable": { "type": "boolean", "default": true, - "description": "Whether this prop accepts a null value. Absent means true — a number prop has an open value set, so null is accepted unless false explicitly asserts otherwise." + "description": "Whether this prop accepts a null value. Absent means true \u2014 a number prop has an open value set, so null is accepted unless false explicitly asserts otherwise." }, "examples": { "type": "array", @@ -1085,13 +1112,16 @@ "const": "image" }, "default": { - "type": ["string", "null"], - "description": "Default image — an images registry reference, or null." + "type": [ + "string", + "null" + ], + "description": "Default image \u2014 an images registry reference, or null." }, "nullable": { "type": "boolean", "default": true, - "description": "Whether this prop accepts a null value. Absent means true — an image prop has an open value set, so null is accepted unless false explicitly asserts otherwise." + "description": "Whether this prop accepts a null value. Absent means true \u2014 an image prop has an open value set, so null is accepted unless false explicitly asserts otherwise." }, "$extensions": { "$ref": "#/definitions/PropExtensions" @@ -1142,7 +1172,7 @@ "properties": { "src": { "type": "string", - "description": "Resolved image source — an emitted asset path (the standard resolved form, relative to the referencing spec file), a data: URI, or an external URL. Absent = unresolved." + "description": "Resolved image source \u2014 an emitted asset path (the standard resolved form, relative to the referencing spec file), a data: URI, or an external URL. Absent = unresolved." }, "$extensions": { "type": "object", @@ -1153,10 +1183,12 @@ "properties": { "imageHash": { "type": "string", - "description": "The Figma image content hash (Plugin ImagePaint.imageHash / REST fills[].imageRef) — reverse-direction tooling reconstructs an ImagePaint from it." + "description": "The Figma image content hash (Plugin ImagePaint.imageHash / REST fills[].imageRef) \u2014 reverse-direction tooling reconstructs an ImagePaint from it." } }, - "required": ["imageHash"], + "required": [ + "imageHash" + ], "additionalProperties": false } }, diff --git a/packages/schema/schema/conventions.schema.json b/packages/schema/schema/conventions.schema.json new file mode 100644 index 00000000..a5857fa4 --- /dev/null +++ b/packages/schema/schema/conventions.schema.json @@ -0,0 +1,200 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://specs.directededges.com/conventions.schema.json", + "title": "Specs Library Conventions", + "description": "Facts about the Figma library a spec was generated from. Every consumer reading the same library declares the same values; differing values produce incorrect output, not merely different output.", + "definitions": { + "VariantStateEntry": { + "type": "object", + "description": "Classifies a Figma variant prop as a semantic state concept. The map key (in figma.states) is the concept name (e.g. hover, disabled). (ADR-055)", + "properties": { + "prop": { + "type": "string", + "description": "Figma variant prop name (e.g. 'state', 'isDisabled', 'focused')." + }, + "value": { + "type": "string", + "description": "Figma variant value that activates this concept (e.g. 'hover', 'pressed'). Omit for boolean props \u2014 defaults to 'true'." + }, + "contract": { + "type": "string", + "enum": [ + "omit", + "keep" + ], + "description": "Overrides the concept's canonical browser-driven / consumer-controlled default." + } + }, + "required": [ + "prop" + ], + "additionalProperties": false + }, + "Conventions": { + "type": "object", + "description": "Facts about the library, namespaced by source.", + "properties": { + "figma": { + "type": "object", + "description": "Conventions describing how the Figma library is authored and organized.", + "properties": { + "naming": { + "type": "string", + "enum": [ + "NONE", + "SENTENCE", + "TITLE" + ], + "default": "NONE", + "description": "Naming convention the Figma file uses for layer and component property names \u2014 the reversal target for settings.spec.keys." + }, + "glyphs": { + "type": "object", + "description": "Glyph content assets. Absence means the library has no glyph naming convention.", + "properties": { + "match": { + "type": "string", + "description": "Naming pattern identifying glyph assets, using the {i} icon-name placeholder." + } + }, + "required": [ + "match" + ], + "additionalProperties": false + }, + "codeOnlyProps": { + "type": "object", + "description": "The code-only props container layer. Absence means the library has no such convention.", + "properties": { + "match": { + "type": "string", + "description": "Literal layer name identifying the container." + } + }, + "required": [ + "match" + ], + "additionalProperties": false + }, + "subcomponents": { + "type": "object", + "description": "Subcomponent organization and naming. Absence means the library has no subcomponent convention.", + "properties": { + "scope": { + "type": "string", + "enum": [ + "NESTED", + "PAGE" + ], + "default": "NESTED", + "description": "Where the library keeps subcomponents. NESTED = anatomy only; PAGE = also the Figma page." + }, + "match": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Naming patterns identifying subcomponents, using {C} and {S} placeholders." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Naming patterns for matched assets the library excludes." + } + }, + "required": [ + "match" + ], + "additionalProperties": false + }, + "instanceExamples": { + "type": "object", + "description": "Instance example organization and naming (ADR-050). Absence means the library has no such convention.", + "properties": { + "scope": { + "type": "string", + "enum": [ + "PAGE", + "FILE" + ], + "default": "PAGE", + "description": "Where the library keeps instance examples." + }, + "match": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Naming patterns narrowing which instance frames qualify, using the {C} placeholder." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Naming patterns for frames the library excludes." + }, + "parentNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Immediate-parent frame or section names a candidate must sit within." + } + }, + "additionalProperties": false + }, + "images": { + "type": "object", + "description": "How the library expresses images (ADR-063). Absence means the library has no image convention.", + "properties": { + "backgroundImage": { + "type": "boolean", + "default": false, + "description": "The library expresses images as container fills, emitted as Styles.backgroundImage." + }, + "match": { + "type": "string", + "description": "Name of the library's designated image component. Requires a non-empty sourceProps." + }, + "sourceProps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Code-only prop names carrying image sources. The first entry is the designated component's own source prop." + } + }, + "additionalProperties": false + }, + "slotConstraints": { + "type": "boolean", + "default": false, + "description": "The library authors slot constraints as code-only props, to be consolidated into the slot property." + }, + "inferNumberProps": { + "type": "boolean", + "default": false, + "description": "The library authors numeric props as Figma TEXT props whose values parse as numbers, to be emitted as NumberProp." + }, + "states": { + "type": "object", + "description": "Concept-keyed map classifying Figma variant props as semantic states.", + "additionalProperties": { + "$ref": "#/definitions/VariantStateEntry" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "figma" + ], + "additionalProperties": false + } + } +} diff --git a/packages/schema/schema/pipeline.schema.json b/packages/schema/schema/pipeline.schema.json new file mode 100644 index 00000000..79ae9007 --- /dev/null +++ b/packages/schema/schema/pipeline.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://specs.directededges.com/pipeline.schema.json", + "title": "Specs Pipeline", + "description": "Work a workspace runs over its specs. Entries carry no output paths: transformers write into the spec structure, and analyses write beside the specs they analyze.", + "definitions": { + "TransformEntry": { + "type": "object", + "description": "A single transformer to run, identified by name. Transformer-specific options sit inline alongside name.", + "properties": { + "name": { + "type": "string", + "description": "Transformer name (e.g. contract, css, react)." + } + }, + "required": [ + "name" + ] + }, + "AnalysisEntry": { + "type": "object", + "description": "A single analysis to run, identified by name. Analysis-specific options sit inline alongside name.", + "properties": { + "name": { + "type": "string", + "description": "Analysis name (e.g. dependencies)." + } + }, + "required": [ + "name" + ] + }, + "Pipeline": { + "type": "object", + "description": "Transformers and analyses a workspace runs.", + "properties": { + "transformers": { + "type": "array", + "description": "Transformers to run via `specs transform`.", + "items": { + "$ref": "#/definitions/TransformEntry" + } + }, + "analyses": { + "type": "array", + "description": "Analyses to run via `specs analyze`.", + "items": { + "$ref": "#/definitions/AnalysisEntry" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/schema/schema/settings.schema.json b/packages/schema/schema/settings.schema.json new file mode 100644 index 00000000..49900f26 --- /dev/null +++ b/packages/schema/schema/settings.schema.json @@ -0,0 +1,203 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://specs.directededges.com/settings.schema.json", + "title": "Specs Settings", + "description": "Choices about a run rather than facts about a library. Changing a setting produces different output, never incorrect output.", + "definitions": { + "SourceEntry": { + "type": "object", + "description": "A source the workspace reads from.", + "properties": { + "key": { + "type": "string", + "description": "Figma file key the source reads from." + }, + "fetch": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Artifacts to download for this source (e.g. file, variables, styles, icons)." + } + }, + "required": [ + "key" + ], + "additionalProperties": false + }, + "Settings": { + "type": "object", + "description": "Run settings, grouped by concern. Each concern carries its own directory.", + "properties": { + "author": { + "type": "string", + "description": "Author recorded in generated spec metadata." + }, + "data": { + "type": "object", + "description": "Source acquisition: what to fetch, and where fetched artifacts, computed caches, and extracted assets are kept.", + "properties": { + "directory": { + "type": "string", + "description": "Directory holding fetched downloads, computed caches, extracted assets, and authored inputs." + }, + "sources": { + "type": "object", + "description": "Sources the workspace reads from, keyed by source name.", + "additionalProperties": { + "$ref": "#/definitions/SourceEntry" + } + } + }, + "additionalProperties": false + }, + "spec": { + "type": "object", + "description": "The generated spec: where it lands, how it is split, what it contains, and how values are serialized.", + "properties": { + "directory": { + "type": "string", + "description": "Directory the generated spec is written to." + }, + "format": { + "type": "string", + "enum": [ + "JSON", + "YAML" + ], + "default": "JSON", + "description": "Serialization format." + }, + "keys": { + "type": "string", + "enum": [ + "SAFE", + "CAMEL", + "SNAKE", + "KEBAB", + "PASCAL", + "TRAIN" + ], + "default": "SAFE", + "description": "Key naming convention applied to anatomy keys, prop keys, and references to them." + }, + "layout": { + "type": "string", + "enum": [ + "LAYOUT", + "PARENT_CHILDREN", + "BOTH" + ], + "default": "LAYOUT", + "description": "Layout representation format." + }, + "tokens": { + "type": "string", + "enum": [ + "TOKEN", + "TOKEN_NAME", + "TOKEN_FIGMA_EXTENSIONS", + "FIGMA_NAME", + "CUSTOM", + "FIGMA_SYNTAX_WEB", + "FIGMA_SYNTAX_IOS", + "FIGMA_SYNTAX_ANDROID" + ], + "default": "TOKEN", + "description": "Token reference serialization profile." + }, + "color": { + "type": "string", + "enum": [ + "HEX", + "HEXA", + "RGB", + "RGBA", + "HSLA", + "HSB", + "OKLCH", + "OKLAB", + "OBJECT" + ], + "default": "HEX", + "description": "Color value output format." + }, + "variantDepth": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 9999 + ], + "default": 9999, + "description": "Depth of variant expansion; 9999 for unlimited." + }, + "details": { + "type": "string", + "enum": [ + "FULL", + "LAYERED" + ], + "default": "LAYERED", + "description": "Level of detail in output." + }, + "collapsePrimitiveWrapper": { + "type": "boolean", + "default": false, + "description": "Strip a plain container wrapping a single text or glyph element and promote the leaf to spec root." + }, + "invalidVariants": { + "type": "boolean", + "default": false, + "description": "Include invalid variants." + }, + "invalidCombinations": { + "type": "boolean", + "default": true, + "description": "Include invalid combinations." + }, + "emptyVariants": { + "type": "boolean", + "default": false, + "description": "Include layered variants that contain no elements." + }, + "defaultSlotContent": { + "type": "boolean", + "default": false, + "description": "Include slot content examples in output (ADR-050)." + }, + "splitComponents": { + "type": "boolean", + "default": true, + "description": "Write one file per component rather than a single combined library file." + }, + "splitConcerns": { + "type": "boolean", + "default": true, + "description": "Write one file per concern (api, styling, variants) rather than a single component file." + }, + "useSubfolders": { + "type": "boolean", + "default": true, + "description": "Nest each component's files in a subfolder named for the component." + } + }, + "additionalProperties": false + }, + "assets": { + "type": "object", + "description": "Shared resources every code output points at, whatever the platform: icons, images, generated CSS, fonts.", + "properties": { + "directory": { + "type": "string", + "description": "Directory holding shared assets." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/schema/schema/workspace.schema.json b/packages/schema/schema/workspace.schema.json deleted file mode 100644 index 29f6c1e2..00000000 --- a/packages/schema/schema/workspace.schema.json +++ /dev/null @@ -1,320 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://specs.directededges.com/workspace.schema.json", - "title": "Specs Workspace Configuration", - "description": "Schema for workspace-level CLI configuration (specs.config.yaml transform block and related settings).", - "definitions": { - "VariantStateEntry": { - "type": "object", - "description": "Classifies a Figma variant prop as a semantic state concept for use by transformers. The map key (in processing.states) is the concept name (e.g. hover, disabled). (ADR-055)", - "properties": { - "prop": { - "type": "string", - "description": "Figma variant prop name (e.g. 'state', 'isDisabled', 'focused')." - }, - "value": { - "type": "string", - "description": "Figma variant value that activates this concept (e.g. 'hover', 'pressed'). Omit for boolean props — defaults to 'true'." - }, - "contract": { - "type": "string", - "enum": ["omit", "keep"], - "description": "Contract generation behavior override. 'omit' = browser-driven, exclude from Props interfaces. 'keep' = consumer-controlled, retain. When absent, the concept's canonical default applies." - } - }, - "required": ["prop"], - "additionalProperties": false - }, - "TransformEntry": { - "type": "object", - "description": "A single transformer to run via specs transform, identified by name. Transformer-specific options sit inline alongside name. (ADR-053)", - "properties": { - "name": { - "type": "string", - "description": "Transformer name (e.g. contract, css, tokens)." - } - }, - "required": [ - "name" - ], - "additionalProperties": true - }, - "Config": { - "type": "object", - "description": "Model configuration used to generate this spec.", - "properties": { - "processing": { - "type": "object", - "properties": { - "subcomponents": { - "type": "object", - "description": "Subcomponent discovery settings: scope, match patterns, and exclusion patterns.", - "properties": { - "scope": { - "type": "string", - "enum": [ - "NESTED", - "PAGE" - ], - "default": "NESTED", - "description": "Where to search for subcomponents. NESTED = anatomy only; PAGE = also search the Figma page." - }, - "match": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "Template patterns defining which assets are subcomponents. Uses {C} (component name) and {S} (subcomponent name) placeholders." - }, - "exclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Template patterns defining which matched assets to exclude. Same {C}/{S} syntax as match." - } - }, - "required": [ - "match" - ], - "additionalProperties": false - }, - "glyphNamePattern": { - "type": "string", - "description": "Naming pattern used to detect glyph content assets (e.g. 'DS Icon Glyph /')" - }, - "codeOnlyPropsPattern": { - "type": "string", - "description": "Naming pattern used to detect the code-only props container layer (e.g. 'Code only props')" - }, - "inferNumberProps": { - "type": "boolean", - "default": false, - "description": "When true, TEXT code-only props whose default and all examples parse as valid numbers (no leading zeros) are emitted as NumberProp instead of StringProp" - }, - "slotConstraints": { - "type": "boolean", - "default": false, - "description": "Whether to consolidate slot constraints (anyOf, minChildren, maxChildren) from code-only props into the slot property" - }, - "collapsePrimitiveWrapper": { - "type": "boolean", - "default": false, - "description": "When true, a component whose root is a plain container wrapping a single text or glyph element (no meaningful container styles, no slot bindings) is collapsed: the wrapper is stripped and the leaf becomes the spec root. All-or-nothing across variants." - }, - "variantDepth": { - "type": "number", - "enum": [ - 1, - 2, - 3, - 9999 - ], - "default": 9999, - "description": "Depth of variant expansion: 1-3 or 9999 for unlimited. Defaults to 9999." - }, - "details": { - "type": "string", - "enum": [ - "FULL", - "LAYERED" - ], - "default": "LAYERED", - "description": "Level of detail in output. Defaults to LAYERED." - }, - "instanceExamples": { - "type": "object", - "description": "Instance example detection settings (ADR-050): named frames demonstrating pre-configured whole-component usage. Absence means no instance example detection.", - "properties": { - "scope": { - "type": "string", - "enum": [ - "PAGE", - "FILE" - ], - "default": "PAGE", - "description": "Search boundary. PAGE = current Figma page only (default); FILE = all pages in the file." - }, - "match": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "Optional name patterns narrowing which instance frames qualify. Uses {C} (component name) placeholder. Absence = every in-scope instance of the component qualifies (subject to exclude/parentNames)." - }, - "exclude": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Name patterns for frames to exclude. Same {C} syntax as match." - }, - "parentNames": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Immediate-parent frame or section names a candidate must be contained within. Absence = no parent-name filtering." - } - }, - "additionalProperties": false - }, - "states": { - "type": "object", - "description": "Concept-keyed map classifying Figma variant props as semantic states. Key = concept name (e.g. hover, disabled, focus-within). Absence means all variant props emit as data-* attribute selectors and all props are retained in contracts. (ADR-055)", - "additionalProperties": { - "$ref": "#/definitions/VariantStateEntry" - } - }, - "images": { - "type": "object", - "description": "Image processing (ADR-063). Presence of this block is the on-switch (like subcomponents); each member is an independent representation trigger. Absence means images are not processed.", - "properties": { - "backgroundImage": { - "type": "boolean", - "default": false, - "description": "Detect image fills on container elements as Styles.backgroundImage. When paired with imageComponent, this is the fallback for fills outside the designated component." - }, - "imageComponent": { - "type": "string", - "description": "Designated image component name (e.g. 'dsImage'). Instances of it are the image primitive; their image routes through sourceProps[0]. Requires a non-empty sourceProps." - }, - "sourceProps": { - "type": "array", - "items": { "type": "string" }, - "description": "Code-only prop names (raw Figma names) that re-type from StringProp to ImageProp on any component. The first entry is the designated image component's own source prop." - } - }, - "required": [], - "additionalProperties": false - } - }, - "required": [], - "additionalProperties": false - }, - "format": { - "type": "object", - "properties": { - "output": { - "type": "string", - "enum": [ - "JSON", - "YAML" - ], - "default": "JSON", - "description": "Output format. Defaults to JSON." - }, - "keys": { - "type": "string", - "enum": [ - "SAFE", - "CAMEL", - "SNAKE", - "KEBAB", - "PASCAL", - "TRAIN" - ], - "default": "SAFE", - "description": "Key naming convention applied to anatomy keys, prop keys, and every reference to them. Every value other than SAFE is a lossy projection of the Figma name; names outside the safe key grammar are preserved in $extensions['com.figma'].name (ADR-066). Defaults to SAFE." - }, - "figmaKeys": { - "type": "string", - "enum": [ - "NONE", - "SENTENCE", - "TITLE" - ], - "default": "NONE", - "description": "Naming convention the Figma file uses for layer names and component property names. The reversal target for keys — a renderer reconstructs a Figma name by re-formatting the spec key into this convention. NONE declares no convention: the safe key grammar is not evaluated, no $extensions['com.figma'].name is emitted for format divergence, and reversal is undefined. Declaring SENTENCE or TITLE opts in to all three. Defaults to NONE." - }, - "layout": { - "type": "string", - "enum": [ - "LAYOUT", - "PARENT_CHILDREN", - "BOTH" - ], - "default": "LAYOUT", - "description": "Layout representation format. Defaults to LAYOUT." - }, - "tokens": { - "type": "string", - "enum": [ - "TOKEN", - "TOKEN_NAME", - "TOKEN_FIGMA_EXTENSIONS", - "FIGMA_NAME", - "CUSTOM", - "FIGMA_SYNTAX_WEB", - "FIGMA_SYNTAX_IOS", - "FIGMA_SYNTAX_ANDROID" - ], - "default": "TOKEN", - "description": "Token reference serialization profile. Optional; defaults to TOKEN. CUSTOM delegates projection entirely to the transformer. FIGMA_SYNTAX_WEB, FIGMA_SYNTAX_IOS, and FIGMA_SYNTAX_ANDROID emit the token's Figma codeSyntax for that platform, falling back to TOKEN output when no code syntax is defined for the platform." - }, - "color": { - "type": "string", - "enum": [ - "HEX", - "HEXA", - "RGB", - "RGBA", - "HSLA", - "HSB", - "OKLCH", - "OKLAB", - "OBJECT" - ], - "default": "HEX", - "description": "Color value output format. HEX (default) emits a 6-digit hex string. OBJECT emits the full ColorObject object (colorSpace, components, alpha, hex). All other values emit a formatted color string in the named notation." - } - }, - "required": [], - "additionalProperties": false - }, - "include": { - "type": "object", - "properties": { - "invalidVariants": { - "type": "boolean", - "default": false, - "description": "Include invalid variants. Defaults to false." - }, - "invalidCombinations": { - "type": "boolean", - "default": true, - "description": "Include invalid combinations. Defaults to true." - }, - "emptyVariants": { - "type": "boolean", - "default": false, - "description": "Include layered variants that contain no elements. When false (default), exclude empty variants from output. When true, include all variants regardless of element presence. Defaults to false." - }, - "defaultSlotContent": { - "type": "boolean", - "default": false, - "description": "Include the component's default slot content as examples in output (ADR-050, Pro). Defaults to false." - } - }, - "required": [], - "additionalProperties": false - }, - "transformers": { - "type": "array", - "description": "Transformers to run via `specs transform`, each with optional inline options. Absence means CLI built-in defaults apply. (ADR-053)", - "items": { - "$ref": "#/definitions/TransformEntry" - } - } - }, - "required": [ - "processing", - "format", - "include" - ], - "additionalProperties": false - } - } -} diff --git a/packages/schema/tests/Config.test-d.ts b/packages/schema/tests/Config.test-d.ts deleted file mode 100644 index 05ce1558..00000000 --- a/packages/schema/tests/Config.test-d.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Type-level tests for Config and ResolvedConfig. - * These files are intentionally never executed — they are compiled with tsc - * to assert that the type shape is correct. - */ -import type { Config, ResolvedConfig, ColorFormat, VariantStateEntry } from '../types/index.js'; -import { DEFAULT_CONFIG } from '../types/index.js'; - -// ─── Helper: minimal valid processing + format + include ────────────────────── - -const minProcessing: Config['processing'] = {}; -const minFormat: Config['format'] = {}; -const minInclude: Config['include'] = {}; - -// ─── Full Config with all fields ────────────────────────────────────────────── - -const fullConfig: Config = { - processing: { - subcomponents: { - scope: 'PAGE', - match: ['{C} / {S}', '{C} / _ / {S}'], - exclude: ['{C} / Examples / {S}', '{C} / Text cases / {S}'], - }, - glyphNamePattern: 'DS Icon Glyph /', - codeOnlyPropsPattern: 'Code only props', - slotConstraints: true, - collapsePrimitiveWrapper: false, - variantDepth: 9999, - details: 'LAYERED', - inferNumberProps: true, - instanceExamples: { - scope: 'FILE', - match: ['{C} / *'], - exclude: ['* / Deprecated / *'], - parentNames: ['Examples'], - }, - }, - format: { - output: 'JSON', - keys: 'SAFE', - figmaKeys: 'SENTENCE', - layout: 'LAYOUT', - tokens: 'TOKEN', - color: 'HEX', - }, - include: { - invalidVariants: false, - invalidCombinations: true, - emptyVariants: false, - defaultSlotContent: true, - }, -}; - -// ─── Minimal Config — only required fields (all empty blocks) ──────────────── - -const minimalConfig: Config = { - processing: minProcessing, - format: minFormat, - include: minInclude, -}; - -// ─── Config with zero overrides — all defaultable fields omitted ───────────── - -const bareConfig: Config = { - processing: {}, - format: {}, - include: {}, -}; - -// ─── processing.variantDepth is optional ───────────────────────────────────── - -const _variantDepthUndefined: Config['processing']['variantDepth'] = undefined; - -// ─── processing.details is optional ────────────────────────────────────────── - -const _detailsUndefined: Config['processing']['details'] = undefined; - -// ─── format.output is optional ─────────────────────────────────────────────── - -const _outputUndefined: Config['format']['output'] = undefined; - -// ─── format.keys is optional ───────────────────────────────────────────────── - -const _keysUndefined: Config['format']['keys'] = undefined; - -// ─── format.layout is optional ─────────────────────────────────────────────── - -const _layoutUndefined: Config['format']['layout'] = undefined; - -// ─── subcomponents.scope is optional, defaults to NESTED ────────────────────── - -const configWithoutScope: Config = { - processing: { - subcomponents: { match: ['{C} / _ / {S}'] }, - variantDepth: 9999, - details: 'FULL', - }, - format: { output: 'YAML', keys: 'CAMEL', layout: 'PARENT_CHILDREN' }, - include: { invalidVariants: true, invalidCombinations: false }, -}; - -// ─── processing.subcomponents is optional ──────────────────────────────────── - -const configWithoutSubcomponents: Config = { - processing: { variantDepth: 9999, details: 'LAYERED' }, - format: minFormat, - include: minInclude, -}; - -const _subcomponentsUndefined: Config['processing']['subcomponents'] = undefined; - -// ─── subcomponents.scope enum values ────────────────────────────────────────── - -type SubcomponentsScope = NonNullable['scope']; -const scopeNested: SubcomponentsScope = 'NESTED'; -const scopePage: SubcomponentsScope = 'PAGE'; -const scopeUndefined: SubcomponentsScope = undefined; - -// ─── subcomponents.exclude is optional ──────────────────────────────────────── - -const configWithExclude: Config = { - processing: { - subcomponents: { - match: ['{C} / {S}'], - exclude: ['{C} / Examples / {S}'], - }, - variantDepth: 9999, - details: 'LAYERED', - }, - format: minFormat, - include: minInclude, -}; - -const _excludeUndefined: NonNullable['exclude'] = undefined; - -// ─── include no longer has subcomponents or variantNames fields ────────────── - -// @ts-expect-error — subcomponents was removed from include -const _badInclude: Config['include']['subcomponents'] = true; - -// @ts-expect-error — variantNames was removed from include (ADR 034) -const _badVarNames: Config['include']['variantNames'] = false; - -// ─── include fields are optional ──────────────────────────────────────────── - -const emptyInclude: Config['include'] = {}; -const _emptyVariantsUndefined: Config['include']['emptyVariants'] = undefined; -const _invalidVariantsUndefined: Config['include']['invalidVariants'] = undefined; -const _invalidCombinationsUndefined: Config['include']['invalidCombinations'] = undefined; - -// ─── processing no longer has subcomponentNamePattern ───────────────────────── - -// @ts-expect-error — subcomponentNamePattern was replaced by subcomponents object -const _badProcessing: Config['processing']['subcomponentNamePattern'] = '{C} / _ / {S}'; - -// ─── All tokens enum values are valid ───────────────────────────────────────── - -const tokenConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'TOKEN' } }; -const tokenNameConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'TOKEN_NAME' } }; -const tokenFigmaExtConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'TOKEN_FIGMA_EXTENSIONS' } }; -const figmaNameConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'FIGMA_NAME' } }; -const customConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'CUSTOM' } }; -const figmaSyntaxWebConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'FIGMA_SYNTAX_WEB' } }; -const figmaSyntaxIosConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'FIGMA_SYNTAX_IOS' } }; -const figmaSyntaxAndroidConfig: Config = { ...fullConfig, format: { ...fullConfig.format, tokens: 'FIGMA_SYNTAX_ANDROID' } }; - -// @ts-expect-error — invalid tokens profile value -const _badTokens: Config['format']['tokens'] = 'FIGMA_SYNTAX_DESKTOP'; - -// ─── DEFAULT_CONFIG is a valid ResolvedConfig ──────────────────────────────── - -const defaultIsResolved: ResolvedConfig = DEFAULT_CONFIG; - -// ─── DEFAULT_CONFIG satisfies Config (ResolvedConfig extends Config) ───────── - -const defaultIsValidConfig: Config = DEFAULT_CONFIG; - -// ─── DEFAULT_CONFIG.format.tokens should be 'TOKEN' ────────────────────────── - -const defaultTokensValue: typeof DEFAULT_CONFIG.format.tokens = 'TOKEN'; - -// ─── format.figmaKeys (ADR-066) ────────────────────────────────────────────── - -// Optional on Config — absence means SENTENCE -const noFigmaKeys: Config = { processing: {}, format: {}, include: {} }; - -const sentenceKeys: Config = { processing: {}, format: { figmaKeys: 'SENTENCE' }, include: {} }; -const titleKeys: Config = { processing: {}, format: { figmaKeys: 'TITLE' }, include: {} }; - -// @ts-expect-error — figmaKeys is deliberately narrower than format.keys -const kebabFigmaKeys: Config = { processing: {}, format: { figmaKeys: 'KEBAB' }, include: {} }; - -const defaultFigmaKeysValue: typeof DEFAULT_CONFIG.format.figmaKeys = 'SENTENCE'; - -// ─── ResolvedConfig requires all defaultable fields ────────────────────────── - -const resolved: ResolvedConfig = { - processing: { slotConstraints: false, collapsePrimitiveWrapper: false, variantDepth: 9999, details: 'LAYERED', inferNumberProps: false }, - format: { output: 'JSON', keys: 'SAFE', figmaKeys: 'SENTENCE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' }, - include: { invalidVariants: false, invalidCombinations: true, emptyVariants: false, defaultSlotContent: false }, - transformers: [], -}; - -// ─── ResolvedConfig requires defaultable fields — cannot omit them ──────────── - -// processing -type _VDRequired = ResolvedConfig['processing']['variantDepth'] extends (1 | 2 | 3 | 9999) ? true : never; -const _vdRequired: _VDRequired = true; - -type _DRequired = ResolvedConfig['processing']['details'] extends ('FULL' | 'LAYERED') ? true : never; -const _dRequired: _DRequired = true; - -type _SCRequired = ResolvedConfig['processing']['slotConstraints'] extends boolean ? true : never; -const _scRequired: _SCRequired = true; - -type _CPWRequired = ResolvedConfig['processing']['collapsePrimitiveWrapper'] extends boolean ? true : never; -const _cpwRequired: _CPWRequired = true; - -type _INPRequired = ResolvedConfig['processing']['inferNumberProps'] extends boolean ? true : never; -const _inpRequired: _INPRequired = true; - -// format -type _ORequired = ResolvedConfig['format']['output'] extends ('JSON' | 'YAML') ? true : never; -const _oRequired: _ORequired = true; - -type _KRequired = ResolvedConfig['format']['keys'] extends ('SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN') ? true : never; -const _kRequired: _KRequired = true; - -type _LRequired = ResolvedConfig['format']['layout'] extends ('LAYOUT' | 'PARENT_CHILDREN' | 'BOTH') ? true : never; -const _lRequired: _LRequired = true; - -type _TRequired = ResolvedConfig['format']['tokens'] extends ('TOKEN' | 'TOKEN_NAME' | 'TOKEN_FIGMA_EXTENSIONS' | 'FIGMA_NAME' | 'CUSTOM' | 'FIGMA_SYNTAX_WEB' | 'FIGMA_SYNTAX_IOS' | 'FIGMA_SYNTAX_ANDROID') ? true : never; -const _tRequired: _TRequired = true; - -// include -type _IVRequired = ResolvedConfig['include']['invalidVariants'] extends boolean ? true : never; -const _ivRequired: _IVRequired = true; - -type _ICRequired = ResolvedConfig['include']['invalidCombinations'] extends boolean ? true : never; -const _icRequired: _ICRequired = true; - -type _EVRequired = ResolvedConfig['include']['emptyVariants'] extends boolean ? true : never; -const _evRequired: _EVRequired = true; - -// subcomponents.scope is required when subcomponents is present -type _ScopeRequired = NonNullable['scope'] extends ('NESTED' | 'PAGE') ? true : never; -const _scopeRequired: _ScopeRequired = true; - -// ─── glyphNamePattern is optional ───────────────────────────────────────────── - -const _glyphUndefined: Config['processing']['glyphNamePattern'] = undefined; - -// ─── inferNumberProps is optional ───────────────────────────────────────────── - -const _inferUndefined: Config['processing']['inferNumberProps'] = undefined; - -// ─── slotConstraints is optional ────────────────────────────────────────────── - -const _slotConstraintsUndefined: Config['processing']['slotConstraints'] = undefined; - -// ─── collapsePrimitiveWrapper is optional on Config ─────────────────────────── - -const _collapsePrimitiveWrapperUndefined: Config['processing']['collapsePrimitiveWrapper'] = undefined; - -const configWithCollapse: Config = { - processing: { collapsePrimitiveWrapper: true }, - format: {}, - include: {}, -}; - -// @ts-expect-error — collapsePrimitiveWrapper must be boolean -const _badCollapse: Config['processing']['collapsePrimitiveWrapper'] = 'yes'; - -// ─── format.color is optional on Config ───────────────────────────────────── - -const _colorUndefined: Config['format']['color'] = undefined; - -// ─── All ColorFormat enum values are valid ────────────────────────────────── - -const _cfHex: ColorFormat = 'HEX'; -const _cfHexa: ColorFormat = 'HEXA'; -const _cfRgb: ColorFormat = 'RGB'; -const _cfRgba: ColorFormat = 'RGBA'; -const _cfHsla: ColorFormat = 'HSLA'; -const _cfHsb: ColorFormat = 'HSB'; -const _cfOklch: ColorFormat = 'OKLCH'; -const _cfOklab: ColorFormat = 'OKLAB'; -const _cfObject: ColorFormat = 'OBJECT'; - -// @ts-expect-error — invalid ColorFormat value -const _cfBad: ColorFormat = 'CMYK'; - -// ─── format.color is required on ResolvedConfig ───────────────────────────── - -import type { ColorFormat as _CF } from '../types/index.js'; -type _CRequired = ResolvedConfig['format']['color'] extends _CF ? true : never; -const _cRequired: _CRequired = true; - -// ─── DEFAULT_CONFIG.format.color should be 'HEX' ─────────────────────────── - -const defaultColorValue: typeof DEFAULT_CONFIG.format.color = 'HEX'; - -// ─── ADR-050: examples config ──────────────────────────────────────────────── - -// processing.instanceExamples is optional (feature toggle; absence = no detection) -const _instanceExamplesUndefined: Config['processing']['instanceExamples'] = undefined; - -// processing.instanceExamples requires match; scope/exclude/parentNames optional -const configWithInstanceExamples: Config = { - processing: { - instanceExamples: { match: ['{C} / *'] }, - }, - format: {}, - include: {}, -}; - -// instanceExamples.scope enum is PAGE | FILE (NESTED is inapplicable) -type InstanceExamplesScope = NonNullable['scope']; -const _ieScopePage: InstanceExamplesScope = 'PAGE'; -const _ieScopeFile: InstanceExamplesScope = 'FILE'; -const _ieScopeUndefined: InstanceExamplesScope = undefined; - -// @ts-expect-error — NESTED is not a valid instanceExamples scope -const _ieScopeBad: InstanceExamplesScope = 'NESTED'; - -// instanceExamples.exclude and parentNames are optional -const _ieExcludeUndefined: NonNullable['exclude'] = undefined; -const _ieParentNamesUndefined: NonNullable['parentNames'] = undefined; -const _ieParentNames: NonNullable['parentNames'] = ['Examples', 'Demos']; - -// include flags are optional on Config -const _includeSlotContentExamplesUndefined: Config['include']['defaultSlotContent'] = undefined; - -// include flags are required on ResolvedConfig -type _SCERequired = ResolvedConfig['include']['defaultSlotContent'] extends boolean ? true : never; -const _sceRequired: _SCERequired = true; - -// instanceExamples block stays optional on ResolvedConfig, but scope is required when present -type _IEScopeRequired = NonNullable['scope'] extends ('PAGE' | 'FILE') ? true : never; -const _ieScopeRequired: _IEScopeRequired = true; -const _resolvedInstanceExamplesUndefined: ResolvedConfig['processing']['instanceExamples'] = undefined; - -// DEFAULT_CONFIG carries the new include flag, defaulting to false -const _defaultSlotContentExamples: typeof DEFAULT_CONFIG.include.defaultSlotContent = false; - -// ─── ADR-055: processing.states ────────────────────────────────────────────── - -// processing.states is optional -const _statesUndefined: Config['processing']['states'] = undefined; - -// VariantStateEntry requires only prop -const minimalStateEntry: VariantStateEntry = { - prop: 'state', -}; - -// value field is optional — the Figma enum value activating this concept -const stateEntryWithValue: VariantStateEntry = { - prop: 'state', - value: 'hover', -}; - -// contract field is optional — 'omit' or 'keep' -const stateEntryOmit: VariantStateEntry = { - prop: 'state', - value: 'pressed', - contract: 'omit', -}; -const stateEntryKeep: VariantStateEntry = { - prop: 'isDisabled', - contract: 'keep', -}; -const stateEntryNoContract: VariantStateEntry = { - prop: 'focused', -}; - -// @ts-expect-error — invalid contract value -const _badContract: VariantStateEntry = { prop: 'x', contract: 'ignore' }; - -// @ts-expect-error — prop is required -const _missingProp: VariantStateEntry = { value: 'hover' }; - -// Config.processing.states accepts a concept-keyed map -const configWithStates: Config = { - processing: { - states: { - hover: { prop: 'state', value: 'hover' }, - active: { prop: 'state', value: 'pressed' }, - disabled: { prop: 'isDisabled' }, - }, - }, - format: {}, - include: {}, -}; - -// ResolvedConfig.processing.states is optional (absence = all data-* attrs) -const _resolvedStatesUndefined: ResolvedConfig['processing']['states'] = undefined; - -// DEFAULT_CONFIG has no states (correct default — absence means all data-* attrs) -const _defaultStates: typeof DEFAULT_CONFIG.processing.states = undefined; diff --git a/packages/schema/tests/Conventions.test-d.ts b/packages/schema/tests/Conventions.test-d.ts new file mode 100644 index 00000000..add15fba --- /dev/null +++ b/packages/schema/tests/Conventions.test-d.ts @@ -0,0 +1,92 @@ +/** + * Type-level tests for Conventions and ResolvedConventions. + * These files are intentionally never executed — they are compiled with tsc + * to assert that the type shape is correct. + */ +import type { Conventions, ResolvedConventions, VariantStateEntry } from '../types/index.js'; +import { DEFAULT_CONVENTIONS } from '../types/index.js'; + +// ─── A library declaring no conventions ─────────────────────────────────────── + +const none: Conventions = { figma: {} }; + +// ─── A library declaring every convention ───────────────────────────────────── + +const full: Conventions = { + figma: { + naming: 'SENTENCE', + glyphs: { match: 'DS Icon Glyph / {i}' }, + codeOnlyProps: { match: 'Code only props' }, + subcomponents: { + scope: 'PAGE', + match: ['{C} / {S}', '{C} / _ / {S}'], + exclude: ['{C} / Examples / {S}'], + }, + instanceExamples: { + scope: 'PAGE', + match: ['{C}*'], + exclude: ['{C} / Draft'], + parentNames: ['Examples'], + }, + images: { + backgroundImage: true, + match: 'DS Image', + sourceProps: ['Image'], + }, + slotConstraints: true, + inferNumberProps: true, + states: { + hover: { prop: 'state', value: 'hover' }, + active: { prop: 'state', value: 'pressed' }, + disabled: { prop: 'disabled' }, + readonly: { prop: 'readOnly' }, + invalid: { prop: 'validation', value: 'invalid', contract: 'keep' }, + }, + }, +}; + +// ─── State entries ──────────────────────────────────────────────────────────── + +const booleanState: VariantStateEntry = { prop: 'disabled' }; +const enumState: VariantStateEntry = { prop: 'state', value: 'pressed' }; + +// @ts-expect-error — prop is required +const noProp: VariantStateEntry = { value: 'pressed' }; + +// @ts-expect-error — contract is a closed set +const badContract: VariantStateEntry = { prop: 'focused', contract: 'inherit' }; + +// ─── Resolution guarantees members inside a declared block ──────────────────── + +declare const resolved: ResolvedConventions; + +// Defaults inside a present block are guaranteed — no null check needed +const scope: 'NESTED' | 'PAGE' | undefined = resolved.figma.subcomponents?.scope; +const naming: 'NONE' | 'SENTENCE' | 'TITLE' = resolved.figma.naming; +const constraints: boolean = resolved.figma.slotConstraints; +const numbers: boolean = resolved.figma.inferNumberProps; + +// The block itself stays optional — absence means the library declares nothing +const blockMayBeAbsent: ResolvedConventions = { figma: { naming: 'NONE', slotConstraints: false, inferNumberProps: false } }; + +// @ts-expect-error — a resolved block must carry its defaulted members +const underResolved: ResolvedConventions = { figma: { naming: 'NONE' } }; + +// @ts-expect-error — match is required once a glyph convention is declared +const glyphsWithoutMatch: Conventions = { figma: { glyphs: {} } }; + +// @ts-expect-error — figma namespace is required +const noNamespace: Conventions = {}; + +// ─── Defaults ───────────────────────────────────────────────────────────────── + +// A library that declares nothing: the three defaultable members, no blocks. +const defaults: ResolvedConventions = DEFAULT_CONVENTIONS; +const defaultNaming: 'NONE' | 'SENTENCE' | 'TITLE' = DEFAULT_CONVENTIONS.figma.naming; + +// @ts-expect-error — no block is defaulted; absence means the library declares none +const defaultedBlock: object = DEFAULT_CONVENTIONS.figma.subcomponents.match; + +export { defaults, defaultNaming, defaultedBlock }; + +export { none, full, booleanState, enumState, noProp, badContract, scope, naming, constraints, numbers, blockMayBeAbsent, underResolved, glyphsWithoutMatch, noNamespace }; diff --git a/packages/schema/tests/Image.test-d.ts b/packages/schema/tests/Image.test-d.ts index 62b14856..37a183d8 100644 --- a/packages/schema/tests/Image.test-d.ts +++ b/packages/schema/tests/Image.test-d.ts @@ -1,7 +1,7 @@ /** * Type-level tests for image content (ADR-063): * ObjectFit, ImageValue, ImageData, Images, ImageProp, ImageBinding, - * Styles.backgroundImage, and Config image fields. + * Styles.backgroundImage, and the image conventions. * * These files are intentionally never executed — they are compiled with tsc * to assert that the type shape is correct. @@ -17,7 +17,7 @@ import type { Styles, Component, PropConfigurationValue, - Config, + Conventions, } from '../types/index.js'; // ─── ObjectFit ──────────────────────────────────────────────────────────────── @@ -126,31 +126,20 @@ const componentWithImages: Component = { images: { hero: { src: 'data:image/png;base64,AAAA' } }, }; -// ─── Config image fields ────────────────────────────────────────────────────── +// ─── Image conventions ──────────────────────────────────────────────────────── -// processing.images: presence-switched block; every member optional on Config -const configImagesAbsent: Config = { processing: {}, format: {}, include: {} }; -const configImagesFillsOnly: Config = { - processing: { images: { backgroundImage: true } }, - format: {}, - include: {}, -}; -const configImagesComponent: Config = { - processing: { images: { imageComponent: 'dsImage', sourceProps: ['source'] } }, - format: {}, - include: {}, -}; -const configImagesAllTriggers: Config = { - processing: { images: { backgroundImage: true, imageComponent: 'dsImage', sourceProps: ['source', 'image'] } }, - format: {}, - include: {}, +// figma.images: presence declares the convention; every member optional +const imagesAbsent: Conventions = { figma: {} }; +const imagesFillsOnly: Conventions = { figma: { images: { backgroundImage: true } } }; +const imagesAllTriggers: Conventions = { + figma: { images: { backgroundImage: true, match: 'dsImage', sourceProps: ['source', 'image'] } }, }; -// @ts-expect-error: imageComponent is a plain name string, not the retired { name, sourceProperty } object -const _configImagesV1Shape: Config = { processing: { images: { imageComponent: { name: 'dsImage' } } }, format: {}, include: {} }; +// @ts-expect-error: match is a plain name string, not the retired { name, sourceProperty } object +const _imagesV1Shape: Conventions = { figma: { images: { match: { name: 'dsImage' } } } }; // @ts-expect-error: sourceProps must be a string array -const _configBadSourceProps: Config = { processing: { images: { sourceProps: 'source' } }, format: {}, include: {} }; +const _imagesBadSourceProps: Conventions = { figma: { images: { sourceProps: 'source' } } }; -// @ts-expect-error: include.imageData was retired — the processing.images block presence is the on-switch -const _configRetiredImageData: Config = { processing: {}, format: {}, include: { imageData: true } }; +// @ts-expect-error: image conventions do not live in settings +const _imagesInSettings: Conventions = { figma: {}, spec: { images: {} } }; diff --git a/packages/schema/tests/Metadata.test-d.ts b/packages/schema/tests/Metadata.test-d.ts index 37260688..b903352a 100644 --- a/packages/schema/tests/Metadata.test-d.ts +++ b/packages/schema/tests/Metadata.test-d.ts @@ -5,10 +5,33 @@ */ import type { Metadata } from '../types/index.js'; -const baseConfig: Metadata['config'] = { - processing: { subcomponents: { match: ['{C} / _ / {S}'] }, variantDepth: 9999, details: 'LAYERED' }, - format: { output: 'JSON', keys: 'SAFE', layout: 'LAYOUT', tokens: 'TOKEN' }, - include: { invalidVariants: false, invalidCombinations: true }, +const baseConventions: Metadata['conventions'] = { + figma: { + naming: 'SENTENCE', + subcomponents: { scope: 'NESTED', match: ['{C} / _ / {S}'] }, + slotConstraints: false, + inferNumberProps: false, + }, +}; + +const baseSettings: Metadata['settings'] = { + spec: { + format: 'JSON', + keys: 'SAFE', + layout: 'LAYOUT', + tokens: 'TOKEN', + color: 'HEX', + variantDepth: 9999, + details: 'LAYERED', + collapsePrimitiveWrapper: false, + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: false, + splitComponents: true, + splitConcerns: true, + useSubfolders: true, + }, }; // Minimal valid Metadata — license absent (optional field) @@ -18,7 +41,8 @@ const withoutLicense: Metadata = { generator: { url: 'https://example.com', version: '1.10.0', name: 'test' }, schema: { url: 'https://example.com/schema', version: '1.0.0' }, source: { pageId: 'p1', nodeId: 'n1', nodeType: 'COMPONENT' }, - config: baseConfig, + conventions: baseConventions, + settings: baseSettings, }; // With generator.license present — both subfields required diff --git a/packages/schema/tests/PropConfigurations.test-d.ts b/packages/schema/tests/PropConfigurations.test-d.ts new file mode 100644 index 00000000..b2551fe5 --- /dev/null +++ b/packages/schema/tests/PropConfigurations.test-d.ts @@ -0,0 +1,81 @@ +/** + * Type-level tests for `PropConfigurationValue`'s `null` arm (ADR-080). + * + * `null` means the prop is unset in this configuration — a value, not an + * absence. These files are intentionally never executed; they are compiled + * with tsc to assert the type shape is correct. + */ +import type { + PropConfigurationValue, + PropConfigurations, + NestedPropConfiguration, + Element, + InstanceExample, + Variant, +} from '../types/index.js'; + +// ─── null is a PropConfigurationValue ─────────────────────────────────────── + +const unset: PropConfigurationValue = null; +const scalar: PropConfigurationValue = 'Surface'; +const flag: PropConfigurationValue = false; +const count: PropConfigurationValue = 3; +const bound: PropConfigurationValue = { $binding: '#/props/label' }; +const filled: PropConfigurationValue = { + $slotContent: '#/components/card/slotContentExamples/cardHeader', +}; + +// A value union member that was never valid stays invalid. +// @ts-expect-error: undefined is not a PropConfigurationValue — an absent key is +// how a configuration says nothing, and it is not the same as an explicit null. +const absent: PropConfigurationValue = undefined; + +// ─── null under a prop key, in every position PropConfigurations occupies ─── + +// A configuration that leaves a nullable slot unset. +const unsetSlot: PropConfigurations = { header: null }; + +// The same prop, set. +const setSlot: PropConfigurations = { + header: { $slotContent: '#/components/card/slotContentExamples/cardHeader' }, +}; + +// Element — a nested instance. +const element: Element = { + instanceOf: 'card', + propConfigurations: { header: null, bordered: 'Solid' }, +}; + +// InstanceExample — a whole-component example. +const example: InstanceExample = { + title: 'Card without a header', + propConfigurations: { header: null }, +}; + +// Variant.configuration. +const variant: Variant = { + configuration: { header: null }, +}; + +// NestedPropConfiguration — a path-addressed descendant ($nested). +const nested: NestedPropConfiguration = { + path: ['contentContainer', 'badge'], + children: null, +}; + +const withNested: PropConfigurations = { $nested: [nested] }; + +// ─── Layering: absent and null are distinct ───────────────────────────────── + +// A base that binds the slot. +const base: PropConfigurations = { + header: { $slotContent: '#/components/card/slotContentExamples/cardHeader' }, +}; + +// A layer that unsets it — an explicit value, which overrides. +const overridesWithUnset: PropConfigurations = { header: null }; + +// A layer that omits it — inherits whatever the base carried. +const inherits: PropConfigurations = { bordered: 'Solid' }; + +export type {}; diff --git a/packages/schema/tests/Props.test-d.ts b/packages/schema/tests/Props.test-d.ts index d69c47a6..0cba0939 100644 --- a/packages/schema/tests/Props.test-d.ts +++ b/packages/schema/tests/Props.test-d.ts @@ -205,6 +205,25 @@ const _numberStringDefault: NumberProp = { type: 'number', default: '24' }; // @ts-expect-error: examples must be number[], not string[] const _numberStringExamples: NumberProp = { type: 'number', examples: ['1', '2'] }; +// ─── NumberProp.enum (ADR 072) ──────────────────────────────────────────────── + +// enum is optional — its absence means the prop accepts any number +const numberNoEnum: NumberProp = { type: 'number', default: 1 }; + +// a numeric VARIANT: numeric type, closed option set, absence excluded +const numberWithEnum: NumberProp = { + type: 'number', + default: 1, + enum: [1, 2, 3, 4, 5, 6, 7, 8], + nullable: false, +}; + +// @ts-expect-error: enum must be number[], not string[] +const _numberStringEnum: NumberProp = { type: 'number', enum: ['1', '2'] }; + +// @ts-expect-error: EnumProp still carries string values only +const _enumNumericValues: EnumProp = { type: 'string', default: '1', enum: [1, 2] }; + // ─── nullable (ADR 065) ─────────────────────────────────────────────────────── // nullable is optional on NumberProp — absent means true diff --git a/packages/schema/tests/Settings.test-d.ts b/packages/schema/tests/Settings.test-d.ts new file mode 100644 index 00000000..28fbbe00 --- /dev/null +++ b/packages/schema/tests/Settings.test-d.ts @@ -0,0 +1,102 @@ +/** + * Type-level tests for Settings, ResolvedSettings, and Pipeline. + * These files are intentionally never executed — they are compiled with tsc + * to assert that the type shape is correct. + */ +import type { + Settings, + ResolvedSettings, + ColorFormat, + SourceEntry, + Pipeline, + ResolvedPipeline, + TransformEntry, + AnalysisEntry, +} from '../types/index.js'; +import { DEFAULT_SETTINGS, DEFAULT_PIPELINE } from '../types/index.js'; + +// ─── Empty settings are valid — every member is defaulted or consumer-supplied ─ + +const empty: Settings = {}; + +// ─── A workspace declaring everything ───────────────────────────────────────── + +const full: Settings = { + author: 'Nathan Curtis', + data: { + directory: './data', + sources: { + library: { key: 'FILE_KEY', fetch: ['file', 'variables', 'styles', 'icons'] }, + }, + }, + spec: { + directory: './specs', + format: 'YAML', + keys: 'CAMEL', + layout: 'LAYOUT', + tokens: 'TOKEN', + color: 'HEXA', + variantDepth: 9999, + details: 'LAYERED', + collapsePrimitiveWrapper: true, + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: true, + splitComponents: true, + splitConcerns: true, + useSubfolders: true, + }, + assets: { directory: './assets' }, +}; + +// ─── Sources ────────────────────────────────────────────────────────────────── + +const source: SourceEntry = { key: 'abc123' }; + +// @ts-expect-error — key is required +const keylessSource: SourceEntry = { fetch: ['file'] }; + +// ─── Closed value sets ──────────────────────────────────────────────────────── + +const color: ColorFormat = 'OKLCH'; + +// @ts-expect-error — not a supported color format +const badColor: ColorFormat = 'CMYK'; + +// @ts-expect-error — variantDepth is a closed set +const badDepth: Settings = { spec: { variantDepth: 4 } }; + +// @ts-expect-error — conventions do not live in settings +const conventionInSettings: Settings = { spec: { glyphNamePattern: 'DS Icon Glyph / {i}' } }; + +// ─── Defaults are fully resolved on the spec concern ────────────────────────── + +const defaults: ResolvedSettings = DEFAULT_SETTINGS; +const format: 'JSON' | 'YAML' = DEFAULT_SETTINGS.spec.format; +const depth: 1 | 2 | 3 | 9999 = DEFAULT_SETTINGS.spec.variantDepth; + +// Consumer-supplied members stay optional after resolution +const dir: string | undefined = DEFAULT_SETTINGS.spec.directory; + +// ─── Pipeline ───────────────────────────────────────────────────────────────── + +const pipeline: Pipeline = { + transformers: [{ name: 'react' }, { name: 'css' }, { name: 'contract' }], + analyses: [{ name: 'dependencies' }], +}; + +const emptyPipeline: Pipeline = {}; +const resolvedPipeline: ResolvedPipeline = DEFAULT_PIPELINE; + +const transformer: TransformEntry = { name: 'react', someOption: true }; +const analysis: AnalysisEntry = { name: 'dependencies' }; + +// @ts-expect-error — name is required +const namelessTransformer: TransformEntry = { someOption: true }; + +export { + empty, full, source, keylessSource, color, badColor, badDepth, conventionInSettings, + defaults, format, depth, dir, pipeline, emptyPipeline, resolvedPipeline, + transformer, analysis, namelessTransformer, +}; diff --git a/packages/schema/tests/SlotContentExamples.test.ts b/packages/schema/tests/SlotContentExamples.test.ts index d7e31a21..ff21699e 100644 --- a/packages/schema/tests/SlotContentExamples.test.ts +++ b/packages/schema/tests/SlotContentExamples.test.ts @@ -166,6 +166,18 @@ describe('PropConfigurations.$nested structural conformance (ADR-052)', () => { ); }); + it('PropConfigurationValue admits null — the prop is unset in this configuration (ADR-080)', () => { + const kinds = (schema.definitions.PropConfigurationValue.oneOf as any[]).map((s) => s.type ?? s.$ref); + expect(kinds).toContain('null'); + }); + + it('InstanceExample.propConfigurations admits null, and still refuses PropBinding (ADR-080)', () => { + const value = schema.definitions.InstanceExample.properties.propConfigurations.additionalProperties; + const kinds = (value.oneOf as any[]).map((s) => s.type ?? s.$ref); + expect(kinds).toContain('null'); + expect(kinds).not.toContain('#/definitions/PropBinding'); + }); + it('NestedPropConfiguration requires path (array of strings, minItems 1)', () => { expect(nestedDef.required).toContain('path'); expect(nestedDef.properties.path.type).toBe('array'); diff --git a/packages/schema/types/Config.ts b/packages/schema/types/Config.ts deleted file mode 100644 index 7ac55173..00000000 --- a/packages/schema/types/Config.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Color value output format. - * - * Controls how `ColorObject` objects are serialized in the spec output. - * `HEX` (default) emits a 6-digit hex string. `OBJECT` emits the full - * `ColorObject` object. All other values emit a formatted color string - * in the named notation. - * - * Tier 1 — Figma UI formats: `HEX`, `HEXA`, `RGB`, `RGBA`, `HSLA`, `HSB` - * Tier 2 — Modern CSS (Level 4): `OKLCH`, `OKLAB` - * Tier 3 — Structured object: `OBJECT` - * - * @since 0.20.0 - */ -export type ColorFormat = 'HEX' | 'HEXA' | 'RGB' | 'RGBA' | 'HSLA' | 'HSB' | 'OKLCH' | 'OKLAB' | 'OBJECT'; - -/** - * Classifies a Figma variant prop as a semantic state concept for deterministic - * use by transformers and plugin output. - * - * The map key is the concept name (e.g. `hover`, `disabled`, `focus-within`). - * `prop` names the Figma variant prop; `value` is the enum value that activates - * the concept (defaults to `"true"` for boolean props). `contract` overrides the - * concept's canonical browser-driven / consumer-controlled default — rarely needed. - * - * @since 0.24.0 - */ -export interface VariantStateEntry { - /** Figma variant prop name (e.g. `state`, `isDisabled`, `focused`). */ - prop: string; - /** - * Figma variant value that activates this concept (e.g. `"hover"`, `"pressed"`). - * Omit for boolean props — defaults to `"true"`. - */ - value?: string; - /** - * Contract generation behavior override. - * - `'omit'` — browser-driven state; exclude this prop from generated Props interfaces. - * - `'keep'` — consumer-controlled state; retain this prop in generated Props interfaces. - * When absent, the concept's canonical default applies (omit for pseudo-class concepts, - * keep for ARIA-attribute concepts). - */ - contract?: 'omit' | 'keep'; -} - -/** - * A single transformer to run via `specs transform`, identified by name. - * Transformer-specific options sit inline alongside `name`. - * - * @since 0.24.0 - */ -export interface TransformEntry { - /** Transformer name (e.g. `contract`, `css`, `tokens`). */ - name: string; - [option: string]: unknown; -} - -/** - * Model configuration used to generate the component spec. - * Full structure matches the transformer's configuration options. - * - * @property processing - Processing options for component transformation. - * @property format - Output format and key naming conventions. - * @property include - Feature flags for what to include in output. - * @property transformers - Transformer selection and options for `specs transform`. - */ -export interface Config { - processing: { - /** Subcomponent discovery settings: scope, match patterns, and exclusion patterns. Optional; absence means no subcomponent detection. @since 0.15.0 */ - subcomponents?: { - /** Where to search for subcomponents. NESTED = anatomy only (default); PAGE = also search the Figma page. */ - scope?: 'NESTED' | 'PAGE'; - /** Template patterns defining which assets are subcomponents. Uses {C} (component name) and {S} (subcomponent name) placeholders. */ - match: string[]; - /** Template patterns defining which matched assets to exclude. Same {C}/{S} syntax as match. */ - exclude?: string[]; - }; - /** Naming pattern used to detect glyph content assets (e.g. "DS Icon Glyph /"). Optional; absence means no glyph detection. */ - glyphNamePattern?: string; - /** Naming pattern used to detect the code-only props container layer (e.g. "Code only props"). Optional; absence means no code-only prop extraction. */ - codeOnlyPropsPattern?: string; - /** Whether to consolidate slot constraints (anyOf, minChildren, maxChildren) from code-only props into the slot property. Optional; defaults to false. @since 0.14.0 */ - slotConstraints?: boolean; - /** - * When true, a component whose root is a plain container wrapping a single `text` or - * `glyph` element (no meaningful container styles, no slot bindings) is collapsed: - * the wrapper is stripped and the leaf becomes the spec root. All-or-nothing across - * variants — if any variant fails eligibility, no collapse occurs. Defaults to false. - * @since 0.26.0 - */ - collapsePrimitiveWrapper?: boolean; - /** Depth of variant expansion: 1-3 or 9999 for unlimited. Optional; defaults to 9999. */ - variantDepth?: 1 | 2 | 3 | 9999; - /** Level of detail in output. Optional; defaults to LAYERED. */ - details?: 'FULL' | 'LAYERED'; - /** When true, TEXT code-only props whose default and all examples parse as valid numbers (no leading zeros) are emitted as NumberProp instead of StringProp */ - inferNumberProps?: boolean; - /** Instance example detection settings (ADR-050): named frames demonstrating pre-configured whole-component usage. Optional; absence means no instance example detection. @since 0.21.0 */ - instanceExamples?: { - /** Search boundary. PAGE = current Figma page only (default); FILE = all pages in the file. */ - scope?: 'PAGE' | 'FILE'; - /** Optional name patterns narrowing which instance frames qualify. Uses {C} (component name) placeholder. Absence = every in-scope instance of the component qualifies (subject to exclude/parentNames). */ - match?: string[]; - /** Name patterns for frames to exclude. Same {C} syntax as match. */ - exclude?: string[]; - /** Immediate-parent frame or section names a candidate must be contained within. Absence = no parent-name filtering. */ - parentNames?: string[]; - }; - /** Concept-keyed map classifying Figma variant props as semantic states. Key = concept name (e.g. `hover`, `disabled`). Optional; absence means all variant props emit as data-* attribute selectors and all props are retained in contracts. @since 0.24.0 */ - states?: Record; - /** Image processing (ADR-063). Presence of this block is the on-switch (like `subcomponents`); each member is an independent representation trigger. Absence means images are not processed at all. @since 0.28.0 */ - images?: { - /** Detect image fills on container elements and emit them as `Styles.backgroundImage`. When paired with `imageComponent`, this is the fallback for fills outside the designated component. Optional; defaults to false. */ - backgroundImage?: boolean; - /** Designated image component name (e.g. `dsImage`). Instances of it are the image primitive; their image routes through the source prop (`sourceProps[0]`) via propConfigurations. Requires a non-empty `sourceProps`. */ - imageComponent?: string; - /** Code-only prop names (raw Figma names, like subcomponent/glyph patterns) that re-type from StringProp to ImageProp on any component. The FIRST entry is the designated image component's own source prop — the forwarding target. Optional; absence means no props re-type. */ - sourceProps?: string[]; - }; - }; - format: { - /** Output format. Optional; defaults to JSON. */ - output?: 'JSON' | 'YAML'; - /** - * Key naming convention applied to anatomy keys, prop keys, and every reference - * to them. Every value other than SAFE is a lossy projection of the Figma name; - * names outside the safe key grammar are preserved in `$extensions['com.figma'].name` - * (ADR-066). Optional; defaults to SAFE. - */ - keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; - /** - * Naming convention the Figma file uses for layer names and component property - * names. The reversal target for `keys` — a renderer reconstructs a Figma name by - * re-formatting the spec key into this convention. - * - * NONE declares no convention: the safe key grammar is not evaluated, no - * `$extensions['com.figma'].name` is emitted for format divergence, and reversal - * is undefined. Declaring SENTENCE or TITLE opts the catalog into all three, at - * the cost of an extension on every name outside the grammar. - * - * Optional; defaults to NONE. - * @since 0.30.0 - */ - figmaKeys?: 'NONE' | 'SENTENCE' | 'TITLE'; - /** Layout representation format. Optional; defaults to LAYOUT. */ - layout?: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; - /** - * Token reference serialization profile. Optional; defaults to TOKEN. - * `FIGMA_SYNTAX_WEB`, `FIGMA_SYNTAX_IOS`, and `FIGMA_SYNTAX_ANDROID` emit the - * token's Figma `codeSyntax` for that platform, falling back to the `TOKEN` - * profile's output when no code syntax is defined for the platform. @since 0.21.0 - */ - tokens?: 'TOKEN' | 'TOKEN_NAME' | 'TOKEN_FIGMA_EXTENSIONS' | 'FIGMA_NAME' | 'CUSTOM' | 'FIGMA_SYNTAX_WEB' | 'FIGMA_SYNTAX_IOS' | 'FIGMA_SYNTAX_ANDROID'; - /** Color value output format. Optional; defaults to HEX. @since 0.20.0 */ - color?: ColorFormat; - }; - include: { - /** Include invalid variants. Optional; defaults to false. */ - invalidVariants?: boolean; - /** Include invalid combinations. Optional; defaults to true. */ - invalidCombinations?: boolean; - /** Include layered variants that contain no elements. When false (default), exclude empty variants from output. When true, include all variants regardless of element presence. Optional; defaults to false. @since 1.0.0 */ - emptyVariants?: boolean; - /** Include slot content examples in output (ADR-050). Optional; defaults to false. @since 0.21.0 */ - defaultSlotContent?: boolean; - }; - /** Transformers to run via `specs transform`, each with optional inline options. Optional; absence means CLI defaults apply. @since 0.24.0 */ - transformers?: TransformEntry[]; -} - -/** - * Fully-resolved model configuration with all defaultable properties guaranteed present. - * Produced by merging a partial `Config` with `DEFAULT_CONFIG`. - * - * Rule: every property with a default in `DEFAULT_CONFIG` is required here. - * Only true feature toggles (where absence = feature disabled) remain optional: - * `subcomponents` (the block), `instanceExamples` (the block), `glyphNamePattern`, - * `codeOnlyPropsPattern`. - * - * @since 0.17.0 - */ -export interface ResolvedConfig { - processing: { - /** Subcomponent discovery settings. Optional; absence means no subcomponent detection. */ - subcomponents?: { - /** Where to search for subcomponents. NESTED = anatomy only; PAGE = also search the Figma page. */ - scope: 'NESTED' | 'PAGE'; - /** Template patterns defining which assets are subcomponents. Uses {C} (component name) and {S} (subcomponent name) placeholders. */ - match: string[]; - /** Template patterns defining which matched assets to exclude. Same {C}/{S} syntax as match. */ - exclude?: string[]; - }; - /** Naming pattern used to detect glyph content assets. Optional; absence means no glyph detection. */ - glyphNamePattern?: string; - /** Naming pattern used to detect the code-only props container layer. Optional; absence means no code-only prop extraction. */ - codeOnlyPropsPattern?: string; - /** Whether to consolidate slot constraints from code-only props into the slot property. */ - slotConstraints: boolean; - /** When true, plain container wrappers around a single text/glyph child are stripped and the leaf is promoted to spec root. */ - collapsePrimitiveWrapper: boolean; - /** Depth of variant expansion: 1-3 or 9999 for unlimited. */ - variantDepth: 1 | 2 | 3 | 9999; - /** Level of detail in output. */ - details: 'FULL' | 'LAYERED'; - /** When true, TEXT code-only props whose default and all examples parse as valid numbers are emitted as NumberProp instead of StringProp. */ - inferNumberProps: boolean; - /** Instance example detection settings (ADR-050). Optional; absence means no instance example detection. When present, `scope` is required (defaults to PAGE). */ - instanceExamples?: { - scope: 'PAGE' | 'FILE'; - match?: string[]; - exclude?: string[]; - parentNames?: string[]; - }; - /** Concept-keyed map classifying Figma variant props as semantic states. Key = concept name (e.g. `hover`, `disabled`). Optional; absence means all variant props emit as data-* attribute selectors and all props are retained in contracts. @since 0.24.0 */ - states?: Record; - /** Image processing (ADR-063). Presence is the on-switch; absence means images are not processed. When present, `backgroundImage` and `sourceProps` are required-with-defaults (false, []). `imageComponent` requires a non-empty `sourceProps`; `sourceProps[0]` is its source prop. */ - images?: { - /** Detect image fills → `Styles.backgroundImage` (the fallback when `imageComponent` is set). */ - backgroundImage: boolean; - /** Designated image component name. Absence = no component routing. */ - imageComponent?: string; - /** Code-only prop names (raw Figma names) that re-type to ImageProp; `sourceProps[0]` is the forwarding target on designated instances. */ - sourceProps: string[]; - }; - }; - format: { - /** Output format. */ - output: 'JSON' | 'YAML'; - /** Key naming convention. */ - keys: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; - /** Naming convention the Figma file uses — the reversal target for `keys`. NONE declares none. @since 0.30.0 */ - figmaKeys: 'NONE' | 'SENTENCE' | 'TITLE'; - /** Layout representation format. */ - layout: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; - /** Token reference serialization profile. */ - tokens: 'TOKEN' | 'TOKEN_NAME' | 'TOKEN_FIGMA_EXTENSIONS' | 'FIGMA_NAME' | 'CUSTOM' | 'FIGMA_SYNTAX_WEB' | 'FIGMA_SYNTAX_IOS' | 'FIGMA_SYNTAX_ANDROID'; - /** Color value output format. */ - color: ColorFormat; - }; - include: { - /** Include invalid variants. */ - invalidVariants: boolean; - /** Include invalid combinations. */ - invalidCombinations: boolean; - /** Include layered variants that contain no elements. */ - emptyVariants: boolean; - /** Include slot content examples in output. */ - defaultSlotContent: boolean; - }; - /** Transformers to run via `specs transform`. @since 0.24.0 */ - transformers: TransformEntry[]; -} - -/** - * Default Model Configuration - * - * Used by both CLI and Plugin to ensure identical behavior with same settings. - * - * Rationale for defaults: - * - processing.slotConstraints: false — opt-in feature, off by default - * - processing.variantDepth: 9999 (no limit) allows full variant combination exploration - * - processing.details: LAYERED reduces output size by only showing differences from default - * - processing.inferNumberProps: false — opt-in feature, off by default - * - format.keys: SAFE prevents corruption of special characters while maintaining readability - * - format.figmaKeys: NONE declares no source convention — the safe key grammar, the name extension, and reversal are opt-in (ADR-066) - * - format.layout: LAYOUT provides tree structure with layout properties - * - format.tokens: TOKEN provides platform-neutral token references with $token path and $type - * - format.color: HEX matches historical v1 behaviour and maximises human readability - * - include.invalidVariants: false excludes variants that can't be instantiated - * - include.invalidCombinations: true helps designers identify property conflicts - * - include.emptyVariants: false reduces output size by excluding semantically empty layered variants - * - include.defaultSlotContent: false — opt-in (ADR-050); off by default so unannotated components are unchanged - * (instanceExamples have no include flag — presence of processing.instanceExamples is the on-switch, like subcomponents) - * - processing.images: absent — image processing is presence-switched (ADR-063), like subcomponents - */ -export const DEFAULT_CONFIG: ResolvedConfig = { - processing: { - slotConstraints: false, - collapsePrimitiveWrapper: false, - variantDepth: 9999, - details: 'LAYERED', - inferNumberProps: false, - }, - format: { - output: 'JSON', - keys: 'SAFE', - figmaKeys: 'NONE', - layout: 'LAYOUT', - tokens: 'TOKEN', - color: 'HEX', - }, - include: { - invalidVariants: false, - invalidCombinations: true, - emptyVariants: false, - defaultSlotContent: false, - }, - transformers: [], -}; \ No newline at end of file diff --git a/packages/schema/types/Conventions.ts b/packages/schema/types/Conventions.ts new file mode 100644 index 00000000..2411e72b --- /dev/null +++ b/packages/schema/types/Conventions.ts @@ -0,0 +1,172 @@ +/** + * Classifies a Figma variant prop as a semantic state concept for deterministic + * use by transformers and plugin output. + * + * The map key is the concept name (e.g. `hover`, `disabled`, `focus-within`). + * `prop` names the Figma variant prop; `value` is the enum value that activates + * the concept (defaults to `"true"` for boolean props). `contract` overrides the + * concept's canonical browser-driven / consumer-controlled default — rarely needed. + * + * @since 0.24.0 + */ +export interface VariantStateEntry { + /** Figma variant prop name (e.g. `state`, `isDisabled`, `focused`). */ + prop: string; + /** + * Figma variant value that activates this concept (e.g. `"hover"`, `"pressed"`). + * Omit for boolean props — defaults to `"true"`. + */ + value?: string; + /** + * Contract generation behavior override. + * - `'omit'` — browser-driven state; exclude this prop from generated Props interfaces. + * - `'keep'` — consumer-controlled state; retain this prop in generated Props interfaces. + * When absent, the concept's canonical default applies (omit for pseudo-class concepts, + * keep for ARIA-attribute concepts). + */ + contract?: 'omit' | 'keep'; +} + +/** + * Facts about the Figma library a spec was generated from. + * + * Every consumer reading the same library declares the same values. Differing + * values produce **incorrect** output rather than merely different output: a + * mismatched pattern leaves a whole class of assets undetected, and a mismatched + * state entry lands a concept on the wrong prop. + * + * Absence of a member means the library declares no such convention, and the + * capability it enables does not apply. There is no separate on-switch. + * + * @since 0.31.0 + */ +export interface Conventions { + figma: { + /** + * Naming convention the Figma file uses for layer names and component property + * names — the reversal target for `Settings.spec.keys`. A renderer reconstructs + * a Figma name by re-formatting the spec key into this convention. + * + * NONE declares no convention: the safe key grammar is not evaluated, no + * `$extensions['com.figma'].name` is emitted for format divergence, and reversal + * is undefined. Optional; defaults to NONE. + */ + naming?: 'NONE' | 'SENTENCE' | 'TITLE'; + /** Glyph content assets. Optional; absence means the library has no glyph naming convention and glyphs are not detected. */ + glyphs?: { + /** Naming pattern identifying glyph assets, using the `{i}` icon-name placeholder (e.g. `"DS Icon Glyph / {i}"`). */ + match: string; + }; + /** The code-only props container layer. Optional; absence means the library has no such convention and code-only props are not extracted. */ + codeOnlyProps?: { + /** Literal layer name identifying the container (e.g. `"Code only props"`). */ + match: string; + }; + /** Subcomponent organization and naming. Optional; absence means the library has no subcomponent convention. */ + subcomponents?: { + /** Where the library keeps subcomponents. NESTED = anatomy only (default); PAGE = also the Figma page. */ + scope?: 'NESTED' | 'PAGE'; + /** Naming patterns identifying subcomponents, using `{C}` (component name) and `{S}` (subcomponent name) placeholders. */ + match: string[]; + /** Naming patterns for matched assets the library excludes. Same `{C}`/`{S}` syntax as `match`. */ + exclude?: string[]; + }; + /** Instance example organization and naming (ADR-050). Optional; absence means the library has no such convention. */ + instanceExamples?: { + /** Where the library keeps instance examples. PAGE = current Figma page (default); FILE = all pages. */ + scope?: 'PAGE' | 'FILE'; + /** Naming patterns narrowing which instance frames qualify, using the `{C}` placeholder. Absence = every in-scope instance qualifies. */ + match?: string[]; + /** Naming patterns for frames the library excludes. Same `{C}` syntax as `match`. */ + exclude?: string[]; + /** Immediate-parent frame or section names a candidate must sit within. Absence = no parent-name filtering. */ + parentNames?: string[]; + }; + /** How the library expresses images (ADR-063). Optional; absence means the library has no image convention. */ + images?: { + /** The library expresses images as container fills, emitted as `Styles.backgroundImage`. Optional; defaults to false. */ + backgroundImage?: boolean; + /** Name of the library's designated image component (e.g. `"DS Image"`). Requires a non-empty `sourceProps`. */ + match?: string; + /** Code-only prop names (raw Figma names) carrying image sources. The FIRST entry is the designated component's own source prop — the forwarding target. */ + sourceProps?: string[]; + }; + /** The library authors slot constraints (anyOf, minChildren, maxChildren) as code-only props, to be consolidated into the slot property. Optional; defaults to false. @since 0.14.0 */ + slotConstraints?: boolean; + /** The library authors numeric props as Figma `TEXT` props whose default and examples parse as valid numbers, to be emitted as NumberProp rather than StringProp. Optional; defaults to false. */ + inferNumberProps?: boolean; + /** Concept-keyed map classifying Figma variant props as semantic states. Key = concept name (e.g. `hover`, `disabled`). Optional; absence means all variant props emit as data-* attribute selectors. @since 0.24.0 */ + states?: Record; + }; +} + +/** + * Fully-resolved conventions, with defaults applied **inside** any declared block. + * + * The blocks themselves stay optional: absence means the library declares no such + * convention, and nothing can supply that. What resolution guarantees is that a + * block, once present, has every defaultable member — `scope`, `backgroundImage`, + * `sourceProps` — so consumers need no null checks within it. + * + * @since 0.31.0 + */ +export interface ResolvedConventions { + figma: { + /** Naming convention the Figma file uses. */ + naming: 'NONE' | 'SENTENCE' | 'TITLE'; + /** Glyph content assets. Optional; absence means no glyph convention. */ + glyphs?: { + match: string; + }; + /** The code-only props container layer. Optional; absence means no such convention. */ + codeOnlyProps?: { + match: string; + }; + /** Subcomponent organization and naming. Optional; absence means no subcomponent convention. */ + subcomponents?: { + scope: 'NESTED' | 'PAGE'; + match: string[]; + exclude?: string[]; + }; + /** Instance example organization and naming. Optional; absence means no such convention. */ + instanceExamples?: { + scope: 'PAGE' | 'FILE'; + match?: string[]; + exclude?: string[]; + parentNames?: string[]; + }; + /** How the library expresses images. Optional; absence means no image convention. */ + images?: { + backgroundImage: boolean; + match?: string; + sourceProps: string[]; + }; + /** Slot constraints are authored as code-only props. */ + slotConstraints: boolean; + /** Numeric props are authored as Figma `TEXT` props. */ + inferNumberProps: boolean; + /** Concept-keyed map classifying Figma variant props as semantic states. Optional; absence means no state convention. */ + states?: Record; + }; +} + +/** + * Default Conventions + * + * A library that declares nothing. Only the three members that *have* a + * meaningful default appear: the naming convention the file follows, and the + * two authoring facts about how props are written. + * + * The blocks — `glyphs`, `codeOnlyProps`, `subcomponents`, `instanceExamples`, + * `images`, `states` — are deliberately absent and have no defaults. Their + * absence means the library declares no such convention, which is a statement + * nothing else can supply. Defaults *inside* a declared block are applied by + * whoever resolves it, per the member documentation above. + */ +export const DEFAULT_CONVENTIONS: ResolvedConventions = { + figma: { + naming: 'NONE', + slotConstraints: false, + inferNumberProps: false, + }, +}; diff --git a/packages/schema/types/Effects.ts b/packages/schema/types/Effects.ts index cd18286b..4deaf0d4 100644 --- a/packages/schema/types/Effects.ts +++ b/packages/schema/types/Effects.ts @@ -19,7 +19,7 @@ export interface Shadow { offsetY: number | TokenReference; blur: number | TokenReference; spread: number | TokenReference; - /** Shadow color — DTCG Color object, token reference, or formatted string when `Config.format.color` is non-`OBJECT`. */ + /** Shadow color — DTCG Color object, token reference, or formatted string when `Settings.spec.color` is non-`OBJECT`. */ color: string | ColorObject | TokenReference; } diff --git a/packages/schema/types/Gradient.ts b/packages/schema/types/Gradient.ts index 3e3a2158..aea9a127 100644 --- a/packages/schema/types/Gradient.ts +++ b/packages/schema/types/Gradient.ts @@ -6,7 +6,7 @@ import { TokenReference, ColorObject } from "./Styles.js"; export interface GradientStop { /** Position along the gradient vector, normalised 0–1. */ position: number; - /** Stop color — DTCG Color object, token reference, or formatted string when `Config.format.color` is non-`OBJECT`. */ + /** Stop color — DTCG Color object, token reference, or formatted string when `Settings.spec.color` is non-`OBJECT`. */ color: string | ColorObject | TokenReference; } diff --git a/packages/schema/types/InstanceExample.ts b/packages/schema/types/InstanceExample.ts index 550497a4..935aa9f5 100644 --- a/packages/schema/types/InstanceExample.ts +++ b/packages/schema/types/InstanceExample.ts @@ -17,10 +17,11 @@ export type InstanceExample = { /** * Prop values for this example. Scalar types for scalar props; - * `SlotContentRef` (into `Component.slotContentExamples`) for slot props. + * `SlotContentRef` (into `Component.slotContentExamples`) for slot props; + * `null` when the prop is unset in this example (ADR-080). * `PropBinding` is not permitted here. */ - propConfigurations?: Record; + propConfigurations?: Record; }; /** diff --git a/packages/schema/types/Metadata.ts b/packages/schema/types/Metadata.ts index f5cf7708..774948ee 100644 --- a/packages/schema/types/Metadata.ts +++ b/packages/schema/types/Metadata.ts @@ -6,9 +6,11 @@ * @property generator - Information about the tool that generated this spec. * @property schema - Schema validation information. * @property source - Figma source information. - * @property config - The model configuration used to generate this spec. + * @property conventions - Facts about the Figma library this spec was generated from. + * @property settings - Choices about the run that generated this spec. */ -import { Config } from './Config.js'; +import { ResolvedConventions } from './Conventions.js'; +import { ResolvedSettings } from './Settings.js'; /** * Represents the metadata for a component. @@ -18,7 +20,8 @@ import { Config } from './Config.js'; * @property generator - Information about the tool that generated this spec. * @property schema - Schema validation information. * @property source - Figma source information. - * @property config - The model configuration used to generate this spec. + * @property conventions - Facts about the Figma library this spec was generated from. + * @property settings - Choices about the run that generated this spec. */ export type Metadata = { author: string; @@ -50,5 +53,6 @@ export type Metadata = { nodeId: string; nodeType: 'COMPONENT' | 'COMPONENT_SET' | 'FRAME'; }; - config: Config; + conventions: ResolvedConventions; + settings: ResolvedSettings; }; diff --git a/packages/schema/types/Pipeline.ts b/packages/schema/types/Pipeline.ts new file mode 100644 index 00000000..5842a5aa --- /dev/null +++ b/packages/schema/types/Pipeline.ts @@ -0,0 +1,63 @@ +/** + * A single transformer to run via `specs transform`, identified by name. + * Transformer-specific options sit inline alongside `name`. + * + * @since 0.24.0 + */ +export interface TransformEntry { + /** Transformer name (e.g. `contract`, `css`, `react`). */ + name: string; + [option: string]: unknown; +} + +/** + * A single analysis to run via `specs analyze`, identified by name. + * Analysis-specific options sit inline alongside `name`. + * + * @since 0.31.0 + */ +export interface AnalysisEntry { + /** Analysis name (e.g. `dependencies`). */ + name: string; + [option: string]: unknown; +} + +/** + * Work a workspace runs over its specs. + * + * Separate from `Settings` because these name *work* rather than how work + * behaves, and separate from `Conventions` because running a different set + * produces different output rather than incorrect output. Entries carry no + * output paths: transformers write into the spec structure, and analyses + * write beside the specs they analyze. + * + * @since 0.31.0 + */ +export interface Pipeline { + /** Transformers to run via `specs transform`, each with optional inline options. */ + transformers?: TransformEntry[]; + /** Analyses to run via `specs analyze`, each with optional inline options. */ + analyses?: AnalysisEntry[]; +} + +/** + * Fully-resolved pipeline with both lists guaranteed present. + * An empty list means no work of that kind runs. + * + * @since 0.31.0 + */ +export interface ResolvedPipeline { + /** Transformers to run via `specs transform`. */ + transformers: TransformEntry[]; + /** Analyses to run via `specs analyze`. */ + analyses: AnalysisEntry[]; +} + +/** + * Default Pipeline — no work declared. + * A workspace that declares nothing runs nothing beyond command defaults. + */ +export const DEFAULT_PIPELINE: ResolvedPipeline = { + transformers: [], + analyses: [], +}; diff --git a/packages/schema/types/PropConfigurations.ts b/packages/schema/types/PropConfigurations.ts index 63be757e..b6182d82 100644 --- a/packages/schema/types/PropConfigurations.ts +++ b/packages/schema/types/PropConfigurations.ts @@ -6,6 +6,10 @@ import { ImageBinding } from "./Image.js"; * The value of a single prop configuration entry (ADR-049). * * - Scalar values (`string | number | boolean`) — static prop values. + * - `null` — the prop is **unset** in this configuration (ADR-080). A value, not + * an absence: an absent key inherits from the layer beneath, a `null` key + * overrides an inherited value with "no value". Meaningful only under a + * nullable prop. * - `PropBinding` — pass-through binding to a parent prop, e.g. * `{ $binding: "#/props/label" }`. Forwards a parent prop value into a * nested instance's prop. @@ -21,6 +25,7 @@ export type PropConfigurationValue = | string | number | boolean + | null | PropBinding | SlotContentRef | ImageBinding; diff --git a/packages/schema/types/Props.ts b/packages/schema/types/Props.ts index c5226ce0..5576b1c6 100644 --- a/packages/schema/types/Props.ts +++ b/packages/schema/types/Props.ts @@ -108,12 +108,25 @@ export interface EnumProp { } /** - * Number property definition (numeric-valued props inferred from TEXT code-only props) + * Number property definition — a numeric-valued prop, whether inferred from a TEXT + * code-only prop or from a VARIANT whose options are all numbers (in which case + * `enum` carries them). */ export interface NumberProp { type: 'number'; /** Default numeric value. Optional — omitted when no meaningful default exists. */ default?: number; + /** + * The closed set of accepted values, when the source enumerates them rather than + * leaving the range open — a Figma VARIANT whose every option is numeric, say. + * Absent means the prop accepts any number. + * + * Its presence changes what `nullable` defaults to in spirit but not in rule: an + * enumerated prop lists every value it accepts, so a numeric enum that excludes + * null should say `nullable: false` rather than rely on the open-set default + * below. @since 0.31.0 + */ + enum?: number[]; /** * Whether this prop accepts a null value. * Absent means `true` — a number prop has an open value set, so null is diff --git a/packages/schema/types/README.md b/packages/schema/types/README.md index c3928dd7..98dfc31e 100644 --- a/packages/schema/types/README.md +++ b/packages/schema/types/README.md @@ -15,8 +15,8 @@ npm install @directededges/specs-schema ## Usage ```typescript -import type { Component, Variant, Anatomy, Config } from '@directededges/specs-schema'; -import { DEFAULT_CONFIG } from '@directededges/specs-schema'; +import type { Component, Variant, Anatomy, Conventions, Settings } from '@directededges/specs-schema'; +import { DEFAULT_SETTINGS } from '@directededges/specs-schema'; // Type-safe component data const component: Component = { @@ -37,12 +37,22 @@ const component: Component = { } }; -// Use default config or customize -const config: Config = { - ...DEFAULT_CONFIG, - format: { - ...DEFAULT_CONFIG.format, - output: 'YAML' +// Facts about the Figma library — every consumer of that library declares the +// same values. There is no defaults constant: a convention's absence means the +// library declares none, and nothing can supply that. +const conventions: Conventions = { + figma: { + naming: 'SENTENCE', + glyphs: { match: 'DS Icon Glyph / {i}' } + } +}; + +// Choices about this run — start from the defaults and override. +const settings: Settings = { + ...DEFAULT_SETTINGS, + spec: { + ...DEFAULT_SETTINGS.spec, + format: 'YAML' } }; ``` @@ -55,10 +65,14 @@ const config: Config = { - **`Props`** - Configurable component properties - **`Variant`** - Component state/configuration - **`Metadata`** - Generation metadata -- **`Config`** - Transformer configuration type +- **`Conventions`** - Facts about the Figma library a spec came from +- **`Settings`** - Choices about the run that produced it +- **`Pipeline`** - Transformers and analyses a workspace runs ### Configuration -- **`DEFAULT_CONFIG`** - Default configuration constant for transformer setup +- **`DEFAULT_CONVENTIONS`** - The three convention members that have a default; no blocks +- **`DEFAULT_SETTINGS`** - Resolved defaults for the settings half +- **`DEFAULT_PIPELINE`** - An empty pipeline: no transformers, no analyses ### Supporting Types - **`Element`** - Individual component element @@ -75,7 +89,8 @@ const config: Config = { │ - JSON Schema definitions │ │ - TypeScript type definitions │ │ - Default configuration constants │ -│ Exports: Component, Config, DEFAULT_CONFIG │ +│ Exports: Component, Conventions, Settings, │ +│ Pipeline, DEFAULT_* constants │ └──────────────────────────────────────────────────┘ ▲ │ imports types & config diff --git a/packages/schema/types/Settings.ts b/packages/schema/types/Settings.ts new file mode 100644 index 00000000..e9b74585 --- /dev/null +++ b/packages/schema/types/Settings.ts @@ -0,0 +1,206 @@ +/** + * Color value output format. + * + * Controls how `ColorObject` objects are serialized in the spec output. + * `HEX` (default) emits a 6-digit hex string. `OBJECT` emits the full + * `ColorObject` object. All other values emit a formatted color string + * in the named notation. + * + * Tier 1 — Figma UI formats: `HEX`, `HEXA`, `RGB`, `RGBA`, `HSLA`, `HSB` + * Tier 2 — Modern CSS (Level 4): `OKLCH`, `OKLAB` + * Tier 3 — Structured object: `OBJECT` + * + * @since 0.20.0 + */ +export type ColorFormat = 'HEX' | 'HEXA' | 'RGB' | 'RGBA' | 'HSLA' | 'HSB' | 'OKLCH' | 'OKLAB' | 'OBJECT'; + +/** + * A source the workspace reads from, keyed by source name. + * + * @since 0.31.0 + */ +export interface SourceEntry { + /** Figma file key the source reads from. */ + key: string; + /** Artifacts to download for this source (e.g. `file`, `variables`, `styles`, `icons`). */ + fetch?: string[]; +} + +/** + * Choices about a run rather than facts about a library. + * + * Changing a setting produces **different** output, never incorrect output: a + * different team reading the same library may set every one of these differently + * and each result is correct. Members are grouped by concern — `data`, `spec`, + * `assets` — and each concern carries its own `directory`. + * + * @since 0.31.0 + */ +export interface Settings { + /** Author recorded in generated spec metadata. */ + author?: string; + /** Source acquisition: what to fetch, and where fetched artifacts, computed caches, and extracted assets are kept. */ + data?: { + /** Directory holding fetched downloads, computed caches, extracted assets, and authored inputs. */ + directory?: string; + /** Sources the workspace reads from, keyed by source name. */ + sources?: Record; + }; + /** The generated spec: where it lands, how it is split, what it contains, and how values are serialized. */ + spec?: { + /** Directory the generated spec is written to. */ + directory?: string; + /** Serialization format. Optional; defaults to JSON. */ + format?: 'JSON' | 'YAML'; + /** + * Key naming convention applied to anatomy keys, prop keys, and every reference + * to them. Every value other than SAFE is a lossy projection of the Figma name; + * names outside the safe key grammar are preserved in `$extensions['com.figma'].name`. + * Optional; defaults to SAFE. + */ + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + /** Layout representation format. Optional; defaults to LAYOUT. */ + layout?: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; + /** + * Token reference serialization profile. Optional; defaults to TOKEN. + * `FIGMA_SYNTAX_WEB`, `FIGMA_SYNTAX_IOS`, and `FIGMA_SYNTAX_ANDROID` emit the + * token's Figma `codeSyntax` for that platform, falling back to the `TOKEN` + * profile's output when no code syntax is defined for the platform. @since 0.21.0 + */ + tokens?: 'TOKEN' | 'TOKEN_NAME' | 'TOKEN_FIGMA_EXTENSIONS' | 'FIGMA_NAME' | 'CUSTOM' | 'FIGMA_SYNTAX_WEB' | 'FIGMA_SYNTAX_IOS' | 'FIGMA_SYNTAX_ANDROID'; + /** Color value output format. Optional; defaults to HEX. @since 0.20.0 */ + color?: ColorFormat; + /** Depth of variant expansion: 1-3 or 9999 for unlimited. Optional; defaults to 9999. */ + variantDepth?: 1 | 2 | 3 | 9999; + /** Level of detail in output. Optional; defaults to LAYERED. */ + details?: 'FULL' | 'LAYERED'; + /** + * When true, a component whose root is a plain container wrapping a single `text` or + * `glyph` element (no meaningful container styles, no slot bindings) is collapsed: + * the wrapper is stripped and the leaf becomes the spec root. All-or-nothing across + * variants — if any variant fails eligibility, no collapse occurs. Defaults to false. + * @since 0.26.0 + */ + collapsePrimitiveWrapper?: boolean; + /** Include invalid variants. Optional; defaults to false. */ + invalidVariants?: boolean; + /** Include invalid combinations. Optional; defaults to true. */ + invalidCombinations?: boolean; + /** Include layered variants that contain no elements. Optional; defaults to false. */ + emptyVariants?: boolean; + /** Include slot content examples in output (ADR-050). Optional; defaults to false. @since 0.21.0 */ + defaultSlotContent?: boolean; + /** Write one file per component rather than a single combined library file. Optional; defaults to true. */ + splitComponents?: boolean; + /** Write one file per concern (api, styling, variants) rather than a single component file. Optional; defaults to true. */ + splitConcerns?: boolean; + /** Nest each component's files in a subfolder named for the component. Optional; defaults to true. */ + useSubfolders?: boolean; + }; + /** Shared resources every code output points at, whatever the platform: icons, images, generated CSS, fonts. */ + assets?: { + /** Directory holding shared assets. */ + directory?: string; + }; +} + +/** + * Fully-resolved settings with every defaultable property guaranteed present. + * Produced by merging a partial `Settings` with `DEFAULT_SETTINGS`. + * + * Rule: every property with a default in `DEFAULT_SETTINGS` is required here. + * Members with no schema-level default — directories, sources, author, and the + * split flags — remain optional, because the value is supplied by the consumer + * rather than by this package. + * + * @since 0.31.0 + */ +export interface ResolvedSettings { + /** Author recorded in generated spec metadata. */ + author?: string; + /** Source acquisition settings. */ + data?: { + directory?: string; + sources?: Record; + }; + /** The generated spec. */ + spec: { + directory?: string; + /** Serialization format. */ + format: 'JSON' | 'YAML'; + /** Key naming convention. */ + keys: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + /** Layout representation format. */ + layout: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; + /** Token reference serialization profile. */ + tokens: 'TOKEN' | 'TOKEN_NAME' | 'TOKEN_FIGMA_EXTENSIONS' | 'FIGMA_NAME' | 'CUSTOM' | 'FIGMA_SYNTAX_WEB' | 'FIGMA_SYNTAX_IOS' | 'FIGMA_SYNTAX_ANDROID'; + /** Color value output format. */ + color: ColorFormat; + /** Depth of variant expansion. */ + variantDepth: 1 | 2 | 3 | 9999; + /** Level of detail in output. */ + details: 'FULL' | 'LAYERED'; + /** Plain container wrappers around a single text/glyph child are stripped. */ + collapsePrimitiveWrapper: boolean; + /** Include invalid variants. */ + invalidVariants: boolean; + /** Include invalid combinations. */ + invalidCombinations: boolean; + /** Include layered variants that contain no elements. */ + emptyVariants: boolean; + /** Include slot content examples in output. */ + defaultSlotContent: boolean; + /** Write one file per component rather than a single combined library file. */ + splitComponents: boolean; + /** Write one file per concern (api, styling, variants) rather than a single component file. */ + splitConcerns: boolean; + /** Nest each component's files in a subfolder named for the component. */ + useSubfolders: boolean; + }; + /** Shared resources every code output points at. */ + assets?: { + directory?: string; + }; +} + +/** + * Default Settings + * + * Used by both CLI and Plugin to ensure identical behavior with same settings. + * + * Rationale for defaults: + * - spec.format: JSON matches historical behaviour + * - spec.keys: SAFE prevents corruption of special characters while maintaining readability + * - spec.layout: LAYOUT provides tree structure with layout properties + * - spec.tokens: TOKEN provides platform-neutral token references with $token path and $type + * - spec.color: HEX matches historical v1 behaviour and maximises human readability + * - spec.variantDepth: 9999 (no limit) allows full variant combination exploration + * - spec.details: LAYERED reduces output size by only showing differences from default + * - spec.collapsePrimitiveWrapper: false — opt-in feature, off by default + * - spec.invalidVariants: false excludes variants that can't be instantiated + * - spec.invalidCombinations: true helps designers identify property conflicts + * - spec.emptyVariants: false reduces output size by excluding semantically empty layered variants + * - spec.defaultSlotContent: false — opt-in (ADR-050); off by default so unannotated components are unchanged + * + * Directories, sources, author, and the split flags carry no default here: the + * consumer supplies them, and this package has no basis for choosing one. + */ +export const DEFAULT_SETTINGS: ResolvedSettings = { + spec: { + format: 'JSON', + keys: 'SAFE', + layout: 'LAYOUT', + tokens: 'TOKEN', + color: 'HEX', + variantDepth: 9999, + details: 'LAYERED', + collapsePrimitiveWrapper: false, + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: false, + splitComponents: true, + splitConcerns: true, + useSubfolders: true, + }, +}; diff --git a/packages/schema/types/Styles.ts b/packages/schema/types/Styles.ts index 633ad8ed..a797acfd 100644 --- a/packages/schema/types/Styles.ts +++ b/packages/schema/types/Styles.ts @@ -153,7 +153,7 @@ export interface ColorObject { /** Candidate */ * whose values are always colour-semantics and may carry gradient data. * * The `string` arm covers formatted colour strings (e.g. `#FF6600`, `rgba(...)`) - * emitted when `Config.format.color` is set to a non-`OBJECT` format. + * emitted when `Settings.spec.color` is set to a non-`OBJECT` format. */ export type ColorStyle = string | ColorObject | TokenReference | GradientValue | null; diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index ffe635db..27ad2b79 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -24,8 +24,12 @@ export type { Composition, Compositions } from './Composition.js'; // Configuration types export type { PropConfigurations, PropConfigurationValue, NestedPropConfiguration } from './PropConfigurations.js'; -export type { Config, ResolvedConfig, ColorFormat, VariantStateEntry, TransformEntry } from './Config.js'; -export { DEFAULT_CONFIG } from './Config.js'; +export type { Conventions, ResolvedConventions, VariantStateEntry } from './Conventions.js'; +export { DEFAULT_CONVENTIONS } from './Conventions.js'; +export type { Settings, ResolvedSettings, ColorFormat, SourceEntry } from './Settings.js'; +export { DEFAULT_SETTINGS } from './Settings.js'; +export type { Pipeline, ResolvedPipeline, TransformEntry, AnalysisEntry } from './Pipeline.js'; +export { DEFAULT_PIPELINE } from './Pipeline.js'; // Style types export type { Styles, Style, ColorStyle, ColorObject, StyleKey, TokenReference, AspectRatioValue, AspectRatioStyle, Typography, Sides, Corners, ItemSpacing, LayoutMode, WrapAlignment, MainAxisAlignment, CrossAxisAlignment, Position, PositionOffset, StrokeDashPattern, TextAlignHorizontal, TextOverflow } from './Styles.js'; diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 9e1bdc74..47f0ae61 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -101,6 +101,7 @@ export default defineConfig({ { label: 'Overview', slug: 'cli' }, { label: 'Workflows', slug: 'cli/workflows' }, { label: 'init', slug: 'cli/commands/init' }, + { label: 'migrate', slug: 'cli/commands/migrate' }, { label: 'fetch', slug: 'cli/commands/fetch' }, { label: 'scan', slug: 'cli/commands/scan' }, { label: 'applyCustomTokens', slug: 'cli/commands/apply-custom-tokens' }, @@ -145,7 +146,9 @@ export default defineConfig({ { label: 'Component', slug: 'schema/component' }, { label: 'Composition', slug: 'schema/composition', badge: pro }, { label: 'Conditional', slug: 'schema/conditional' }, - { label: 'Config', slug: 'schema/config' }, + { label: 'Conventions', slug: 'schema/conventions' }, + { label: 'Settings', slug: 'schema/settings' }, + { label: 'Pipeline', slug: 'schema/pipeline' }, { label: 'Corners', slug: 'schema/corners' }, { label: 'Effects', slug: 'schema/effects' }, { label: 'Elements', slug: 'schema/elements' }, @@ -167,57 +170,51 @@ export default defineConfig({ ], }, { - label: 'Settings', + label: 'Configuration', collapsed: true, items: [ { label: 'Overview', slug: 'settings' }, { - label: 'Format', + label: 'Conventions', + collapsed: true, items: [ - { label: 'output', slug: 'settings/output-format' }, - { label: 'keys', slug: 'settings/keys' }, - { label: 'tokens', slug: 'settings/tokens', badge: pro }, - { label: 'color', slug: 'settings/color' }, + { label: 'naming', slug: 'settings/figma-keys' }, + { label: 'glyphs', slug: 'settings/glyph-name-pattern' }, + { label: 'codeOnlyProps', slug: 'settings/code-only-props-pattern' }, + { label: 'subcomponents', slug: 'settings/subcomponents' }, + { label: 'instanceExamples', slug: 'settings/instance-examples', badge: pro }, + { label: 'images', slug: 'settings/images' }, + { label: 'slotConstraints', slug: 'settings/slot-constraints', badge: pro }, + { label: 'inferNumberProps', slug: 'settings/infer-number-props' }, + { label: 'states', slug: 'settings/states', badge: experimental }, ], }, { - label: 'Variants', + label: 'Settings', + collapsed: true, items: [ + { label: 'format', slug: 'settings/output-format' }, + { label: 'keys', slug: 'settings/keys' }, + { label: 'tokens', slug: 'settings/tokens', badge: pro }, + { label: 'color', slug: 'settings/color' }, + { label: 'layout', slug: 'settings/layout' }, + { label: 'details', slug: 'settings/details' }, { label: 'variantDepth', slug: 'settings/variant-depth' }, { label: 'emptyVariants', slug: 'settings/empty-variants' }, { label: 'invalidVariants', slug: 'settings/invalid-variants' }, { label: 'invalidCombinations', slug: 'settings/invalid-combinations', badge: pro }, - ], - }, - { - label: 'Elements', - items: [ - { label: 'glyphNamePattern', slug: 'settings/glyph-name-pattern' }, - { label: 'subcomponents', slug: 'settings/subcomponents' }, { label: 'collapsePrimitiveWrapper', slug: 'settings/collapse-primitive-wrapper' }, { label: 'defaultSlotContent', slug: 'settings/default-slot-content', badge: pro }, - { label: 'instanceExamples', slug: 'settings/instance-examples', badge: pro }, - ], - }, - { - label: 'Props', - items: [ - { label: 'codeOnlyPropsPattern', slug: 'settings/code-only-props-pattern' }, - { label: 'slotConstraints', slug: 'settings/slot-constraints', badge: pro }, - { label: 'inferNumberProps', slug: 'settings/infer-number-props' }, + { label: 'sources', slug: 'settings/data-sources' }, + { label: 'Folders', slug: 'settings/folders' }, + { label: 'Output', slug: 'settings/output' }, ], }, - { label: 'Layout', slug: 'settings/layout' }, - { label: 'images', slug: 'settings/images' }, - { label: 'details', slug: 'settings/details' }, - { label: 'states', slug: 'settings/states', badge: experimental }, - { label: 'transformers', slug: 'settings/transform', badge: experimental }, { - label: 'Files and Folders', + label: 'Pipeline', + collapsed: true, items: [ - { label: 'sources', slug: 'settings/data-sources' }, - { label: 'Folders', slug: 'settings/folders' }, - { label: 'Output', slug: 'settings/output' }, + { label: 'transformers', slug: 'settings/transform', badge: experimental }, ], }, ], diff --git a/site/src/content/docs/cli/analyze/dependencies.md b/site/src/content/docs/cli/analyze/dependencies.md index c6f443e0..9155be16 100644 --- a/site/src/content/docs/cli/analyze/dependencies.md +++ b/site/src/content/docs/cli/analyze/dependencies.md @@ -64,7 +64,7 @@ For every component directory containing an `api.yaml`, the analyzer reads `inst ## Outputs -Two aggregate files are written to `_analysis/` after all components are processed. The extension follows `format.output` in your config — `.json` shown here, `.yaml` when configured. +Two aggregate files are written to `_analysis/` after all components are processed. The extension follows `spec.format` in `config/settings.yaml` — `.json` shown here, `.yaml` when configured. | File | Answers | |------|---------| diff --git a/site/src/content/docs/cli/analyze/index.md b/site/src/content/docs/cli/analyze/index.md index d0b1995f..c7b011f2 100644 --- a/site/src/content/docs/cli/analyze/index.md +++ b/site/src/content/docs/cli/analyze/index.md @@ -30,9 +30,9 @@ specs analyze props --analysis ./reports | Option | Description | |--------|-------------| -| `-o, --output ` | Path to the specs directory (input). Defaults to config `outputDirectory` or cwd. | +| `-o, --output ` | Path to the specs directory (input). Defaults to `spec.directory` from `config/settings.yaml` or cwd. | | `--analysis ` | Where to write analysis output. Defaults to `/_analysis/`. | -| `--config ` | Path to `specs.config.yaml`. | +| `--config ` | Path to the `config/` directory. | | `--verbose` | Log each component as it is processed. | ## Available Analyzers @@ -42,7 +42,7 @@ specs analyze props --analysis ./reports | [`props`](/cli/analyze/props/) | `_analysis/props.yaml` | Cross-library prop inventory — frequency, enum discordance, API surface, slots | | [`styling`](/cli/analyze/styling/) | `_analysis/styling.byComponent.json`, `_analysis/styling.byToken.json`, `_analysis/styling.unused.json` | Token usage indexed by component and by token name, plus tokens no spec references | | [`dependencies`](/cli/analyze/dependencies/) | `_analysis/dependencies.graph.json`, `_analysis/dependencies.byComponent.json` | Component dependency graph — blast radius of a change, and which props consumers configure | -| [`keys`](/cli/analyze/keys/) | `_analysis/keys.yaml` | Figma names a formatted key cannot reconstruct, as a per-component checklist. Requires `format.figmaKeys` | +| [`keys`](/cli/analyze/keys/) | `_analysis/keys.yaml` | Figma names a formatted key cannot reconstruct, as a per-component checklist. Requires `figma.naming` | ## Output Directory @@ -64,5 +64,5 @@ specs/ ## See Also -- [`analyze` command](/cli/commands/analyze/) — full CLI reference +- [`transform` command](/cli/commands/transform/) — the sibling command that emits code artifacts - [Transforms overview](/cli/transforms/) — build artifacts (contract, css) diff --git a/site/src/content/docs/cli/analyze/keys.md b/site/src/content/docs/cli/analyze/keys.md index 5bf5b05b..b9aade89 100644 --- a/site/src/content/docs/cli/analyze/keys.md +++ b/site/src/content/docs/cli/analyze/keys.md @@ -9,12 +9,12 @@ Reads every component's `api.yaml` and produces `_analysis/keys.yaml`: every Fig ## Requires a declared convention -This analyzer reports names the producer recorded in `$extensions['com.figma'].name`, which only happens when [`format.figmaKeys`](/settings/figma-keys/) declares a source convention: +This analyzer reports names the producer recorded in `$extensions['com.figma'].name`, which only happens when [`figma.naming`](/settings/figma-keys/) declares a source convention: ```yaml -config: - format: - figmaKeys: SENTENCE +# config/conventions.yaml +figma: + naming: SENTENCE ``` Under the default `NONE`, no convention is declared, no names are recorded, and this report is empty. That is correct rather than a failure — nothing has diverged from a convention you never stated. @@ -104,7 +104,7 @@ byCause: names: - Alternate Half - Children minItems - - EGDS Bottom Sheet + - DS Bottom Sheet ``` ### byName @@ -143,7 +143,7 @@ Nothing here is a validation failure. Every name it lists is fully supported — Two things worth knowing before a cleanup: - Renaming a layer or property in Figma changes the spec key too, which is a breaking change for anything consuming that key. -- The report cannot see names in a catalog that has never declared `figmaKeys`. Declare a convention first, generate, then analyze. +- The report cannot see names in a catalog that has never declared `figma.naming`. Declare a convention first, generate, then analyze. ## See Also diff --git a/site/src/content/docs/cli/analyze/styling.md b/site/src/content/docs/cli/analyze/styling.md index e5e1c1b9..bd95c956 100644 --- a/site/src/content/docs/cli/analyze/styling.md +++ b/site/src/content/docs/cli/analyze/styling.md @@ -38,7 +38,7 @@ In addition to the aggregate reports below, each component folder receives its o ## Outputs -Three aggregate files are written to `_analysis/` after all components are processed. The extension follows `format.output` in your config — `.json` shown here, `.yaml` when configured. +Three aggregate files are written to `_analysis/` after all components are processed. The extension follows `spec.format` in `config/settings.yaml` — `.json` shown here, `.yaml` when configured. | File | Answers | |------|---------| @@ -159,7 +159,7 @@ Tokens are grouped under the four category keys. Each token name maps to an arra The inverted audit: which published tokens does *no* spec reference? Where `byComponent` and `byToken` describe what specs use, `styling.unused` compares that usage against the full token universe of your design system and lists what's left over. -The universe is built from the foundations data downloaded by `specs fetch` into your data directory — every source in `specs.config.yaml` that declares `variables` or `styles` data contributes its `{alias}.variables.json` and `{alias}.styles.json`. **If no foundations data files are found, this report is skipped** and the other two are still written. +The universe is built from the foundations data downloaded by `specs fetch` into your data directory — every source in `config/settings.yaml` that declares `variables` or `styles` under `fetch` contributes its `{alias}.variables.json` and `{alias}.styles.json`. **If no foundations data files are found, this report is skipped** and the other two are still written. The comparison works on token names, constructed exactly as specs reference them: diff --git a/site/src/content/docs/cli/commands/apply-custom-tokens.md b/site/src/content/docs/cli/commands/apply-custom-tokens.md index f3518ee3..0a7af655 100644 --- a/site/src/content/docs/cli/commands/apply-custom-tokens.md +++ b/site/src/content/docs/cli/commands/apply-custom-tokens.md @@ -11,7 +11,7 @@ You can't make Specs guess your format. You need a way to say: _"For this Figma ## The Solution -`applyCustomTokens` lets you inject a `$custom` object onto each variable and style in your fetched data files. When you set `format.tokens: CUSTOM` in your config, Specs uses those objects **verbatim** as the token reference value in generated output — no transformation, no reformatting. Your object becomes the property value. +`applyCustomTokens` lets you inject a `$custom` object onto each variable and style in your fetched data files. When you set `spec.tokens: CUSTOM` in `config/settings.yaml`, Specs uses those objects **verbatim** as the token reference value in generated output — no transformation, no reformatting. Your object becomes the property value. ### Where It Fits in the Pipeline @@ -20,7 +20,7 @@ The command runs **between `fetch` and `generate`** — it modifies the raw data ``` specs fetch ← Downloads raw Figma data specs applyCustomTokens mapping.json ← Injects $custom onto matched entries -specs generate ← Reads $custom when format.tokens: CUSTOM +specs generate ← Reads $custom when spec.tokens: CUSTOM ``` No changes to `fetch` or `generate` are needed. The augmented files are transparent to both commands. @@ -43,9 +43,9 @@ Path to a JSON mapping file. Keys are Figma variable or style IDs; each entry mu |------|-------------| | `-v, --variables ` | Path to a variables JSON file. Overrides config-based discovery | | `-s, --styles ` | Path to a styles JSON file. Overrides config-based discovery | -| `--config ` | Use a specific config file instead of auto-discovery | +| `--config ` | Use a specific `config/` directory (or legacy config file) instead of auto-discovery | -When `-v` and `-s` are omitted, the command auto-discovers data files from `specs.config.yaml` using `dataDirectory` and `sources` — the same resolution that `generate` uses. +When `-v` and `-s` are omitted, the command auto-discovers data files from `config/settings.yaml` using `data.directory` and `data.sources` — the same resolution that `generate` uses. ## The Mapping File @@ -128,14 +128,13 @@ Everything else is preserved — only `$custom` is added (or overwritten if it a ## Impact on Generated Output -When `generate` runs with `format.tokens: CUSTOM`, every token reference that has a `$custom` object uses it verbatim. References without `$custom` fall back to the `TOKEN_FIGMA_EXTENSIONS` format. +When `generate` runs with `spec.tokens: CUSTOM`, every token reference that has a `$custom` object uses it verbatim. References without `$custom` fall back to the `TOKEN_FIGMA_EXTENSIONS` format. **Config:** ```yaml -# specs.config.yaml -model: - format: - tokens: CUSTOM +# config/settings.yaml +spec: + tokens: CUSTOM ``` **Generated output** (YAML shown): @@ -162,7 +161,7 @@ elements: This applies uniformly to all reference sites — including gradient stop colors and any other property bound to a Figma variable or style. -> **Without `format.tokens: CUSTOM`**, the `$custom` objects sit inert in the data files. Other token profiles (`TOKEN`, `TOKEN_NAME`, etc.) ignore `$custom` entirely. +> **Without `spec.tokens: CUSTOM`**, the `$custom` objects sit inert in the data files. Other token profiles (`TOKEN`, `TOKEN_NAME`, etc.) ignore `$custom` entirely. ## Full Pipeline Example @@ -216,7 +215,7 @@ After running, the command reports what it did: ## Branch-Fetched Data -If your data files were fetched from a Figma branch (using a branch file key in `sources`), variable and style IDs may differ from the IDs on main. Your mapping file must use the IDs that appear in the branch data. +If your data files were fetched from a Figma branch (using a branch file key in `data.sources`), variable and style IDs may differ from the IDs on main. Your mapping file must use the IDs that appear in the branch data. Inspect the fetched files directly to verify which IDs are present. If you maintain separate mapping files per branch, keep them alongside the branch data. @@ -226,6 +225,6 @@ See [Fetching Figma Branches](/cli/commands/fetch/#fetching-figma-branches) for **See Also:** - [Tokens configuration](/settings/tokens/) — all token profiles compared -- [Configuration Reference](/settings/) — `dataDirectory` and `sources` setup +- [Configuration Reference](/settings/) — `data.directory` and `data.sources` setup - [Generate Command](/cli/commands/generate/) — processing augmented data - [Fetch Command](/cli/commands/fetch/) — fetching raw data before augmentation diff --git a/site/src/content/docs/cli/commands/cache.md b/site/src/content/docs/cli/commands/cache.md index 015f58cc..86a50fe4 100644 --- a/site/src/content/docs/cli/commands/cache.md +++ b/site/src/content/docs/cli/commands/cache.md @@ -22,7 +22,7 @@ That data arrives as whole Figma API responses. A file payload for a large libra ## What it writes -Four files under `{dataDirectory}/cache/`, each covering every source you've fetched: +Four files under `{data.directory}/cache/`, each covering every source you've fetched: | File | Maps | Built from | |------|------|------------| @@ -35,7 +35,7 @@ They're generated files. Deleting them is safe — the next `specs cache` rebuil Each entry records which source it came from, because node ids are file-scoped: knowing an entry's origin is what lets `render` tell whether an id is usable in the file it's rendering into, and it lets one library be rebuilt without re-reading the others. -`components.yaml` also records each component's raw Figma name. A spec refers to a component by a formatted key, and that transform is lossy — `DS Link/On overlay/M` and `DS Link On Overlay M` produce the same key — so the name cannot be recovered from the key. Recording it lets `render` place an instance of a component the workspace has no spec for: the library's names are formatted the same way and matched against the spec's key, then the component is imported by its published key. Names are stored raw and formatted at render time, so changing `format.keys` needs no rebuild. +`components.yaml` also records each component's raw Figma name. A spec refers to a component by a formatted key, and that transform is lossy — `DS Link/On overlay/M` and `DS Link On Overlay M` produce the same key — so the name cannot be recovered from the key. Recording it lets `render` place an instance of a component the workspace has no spec for: the library's names are formatted the same way and matched against the spec's key, then the component is imported by its published key. Names are stored raw and formatted at render time, so changing `spec.keys` needs no rebuild. Every file also records what it was built from — the payload's name, size, and modification time, plus the glyph naming pattern for `icons.yaml`. That's how staleness is detected. @@ -52,13 +52,13 @@ Run `specs cache` to rebuild it. `render` refuses rather than rebuilding, for two reasons: rebuilding is exactly the per-render cost the cache removes, and rendering against data that no longer matches what was fetched binds a spec to the wrong variables or drops content — a failure that surfaces far from its cause. -The cache goes stale when a payload is re-fetched, when `applyCustomTokens` rewrites your variables, or when `config.processing.glyphNamePattern` changes — a pattern edit changes what the icon entries mean without any file changing. All three are detected. +The cache goes stale when a payload is re-fetched, when `applyCustomTokens` rewrites your variables, or when the `figma.glyphs.match` convention changes — a pattern edit changes what the icon entries mean without any file changing. All three are detected. ## Options ### `--config ` -Use a specific config file instead of the default `specs.config.yaml`. +Use a specific `config/` directory instead of the default `config/` in the working directory. ### `--force` @@ -72,7 +72,7 @@ specs cache --force ## Sources that aren't fetched -The cache is built for every source declared under `sources` in your config. A source you haven't fetched yet is skipped and reported, not treated as an error: +The cache is built for every source declared under `data.sources` in `config/settings.yaml`. A source you haven't fetched yet is skipped and reported, not treated as an error: ``` Cache rebuilt: library diff --git a/site/src/content/docs/cli/commands/fetch.md b/site/src/content/docs/cli/commands/fetch.md index 1d8830a6..f4601461 100644 --- a/site/src/content/docs/cli/commands/fetch.md +++ b/site/src/content/docs/cli/commands/fetch.md @@ -12,20 +12,20 @@ specs fetch [options] ## Requirements - `FIGMA_TOKEN` must be set in your environment. -- `specs.config.yaml` must include `dataDirectory` (or deprecated `sourceDirectory`) and `sources`. +- `config/settings.yaml` must include `data.directory` and `data.sources`. - Fetching `variables` or `styles` requires your Figma organization to be on an **Enterprise** plan — Figma restricts those REST endpoints regardless of your Specs license. `file` and `icons` data work on any plan. See [CLI Requirements](/cli/#requirements). - Fetching `icons` additionally requires: - - `config.processing.glyphNamePattern` set in your config (see [Glyph Name Pattern](/guides/glyph-name-pattern/)) - - `outputDirectory` set in your config — icon assets are written to the spec workspace, not the data directory - - the source's `file` payload — listed before `icons` in the same `data` array, or fetched in a previous run + - `figma.glyphs.match` set in `config/conventions.yaml` (see [Glyph Name Pattern](/guides/glyph-name-pattern/)) + - `spec.directory` set in `config/settings.yaml` — icon assets are written to the spec workspace, not the data directory + - the source's `file` payload — listed before `icons` in the same `fetch` array, or fetched in a previous run ## Options ### `--config ` -Use a specific config file. +Use a specific `config/` directory. ### `--data-dir ` -Override output directory for fetched payloads. Defaults to `dataDirectory` from config, or `./data` if not configured. +Override output directory for fetched payloads. Defaults to `data.directory` from `config/settings.yaml`, or `./data` if not configured. ```bash specs fetch --data-dir ./custom-data @@ -34,7 +34,7 @@ specs fetch --data-dir ./custom-data > **Deprecated alias**: `--outDir` still works but will emit a deprecation warning. Prefer `--data-dir`. ### `--only ` -Fetch only specific aliases from `sources`. +Fetch only specific aliases from `data.sources`. ### `--no-geometry` Omit geometry data from file payloads. By default, `fetch` requests `?geometry=paths` from the Figma API, which includes `fillGeometry`, `strokeGeometry`, `size`, and `relativeTransform` on every node. This roughly doubles the payload size. @@ -50,7 +50,7 @@ Show request URLs and write locations. ## The Render Cache -After downloading, `fetch` builds the lookup tables [`render`](/cli/commands/render/) resolves specs against, under `{dataDirectory}/cache/`. This is why a normal workflow never needs to run [`specs cache`](/cli/commands/cache/) by hand. +After downloading, `fetch` builds the lookup tables [`render`](/cli/commands/render/) resolves specs against, under `{data.directory}/cache/`. This is why a normal workflow never needs to run [`specs cache`](/cli/commands/cache/) by hand. It covers every source in your config that has been fetched — the ones downloaded this run, plus any downloaded previously — and rebuilds only the ones whose payloads actually changed, so refreshing one library doesn't re-read the rest. A source you haven't fetched yet is skipped and reported: @@ -72,19 +72,21 @@ specs fetch --only foundations --verbose ## Fetching Icon Assets -Add `icons` to a source's `data` array to download the library's icon glyphs as SVG files: +Add `icons` to a source's `fetch` array to download the library's icon glyphs as SVG files: ```yaml -sources: - library: - key: YOUR_FILE_KEY - data: ['file', 'variables', 'styles', 'icons'] +# config/settings.yaml +data: + sources: + library: + key: YOUR_FILE_KEY + fetch: ['file', 'variables', 'styles', 'icons'] ``` How it works: -- Glyph components are **derived from the file payload** — every `COMPONENT` node whose name matches `config.processing.glyphNamePattern` (with `{i}` capturing the icon name). No `scan` step is involved. -- SVGs are exported through the Figma images API in batches and written to `/_icons/` — beside the `_images/` assets and the component specs that reference them, not into the regenerable data cache. +- Glyph components are **derived from the file payload** — every `COMPONENT` node whose name matches the `figma.glyphs.match` convention (with `{i}` capturing the icon name). No `scan` step is involved. +- SVGs are exported through the Figma images API in batches and written to `/_icons/` — beside the `_images/` assets and the component specs that reference them, not into the regenerable data cache. - Filenames are stable kebab-case slugs of the captured icon name, including camelCase splitting: `expandMore` → `expand-more.svg`, `Arrow Left` → `arrow-left.svg`. - Two icons that slug identically keep the first as-is; later duplicates are suffixed with their node id so nothing is silently dropped. @@ -103,17 +105,19 @@ Because glyphs come from the saved file payload, `icons` runs after the other ki specs fetch --only library --verbose ``` -The downloaded assets match the slugs referenced by generated component output (masked glyph spans resolve `/assets/icons/.svg`), so serving `/_icons/` as a static assets directory — for example in Storybook — makes icons render without further mapping. Keeping icons in the spec workspace means a cloned workspace renders completely without re-fetching. +The downloaded assets match the slugs referenced by generated component output (masked glyph spans resolve `/assets/icons/.svg`), so serving `/_icons/` as a static assets directory — for example in Storybook — makes icons render without further mapping. Keeping icons in the spec workspace means a cloned workspace renders completely without re-fetching. ## Fetching Figma Branches -You can fetch data from a Figma branch instead of the main file by using the branch's file key in your `sources` config. Every Figma branch has its own unique key, which works anywhere a main file key does. +You can fetch data from a Figma branch instead of the main file by using the branch's file key in your `data.sources` config. Every Figma branch has its own unique key, which works anywhere a main file key does. ```yaml -sources: - library: - key: BRANCH_FILE_KEY # branch key instead of main file key - data: ['file', 'variables', 'styles'] +# config/settings.yaml +data: + sources: + library: + key: BRANCH_FILE_KEY # branch key instead of main file key + fetch: ['file', 'variables', 'styles'] ``` ### How to find a branch key @@ -135,5 +139,5 @@ If you use `applyCustomTokens` with branch-fetched data, be aware that Figma var --- **See Also:** -- [Configuration Reference](/settings/) - dataDirectory and sources setup +- [Configuration Reference](/settings/) - data.directory and data.sources setup - [Generate Command](/cli/commands/generate/) - Processing fetched data diff --git a/site/src/content/docs/cli/commands/generate.md b/site/src/content/docs/cli/commands/generate.md index 45469ea9..c5febbc0 100644 --- a/site/src/content/docs/cli/commands/generate.md +++ b/site/src/content/docs/cli/commands/generate.md @@ -36,16 +36,16 @@ specs scan specs generate ``` -With no arguments, step 4 uses the default manifest (`{dataDirectory}/{alias}.manifest.md`) and writes to `config.outputDirectory`. Pass either explicitly to override: +With no arguments, step 4 uses the default manifest (`{data.directory}/{alias}.manifest.md`) and writes to `spec.directory` from `config/settings.yaml`. Pass either explicitly to override: ```bash specs generate components.md -o specs/library.yaml ``` -Manifest mode requires an output destination — `-o` or `config.outputDirectory` — since it can produce many files. Control the file layout with [`--split-components`](#--split-components), [`--split-concerns`](#--split-concerns), and [`--use-subfolders`](#--use-subfolders): +Manifest mode requires an output destination — `-o` or `spec.directory` — since it can produce many files. By default it writes the full split layout: one folder per component, holding one file per concern. Turn parts of that off with [`--combine-as-library`](#--combine-as-library), [`--combine-concerns`](#--combine-concerns), and [`--no-subfolders`](#--no-subfolders): ```bash -specs generate -o specs/ --split-components +specs generate -o specs/ ``` ``` @@ -78,7 +78,7 @@ The component is resolved against the JSON file's components and component sets. specs generate data/library.file.json -c "1234:5678" -o specs/button.yaml ``` -Without `-o` (and with no `config.outputDirectory`), the spec goes to stdout — handy for piping: +Without `-o` (and with no configured `spec.directory`), the spec goes to stdout — handy for piping: ```bash specs generate data/library.file.json -c "DS Button" -f yaml | yq '.dsButton.anatomy' @@ -102,8 +102,8 @@ specs generate --from-bridge -o specs/button.yaml Because the plugin does the generating, bridge mode behaves differently from the other two in ways worth knowing: -- **The plugin's settings and license govern the spec.** The config that shaped the output — `Config` keys, formatting, tier-gated detail — is the plugin's, not your `specs.config.yaml`. `-l/--license` has no effect; the plugin uses the license stored in its own UI. -- **Your CLI config still controls where and how the spec is written.** `outputDirectory`, `format.output`, and the output flags all apply as usual. +- **The plugin's settings and license govern the spec.** The conventions and settings that shaped the output — key formatting, tier-gated detail — are the plugin's, not your `config/` files. `-l/--license` has no effect; the plugin uses the license stored in its own UI. +- **Your CLI config still controls where and how the spec is written.** `spec.directory`, `spec.format`, and the output flags all apply as usual. - **The output is as current as the file.** Unsaved and just-edited work is included, so this reflects the document rather than the last fetch. - **`-c`, `-v`, `-s`, and `--data-dir` are ignored**, and passing a `source` argument is an error. @@ -129,7 +129,7 @@ Common failures: ### `[source]` Path to a markdown manifest or a Figma REST API JSON file. The mode is detected from its content. -- **Not provided**: defaults to `{dataDirectory}/{alias}.manifest.md`, where `dataDirectory` comes from `specs.config.yaml` and `alias` is `library` if configured with `data: [file]`, otherwise the first source alias with `data: [file]`. This matches the default output of `specs scan`. +- **Not provided**: defaults to `{data.directory}/{alias}.manifest.md`, where `data.directory` comes from `config/settings.yaml` and `alias` is `library` if configured with `fetch: [file]`, otherwise the first source alias with `fetch: [file]`. This matches the default output of `specs scan`. - **Markdown manifest**: manifest mode. - **Figma JSON**: single component mode — requires `-c`. - **Bridge mode**: takes no source argument; passing one is an error. @@ -144,10 +144,10 @@ Output file or directory path. - **File path**: writes all output to a single file (e.g. `-o specs/library.yaml`). - **Directory path**: writes output files into the directory (e.g. `-o specs/`). -- **Not provided**: falls back to `config.outputDirectory` (default `./specs`). Required in manifest mode if that isn't configured; single component and bridge mode write to stdout instead. +- **Not provided**: falls back to `spec.directory` from `config/settings.yaml` (default `./specs`). Required in manifest mode if that isn't configured; single component and bridge mode write to stdout instead. ### `-f, --format ` -Output format: `yaml` or `json`. Defaults to `config.format.output` (or JSON with no config); the flag takes precedence. +Output format: `yaml` or `json`. Defaults to `spec.format` from `config/settings.yaml` (or JSON with no config); the flag takes precedence. ### `-l, --license ` License key for premium features. @@ -161,44 +161,53 @@ export SPECS_LICENSE_KEY="your-license-key" specs generate ``` -No effect in bridge mode, where the plugin's own license applies. See [Getting Started — License](/cli/getting-started.md/#step-3-set-your-license-key-optional) for setup. +No effect in bridge mode, where the plugin's own license applies. See [Getting Started](/cli/getting-started/#step-2-set-up-your-environment) for setup. ### `--data-dir ` -Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `dataDirectory` from config, or `./data`. +Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `data.directory` from `config/settings.yaml`, or `./data`. ### `-v, --variables ` External variables JSON file. -- **Default** (no flag): loads all `${alias}.variables.json` for aliases in config whose `data` includes `variables`. +- **Default** (no flag): loads all `${alias}.variables.json` for aliases in config whose `fetch` includes `variables`. - **Fallback** (no sources configured): tries `foundations/variables.json` next to the source JSON file. - **Override**: the flag replaces that list for this run. ### `-s, --styles ` External styles JSON file. -- **Default** (no flag): loads all `${alias}.styles.json` for aliases in config whose `data` includes `styles`. +- **Default** (no flag): loads all `${alias}.styles.json` for aliases in config whose `fetch` includes `styles`. - **Fallback** (no sources configured): tries `foundations/styles.json` next to the source JSON file. - **Override**: the flag replaces that list for this run. -### `--split-components` -Create a separate file per component, instead of one file containing all of them. +### Default layout +With no layout flags, each component gets its own directory of concern files: `api.yaml` (anatomy, props), `variants.yaml` (default, variants), and `examples.yaml` (`slotContentExamples`, `instanceExamples`). ```bash -specs generate -o specs/ --split-components +specs generate -o specs/ ``` ``` specs/ -├── dsButton.yaml -├── dsAlert.yaml -└── dsCard.yaml +├── dsButton/ +│ ├── api.yaml +│ └── variants.yaml +├── dsAlert/ +│ ├── api.yaml +│ ├── variants.yaml +│ └── examples.yaml +└── dsCard/ + ├── api.yaml + └── variants.yaml ``` -### `--split-concerns` -Separate API specification, variant configuration, and examples into up to three files: `api.yaml` (anatomy, props), `variants.yaml` (default, variants), and `examples.yaml` (`slotContentExamples`, `instanceExamples`). +`examples.yaml` is written only when at least one component has example data, and components without examples are omitted from it. Example output is a [Pro feature](/settings/default-slot-content/) — on the free tier it's omitted entirely, so no `examples.yaml` is produced. + +### `--combine-as-library` +Write every component into one library file, instead of a file per component. Concerns still split, so the three concern files now span the whole library: ```bash -specs generate -o specs/ --split-concerns +specs generate -o specs/ --combine-as-library ``` ``` @@ -208,50 +217,63 @@ specs/ └── examples.yaml ``` -`examples.yaml` is written only when at least one component has example data, and components without examples are omitted from it. Example output is a [Pro feature](/settings/default-slot-content/) — on the free tier it's omitted entirely, so no `examples.yaml` is produced. +Add `--combine-concerns` for a single file holding everything: + +```bash +specs generate -o specs/library.yaml --combine-as-library --combine-concerns +``` -Combined with `--split-components`, each component gets its own directory of concern files: +### `--combine-concerns` +Write API, variants, and examples into one file per component, instead of separate concern files. ```bash -specs generate -o specs/ --split-components --split-concerns +specs generate -o specs/ --combine-concerns ``` ``` specs/ ├── dsButton/ -│ ├── api.yaml -│ └── variants.yaml +│ └── dsButton.yaml ├── dsAlert/ -│ ├── api.yaml -│ ├── variants.yaml -│ └── examples.yaml +│ └── dsAlert.yaml └── dsCard/ - ├── api.yaml - └── variants.yaml + └── dsCard.yaml ``` -### `--use-subfolders` -Organize component files in subdirectories (requires `--split-components`). Wraps each component file in its own folder. +Combined with `--no-subfolders`, that flattens to one file per component: ```bash -specs generate -o specs/ --split-components --use-subfolders +specs generate -o specs/ --combine-concerns --no-subfolders ``` ``` specs/ -├── dsButton/ -│ └── dsButton.yaml -├── dsAlert/ -│ └── dsAlert.yaml -└── dsCard/ - └── dsCard.yaml +├── dsButton.yaml +├── dsAlert.yaml +└── dsCard.yaml ``` +### `--no-subfolders` +Write component files side by side instead of nesting each in its own folder. Only meaningful while components are split. + +```bash +specs generate -o specs/ --combine-concerns --no-subfolders +``` + +``` +specs/ +├── dsButton.yaml +├── dsAlert.yaml +└── dsCard.yaml +``` + +With concerns still split, each component's concern files need a folder to live in, so this flag has no effect. + ### `--get-images` -Resolve unresolved registry images into real image files. Requires a [`processing.images`](/settings/images/) block in config, a configured source file key, and the `FIGMA_TOKEN` environment variable (the same token `specs fetch` uses). +Resolve unresolved registry images into real image files. Requires a [`figma.images`](/settings/images/) convention in `config/conventions.yaml`, a configured source file key, and the `FIGMA_TOKEN` environment variable (the same token `specs fetch` uses). ```bash -specs generate -o specs/ --split-components --get-images +specs generate -o specs/ --get-images ``` Generation alone (the *detect* phase) records each image fill as an unresolved registry entry — the Figma identity in `$extensions['com.figma'].imageHash`, no `src` — structurally complete, but with no pixels. With `--get-images`, the CLI calls Figma's Get Image Fills endpoint, downloads each distinct image once, writes it as `_images/.` inside the output directory (format detected from the bytes — png, jpg, gif, or webp), and **adds** `src` to each entry — a path relative to the spec file that references it. The Figma identity survives for reverse-direction tooling: @@ -280,7 +302,7 @@ specs/ └── dsCard.yaml ``` -`$image` pointers (in `backgroundImage` fills and `ImageBinding` examples) are unaffected — resolution touches one registry entry per image, never the references. Files are named by Figma's content hash, so an image shared by many components is downloaded and stored once, and re-runs are idempotent. Figma's download URLs are temporary and are never persisted. With `--use-subfolders` (or the combined component + concern layout), `src` becomes `../_images/...` so it still resolves relative to each spec file. +`$image` pointers (in `backgroundImage` fills and `ImageBinding` examples) are unaffected — resolution touches one registry entry per image, never the references. Files are named by Figma's content hash, so an image shared by many components is downloaded and stored once, and re-runs are idempotent. Figma's download URLs are temporary and are never persisted. In the default subfolder layout (or any component + concern layout), `src` becomes `../_images/...` so it still resolves relative to each spec file. ### `--from-bridge` Generate from the current selection in a connected Figma file via the [CLI bridge](/cli/commands/bridge/), instead of from a manifest or downloaded JSON. See [Bridge Mode](#bridge-mode). @@ -296,10 +318,10 @@ Target a specific connected Figma file (bridge mode only). More than one file ca Generate from a specific node ID instead of the current selection (bridge mode only). The plugin selects the node first, switching pages if the node lives on another one, then restores the page you were on. ### `--config ` -Path to a configuration file, when it isn't the `specs.config.yaml` in the working directory. +Path to a `config/` directory, when it isn't the `config/` directory in the working directory. ```bash -specs generate --config configs/mobile.yaml -o specs/mobile.yaml +specs generate --config workspaces/mobile/config -o specs/mobile.yaml ``` ### `--verbose` diff --git a/site/src/content/docs/cli/commands/init.md b/site/src/content/docs/cli/commands/init.md index 18137ab6..0a81246b 100644 --- a/site/src/content/docs/cli/commands/init.md +++ b/site/src/content/docs/cli/commands/init.md @@ -1,7 +1,7 @@ --- title: "init" --- -Initialize a `specs.config.yaml` file with production-ready defaults. +Initialize the `config/` directory — `conventions.yaml`, `settings.yaml`, and `pipeline.yaml` — with production-ready defaults. ## Usage @@ -11,72 +11,95 @@ specs init [options] ## Purpose -The `init` command scaffolds a fully-populated configuration file with: -- Sensible defaults for dataDirectory (`./data`) and outputDirectory (`./specs`) -- All processing, format, and include options with production-ready values -- Inline documentation with links to the full configuration reference -- Empty sources object ready for your Figma file keys +The `init` command scaffolds three configuration files, each answering one question: + +- **`config/conventions.yaml`** — facts about the Figma library: naming patterns, state classification, how images are expressed. See [Conventions](/schema/conventions/). +- **`config/settings.yaml`** — choices about this run: sources, spec output, assets. See [Settings](/schema/settings/). +- **`config/pipeline.yaml`** — transformers and analyses to run. See [Pipeline](/schema/pipeline/). + +Each file ships with sensible defaults and inline documentation with links to the full configuration reference. `settings.yaml` includes an empty `data.sources` object ready for your Figma file keys. This is the recommended way to get started with Specs in a new project. ## What Gets Created -The init command creates a `specs.config.yaml` file with the following structure: +``` +config/ + conventions.yaml # what the library is + settings.yaml # how output behaves, and where it goes + pipeline.yaml # what to run +``` + +`config/conventions.yaml` declares how the library is authored — most conventions start commented out, since absence means the library declares no such convention: ```yaml -# Specs CLI Configuration (production-ready defaults) -# -# This file configures how Specs fetches and processes Figma component data. -# See: https://www.specsplugin.com/settings/ for complete documentation. - -# Where fetch writes payloads, and where generate reads from. -# See: https://docs.specs.dev/settings/data-sources -dataDirectory: ./data - -# Default location for generated spec files (can override with -o flag). -# See: https://docs.specs.dev/settings/data-sources -outputDirectory: ./specs - -# Figma file sources to fetch and process. -# See: https://docs.specs.dev/settings/data-sources -sources: {} - -# Processing and output configuration. -# See: https://docs.specs.dev/settings/ -config: - processing: - subcomponents: - # scope: NESTED - match: - - '{C} / _ / {S}' - # exclude: - # - '{C} / Examples / {S}' - # glyphNamePattern: 'DS Icon Glyph /' - variantDepth: 9999 - details: LAYERED - format: - output: JSON - keys: SAFE - layout: LAYOUT - tokens: TOKEN - include: - invalidVariants: false - invalidCombinations: true - - # transformers: - # - name: contract - # - name: css - # - name: styling +# Facts about the Figma library — every consumer of that library declares the same values. +figma: + # naming: NONE + + # glyphs: + # match: 'DS Icon Glyph / {i}' + + subcomponents: + # scope: NESTED + match: + - '{C} / _ / {S}' + # exclude: + # - '{C} / Examples / {S}' + + slotConstraints: false + + # states: + # hover: + # prop: state + # value: hover +``` + +`config/settings.yaml` declares run choices, grouped by concern: + +```yaml +# Choices about this run — sources, spec output, assets. +author: + +data: + directory: ./data + sources: {} + +spec: + directory: ./specs + format: JSON + keys: SAFE + layout: LAYOUT + tokens: TOKEN + color: HEX + variantDepth: 9999 + details: LAYERED + # splitComponents: true + # splitConcerns: true + # useSubfolders: true +``` + +`config/pipeline.yaml` declares the work to run — everything commented out until you opt in: + +```yaml +# Work this workspace runs: transformers and analyses. +# transformers: +# - name: contract +# - name: css +# - name: react + +# analyses: +# - name: dependencies ``` Each section includes inline comments with references to the full documentation. ## Options -### `--force` / `-f` -Overwrite existing config file without prompting. +### `--force` +Overwrite existing config files without prompting. -By default, if `specs.config.yaml` exists, `init` prompts before overwriting. Use `--force` to skip the prompt. +By default, if any of the three files exists, `init` prompts before overwriting. Use `--force` to skip the prompt. ```bash # Prompt before overwrite (default) @@ -87,15 +110,12 @@ specs init --force ``` ### `--config ` / `-c ` -Custom path for the config file (default: `specs.config.yaml`). +Custom directory to write the `config/` folder into (default: current directory). ```bash -# Create config in a custom location -specs init --config ./configs/dev.yaml - -# Create multiple configs for different environments -specs init --config ./configs/dev.yaml --force -specs init --config ./configs/prod.yaml --force +# Scaffold config/ inside a workspace subdirectory +specs init --config ./workspaces/library +# Creates ./workspaces/library/config/{conventions,settings,pipeline}.yaml ``` ## Examples @@ -107,28 +127,26 @@ cd my-design-system specs init # Output: -# ✓ Created specs.config.yaml +# ✓ Created config/conventions.yaml +# ✓ Created config/settings.yaml +# ✓ Created config/pipeline.yaml # 📚 Next steps: # 1. Edit the config file to add your Figma file keys # 2. Run: specs fetch # 3. Run: specs scan # 4. Run: specs generate -# -# 📖 Documentation: https://www.specsplugin.com/settings/ ``` -### Example 2: Environment-Specific Configs +### Example 2: Multiple Workspaces ```bash -# Development config -specs init --config .specs.dev.yaml --force - -# Production config -specs init --config .specs.prod.yaml --force +# One config/ directory per workspace +specs init --config ./workspaces/dev --force +specs init --config ./workspaces/prod --force # Use with --config flag on other commands -specs fetch --config .specs.dev.yaml -specs generate data/library.file.json -c "Button" --config .specs.prod.yaml +specs fetch --config ./workspaces/dev/config +specs generate data/library.file.json -c "Button" --config ./workspaces/prod/config ``` ### Example 3: Force Overwrite @@ -138,6 +156,18 @@ specs generate data/library.file.json -c "Button" --config .specs.prod.yaml specs init --force ``` +## Upgrading from a single config file + +A pre-split `specs.config.yaml` (or `.json`) is no longer read (ADR-071), and `init` refuses to run while one is present — scaffolding defaults over it would quietly replace whatever the workspace actually declared: + +``` +Error: found specs.config.yaml — a pre-split configuration (ADR-071). + Run `specs migrate config` to convert it, keeping what this workspace declares. + To scaffold fresh defaults instead, remove specs.config.yaml first. +``` + +Run [`specs migrate config`](/cli/commands/migrate/) to convert the file into the three-file layout — the [Settings](/schema/settings/) and [Conventions](/schema/conventions/) references show where each former member now lives. Use `init` only for a workspace with no existing configuration. + --- **See Also:** diff --git a/site/src/content/docs/cli/commands/migrate.md b/site/src/content/docs/cli/commands/migrate.md new file mode 100644 index 00000000..086fc0b2 --- /dev/null +++ b/site/src/content/docs/cli/commands/migrate.md @@ -0,0 +1,116 @@ +--- +title: "migrate" +--- +Run a versioned migration over the workspace — converting artifacts written for an older version of Specs into the current layout. + +## Usage + +```bash +specs migrate [options] +``` + +## Purpose + +Migrations are named by *subject* — what they convert — and registered per source version, so `migrate` stays meaningful as new migrations arrive. One migration is registered today: + +- **`specs migrate config`** (v1 → v2) — converts a pre-split `specs.config.yaml` / `specs.config.json` into the split `config/` directory introduced by ADR-071. + +Migrations write to the workspace, which is why they are a command you run deliberately rather than something the config loader does on your behalf: config loading happens inside read-only commands and in CI, and a read path must not mutate a checkout. + +## `specs migrate config` + +Converts the pre-split single file into the current three-file layout. The CLI no longer reads `specs.config.yaml` — every command stops with an error until the file is converted: + +``` +specs.config.yaml is no longer read (ADR-071). + Run `specs migrate config` to write config/conventions.yaml, config/settings.yaml and config/pipeline.yaml from it. + Docs: https://specs.directededges.com/settings/ +``` + +Running the migration: + +1. Reads `specs.config.yaml` (or `specs.config.json`) from the current directory. +2. Writes each shape the file declares to its home: `config/conventions.yaml`, `config/settings.yaml`, `config/pipeline.yaml`. A shape the file never configured is skipped rather than written empty. +3. Renames the source to `specs.config.yaml.migrated` so config discovery stops finding it. The rename preserves the only record of what the workspace declared — the file is safe to delete once you have reviewed the generated files. + +```bash +specs migrate config + +# Output: +# config v1 → v2 +# Wrote: config/conventions.yaml +# Wrote: config/settings.yaml +# Wrote: config/pipeline.yaml +# Renamed: specs.config.yaml → specs.config.yaml.migrated (safe to delete once you have reviewed the new files) +# +# Review the generated files before committing. +``` + +### When it refuses + +If `config/` already contains any of `conventions`, `settings`, or `pipeline` (`.yaml` or `.json`), the migration refuses rather than overwrite authored files: + +``` +Error: config/ already contains settings.yaml — migrating would overwrite authored files. Move or delete them first. +``` + +If no pre-split file exists in the current directory, there is nothing to do: + +``` +Nothing to migrate — no v1 config found in this directory. +``` + +## Options + +### `--dry-run` +Report what the migration would write and rename, without touching any file. + +```bash +specs migrate config --dry-run + +# Output: +# config v1 → v2 +# Would write: config/conventions.yaml +# Would write: config/settings.yaml +# Would write: config/pipeline.yaml +# Would rename: specs.config.yaml → specs.config.yaml.migrated +``` + +### `--from ` +Choose the source version to migrate from. By default, `migrate` picks the registered migration whose source it detects in the current directory. Only one `config` migration exists today (`--from v1`), so the flag matters only once multiple versions are registered. + +### `--list` +List every registered migration and exit. + +```bash +specs migrate --list + +# Output: +# Available migrations: +# +# specs migrate config --from v1 +# v1 → v2: single specs.config.yaml → config/{conventions,settings,pipeline}.yaml (ADR-071) +``` + +## Examples + +### Example 1: Upgrade a pre-split workspace + +```bash +cd my-design-system +specs migrate config +# Review config/*.yaml, then: +rm specs.config.yaml.migrated +``` + +### Example 2: Preview before converting + +```bash +specs migrate config --dry-run +``` + +--- + +**See Also:** +- [Configuration Reference](/settings/) - The split `config/` layout and every option +- [init Command](/cli/commands/init/) - Scaffolding `config/` in a workspace with no existing configuration diff --git a/site/src/content/docs/cli/commands/render.md b/site/src/content/docs/cli/commands/render.md index 12dbda5a..bc262d8c 100644 --- a/site/src/content/docs/cli/commands/render.md +++ b/site/src/content/docs/cli/commands/render.md @@ -34,7 +34,7 @@ specs render [specPath] [options] What to render. Three shapes are accepted: - **A spec file** (`.yaml`, `.yml`, `.json`) — one component. -- **A component folder** — a directory holding `api.*` and `variants.*` (plus optional `examples.*`), as produced by `generate --split-components --split-concerns`. Renders that one component. +- **A component folder** — a directory holding `api.*` and `variants.*` (plus optional `examples.*`), as `generate` produces by default. Renders that one component. - **A directory of component folders** — renders every component beneath it, sequentially, in path order. ```bash @@ -46,7 +46,7 @@ specs render specs/forms/ # every component in one group Batch scanning looks at most two levels deep, so both `specs/deButton/` and `specs/forms/deInput/` are found. It never descends into a component folder. -Optional — when omitted, `render` uses the configured `outputDirectory` as a batch. +Optional — when omitted, `render` uses the configured `spec.directory` as a batch. A directory batch renders in path order. Render order isn't configurable, so when one component's spec references another, render them individually in the order you need. @@ -54,7 +54,7 @@ A directory batch renders in path order. Render order isn't configurable, so whe ### `--config ` -Use a specific config file instead of the default `specs.config.yaml`. +Use a specific `config/` directory instead of the default `config/` in the working directory. ### `--file ` @@ -184,7 +184,7 @@ specs render specs/deButton.yaml # Render every component in the output directory specs render specs/ -# Same, resolved from config (outputDirectory) +# Same, resolved from config (spec.directory) specs render # Re-render on every save while iterating on a spec @@ -247,7 +247,7 @@ schedules. Expect these in a comparison until they are fixed. |------|---------| | `0` | Success | | `1` | General error — bridge unreachable (run `specs bridge start`), render failed, or the plugin isn't connected | -| `2` | Invalid arguments — spec path not found, `--watch` without a spec path, or no spec path given and no `outputDirectory` to fall back to | +| `2` | Invalid arguments — spec path not found, `--watch` without a spec path, or no spec path given and no `spec.directory` to fall back to | --- diff --git a/site/src/content/docs/cli/commands/scan.md b/site/src/content/docs/cli/commands/scan.md index 1253fe49..ac72267c 100644 --- a/site/src/content/docs/cli/commands/scan.md +++ b/site/src/content/docs/cli/commands/scan.md @@ -11,7 +11,7 @@ specs scan [file] [options] ## Format -The manifest is a markdown file with a metadata header, a Components table, and (when `glyphNamePattern` is configured) a read-only Glyphs table: +The manifest is a markdown file with a metadata header, a Components table, and (when a `glyphs` convention is declared) a read-only Glyphs table: ```markdown # Component Manifest @@ -94,13 +94,13 @@ A summary line is printed after each merge, e.g. `Merge: 2 added, 1 removed, 5 u ### Glyph partitioning -When `config.processing.glyphNamePattern` is set in `specs.config.yaml`, top-level components whose names match the pattern are routed to a separate `## Glyphs` section in the manifest instead of `## Components`. The pattern uses `{i}` as the glyph-name placeholder — for example, `'DS Icon Glyph / {i}'` matches `DS Icon Glyph / arrow-down` and extracts `arrow-down`. This is the same pattern syntax the processing engine uses for glyph detection inside component instances, so what `scan` partitions matches what `generate` treats as a glyph at processing time. +When `figma.glyphs.match` is declared in `config/conventions.yaml`, top-level components whose names match the pattern are routed to a separate `## Glyphs` section in the manifest instead of `## Components`. The pattern uses `{i}` as the glyph-name placeholder — for example, `'DS Icon Glyph / {i}'` matches `DS Icon Glyph / arrow-down` and extracts `arrow-down`. This is the same pattern syntax the processing engine uses for glyph detection inside component instances, so what `scan` partitions matches what `generate` treats as a glyph at processing time. ```yaml -# specs.config.yaml -config: - processing: - glyphNamePattern: 'DS Icon Glyph / {i}' +# config/conventions.yaml +figma: + glyphs: + match: 'DS Icon Glyph / {i}' ``` Glyphs in the partitioned section are: @@ -109,7 +109,7 @@ Glyphs in the partitioned section are: - **Re-derived on every scan.** The Glyphs section is rebuilt from the current Figma payload — manual edits to that section won't survive a rescan. - **Omitted when empty.** If no components match the pattern, the section isn't written. -If you remove `glyphNamePattern` from config and rescan, previously-partitioned glyphs return to `## Components` and become curatable again. +If you remove the `glyphs` convention and rescan, previously-partitioned glyphs return to `## Components` and become curatable again. ## Examples @@ -117,7 +117,7 @@ If you remove `glyphNamePattern` from config and rescan, previously-partitioned ```bash # Zero-config: auto-resolves the only configured source -# Default output: {dataDirectory}/library.manifest.md +# Default output: {data.directory}/library.manifest.md specs scan # Or pass an explicit file path @@ -127,7 +127,7 @@ specs scan data/library.file.json -o components.md ### Multiple sources ```bash -# When specs.config.yaml has multiple sources, pick one: +# When config/settings.yaml declares multiple data.sources, pick one: specs scan --source library specs scan --source foundations ``` @@ -156,7 +156,7 @@ specs scan --verbose ## Arguments ### `[file]` (optional) -Path to Figma REST API JSON file. When omitted, `scan` resolves the file from your configured sources in `specs.config.yaml`: +Path to Figma REST API JSON file. When omitted, `scan` resolves the file from the sources configured under `data.sources` in `config/settings.yaml`: - **1 source configured** → auto-selected - **2+ sources configured** → must specify one with `--source ` @@ -176,32 +176,32 @@ specs scan data/library.file.json ## Options ### `--source ` -Configured source alias to scan. Required when multiple sources exist in `specs.config.yaml`. Cannot be combined with an explicit `[file]` argument. +Configured source alias to scan. Required when multiple sources exist under `data.sources` in `config/settings.yaml`. Cannot be combined with an explicit `[file]` argument. ```bash specs scan --source library ``` ### `-o, --output ` -Output manifest path. Optional — defaults to `{dataDirectory}/{alias}.manifest.md`, where `dataDirectory` comes from `specs.config.yaml` and `alias` is either the resolved source name or derived from the input filename (e.g., `library.file.json` → `library.manifest.md`). +Output manifest path. Optional — defaults to `{data.directory}/{alias}.manifest.md`, where `data.directory` comes from `config/settings.yaml` and `alias` is either the resolved source name or derived from the input filename (e.g., `library.file.json` → `library.manifest.md`). ```bash # Explicit output path specs scan -o manifests/design-system.md -# Default: writes to data/library.manifest.md (from config dataDirectory) -specs scan --config specs.config.yaml +# Default: writes to data/library.manifest.md (from settings data.directory) +specs scan --config config/ ``` ### `--data-dir ` -Override the data directory used for resolving input files and default output path. Defaults to `dataDirectory` from config, or `./data` if not configured. +Override the data directory used for resolving input files and default output path. Defaults to `data.directory` from `config/settings.yaml`, or `./data` if not configured. ```bash specs scan --data-dir ./custom-data ``` ### `--config ` -Path to config file. Used to resolve `dataDirectory` and `sources` for auto-selection and the default output path. +Path to the `config/` directory. Used to resolve `data.directory` and `data.sources` for auto-selection and the default output path. ### `--include-all` Include all components regardless of devStatus or heuristics. Overrides the default rule and bypasses the merge step entirely. @@ -241,5 +241,5 @@ Manifests produced by older versions of `scan` (checkbox-list format like `- [x] **See Also:** - [Generate Command](/cli/commands/generate/) - Generate specs from manifest or single component - [Render Command](/cli/commands/render/) - Uses scan data to bind glyphs, styles, and variables when rendering in Figma -- [glyphNamePattern](/settings/glyph-name-pattern/) - Pattern syntax that drives Glyphs-section partitioning +- [figma.glyphs.match](/settings/glyph-name-pattern/) - Pattern syntax that drives Glyphs-section partitioning - [Configuration Reference](/settings/) - Format and config options diff --git a/site/src/content/docs/cli/commands/transform.md b/site/src/content/docs/cli/commands/transform.md index c9ed1c63..5a7c9209 100644 --- a/site/src/content/docs/cli/commands/transform.md +++ b/site/src/content/docs/cli/commands/transform.md @@ -16,7 +16,7 @@ specs transform [transformers...] [options] ### `[transformers...]` -One or more transformer names to run. When omitted, uses `config.transform.transformers` from your config file, then falls back to the CLI default (`contract`). +One or more transformer names to run. When omitted, uses `transformers` from `config/pipeline.yaml`, then falls back to the CLI default (`contract`). ```bash specs transform contract css react stories @@ -28,7 +28,7 @@ specs transform contract css react stories Override the output directory for generated artifacts. ### `--config ` -Use a specific config file. +Use a specific `config/` directory. ### `--components ` Only transform the named component folders instead of every component discovered in the output directory. Unknown component keys log a warning and are skipped. @@ -51,5 +51,5 @@ Show detailed output during transformation. ## See Also -- [transform config](/settings/transform/) — configure which transformers run by default +- [Pipeline](/schema/pipeline/) — configure which transformers run by default in `config/pipeline.yaml` - [tokens config](/settings/tokens/) — control how token references are serialized in spec output diff --git a/site/src/content/docs/cli/getting-started.md b/site/src/content/docs/cli/getting-started.md index 5847124d..1fa56b81 100644 --- a/site/src/content/docs/cli/getting-started.md +++ b/site/src/content/docs/cli/getting-started.md @@ -33,19 +33,25 @@ specs --version ## Step 2: Set up your environment -Three things to configure: a config file, your Figma file key, and a Figma access token. +Three things to configure: config files, your Figma file key, and a Figma access token. ### Initialize config -Generate a `specs.config.yaml` with sensible defaults: +Scaffold the `config/` directory with sensible defaults: ```bash specs init ``` +This creates three files, each answering one question: + +- **`config/conventions.yaml`** — facts about the Figma library: naming patterns, state classification, how images are expressed. See [Conventions](/schema/conventions/). +- **`config/settings.yaml`** — choices about the run: sources, spec output, assets. See [Settings](/schema/settings/). +- **`config/pipeline.yaml`** — transformers and analyses to run. See [Pipeline](/schema/pipeline/). + ### Add your Figma file key -Open `specs.config.yaml` and add your Figma file key. Find it by copying your Figma file's URL — the key is the string between `/design/` (or `/file/`) and the file name: +Open `config/settings.yaml` and add your Figma file key. Find it by copying your Figma file's URL — the key is the string between `/design/` (or `/file/`) and the file name: ``` https://www.figma.com/design/AbCdEfGhIjKlMnOpQr/My-Design-System @@ -53,13 +59,15 @@ https://www.figma.com/design/AbCdEfGhIjKlMnOpQr/My-Design-System This is your file key ``` -Add it under `sources`, which should be uncommented: +Add it under `data.sources`, which should be uncommented: ```yaml -sources: - library: - key: AbCdEfGhIjKlMnOpQr - data: [file, variables, styles] +data: + directory: ./data + sources: + library: + key: AbCdEfGhIjKlMnOpQr + fetch: [file, variables, styles] ``` ### Add your Figma access token @@ -76,11 +84,22 @@ A **license key** is optional — Specs CLI works at a free tier without one. To ### Other configuration (optional) -The defaults from `specs init` work well for most setups. When you're ready to customize, these options are available in `specs.config.yaml`: +The defaults from `specs init` work well for most setups. When you're ready to customize: + +- **`data.directory` / `spec.directory`** in `config/settings.yaml` — where fetched data is stored and specs are written. Defaults: `./data` and `./specs`. +- **`spec.splitComponents` / `spec.splitConcerns` / `spec.useSubfolders`** in `config/settings.yaml` — controls file organization: split per component, split by concern, use subfolders. All default to `true`, which is the layout `transform` and `analyze` read. +- **`config/conventions.yaml`** — declare how your library names glyphs, subcomponents, and images, and classify variant props as [states](/settings/states/). See [Conventions](/schema/conventions/). +- **`config/pipeline.yaml`** — the transformers and analyses to run. See [Pipeline](/schema/pipeline/). + +:::note[Upgrading from a single config file?] +A pre-split `specs.config.yaml` is no longer read — every command stops with an error until it's converted, and `specs init` refuses to scaffold over it. Run one command to convert it: -- **`dataDirectory` / `outputDirectory`** — where fetched data is stored and specs are written. Defaults: `./data` and `./specs`. -- **`config`** — controls output format (JSON/YAML, key casing, token format), processing behavior (variant depth, detail level), and what to include. See [Configuration Reference](/schema/config/). -- **`output`** — controls file organization: split per component, split by concern, use subfolders. All default to `false`. +```bash +specs migrate config +``` + +It writes `config/conventions.yaml`, `config/settings.yaml`, and `config/pipeline.yaml` from your file, then renames the original to `specs.config.yaml.migrated`. See [`migrate`](/cli/commands/migrate/). +::: ## Step 3: Fetch the Figma file diff --git a/site/src/content/docs/cli/index.md b/site/src/content/docs/cli/index.md index dd50d095..cbe5cd9d 100644 --- a/site/src/content/docs/cli/index.md +++ b/site/src/content/docs/cli/index.md @@ -8,14 +8,14 @@ The Specs command-line interface (CLI) generates design system specifications fr | Command | Purpose | Output | |---------|---------|--------| -| [`init`](/cli/commands/init/) | Initialize config file with defaults | `specs.config.yaml` | -| [`fetch`](/cli/commands/fetch/) | Download raw REST payloads from Figma | JSON files in `dataDirectory` | +| [`init`](/cli/commands/init/) | Initialize config files with defaults | `config/conventions.yaml`, `config/settings.yaml`, `config/pipeline.yaml` | +| [`fetch`](/cli/commands/fetch/) | Download raw REST payloads from Figma | JSON files in `data.directory` | | [`scan`](/cli/commands/scan/) | List all components in file | Markdown manifest | | [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) | Inject `$custom` objects into fetched data | Modified variables/styles JSON | | [`generate`](/cli/commands/generate/) | Generate specs from a manifest or single component | YAML/JSON spec file(s) | | [`transform`](/cli/commands/transform/) *(experimental)* | Run transformers over generated specs | Code artifacts per transformer | | [`bridge`](/cli/commands/bridge/) *(experimental)* | Start/stop/check the local bridge `render` talks to | Background process | -| [`cache`](/cli/commands/cache/) *(experimental)* | Build the lookup tables `render` resolves specs against | YAML files in `dataDirectory/cache` | +| [`cache`](/cli/commands/cache/) *(experimental)* | Build the lookup tables `render` resolves specs against | YAML files in `{data.directory}/cache` | | [`render`](/cli/commands/render/) *(experimental)* | Send a spec to the CLI bridge to render it live in Figma | Live Figma component | ### Global Options @@ -77,7 +77,7 @@ components: `specs fetch` writes deterministic filenames based on your config aliases. -Example (with `dataDirectory: ./data`): +Example (with `data.directory: ./data` in `config/settings.yaml`): ``` data/ @@ -88,7 +88,7 @@ data/ └── foundations.styles.json ``` -`generate` uses these files by default when your `specs.config.yaml` declares the corresponding aliases and data types. +`generate` uses these files by default when `data.sources` in `config/settings.yaml` declares the corresponding aliases and fetch kinds. ## Requirements diff --git a/site/src/content/docs/cli/transforms/contract.md b/site/src/content/docs/cli/transforms/contract.md index a221060d..824027a8 100644 --- a/site/src/content/docs/cli/transforms/contract.md +++ b/site/src/content/docs/cli/transforms/contract.md @@ -88,22 +88,26 @@ A slot is required (non-optional in `Slots`) only when its rule is `always`. The ## Config -No transformer-specific options. Prop omission for browser-driven states comes from [`config.processing.states`](/settings/states/). +No transformer-specific options. Prop omission for browser-driven states comes from the [`figma.states`](/settings/states/) convention in `config/conventions.yaml`. ```yaml -config: - processing: - states: # optional — omit to retain all props in contracts - hover: - prop: state - value: hover - disabled: - prop: isDisabled - transformers: - - name: contract +# config/conventions.yaml +figma: + states: # optional — omit to retain all props in contracts + hover: + prop: state + value: hover + disabled: + prop: isDisabled ``` -When `processing.states` is absent, all props from `api.yaml` appear in the generated interface. When present, props mapped to browser-driven concepts (`hover`, `active`, `focus`, `focus-within`, etc.) are omitted — the browser fires these without the application setting them. +```yaml +# config/pipeline.yaml +transformers: + - name: contract +``` + +When the `states` convention is absent, all props from `api.yaml` appear in the generated interface. When present, props mapped to browser-driven concepts (`hover`, `active`, `focus`, `focus-within`, etc.) are omitted — the browser fires these without the application setting them. ## Subcomponent Contracts @@ -121,12 +125,12 @@ dsActionList/ Item.contract.ts ← subcomponent (DsActionListItemProps, DsActionListItemDefaults) ``` -The parent contract file only includes the parent component's own types — subcomponent types do not appear in it. Configure subcomponent discovery in [`config.processing.subcomponents`](/settings/subcomponents/). +The parent contract file only includes the parent component's own types — subcomponent types do not appear in it. Configure subcomponent discovery in the [`figma.subcomponents`](/settings/subcomponents/) convention. ## See Also - [Transforms overview](/cli/transforms/) -- [`processing.states` config](/settings/states/) — classify which props are browser-driven vs consumer-controlled +- [`figma.states` convention](/settings/states/) — classify which props are browser-driven vs consumer-controlled - [`css` transformer](/cli/transforms/css/) - [`react` transformer](/cli/transforms/react/) — consumes `Slots`/`SlotRules` to gate element rendering - [`stories` transformer](/cli/transforms/stories/) diff --git a/site/src/content/docs/cli/transforms/css.md b/site/src/content/docs/cli/transforms/css.md index d47a9028..5271b678 100644 --- a/site/src/content/docs/cli/transforms/css.md +++ b/site/src/content/docs/cli/transforms/css.md @@ -88,7 +88,7 @@ Root element selectors use the component's kebab-cased name. Child elements use ## Token Resolution -Token references are resolved to CSS `var(--)` based on `config.format.tokens`: +Token references are resolved to CSS `var(--)` based on `spec.tokens`: | Format | Resolution | |--------|------------| @@ -99,28 +99,36 @@ Token references are resolved to CSS `var(--)` based on `config.format.tokens`: ## Config -No transformer-specific options. Token format comes from `config.format.tokens`. Selector strategy for variant props comes from [`config.processing.states`](/settings/states/). +No transformer-specific options. Token format comes from `spec.tokens` in `config/settings.yaml`. Selector strategy for variant props comes from the [`figma.states`](/settings/states/) convention in `config/conventions.yaml`. ```yaml -config: - format: - tokens: TOKEN # controls how token vars are named - processing: - states: # optional — omit to keep data-* attribute selectors - hover: - prop: state - value: hover - disabled: - prop: isDisabled - transformers: - - name: css +# config/settings.yaml +spec: + tokens: TOKEN # controls how token vars are named ``` -When `processing.states` is absent, all variant props produce `[data-*]` selectors (the default shown in the example above). When present, classified props emit semantic CSS pseudo-classes and ARIA attribute selectors instead. +```yaml +# config/conventions.yaml +figma: + states: # optional — omit to keep data-* attribute selectors + hover: + prop: state + value: hover + disabled: + prop: isDisabled +``` + +```yaml +# config/pipeline.yaml +transformers: + - name: css +``` + +When the `states` convention is absent, all variant props produce `[data-*]` selectors (the default shown in the example above). When present, classified props emit semantic CSS pseudo-classes and ARIA attribute selectors instead. ### Disabled guard on hover and active -When the `disabled` concept is configured in `processing.states`, the transformer automatically appends `:not(:disabled):not([aria-disabled="true"])` to every `:hover` and `:active` selector — including compound variants that mix a data attribute with `:hover` or `:active`. This prevents hover and active styles from firing on disabled elements without any extra CSS to write. +When the `disabled` concept is configured in `figma.states`, the transformer automatically appends `:not(:disabled):not([aria-disabled="true"])` to every `:hover` and `:active` selector — including compound variants that mix a data attribute with `:hover` or `:active`. This prevents hover and active styles from firing on disabled elements without any extra CSS to write. ```css /* disabled concept configured → hover and active are guarded */ @@ -202,12 +210,12 @@ The subcomponent stylesheet follows the same structure as the parent — default } ``` -Subcomponent stylesheets are fully self-contained — elements and variants from the parent component never appear in them. Configure subcomponent discovery in [`config.processing.subcomponents`](/settings/subcomponents/). +Subcomponent stylesheets are fully self-contained — elements and variants from the parent component never appear in them. Configure subcomponent discovery in the [`figma.subcomponents`](/settings/subcomponents/) convention. ## See Also - [Transforms overview](/cli/transforms/) -- [`processing.states` config](/settings/states/) — classify variant props as semantic states +- [`figma.states` convention](/settings/states/) — classify variant props as semantic states - [`contract` transformer](/cli/transforms/contract/) - [`react` transformer](/cli/transforms/react/) — imports this stylesheet into the generated and authored components - [tokens config](/settings/tokens/) diff --git a/site/src/content/docs/cli/transforms/index.md b/site/src/content/docs/cli/transforms/index.md index 2cccdd2e..a50700f1 100644 --- a/site/src/content/docs/cli/transforms/index.md +++ b/site/src/content/docs/cli/transforms/index.md @@ -45,12 +45,14 @@ The `react` transformer additionally seeds `src/react/{Component}.tsx` plus `.ex ### Prerequisites -`specs transform` discovers components by scanning the output directory for subfolders that each contain an `api.yaml`. That exact shape — a per-component subfolder with `api.yaml` and `variants.yaml` inside it — only comes from running `generate` with **both** `--split-components` and `--split-concerns`: +`specs transform` discovers components by scanning the output directory for subfolders that each contain an `api.yaml`. That exact shape — a per-component subfolder with `api.yaml` and `variants.yaml` inside it — is what `generate` writes by default: ```bash -specs generate --split-components --split-concerns +specs generate ``` +A workspace that has turned the split off (`--combine-as-library` or `--combine-concerns`, or the equivalent `spec` settings) will not produce it, and `transform` will find nothing to discover. + If you only see a single `library.yaml`, or `{Component}.yaml` files with no `api.yaml` inside, re-run `generate` with both flags above before transforming. ### Processing @@ -61,12 +63,12 @@ If you only see a single `library.yaml`, or `{Component}.yaml` files with no `ap specs transform [transformers...] [options] ``` -Transformer names can be passed as positional arguments, configured in `specs.config.yaml`, or left absent to use the CLI default (`contract`). +Transformer names can be passed as positional arguments, configured in `config/pipeline.yaml`, or left absent to use the CLI default (`contract`). #### Resolution Order 1. Positional arguments — `specs transform css react` -2. `config.transform.transformers` in `specs.config.yaml` +2. `transformers` in `config/pipeline.yaml` 3. CLI default: `contract` ## Types @@ -94,15 +96,14 @@ specs transform react stories --components dsAlert dsBadge specs transform contract css react stories ``` -Or configure them in `specs.config.yaml` so `specs transform` alone is enough: +Or configure them in `config/pipeline.yaml` so `specs transform` alone is enough: ```yaml -config: - transformers: - - name: contract - - name: css - - name: react - - name: stories +transformers: + - name: contract + - name: css + - name: react + - name: stories ``` `react` and `stories` both assume `contract` and `css` have already produced `generated/{Component}.contract.ts` and `generated/{Component}.styles.css` for the component — list them in this order. @@ -110,4 +111,4 @@ config: ## See Also - [`transform` command](/cli/commands/transform/) — full CLI reference -- [transform config](/settings/transform/) — configure default transformers +- [Pipeline](/schema/pipeline/) — configure default transformers in `config/pipeline.yaml` diff --git a/site/src/content/docs/cli/transforms/react.md b/site/src/content/docs/cli/transforms/react.md index 35c29c8e..6b56f18f 100644 --- a/site/src/content/docs/cli/transforms/react.md +++ b/site/src/content/docs/cli/transforms/react.md @@ -89,7 +89,7 @@ export function DsAlert(props: DsAlertScaffoldProps) { ## Rendering Rules -- **Root element** gets the component's kebab-cased class, every variant prop as a `data-*` attribute (boolean props use presence attributes, string/enum props use value attributes), and ARIA attributes for any prop classified in [`config.processing.states`](/settings/states/) whose selector resolves to an `aria-*` attribute. +- **Root element** gets the component's kebab-cased class, every variant prop as a `data-*` attribute (boolean props use presence attributes, string/enum props use value attributes), and ARIA attributes for any prop classified in the [`figma.states`](/settings/states/) convention whose selector resolves to an `aria-*` attribute. - **Child elements** get the `__element` BEM suffix, matching the `css` transformer's selectors. - **Slot-typed elements** with a `slot` type surface as an additional `React.ReactNode` prop on `{Component}ScaffoldProps` (not in the spec-derived `Props` interface itself), rendered as `{p.slotName}`. - **Text elements** bound to a prop render `{p.propName}`; text elements with static spec content render that content verbatim (escaped for JSX). @@ -101,11 +101,11 @@ export function DsAlert(props: DsAlertScaffoldProps) { No transformer-specific options today. ```yaml -config: - transformers: - - name: contract - - name: css - - name: react +# config/pipeline.yaml +transformers: + - name: contract + - name: css + - name: react ``` ## Subcomponent Output diff --git a/site/src/content/docs/cli/transforms/stories.md b/site/src/content/docs/cli/transforms/stories.md index e1d317f3..908e389e 100644 --- a/site/src/content/docs/cli/transforms/stories.md +++ b/site/src/content/docs/cli/transforms/stories.md @@ -76,12 +76,12 @@ export const Dismissible: Story = { args: { dismissible: true } }; No transformer-specific options today. ```yaml -config: - transformers: - - name: contract - - name: css - - name: react - - name: stories +# config/pipeline.yaml +transformers: + - name: contract + - name: css + - name: react + - name: stories ``` Running `stories` without `react` first still works — the `react` transformer's `src/react/{Component}.tsx` seed step is idempotent and only needs to have run at least once for the imported module to exist. In practice, run `react` before `stories` so the authored component is in place. diff --git a/site/src/content/docs/cli/workflows.md b/site/src/content/docs/cli/workflows.md index 3e87aa38..f30b00ea 100644 --- a/site/src/content/docs/cli/workflows.md +++ b/site/src/content/docs/cli/workflows.md @@ -88,7 +88,7 @@ Change `[x]` to `[ ]` (or vice versa) in the first column for components to excl ### Step 3: Generate ```bash -# Zero-config: reads default manifest and writes to outputDirectory +# Zero-config: reads default manifest and writes to spec.directory specs generate --verbose # Or with explicit paths: @@ -116,7 +116,7 @@ on: push: paths: - 'manifests/**' - - 'specs.config.yaml' + - 'config/**' jobs: generate-specs: diff --git a/site/src/content/docs/guides/code-only-props.md b/site/src/content/docs/guides/code-only-props.md index 6c5e0119..f4a6ed18 100644 --- a/site/src/content/docs/guides/code-only-props.md +++ b/site/src/content/docs/guides/code-only-props.md @@ -3,19 +3,19 @@ title: "Code-Only Props" description: "Extract non-visual component properties from hidden Figma container layers" --- -Design systems encode more than just visual styling. Accessibility labels, semantic heading levels, ARIA roles, min/max constraints — these properties affect the coded component but have no visual representation in Figma. The `codeOnlyPropsPattern` feature tells the processing engine where to find these hidden properties and promotes them into the spec alongside visual props. +Design systems encode more than just visual styling. Accessibility labels, semantic heading levels, ARIA roles, min/max constraints — these properties affect the coded component but have no visual representation in Figma. The `codeOnlyProps` convention tells the processing engine where to find these hidden properties and promotes them into the spec alongside visual props. ## The Problem Figma's component property system is visual — variant properties control layer visibility, text properties bind to text content, boolean properties toggle layers. There's no built-in way to express a property like `aria-label` or `tabIndex` that only matters in code. -Teams solve this by creating a hidden container layer inside their Figma components. The container (typically named "Code only props") is effectively invisible — near-zero dimensions, clipped contents — and hosts sub-layers whose names and bindings encode the non-visual properties. Without `codeOnlyPropsPattern`, the processing engine treats this container as an ordinary anatomy element. The props it encodes are invisible in the spec output. +Teams solve this by creating a hidden container layer inside their Figma components. The container (typically named "Code only props") is effectively invisible — near-zero dimensions, clipped contents — and hosts sub-layers whose names and bindings encode the non-visual properties. Without a `codeOnlyProps` convention, the processing engine treats this container as an ordinary anatomy element. The props it encodes are invisible in the spec output. ## What It Does -When `codeOnlyPropsPattern` is set, the processing engine: +When `codeOnlyProps` is declared, the processing engine: -1. **Finds the container** — scans the component's direct children for a layer whose name exactly matches the pattern string +1. **Finds the container** — scans the component's direct children for a layer whose name exactly matches the `match` string 2. **Excludes it from anatomy** — the container and its descendants are removed from the element tree, since they carry no visual meaning 3. **Extracts props** — each sub-layer inside the container becomes a prop in the spec, with its type inferred from the Figma node type: @@ -27,7 +27,7 @@ When `codeOnlyPropsPattern` is set, the processing engine: Every extracted prop carries source provenance in `$extensions["com.figma"]`, identifying it as `kind: "codeOnlyProp"` with the originating layer name. This lets consumers distinguish code-only props from visually-derived ones when needed. -### Before (without `codeOnlyPropsPattern`) +### Before (without a `codeOnlyProps` convention) The container appears as an anatomy element, and its sub-layers are treated as nested elements: @@ -39,7 +39,7 @@ anatomy: - Code only props # unwanted noise in the element tree ``` -### After (with `codeOnlyPropsPattern: "Code only props"`) +### After (with `codeOnlyProps.match: "Code only props"`) The container is excluded from anatomy. Its sub-layers become props: @@ -71,28 +71,28 @@ props: ## When to Use It -Enable `codeOnlyPropsPattern` when your Figma library follows the code-only props convention — a hidden container layer whose sub-layers encode non-visual properties. This is most valuable when: +Declare `codeOnlyProps` when your Figma library follows the code-only props convention — a hidden container layer whose sub-layers encode non-visual properties. This is most valuable when: - **Components have accessibility props** — labels, roles, live-region attributes that code needs but Figma can't visualize - **Components have semantic props** — heading levels, tab indices, character limits - **You want a clean anatomy** — removing the code-only container from the element tree eliminates noise in the spec -Leave it unset if your library doesn't use the code-only props convention. +Leave it undeclared if your library doesn't use the code-only props convention. ## Configuration -Set `codeOnlyPropsPattern` under `model.processing` in your config file. The value is the exact name of the container layer: +Declare `codeOnlyProps` under `figma` in `config/conventions.yaml`. The `match` value is the exact name of the container layer: ```yaml -# specs.config.yaml -model: - processing: - codeOnlyPropsPattern: "Code only props" +# config/conventions.yaml +figma: + codeOnlyProps: + match: "Code only props" ``` **Default**: absent. When omitted, no code-only prop extraction occurs and the container (if present) is treated as a normal anatomy element. -The pattern is matched by **exact string equality** against direct children of the component root. It is not a regex or glob — the layer name must match exactly. +The `match` value is matched by **exact string equality** against direct children of the component root. It is not a regex or glob — the layer name must match exactly. ## Nesting diff --git a/site/src/content/docs/guides/data-layout.md b/site/src/content/docs/guides/data-layout.md index 8ebbda0d..b32153ea 100644 --- a/site/src/content/docs/guides/data-layout.md +++ b/site/src/content/docs/guides/data-layout.md @@ -106,13 +106,12 @@ Best for: consumers that need both tree traversal and direct element lookups, or ## Configuration -Set `layout` under `model.format` in your config file: +Set `layout` under `spec` in `config/settings.yaml`: ```yaml -# specs.config.yaml -model: - format: - layout: LAYOUT # Default — top-level tree only +# config/settings.yaml +spec: + layout: LAYOUT # Default — top-level tree only ``` **Default**: `LAYOUT`. diff --git a/site/src/content/docs/guides/default-slot-content.md b/site/src/content/docs/guides/default-slot-content.md index 1cf00945..5a453f1e 100644 --- a/site/src/content/docs/guides/default-slot-content.md +++ b/site/src/content/docs/guides/default-slot-content.md @@ -43,10 +43,9 @@ Identical fills across variants and components de-duplicate to a single entry, k ## Configuration ```yaml -# specs.config.yaml -config: - include: - defaultSlotContent: true +# config/settings.yaml +spec: + defaultSlotContent: true ``` **Default**: `false`. Existing output is unchanged until you opt in. @@ -60,7 +59,7 @@ config: These are two different things, often confused: - **Default slot content** (this guide) — the content *inside a slot*, captured structurally. No detection config; `defaultSlotContent` is the only switch. -- **[Instance (ready-made) examples](/guides/instance-examples/)** — whole pre-configured *instances* of a component, detected from named frames via `processing.instanceExamples`. +- **[Instance (ready-made) examples](/guides/instance-examples/)** — whole pre-configured *instances* of a component, detected from named frames via the `figma.instanceExamples` convention. ## Further Reading diff --git a/site/src/content/docs/guides/glyph-name-pattern.md b/site/src/content/docs/guides/glyph-name-pattern.md index 33b7616a..dea5e4d6 100644 --- a/site/src/content/docs/guides/glyph-name-pattern.md +++ b/site/src/content/docs/guides/glyph-name-pattern.md @@ -3,7 +3,7 @@ title: "Glyph Name Pattern" description: "Detect icon glyph instances and classify them as content elements instead of generic instances" --- -Design system components frequently contain icon instances — a `Button` with a leading icon, an `Alert` with a status icon, a `MenuItem` with an action icon. In Figma, these icons are component instances just like any other nested component. Without special handling, they appear as generic `instance` elements with an `instanceOf` reference to the full icon component. The `glyphNamePattern` setting tells the processing engine how to recognize these icon instances and reclassify them as lightweight `glyph` elements with a human-readable content name. +Design system components frequently contain icon instances — a `Button` with a leading icon, an `Alert` with a status icon, a `MenuItem` with an action icon. In Figma, these icons are component instances just like any other nested component. Without special handling, they appear as generic `instance` elements with an `instanceOf` reference to the full icon component. The `glyphs` convention tells the processing engine how to recognize these icon instances and reclassify them as lightweight `glyph` elements with a human-readable content name. ## The Problem @@ -24,7 +24,7 @@ This is technically accurate but unhelpful. The consumer doesn't need to know th ## What It Does -When `glyphNamePattern` is configured, the processing engine tests every `INSTANCE` node against the pattern during element detection. If the instance's **main component name** matches, the element is created as a `glyph` instead of an `instance`: +When the `glyphs` convention is declared, the processing engine tests every `INSTANCE` node against its `match` pattern during element detection. If the instance's **main component name** matches, the element is created as a `glyph` instead of an `instance`: ```yaml elements: @@ -53,8 +53,9 @@ Glyph elements carry fewer style properties because icon assets are typically si The pattern is a plain string with a single placeholder: **`{i}`** marks where the glyph name appears in the component name. ```yaml -processing: - glyphNamePattern: 'DS Icon Glyph / {i}' +figma: + glyphs: + match: 'DS Icon Glyph / {i}' ``` The engine converts the pattern into a regex internally: all special characters are escaped, and `{i}` becomes a `(.+)` capture group. The captured text becomes the glyph's `content` value. @@ -79,10 +80,12 @@ Parentheses, dots, and other regex-special characters in the pattern are escaped ```yaml # Parentheses in prefix -glyphNamePattern: 'Icons (v2) / {i}' +glyphs: + match: 'Icons (v2) / {i}' # Dots in prefix -glyphNamePattern: 'icon.glyph.{i}' +glyphs: + match: 'icon.glyph.{i}' ``` ### Whitespace @@ -91,51 +94,58 @@ The component name is whitespace-normalized before matching — multiple spaces ## Configuration -Set `glyphNamePattern` under `processing` in your config file: +Declare `glyphs` under `figma` in `config/conventions.yaml`: ```yaml -# specs.config.yaml -processing: - glyphNamePattern: 'DS Icon Glyph / {i}' +# config/conventions.yaml +figma: + glyphs: + match: 'DS Icon Glyph / {i}' ``` -**Default**: absent (no glyph detection). When omitted, all `INSTANCE` nodes are treated as regular instance elements. +**Default**: absent (no glyph detection). When omitted, all `INSTANCE` nodes are treated as regular instance elements — the library declares no glyph convention. -**Validation**: if the value is not a string or is empty/whitespace-only, it is silently ignored and glyph detection is disabled. +**Validation**: if `match` is not a string or is empty/whitespace-only, the block is dropped and glyph detection is disabled. ### Common patterns **Slash-separated prefix** (most common): ```yaml -glyphNamePattern: 'DS Icon Glyph / {i}' +glyphs: + match: 'DS Icon Glyph / {i}' ``` **Shorter prefix**: ```yaml -glyphNamePattern: 'Icon / {i}' +glyphs: + match: 'Icon / {i}' ``` **Dot-separated**: ```yaml -glyphNamePattern: 'icon.glyph.{i}' +glyphs: + match: 'icon.glyph.{i}' ``` **Versioned prefix**: ```yaml -glyphNamePattern: 'Icons (v2) / {i}' +glyphs: + match: 'Icons (v2) / {i}' ``` The right pattern depends on how your Figma library names its icon glyph components. Open your icon library in Figma and look at the component names — the prefix before the individual icon name is what goes before `{i}`. ## Fetching the Assets -The same pattern powers asset download: `specs fetch` with `icons` in a source's `data` array walks the fetched file payload for components matching `glyphNamePattern` and downloads each one as an SVG to `/_icons/`, beside the `_images/` assets: +The same pattern powers asset download: `specs fetch` with `icons` in a source's `fetch` array walks the fetched file payload for components matching the `glyphs` pattern and downloads each one as an SVG to `/_icons/`, beside the `_images/` assets: ```yaml -sources: - library: - key: YOUR_FILE_KEY - data: ['file', 'icons'] +# config/settings.yaml +data: + sources: + library: + key: YOUR_FILE_KEY + fetch: ['file', 'icons'] ``` Filenames are kebab-case slugs of the extracted name (`Arrow Left` → `arrow-left.svg`, `expandMore` → `expand-more.svg`) — the same slugs generated component output references, so serving the directory as static assets makes glyphs render directly. See [fetch](/cli/commands/fetch/#fetching-icon-assets) for details. @@ -163,7 +173,7 @@ This binding is created automatically — no additional configuration needed. It **Glyphs are mutually exclusive with instances.** A matched node becomes a `glyph`; an unmatched node stays an `instance`. If the pattern is too broad, real subcomponent instances might be misclassified as glyphs. Keep the pattern specific to your icon asset naming convention. -**Start without it.** Generate specs first without `glyphNamePattern` to see which elements are instances. Look for icon instances that would be more useful as content references, then add the pattern to capture them. +**Start without it.** Generate specs first without a `glyphs` convention to see which elements are instances. Look for icon instances that would be more useful as content references, then declare the convention to capture them. ## Further Reading diff --git a/site/src/content/docs/guides/images.md b/site/src/content/docs/guides/images.md index 4b564eef..7b52afb4 100644 --- a/site/src/content/docs/guides/images.md +++ b/site/src/content/docs/guides/images.md @@ -3,7 +3,7 @@ title: "Images" description: "Capture image fills and image-source props — as a designated image component or a container background fill" --- -Design systems use images two ways, and Specs captures both. Sometimes an image is a **layer fill** — a card with a photographic background, a hero banner painted directly on a container. Other times it flows through a dedicated **image component** — a `dsImage` or `dsAvatar` with a `source`-like prop that renders whatever image is passed in. Which one you get is a config choice, not a guess. +Design systems use images two ways, and Specs captures both. Sometimes an image is a **layer fill** — a card with a photographic background, a hero banner painted directly on a container. Other times it flows through a dedicated **image component** — a `dsImage` or `dsAvatar` with a `source`-like prop that renders whatever image is passed in. Which one you get is a declared convention, not a guess. ## The Problem @@ -11,7 +11,7 @@ Until now the schema had no representation for images at all. An `IMAGE`-type fi ## What It Does -When [`processing.images`](/settings/images/) is configured, the engine captures image fills and image-source props and stores each distinct image once in a `Component.images` registry, referenced by an `$image` pointer: +When the [`figma.images`](/settings/images/) convention is declared, the engine captures image fills and image-source props and stores each distinct image once in a `Component.images` registry, referenced by an `$image` pointer: ```yaml default: @@ -66,23 +66,22 @@ Sourcing binds through `propConfigurations`, never through `backgroundImage` — ## Configuration -Everything lives in one block: [`processing.images`](/settings/images/). Its presence is the on-switch, and each member is an independent representation trigger: +Everything lives in one block: [`figma.images`](/settings/images/) in `config/conventions.yaml`. Its presence is the on-switch, and each member is an independent representation trigger: ```yaml -# specs.config.yaml -config: - processing: - images: - backgroundImage: true # detect image fills → Styles.backgroundImage - imageComponent: dsImage # designate an image component (requires sourceProps) - sourceProps: [source] # code-only props typed as images; first = dsImage's source prop +# config/conventions.yaml +figma: + images: + backgroundImage: true # detect image fills → Styles.backgroundImage + match: dsImage # designate an image component (requires sourceProps) + sourceProps: [source] # code-only props typed as images; first = dsImage's source prop ``` -| Goal | Config | Behavior | +| Goal | Convention | Behavior | |------|--------|----------| | Background fills only | `backgroundImage: true` | Every image fill → `backgroundImage` on containers | -| Component + fill fallback | `imageComponent` + `sourceProps` + `backgroundImage: true` | Image props route through the component; stray fills still emit as `backgroundImage` | -| Component only | `imageComponent` + `sourceProps` | The component is the **only** image representation; stray fills are not detected | +| Component + fill fallback | `match` + `sourceProps` + `backgroundImage: true` | Image props route through the component; stray fills still emit as `backgroundImage` | +| Component only | `match` + `sourceProps` | The component is the **only** image representation; stray fills are not detected | | Typed image props only | `sourceProps` alone | Listed code-only props re-type to `ImageProp`; no fill detection, no component routing | ## Object Fit @@ -101,7 +100,7 @@ The Figma plugin cannot embed raw image bytes on the component asset (Figma caps ## Further Reading -- [`processing.images`](/settings/images/) — config reference +- [`figma.images`](/settings/images/) — convention reference - [Schema: Styles](/schema/styles/) — the `backgroundImage` value shape - [Schema: Props](/schema/props/) — the `ImageProp` shape - [Schema: Prop Configurations](/schema/prop-configurations/) — the `ImageBinding` shape diff --git a/site/src/content/docs/guides/instance-examples.md b/site/src/content/docs/guides/instance-examples.md index 8c4f336f..3f3cffb3 100644 --- a/site/src/content/docs/guides/instance-examples.md +++ b/site/src/content/docs/guides/instance-examples.md @@ -34,27 +34,26 @@ instanceExamples: ## Configuration -Detection mirrors [`subcomponents`](/settings/subcomponents/): the **presence** of `processing.instanceExamples` is the on-switch. There is no separate `include` flag — when the block is present (and the license is Pro), examples are detected *and* emitted. +Detection mirrors [`subcomponents`](/settings/subcomponents/): the **presence** of the `figma.instanceExamples` convention is the on-switch. There is no separate `include` flag — when the block is present (and the license is Pro), examples are detected *and* emitted. ```yaml -# specs.config.yaml -config: - processing: - instanceExamples: - scope: PAGE # PAGE (default) or FILE - match: - - "{C} - *" # {C} = component name - parentNames: - - Examples # immediate parent must be an "Examples" frame/section +# config/conventions.yaml +figma: + instanceExamples: + scope: PAGE # PAGE (default) or FILE + match: + - "{C} - *" # {C} = component name + parentNames: + - Examples # immediate parent must be an "Examples" frame/section ``` -- `match` (required) — name patterns identifying example frames; `{C}` expands to the component name, `*` is a wildcard. +- `match` (optional) — name patterns identifying example frames; `{C}` expands to the component name, `*` is a wildcard. Omit to match every in-scope instance of the component. - `exclude` — patterns that disqualify a frame (exclusion wins over match). - `parentNames` — restrict to frames whose **immediate** parent is one of these names. - `scope` — `PAGE` (component's page) or `FILE` (all pages, e.g. a dedicated Examples page). :::note[Pro feature] -Instance examples require a [Pro license](/overview/licensing/). On the free tier `processing.instanceExamples` is ignored — no detection, no output. In the Figma plugin the control is hidden until a Pro license is active. +Instance examples require a [Pro license](/overview/licensing/). On the free tier `figma.instanceExamples` is ignored — no detection, no output. In the Figma plugin the control is hidden until a Pro license is active. ::: ## Naming Tips @@ -68,7 +67,7 @@ Instance examples require a [Pro license](/overview/licensing/). On the free tie ## Further Reading -- [`processing.instanceExamples`](/settings/instance-examples/) — config reference +- [`figma.instanceExamples`](/settings/instance-examples/) — convention reference - [Default Slot Content](/guides/default-slot-content/) — the sibling feature - [`subcomponents`](/settings/subcomponents/) — the presence-driven detection model this mirrors - [Schema: Component](/schema/component/) — the `instanceExamples` registry shape diff --git a/site/src/content/docs/guides/invalid-variant-combinations.md b/site/src/content/docs/guides/invalid-variant-combinations.md index a324a6ce..bc210424 100644 --- a/site/src/content/docs/guides/invalid-variant-combinations.md +++ b/site/src/content/docs/guides/invalid-variant-combinations.md @@ -70,7 +70,7 @@ A consumer reading this spec knows exactly which prop pairings to avoid — and The processing engine computes invalid combinations by comparing the full set of theoretical prop combinations against the variants actually present in the Figma component set. Any theoretical combination that has no matching variant is recorded as invalid. -This is controlled by the `include.invalidCombinations` config option: +This is controlled by the `spec.invalidCombinations` setting: | Value | Behavior | |-------|----------| @@ -78,8 +78,8 @@ This is controlled by the `include.invalidCombinations` config option: | `false` | Omit the field entirely | ```yaml -# Config -include: +# config/settings.yaml +spec: invalidCombinations: true # default ``` @@ -97,7 +97,7 @@ Together they form a complete picture of the component's variant space. A prop c ### Relationship to `Variant.invalid` -Individual variants can also carry an `invalid: true` flag. This flag marks a specific variant record as invalid and is controlled by the separate `include.invalidVariants` config option (default: `false`). +Individual variants can also carry an `invalid: true` flag. This flag marks a specific variant record as invalid and is controlled by the separate `spec.invalidVariants` setting (default: `false`). The two mechanisms serve different purposes: @@ -106,7 +106,7 @@ The two mechanisms serve different purposes: | `invalidVariantCombinations` | Summary list on `Component` | Included (`true`) | | `Variant.invalid` | Flag on individual `Variant` records | Excluded (`false`) | -When `include.invalidVariants` is `true`, invalid variants appear in the `variants` array with `invalid: true` and empty `elements`. When `include.invalidCombinations` is `true`, the same combinations appear in the summary list. Both can be active simultaneously — the summary list provides a quick lookup table, while the variant-level flag preserves the combination within the variant array for tools that iterate all variants. +When `spec.invalidVariants` is `true`, invalid variants appear in the `variants` array with `invalid: true` and empty `elements`. When `spec.invalidCombinations` is `true`, the same combinations appear in the summary list. Both can be active simultaneously — the summary list provides a quick lookup table, while the variant-level flag preserves the combination within the variant array for tools that iterate all variants. ## Schema Reference @@ -118,7 +118,7 @@ invalidVariantCombinations?: PropConfigurations[]; An array of `PropConfigurations` objects. Each object is a `Record` mapping prop names to the values that form the invalid combination. -### Config: `include.invalidCombinations` +### Setting: `spec.invalidCombinations` ```typescript include: { @@ -136,4 +136,4 @@ When `true`, the component output includes the `invalidVariantCombinations` fiel - [Variant Depth](/guides/variant-depth/) — controlling variant expansion depth - [Variants](/schema/variants/) — variant schema reference - [Component](/schema/component/) — top-level component schema -- [Config](/schema/config/) — full configuration options +- [Settings](/schema/settings/) — full run settings diff --git a/site/src/content/docs/guides/key-formatting.md b/site/src/content/docs/guides/key-formatting.md index 07ae5f3c..27fdb2b3 100644 --- a/site/src/content/docs/guides/key-formatting.md +++ b/site/src/content/docs/guides/key-formatting.md @@ -74,13 +74,12 @@ The transformation applies to keys throughout the entire spec — not just props ## Configuration -Set `keys` under `model.format` in your config file: +Set `keys` under `spec` in `config/settings.yaml`: ```yaml -# specs.config.yaml -model: - format: - keys: CAMEL # Transform all keys to camelCase +# config/settings.yaml +spec: + keys: CAMEL # Transform all keys to camelCase ``` **Default**: `SAFE` — preserves Figma names without modification. @@ -103,13 +102,18 @@ Two settings make the round trip reliable. ### The source convention -`figmaKeys` declares the convention your Figma file already uses, so a formatted key has a defined name to reverse into: +The `figma.naming` convention declares the convention your Figma file already uses, so a formatted key has a defined name to reverse into. It lives in `config/conventions.yaml` — it is a fact about the library, while `keys` is a choice about the run: ```yaml -model: - format: - figmaKeys: SENTENCE # what your Figma file uses - keys: KEBAB # what the spec emits +# config/conventions.yaml +figma: + naming: SENTENCE # what your Figma file uses +``` + +```yaml +# config/settings.yaml +spec: + keys: KEBAB # what the spec emits ``` | Value | Shape | Example | @@ -122,7 +126,7 @@ Everything in the rest of this guide is **opt-in**. Under the `NONE` default, na ### The safe key grammar -*Applies when `figmaKeys` is `SENTENCE` or `TITLE`.* +*Applies when `figma.naming` is `SENTENCE` or `TITLE`.* A Figma name survives every `keys` format when it satisfies all of the following: @@ -130,10 +134,10 @@ A Figma name survives every `keys` format when it satisfies all of the following - Exactly one space between words, with no leading, trailing, or repeated spaces - Each word is either all letters or all digits — `Badge count 2` is fine, `Badge count2` is not - The name does not begin with a digit -- Casing matches your declared `figmaKeys` +- Casing matches your declared `figma.naming` ```yaml -# figmaKeys: SENTENCE +# naming: SENTENCE # Safe — reconstructs under every keys format Icon leading diff --git a/site/src/content/docs/guides/number-inference.md b/site/src/content/docs/guides/number-inference.md index de92d28e..a7a03169 100644 --- a/site/src/content/docs/guides/number-inference.md +++ b/site/src/content/docs/guides/number-inference.md @@ -69,16 +69,15 @@ Leave it disabled if your library uses TEXT props for values that look numeric b ## Configuration -Add `inferNumberProps: true` under `model.processing` in your config file: +Declare `inferNumberProps: true` under `figma` in `config/conventions.yaml` — it states how the library authors numeric props: ```yaml -# specs.config.yaml -model: - processing: - inferNumberProps: true +# config/conventions.yaml +figma: + inferNumberProps: true ``` -**Default**: `false` (absent). Existing specs are unaffected until you opt in. +**Default**: `false` (absent). Existing specs are unaffected until you declare it. ## Design Rationale diff --git a/site/src/content/docs/guides/render-to-figma.md b/site/src/content/docs/guides/render-to-figma.md index 933af97b..094e8a74 100644 --- a/site/src/content/docs/guides/render-to-figma.md +++ b/site/src/content/docs/guides/render-to-figma.md @@ -10,7 +10,7 @@ Every other command in Specs reads Figma and produces a spec. [`render`](/cli/co ## The Chain ``` -specs init config: sources, dataDirectory, outputDirectory +specs init config/: conventions, settings (sources, directories), pipeline ↓ specs fetch Figma payloads → data/{alias}.file.json, .variables.json ↓ (and builds the cache as its last step) @@ -36,13 +36,13 @@ A render workspace is an ordinary Specs workspace. If you don't have one: specs init ``` -Then fill in `sources` with your Figma file keys. Three config values matter to the render path: +Then fill in `data.sources` in `config/settings.yaml` with your Figma file keys. Three settings matter to the render path: | Setting | Why `render` cares | |---|---| -| `sources` | Each declared source must be fetched and cached. `render` fails naming any that aren't. | -| `dataDirectory` | Where fetched payloads and `cache/` live. `--refresh-cache` requires it. | -| `outputDirectory` | What `specs render` with no path argument renders as a batch. | +| `data.sources` | Each declared source must be fetched and cached. `render` fails naming any that aren't. | +| `data.directory` | Where fetched payloads and `cache/` live. `--refresh-cache` requires it. | +| `spec.directory` | What `specs render` with no path argument renders as a batch. | See [sources config](/settings/data-sources/) for the full shape. @@ -68,7 +68,7 @@ See [`fetch`](/cli/commands/fetch/) for flags, and note that `variables` and `st ## Stage 3 — The Cache -The [cache](/cli/commands/cache/) is the set of lookup tables that give a spec's names meaning. Four files under `{dataDirectory}/cache/`: +The [cache](/cli/commands/cache/) is the set of lookup tables that give a spec's names meaning. Four files under `{data.directory}/cache/`: | File | Resolves | What breaks without it | |---|---|---| @@ -165,7 +165,7 @@ The node id is the component that was created or updated — open it directly at ### A batch -Point `render` at a directory of component folders, or omit the path entirely to use `outputDirectory`: +Point `render` at a directory of component folders, or omit the path entirely to use `spec.directory`: ```bash specs render specs/ # every component beneath specs/ @@ -258,7 +258,7 @@ Graceful first, forced if it doesn't exit within a few seconds. Safe to run when | `⚠ INCOMPLETE: n instance element(s)` | 2–3 | The cache can't resolve a referenced component — `specs fetch` to refresh it | | Missing glyphs, styles, or variable bindings | 2–3 | `specs fetch`, then render again | | Rendered onto the wrong page | 5 | Pass `--page `; without it the target is the plugin's current page | -| `Error: provide a spec path.` | 6 | No path given and no `outputDirectory` in config to fall back to | +| `Error: provide a spec path.` | 6 | No path given and no `spec.directory` in config to fall back to | | `no component folders found` | 6 | A component folder holds `api.(yaml\|json)` and `variants.(yaml\|json)`, at most two levels deep | ## Limits diff --git a/site/src/content/docs/guides/slot-constraints.md b/site/src/content/docs/guides/slot-constraints.md index dc614ecf..1069ba5f 100644 --- a/site/src/content/docs/guides/slot-constraints.md +++ b/site/src/content/docs/guides/slot-constraints.md @@ -68,13 +68,12 @@ This feature is most valuable when: ## Configuration ```yaml -# specs.config.yaml -config: - processing: - slotConstraints: true +# config/conventions.yaml +figma: + slotConstraints: true ``` -**Default**: `false` (absent). Existing specs are unaffected until you opt in. +**Default**: `false` (absent). Existing specs are unaffected until you declare it. It lives in `config/conventions.yaml` because it states how the library authors slot constraints — as code-only props — and requires a `codeOnlyProps` convention to read them from. ## Design Rationale diff --git a/site/src/content/docs/guides/subcomponent-scoping.md b/site/src/content/docs/guides/subcomponent-scoping.md index 15f2d008..ecc8b6ed 100644 --- a/site/src/content/docs/guides/subcomponent-scoping.md +++ b/site/src/content/docs/guides/subcomponent-scoping.md @@ -65,19 +65,18 @@ The `exclude` array uses the same `{C}` / `{S}` template syntax. If a matched as ## Configuration -Set `subcomponents` under `model.processing` in your config file: +Declare `subcomponents` under `figma` in `config/conventions.yaml`: ```yaml -# specs.config.yaml -model: - processing: - subcomponents: - scope: PAGE - match: - - '{C} / {S}' - - '{C} / _ / {S}' - exclude: - - '{C} / Examples / {S}' +# config/conventions.yaml +figma: + subcomponents: + scope: PAGE + match: + - '{C} / {S}' + - '{C} / _ / {S}' + exclude: + - '{C} / Examples / {S}' ``` **Defaults**: diff --git a/site/src/content/docs/guides/variant-depth.md b/site/src/content/docs/guides/variant-depth.md index 329f352a..a24a0e6d 100644 --- a/site/src/content/docs/guides/variant-depth.md +++ b/site/src/content/docs/guides/variant-depth.md @@ -59,16 +59,15 @@ Depth 2 includes everything from depth 1 plus all two-property pairings. Each de ## Configuration -Set `variantDepth` under `model.processing` in your config file: +Set `variantDepth` under `spec` in `config/settings.yaml`: ```yaml -# specs.config.yaml -model: - processing: - variantDepth: 2 # Recommended for production +# config/settings.yaml +spec: + variantDepth: 2 # Recommended for production ``` -**Default**: `9999` (unlimited) in the processing engine. The CLI config template suggests `2` as a practical starting point. +**Default**: `9999` (unlimited) — the value the `specs init` template ships. `2` is a practical starting point for large variant spaces. **Allowed values**: `1`, `2`, `3`, or `9999`. diff --git a/site/src/content/docs/guides/variant-layering.md b/site/src/content/docs/guides/variant-layering.md index de8ede29..970d67f3 100644 --- a/site/src/content/docs/guides/variant-layering.md +++ b/site/src/content/docs/guides/variant-layering.md @@ -9,7 +9,7 @@ Variant layering is the schema model that describes how component properties acc The layering model is analogous to CSS cascading rules: each variant contributes only the properties that change at that configuration level, and the final rendered state is computed by merging all matching variant layers in sequence. -This model is active when `processing.details` is set to `'LAYERED'` (the default). When set to `'FULL'`, every variant contains its complete resolved state with no layering. +This model is active when `spec.details` is set to `'LAYERED'` (the default). When set to `'FULL'`, every variant contains its complete resolved state with no layering. --- @@ -368,7 +368,7 @@ default: ### Reading Non-Default Variants -When `processing.details` is `'LAYERED'` (the default), non-default variants list only changed properties: +When `spec.details` is `'LAYERED'` (the default), non-default variants list only changed properties: ```yaml variants: @@ -390,7 +390,7 @@ Element removal must be explicitly marked (typically via `visible: false` or pro ### Empty Variants -Some variant configurations produce no element changes at all. By default these are excluded from output. Set `include.emptyVariants: true` to include them — useful for verifying that a configuration was processed even when it produces no visual difference. +Some variant configurations produce no element changes at all. By default these are excluded from output. Set `spec.emptyVariants: true` to include them — useful for verifying that a configuration was processed even when it produces no visual difference. --- @@ -444,11 +444,11 @@ configuration: ### `Variant.invalid` -Boolean flag indicating this prop combination cannot be instantiated (e.g., `disabled: true, hover: true`). Invalid variants are excluded from layer resolution. Controlled by `include.invalidVariants` (default: `false`). +Boolean flag indicating this prop combination cannot be instantiated (e.g., `disabled: true, hover: true`). Invalid variants are excluded from layer resolution. Controlled by `spec.invalidVariants` (default: `false`). ### `Component.invalidVariantCombinations` -When `include.invalidCombinations` is `true` (the default), the component output includes an `invalidVariantCombinations` array listing prop combinations that have no corresponding valid variant. +When `spec.invalidCombinations` is `true` (the default), the component output includes an `invalidVariantCombinations` array listing prop combinations that have no corresponding valid variant. ```yaml invalidVariantCombinations: @@ -460,7 +460,7 @@ invalidVariantCombinations: **Use case**: Documenting constraints for code generation or validation. -### `Config.processing.details` +### `Settings.spec.details` Controls whether variant output uses layering: @@ -471,7 +471,7 @@ Controls whether variant output uses layering: **Default**: `'LAYERED'` -### `Config.processing.variantDepth` +### `Settings.spec.variantDepth` Controls how many prop dimensions are expanded. See the [Variant Depth](/guides/variant-depth/) guide. @@ -480,6 +480,6 @@ Controls how many prop dimensions are expanded. See the [Variant Depth](/guides/ ## See Also - [Variant Depth](/guides/variant-depth/) — controlling variant expansion depth -- [Variant type](/../packages/schema/types/Variant.ts/) — schema definition -- [Config type](/../packages/schema/types/Config.ts/) — full configuration options -- [component.schema.json](/../packages/schema/schema/component.schema.json/) — JSON Schema validation rules +- [Variants](/schema/variants/) — schema definition +- [Settings](/schema/settings/) — full run settings +- [component.schema.json](https://github.com/DirectedEdges/specs/blob/main/packages/schema/schema/component.schema.json) — JSON Schema validation rules diff --git a/site/src/content/docs/overview/licensing.md b/site/src/content/docs/overview/licensing.md index 3c949c04..e1ba540e 100644 --- a/site/src/content/docs/overview/licensing.md +++ b/site/src/content/docs/overview/licensing.md @@ -89,7 +89,7 @@ Without a license key, every generated spec includes: - **Component structure** — anatomy (element tree), props (with raw values), and layout - **Default variant** — the component in its base state, with all style values as raw numbers, colors, and strings -- **Metadata** — generator info, author, timestamps, and the config used to produce the spec +- **Metadata** — generator info, author, timestamps, and the conventions and settings used to produce the spec Free-tier output gives you a complete structural picture of each component — enough to understand what a component is, how it's built, and what its default state looks like. @@ -103,7 +103,7 @@ With a valid license key, specs additionally include: | **Design token references** | Variable bindings on style properties (spacing, color, corner radius, stroke, typography, shadows, gradients) — connecting raw values to your token system | | **Named style references** | Links to Figma text styles, color styles, and effect styles instead of inline values | | **Prop bindings** | `$binding` references connecting anatomy elements to component props (slot content, instance swaps, visibility toggles, text overrides) | -| **Invalid combinations** | The `invalidVariantCombinations` array showing which property combinations are impossible (requires `config.include.invalidCombinations: true`) | +| **Invalid combinations** | The `invalidVariantCombinations` array showing which property combinations are impossible (requires `spec.invalidCombinations: true` in `config/settings.yaml`) | ### Example: Free vs Pro Output @@ -146,7 +146,7 @@ elements: Pro features are never stripped from output — they're simply not created at the free tier. If a style property has a bound Figma variable, free-tier output shows the raw resolved value; pro-tier output shows the token reference alongside the value. -> **Note**: Token references require that your source config includes `variables` in the `data` array. Style references require `styles`. See [Configuration](/settings/) for details. +> **Note**: Token references require that your source's `fetch` list in `config/settings.yaml` includes `variables`. Style references require `styles`. See [Configuration](/settings/) for details. :::caution[Fetching variables and styles requires a Figma Enterprise plan] A Specs Pro license controls whether **already-fetched** variable and style data gets turned into token references and style references — it does not control whether that data can be fetched from Figma in the first place. @@ -156,13 +156,13 @@ Figma's REST API restricts the `variables` and `styles` endpoints to organizatio ### Config Settings and Licensing -All config settings work at both tiers. Two settings interact with licensing: +All configuration values work at both tiers. Two settings interact with licensing: -#### `format.tokens` +#### `spec.tokens` Controls **how** token references are serialized — not **whether** they appear. At free tier, no token references are created regardless of this setting. At pro tier, this controls the output shape. -#### `include.invalidCombinations` +#### `spec.invalidCombinations` Controls whether invalid variant combinations are computed. Even when set to `true` (the default), this feature requires a pro license. At free tier, the setting is accepted but the computation is skipped — the `invalidVariantCombinations` array is simply absent from output. @@ -224,18 +224,18 @@ When no license key is provided, the `license` block is omitted from metadata. W **I have a Pro key but my output looks like free tier** 1. Check that the CLI prints `License: PRO (active)` after generation. If it prints `FREE`, the key wasn't accepted. 2. Verify the key is being picked up: CLI flag (`-l`) takes priority over `SPECS_LICENSE_KEY` env var, which takes priority over `.env` file. Make sure `.env` is in the directory you're running from. -3. For token references specifically, confirm your source config includes `variables` in the `data` array — tokens require fetched variable data. +3. For token references specifically, confirm your source's `fetch` list includes `variables` — tokens require fetched variable data. **I see "network-error" in the license output** License validation requires a brief network call to the license server. If your network blocks outbound HTTPS or you're offline, validation fails and output falls back to free tier. Generation still completes — it does not error out. **My `invalidVariantCombinations` are missing** -This requires both `config.include.invalidCombinations: true` (the default) and an active Pro license. If either condition is missing, the array is silently omitted. +This requires both `spec.invalidCombinations: true` (the default) and an active Pro license. If either condition is missing, the array is silently omitted. ## FAQ **Can I use all config settings at the free tier?** -Yes. Every config setting is accepted at both tiers. Settings that affect pro-only features (like `format.tokens` or `include.invalidCombinations`) are stored in your config and take effect when you add a license key. +Yes. Every config setting is accepted at both tiers. Settings that affect pro-only features (like `spec.tokens` or `spec.invalidCombinations`) are stored in your `config/settings.yaml` and take effect when you add a license key. **Will my specs break if my license expires?** No. Previously generated specs are static files — they don't change. Future generations will produce free-tier output (raw values, default variant only) until the license is renewed. @@ -254,6 +254,6 @@ License terms depend on your plan, but generally, no. Each Pro license is intend ## See Also -- [Getting Started](/overview/cli/getting-started/) — Installation and first spec +- [Getting Started](/cli/getting-started/) — Installation and first spec - [Configuration Reference](/settings/) — All config options -- [Config Schema](/overview/schema/config/) — Config type reference and defaults +- [Settings Schema](/schema/settings/) — settings reference and defaults diff --git a/site/src/content/docs/pro/thankyou.mdx b/site/src/content/docs/pro/thankyou.mdx new file mode 100644 index 00000000..6854acd6 --- /dev/null +++ b/site/src/content/docs/pro/thankyou.mdx @@ -0,0 +1,41 @@ +--- +title: Thank you for your order +description: Welcome to Specs 2 Pro. Activate your license, manage your subscription, and find support. +tableOfContents: false +head: + - tag: style + content: ':root{--sl-sidebar-width:0rem}.sidebar-pane{display:none}starlight-menu-button{display:none}.right-sidebar-container{display:none}.main-pane{width:100%}' +--- + +Your purchase is complete, and a confirmation email is on its way with your license keys and receipt. Thank you for supporting Specs — your subscription unlocks the full set of Pro features across the Figma plugin and the CLI, and helps fund continued development. If you have any trouble at all, we're here to help. + +## Activate your license + +A subscription is not the same thing as a license. The subscription is what you bought; the licenses are what it contains — a plugin key and a CLI key per seat, tied to the email address that holds the seat and active for the duration of your billing period. Full terms, including seat counts, renewals, and team licensing, are described in the [licensing overview](/overview/licensing/). + +### Individual subscriptions + +Check the email sent to the address you used at checkout. It contains your plugin and CLI license keys — paste them into the [Specs 2 plugin](https://www.figma.com/community/plugin/1549454283615386215/specs-2-formerly-anova) and the [CLI](/cli/getting-started/) to activate Pro on each surface. You can retrieve the keys again at any time from your account in the [Polar customer portal](https://polar.sh/directed-edges-llc/portal). + +### Team subscriptions + +A Teams subscription gives you, the administrator, a pool of seats rather than a set of keys. To hand those seats out, sign in to the [Polar customer portal](https://polar.sh/directed-edges-llc/portal), choose **Manage your subscription**, and then **Invite members** — once for each seat you purchased. Each invited person receives a message with a link to claim their seat, obtain their license, and get started. + +Invitations must be claimed within 24 hours, after which they expire and can be re-sent from the portal. Once a team member claims their seat, they receive their own plugin and CLI license key pair by email and activate them exactly as an individual subscriber does. + +## Manage your subscription + +Use the [Polar customer portal](https://polar.sh/directed-edges-llc/portal) to view receipts, update your payment method, change your billing email, or cancel your subscription. It's also where you download invoices for expensing. Sign in with the email used at checkout — Polar sends you a one-time login link. + +All seat management happens in the customer portal alongside the rest of your subscription. You can see which seats are claimed, revoke a seat when someone leaves and reassign it to a new email, and adjust the total seat count up or down at any time. Changes are prorated on your month-to-month invoice — adding a seat mid-cycle adds a partial charge, removing one issues a partial credit, and volume discount tiers re-evaluate automatically whenever your seat count crosses a threshold. + +## Get support + +Questions, bug reports, and feature requests are all welcome. + +- Email [nathan@specsplugin.com](mailto:nathan@specsplugin.com) for anything license- or billing-related. +- [File an issue on GitHub](https://github.com/DirectedEdges/specs/issues) to report defects or track existing requests. + +--- + +Thanks again — we can't wait to see what you spec. diff --git a/site/src/content/docs/schema/color-object.md b/site/src/content/docs/schema/color-object.md index 7e9f5d1d..200122b3 100644 --- a/site/src/content/docs/schema/color-object.md +++ b/site/src/content/docs/schema/color-object.md @@ -3,7 +3,7 @@ title: "ColorObject" description: "Structured color value preserving color space and components" --- -An inline resolved color value per [DTCG Color Module §4.1](https://tr.designtokens.org/color/). Used as one arm of [`ColorStyle`](/schema/styles/background-color/#types) when `Config.format.color` is set to `OBJECT`. +An inline resolved color value per [DTCG Color Module §4.1](https://tr.designtokens.org/color/). Used as one arm of [`ColorStyle`](/schema/styles/background-color/#types) when `settings.spec.color` is set to `OBJECT`. ```ts interface ColorObject { diff --git a/site/src/content/docs/schema/component.md b/site/src/content/docs/schema/component.md index 72630ac9..59945b73 100644 --- a/site/src/content/docs/schema/component.md +++ b/site/src/content/docs/schema/component.md @@ -19,7 +19,7 @@ The `Component` type is the root object of every spec. It contains the component | `metadata` | [`Metadata`](/schema/metadata/) | No | Generation metadata (author, schema version, config) | | `instanceExamples` | [`InstanceExamples`](/schema/instance-examples/) | No | **Pro.** Documented whole-component usage examples (emitted only with a Pro license) | | `slotContentExamples` | `Record` | No | **Pro.** Named slot-content fills, referenced by [`SlotContentRef`](/schema/slot-content-ref/) from slot bindings and from `Element.propConfigurations` slot-prop entries | -| `images` | `Record` | No | Registry of image data keyed by id, referenced by `Styles.backgroundImage`, `ImageBinding` examples, and `ImageProp` defaults. Each entry carries the Figma identity in `$extensions['com.figma'].imageHash`; resolution adds `src` (asset path, `data:` URI, or URL) without replacing it. Emitted when [`processing.images`](/schema/config/#processingimages) is configured | +| `images` | `Record` | No | Registry of image data keyed by id, referenced by `Styles.backgroundImage`, `ImageBinding` examples, and `ImageProp` defaults. Each entry carries the Figma identity in `$extensions['com.figma'].imageHash`; resolution adds `src` (asset path, `data:` URI, or URL) without replacing it. Emitted when [`figma.images`](/schema/conventions/#figmaimages) is configured | ## Examples and composed content diff --git a/site/src/content/docs/schema/config.md b/site/src/content/docs/schema/config.md deleted file mode 100644 index 44b2df08..00000000 --- a/site/src/content/docs/schema/config.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: "Config" -description: "Generation configuration — format, inclusion, and processing options" ---- - -Controls how specs are generated. See the [settings reference](/settings/) for detailed explanations of each option. - -## `format` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| [`output`](/settings/output-format/) | `'JSON' \| 'YAML'` | `'JSON'` | Output file format | -| [`keys`](/guides/key-formatting/) | `'SAFE' \| 'CAMEL' \| 'SNAKE' \| 'KEBAB' \| 'PASCAL' \| 'TRAIN'` | `'SAFE'` | Key casing style | -| [`figmaKeys`](/settings/figma-keys/) | `'NONE' \| 'SENTENCE' \| 'TITLE'` | `'NONE'` | Naming convention the Figma file uses — the reversal target for `keys`. `NONE` declares none, making key preservation opt-in | -| [`layout`](/guides/data-layout/) | `'LAYOUT' \| 'PARENT_CHILDREN' \| 'BOTH'` | `'LAYOUT'` | Element hierarchy representation | -| [`tokens`](/settings/tokens/) | `'TOKEN' \| 'TOKEN_NAME' \| 'TOKEN_FIGMA_EXTENSIONS' \| 'FIGMA_NAME' \| 'CUSTOM' \| 'FIGMA_SYNTAX_WEB' \| 'FIGMA_SYNTAX_IOS' \| 'FIGMA_SYNTAX_ANDROID'` | `'TOKEN'` | Token reference output format — `FIGMA_SYNTAX_*` emit per-platform Figma code syntax, falling back to `TOKEN` | -| [`color`](/settings/color/) | `ColorFormat` | `'HEX'` | Color value output format — `HEX`, `HEXA`, `RGB`, `RGBA`, `HSLA`, `HSB`, `OKLCH`, `OKLAB`, or `OBJECT` | - -## `include` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| [`invalidVariants`](/settings/invalid-variants/) | `boolean` | `false` | Include variants marked invalid | -| [`invalidCombinations`](/guides/invalid-variant-combinations/) | `boolean` | `true` | Include `invalidVariantCombinations` list | -| [`emptyVariants`](/settings/empty-variants/) | `boolean` | `false` | Include variants with no element overrides | -| [`defaultSlotContent`](/guides/default-slot-content/) | `boolean` | `false` | **Pro.** Emit the component's default slot content into `Component.slotContentExamples` (structurally detected slot fills). Ignored on the free tier | - -`instanceExamples` has no `include` flag — emitting it is driven by the presence of [`processing.instanceExamples`](#processinginstanceexamples) (Pro only), like `subcomponents`. - -## `processing` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| [`subcomponents`](/guides/subcomponent-scoping/) | `object` | — | Subcomponent detection. Absent = no detection. See [`processing.subcomponents`](#processingsubcomponents) | -| [`glyphNamePattern`](/guides/glyph-name-pattern/) | `string` | — | Name prefix for identifying glyph/icon instances | -| [`codeOnlyPropsPattern`](/guides/code-only-props/) | `string` | — | Name pattern for code-only prop containers | -| [`slotConstraints`](/guides/slot-constraints/) | `boolean` | `false` | Emit `minChildren`, `maxChildren`, `anyOf` on slot props | -| [`variantDepth`](/guides/variant-depth/) | `1 \| 2 \| 3 \| 9999` | `9999` | Maximum variant nesting depth (9999 = unlimited) | -| [`details`](/guides/variant-layering/) | `'FULL' \| 'LAYERED'` | `'LAYERED'` | Output detail level | -| [`inferNumberProps`](/guides/number-inference/) | `boolean` | `false` | Infer number-typed props from Figma variant values | -| [`collapsePrimitiveWrapper`](/settings/collapse-primitive-wrapper/) | `boolean` | `false` | Strip plain container wrappers around a single text/glyph child and promote the leaf to spec root | -| [`instanceExamples`](/guides/instance-examples/) | `object` | — | **Pro.** Instance example detection. Absent = no detection; ignored on the free tier. See [`processing.instanceExamples`](#processinginstanceexamples) | -| [`states`](/settings/states/) | `object` | — | Concept-keyed map classifying Figma variant props as semantic states. Absent = all variant props emit as `data-*` attribute selectors. See [`processing.states`](#processingstates) | -| [`images`](/guides/images/) | `object` | — | Image processing (ADR-063). Presence is the on-switch; each member is an independent representation trigger. Absent = images are not processed. See [`processing.images`](#processingimages) | - -### `processing.subcomponents` - -Presence of this block is the on-switch for [subcomponent detection](/guides/subcomponent-scoping/). - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `scope` | `'NESTED' \| 'PAGE'` | `'NESTED'` | Where to search — the component's own anatomy, or the whole Figma page | -| `match` | `string[]` | *(required)* | Template patterns defining which assets are subcomponents. `{C}` = component name, `{S}` = subcomponent name (e.g. `'{C} / {S}'`) | -| `exclude` | `string[]` | — | Patterns to exclude from matches, same placeholders | - -### `processing.instanceExamples` - -**Pro.** Presence of this block is the on-switch for [instance example detection](/guides/instance-examples/). - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `scope` | `'PAGE' \| 'FILE'` | `'PAGE'` | Where to search for candidate instances | -| `match` | `string[]` | — | Optional name filter. `{C}` = component name (e.g. `'{C} Example'`). When omitted, every in-scope instance of the component qualifies | -| `exclude` | `string[]` | — | Patterns to exclude from matches, `{C}` placeholder | -| `parentNames` | `string[]` | — | A candidate's immediate parent frame or section must match one of these names | - -### `processing.states` - -A map keyed by [state concept](/settings/states/) name (e.g. `hover`, `disabled`, `readonly`). Each entry classifies one Figma variant prop as that semantic state: - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `prop` | `string` | *(required)* | Figma variant prop name (e.g. `state`, `isDisabled`) | -| `value` | `string` | `"true"` | Variant value that activates this concept (e.g. `"hover"`). Omit for boolean props | -| `contract` | `'omit' \| 'keep'` | *(per concept)* | Contract generation override — exclude (`omit`, browser-driven) or retain (`keep`, consumer-controlled) the prop in generated Props interfaces | - -### `processing.images` - -Presence of this block is the on-switch for [image processing](/guides/images/); each member is an independent representation trigger. - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `backgroundImage` | `boolean` | `false` | Detect image fills on containers as `Styles.backgroundImage`; the fallback for stray fills when `imageComponent` is set | -| `imageComponent` | `string` | — | Designated image component name (e.g. `DS Image`). Instances of it are the image primitive; their image routes through `sourceProps[0]`. Requires a non-empty `sourceProps` | -| `sourceProps` | `string[]` | — | Raw Figma code-only prop names that re-type to `ImageProp` on any component; the first entry is the designated component's own source prop | - -## DEFAULT_CONFIG - -The only runtime export from `@directededges/specs-schema`. Provides defaults for all config properties that have a default value. Typed as `ResolvedConfig` — all defaulted properties are required: - -```ts -const DEFAULT_CONFIG: ResolvedConfig = { - processing: { - slotConstraints: false, - collapsePrimitiveWrapper: false, - variantDepth: 9999, - details: 'LAYERED', - inferNumberProps: false, - }, - format: { - output: 'JSON', - keys: 'SAFE', - figmaKeys: 'NONE', - layout: 'LAYOUT', - tokens: 'TOKEN', - color: 'HEX', - }, - include: { - invalidVariants: false, - invalidCombinations: true, - emptyVariants: false, - defaultSlotContent: false, - }, - transformers: [], -}; -``` - -The object-valued options (`subcomponents`, `instanceExamples`, `states`, `images`) and the pattern strings (`glyphNamePattern`, `codeOnlyPropsPattern`) are **deliberately absent** — they are feature toggles whose *presence* is the on-switch. Absence means "feature off"; there is no meaningful default value to provide, and they are typed as optional on `ResolvedConfig` for exactly that reason. They are never `null`: a `null` would introduce a third state ("present but empty") that no consumer distinguishes from absence, so the schema does not allow it. (`include.defaultSlotContent` looks similar but is a gate over independently-detected data rather than a detector, so it carries a real `false` default.) diff --git a/site/src/content/docs/schema/conventions.md b/site/src/content/docs/schema/conventions.md new file mode 100644 index 00000000..8685684e --- /dev/null +++ b/site/src/content/docs/schema/conventions.md @@ -0,0 +1,75 @@ +--- +title: "Conventions" +description: "Facts about the Figma library a spec was generated from" +--- + +Declares how a Figma library is authored and organized. Every consumer reading the same library declares the same values — differing values produce **incorrect** output rather than merely different output: a mismatched pattern leaves a whole class of assets undetected, and a mismatched state entry lands a concept on the wrong prop. + +Authored in `config/conventions.yaml`. Absence of a member means the library declares no such convention, and the capability it enables does not apply — there is no separate on-switch. + +## `figma` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| [`naming`](/settings/figma-keys/) | `'NONE' \| 'SENTENCE' \| 'TITLE'` | `'NONE'` | Naming convention the Figma file uses — the reversal target for [`settings.spec.keys`](/schema/settings/) | +| [`glyphs`](/guides/glyph-name-pattern/) | `object` | — | Glyph asset naming. Absent = no glyph convention | +| [`codeOnlyProps`](/guides/code-only-props/) | `object` | — | Code-only props container naming. Absent = no such convention | +| [`subcomponents`](/guides/subcomponent-scoping/) | `object` | — | Subcomponent organization and naming. Absent = no subcomponent convention | +| [`instanceExamples`](/guides/instance-examples/) | `object` | — | **Pro.** Instance example organization and naming. Absent = no such convention | +| [`images`](/guides/images/) | `object` | — | How the library expresses images. Absent = no image convention | +| [`slotConstraints`](/guides/slot-constraints/) | `boolean` | `false` | The library authors slot constraints as code-only props | +| [`inferNumberProps`](/guides/number-inference/) | `boolean` | `false` | The library authors numeric props as Figma `TEXT` props with numeric defaults | +| [`states`](/settings/states/) | `object` | — | Concept-keyed map classifying Figma variant props as semantic states | + +### `figma.glyphs` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `match` | `string` | *(required)* | Naming pattern identifying glyph assets. `{i}` = icon name (e.g. `'DS Icon Glyph / {i}'`) | + +### `figma.codeOnlyProps` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `match` | `string` | *(required)* | Literal layer name identifying the container (e.g. `'Code only props'`) | + +### `figma.subcomponents` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `scope` | `'NESTED' \| 'PAGE'` | `'NESTED'` | Where the library keeps subcomponents — the component's own anatomy, or the whole Figma page | +| `match` | `string[]` | *(required)* | Naming patterns identifying subcomponents. `{C}` = component name, `{S}` = subcomponent name | +| `exclude` | `string[]` | — | Patterns the library excludes, same placeholders | + +### `figma.instanceExamples` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `scope` | `'PAGE' \| 'FILE'` | `'PAGE'` | Where the library keeps instance examples | +| `match` | `string[]` | — | Name filter. `{C}` = component name. Omitted = every in-scope instance qualifies | +| `exclude` | `string[]` | — | Patterns the library excludes, `{C}` placeholder | +| `parentNames` | `string[]` | — | A candidate's immediate parent frame or section must match one of these | + +### `figma.images` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `backgroundImage` | `boolean` | `false` | The library expresses images as container fills, emitted as `Styles.backgroundImage` | +| `match` | `string` | — | Name of the library's designated image component (e.g. `DS Image`). Requires a non-empty `sourceProps` | +| `sourceProps` | `string[]` | — | Code-only prop names carrying image sources; the first is the designated component's own source prop | + +### `figma.states` + +A map keyed by [state concept](/settings/states/) name (e.g. `hover`, `disabled`, `readonly`). Each entry classifies one Figma variant prop as that semantic state: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `prop` | `string` | *(required)* | Figma variant prop name (e.g. `state`, `isDisabled`) | +| `value` | `string` | `"true"` | Variant value that activates this concept (e.g. `"hover"`). Omit for boolean props | +| `contract` | `'omit' \| 'keep'` | *(per concept)* | Contract generation override — exclude (`omit`, browser-driven) or retain (`keep`, consumer-controlled) the prop in generated Props interfaces | + +## Resolution + +`ResolvedConventions` applies defaults **inside** any declared block: once `subcomponents` is present, its `scope` is guaranteed; once `images` is present, `backgroundImage` and `sourceProps` are. + +The blocks themselves stay optional after resolution. Absence means the library declares no such convention, and nothing can supply that — which is why `DEFAULT_SETTINGS` has no conventions counterpart. diff --git a/site/src/content/docs/schema/index.md b/site/src/content/docs/schema/index.md index 6794c1ee..5bce727b 100644 --- a/site/src/content/docs/schema/index.md +++ b/site/src/content/docs/schema/index.md @@ -42,7 +42,8 @@ components: ├─ subcomponents: │ └─ {name}: { …same shape as above } ├─ metadata: - │ └─ config: + │ ├─ conventions: + │ └─ settings: ├─ instanceExamples: → InstanceExample Pro │ └─ {name}: { title, propConfigurations } └─ slotContentExamples: → SlotContent Pro @@ -64,11 +65,13 @@ components: ## Package Exports -The package exports TypeScript types for every node in the schema, plus one runtime value: +The package exports TypeScript types for every node in the schema, plus two runtime values: ```ts import type { Component } from '@directededges/specs-schema'; -import { DEFAULT_CONFIG } from '@directededges/specs-schema'; +import { DEFAULT_SETTINGS, DEFAULT_PIPELINE } from '@directededges/specs-schema'; ``` -`DEFAULT_CONFIG` is the only runtime export. All other exports are type-only. +`DEFAULT_SETTINGS` and `DEFAULT_PIPELINE` are the only runtime exports. All other exports are type-only. + +There is no defaults export for [Conventions](/schema/conventions/): a convention's absence means the library declares no such convention, and nothing can supply that. diff --git a/site/src/content/docs/schema/instance-examples.md b/site/src/content/docs/schema/instance-examples.md index 80d1d69b..3f28536a 100644 --- a/site/src/content/docs/schema/instance-examples.md +++ b/site/src/content/docs/schema/instance-examples.md @@ -11,7 +11,7 @@ An `InstanceExample` is a pre-configured usage of a *whole* component — a docu ```ts type InstanceExample = { title?: string; - propConfigurations?: Record; + propConfigurations?: Record; }; type InstanceExamples = Record; @@ -22,10 +22,25 @@ type InstanceExamples = Record; | Property | Type | Required | Description | |----------|------|----------|-------------| | `title` | `string` | No | Human-readable label for this example | -| `propConfigurations` | `Record` | No | Prop values — scalars for scalar props, a `SlotContentRef` for slot props | +| `propConfigurations` | `Record` | No | Prop values — scalars for scalar props, a `SlotContentRef` for slot props, `null` for a prop this example leaves unset | A [`PropBinding`](/schema/prop-binding/) is **not** permitted in `propConfigurations` — an instance example is a static configuration, not a binding. +`null` under a prop key means the example leaves that prop unset (since 0.31.0). An example that shows no header says so with the header prop itself, rather than with a separate flag beside it: + +```yaml +instanceExamples: + cardWithHeader: + title: Card with a header + propConfigurations: + header: + $slotContent: "#/components/card/slotContentExamples/cardHeader" + cardWithoutHeader: + title: Card without a header + propConfigurations: + header: null +``` + Slot fills encountered inside an example instance are contributed to the shared [`slotContentExamples`](/schema/slot-content/) registry (de-duplicated against existing entries), so an `InstanceExample` holds no slot content of its own — only a `SlotContentRef` into that registry. ## Registry shape @@ -34,7 +49,7 @@ Slot fills encountered inside an example instance are contributed to the shared ## Detection -How example instances are harvested from a Figma file is controlled by [`processing.instanceExamples`](/settings/instance-examples/). +How example instances are harvested from a Figma file is controlled by [`conventions.figma.instanceExamples`](/schema/conventions/#figmainstanceexamples). ## Further Reading diff --git a/site/src/content/docs/schema/layout.md b/site/src/content/docs/schema/layout.md index 2b3308b2..12e7972f 100644 --- a/site/src/content/docs/schema/layout.md +++ b/site/src/content/docs/schema/layout.md @@ -20,7 +20,7 @@ A leaf is a plain string (element name). A branch is an object mapping a parent - control ``` -The [`format.layout`](/schema/config.md/#format) config option controls which representation appears in the output: `LAYOUT` (tree only), `PARENT_CHILDREN` (flat parent/children on each element), or `BOTH`. +The [`settings.spec.layout`](/schema/settings/#spec) config option controls which representation appears in the output: `LAYOUT` (tree only), `PARENT_CHILDREN` (flat parent/children on each element), or `BOTH`. ## Example diff --git a/site/src/content/docs/schema/metadata.md b/site/src/content/docs/schema/metadata.md index e65b3112..d6f20ec3 100644 --- a/site/src/content/docs/schema/metadata.md +++ b/site/src/content/docs/schema/metadata.md @@ -16,7 +16,8 @@ Generation metadata attached to the spec. Present when the spec was produced by | `generator.license` | `object` | No | License status (`status`: VALID/EXPIRED/NONE) and `level` (FREE/PRO/EXTENDED) | | `schema` | `object` | Yes | Schema version info — `url`, `version`, and optional `latest` URL | | `source` | `object` | Yes | Figma source — `pageId`, `nodeId`, `nodeType` (COMPONENT, COMPONENT_SET, or FRAME) | -| `config` | [`Config`](/schema/config/) | Yes | The configuration used to generate this spec | +| `conventions` | [`Conventions`](/schema/conventions/) | Yes | Facts about the Figma library this spec was generated from | +| `settings` | [`Settings`](/schema/settings/) | Yes | Choices about the run that generated this spec | ## Further Reading diff --git a/site/src/content/docs/schema/pipeline.md b/site/src/content/docs/schema/pipeline.md new file mode 100644 index 00000000..1d16a986 --- /dev/null +++ b/site/src/content/docs/schema/pipeline.md @@ -0,0 +1,31 @@ +--- +title: "Pipeline" +description: "Work a workspace runs over its specs" +--- + +Declares the transformers and analyses a workspace runs. Authored in `config/pipeline.yaml`. + +Separate from [Settings](/schema/settings/) because these name *work* rather than how work behaves, and separate from [Conventions](/schema/conventions/) because running a different set produces different output rather than incorrect output. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `transformers` | `TransformEntry[]` | `[]` | Transformers to run via `specs transform` | +| `analyses` | `AnalysisEntry[]` | `[]` | Analyses to run via `specs analyze` | + +## `TransformEntry` / `AnalysisEntry` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `name` | `string` | *(required)* | Transformer or analysis name (e.g. `react`, `css`, `dependencies`) | +| *(other)* | `unknown` | — | Entry-specific options sit inline alongside `name` | + +Entries carry no output paths. Transformers write into the spec structure — `contract`, `css`, and `react` output land beside each other under a component's generated folder — and analyses write beside the specs they analyze. Where an assembled library and its storybook live is owned by the package that produces them, not by this schema. + +```yaml +transformers: + - name: react + - name: css + - name: contract +analyses: + - name: dependencies +``` diff --git a/site/src/content/docs/schema/prop-configurations.md b/site/src/content/docs/schema/prop-configurations.md index b1728330..10247c9a 100644 --- a/site/src/content/docs/schema/prop-configurations.md +++ b/site/src/content/docs/schema/prop-configurations.md @@ -8,18 +8,37 @@ A flat map of prop names to the values they must hold for a condition to apply, ```ts type PropConfigurations = Record< string, - string | number | boolean | PropBinding | CompositionRef | ImageBinding + string | number | boolean | null | PropBinding | CompositionRef | ImageBinding >; ``` | Arm | Where it applies | Description | |-----|------------------|-------------| | `string \| number \| boolean` | Anywhere `PropConfigurations` is used | Static scalar prop value | +| `null` | Anywhere `PropConfigurations` is used, under a nullable prop | The prop is unset in this configuration (since 0.31.0) | | `PropBinding` (`{ $binding }`) | `Element.propConfigurations` only | Pass-through binding to a parent prop | | `CompositionRef` (`{ $composition }`) | `Element.propConfigurations` only, under a slot-prop key | JSON Pointer to a named `Composition` (in `Component.slotContent` or in an external composition file) used to fill a nested instance's slot | | `ImageBinding` (`{ $binding, examples? }`) | `Element.propConfigurations` only, under an image-prop key | Forwards a parent image prop into a nested image instance's source prop, carrying authoring-default example images (since 0.28.0) | -`InstanceExample.propConfigurations` is scalar-only by design — `PropBinding` and `CompositionRef` are not permitted there. +`InstanceExample.propConfigurations` accepts scalars, `null`, and slot-content references — `PropBinding` is not permitted there. + +## Unset props + +`null` means the prop is **unset** in this configuration. It is a value, not an absence, and the distinction matters because configurations layer: + +- an **absent** key inherits whatever the layer beneath it set +- a **`null`** key overrides an inherited value with "no value" + +A nullable prop is therefore fully described by its own key. Nothing beside it — a paired visibility flag, a producer extension — needs to be read to know whether the prop is set: + +```yaml +# The header slot is filled +header: + $slotContent: "#/components/card/slotContentExamples/cardHeader" + +# The header slot is unset — this component shows no header +header: null +``` ## Usage @@ -28,7 +47,7 @@ PropConfigurations appear in four places: - **Variant `configuration`** — declares which prop combination activates a [variant](/schema/variants.md/#variant). When all listed props match their specified values, the variant's overrides are applied. - **`invalidVariantCombinations`** — an array on the [Component](/schema/component/) root that declares prop combinations which should never occur together. - **`Element.propConfigurations`** — sets prop values on a nested instance element; accepts the full union (scalars, `PropBinding`, `CompositionRef`). -- **`InstanceExample.propConfigurations`** — documents a complete scalar-prop configuration; scalars only. +- **`InstanceExample.propConfigurations`** — documents a complete configuration; scalars, `null`, and slot-content references. ## Example diff --git a/site/src/content/docs/schema/props.md b/site/src/content/docs/schema/props.md index 109a91c9..c383bb70 100644 --- a/site/src/content/docs/schema/props.md +++ b/site/src/content/docs/schema/props.md @@ -51,7 +51,7 @@ A `StringProp` is distinguished from an `EnumProp` by the absence of `enum`. | `nullable` | `boolean` | No | Whether `null` is a valid value — absent means `true` (since 0.29.0) | | `examples` | `number[]` | No | Example values | -Inferred from Figma variant values when [`inferNumberProps`](/schema/config.md/#processing) is enabled. +Inferred from Figma variant values when [`conventions.figma.inferNumberProps`](/schema/conventions/#figma) is enabled. ### SlotProp @@ -65,7 +65,7 @@ Inferred from Figma variant values when [`inferNumberProps`](/schema/config.md/# | `anyOf` | `string[]` | No | Permitted component type names (since 0.14.0) | | `$extensions` | `PropExtensions` | No | Vendor extensions | -Slot constraint properties (`minChildren`, `maxChildren`, `anyOf`) are emitted when [`slotConstraints`](/schema/config.md/#processing) is enabled in config. +Slot constraint properties (`minChildren`, `maxChildren`, `anyOf`) are emitted when [`conventions.figma.slotConstraints`](/schema/conventions/#figma) is declared. ### ImageProp @@ -76,7 +76,7 @@ Slot constraint properties (`minChildren`, `maxChildren`, `anyOf`) are emitted w | `nullable` | `boolean` | No | Whether `null` is a valid value — absent means `true` | | `$extensions` | `PropExtensions` | No | Vendor extensions | -An image-valued property (e.g. a `dsImage` `source` prop). The authoring-default image rides on the [`ImageBinding`](/schema/prop-configurations/) at the binding site, not on the prop. Emitted for code-only props named in [`processing.images.sourceProps`](/schema/config/#processingimages) (since 0.28.0). +An image-valued property (e.g. a `dsImage` `source` prop). The authoring-default image rides on the [`ImageBinding`](/schema/prop-configurations/) at the binding site, not on the prop. Emitted for code-only props named in [`figma.images.sourceProps`](/schema/conventions/#figmaimages) (since 0.28.0). ## Nullability diff --git a/site/src/content/docs/schema/settings.md b/site/src/content/docs/schema/settings.md new file mode 100644 index 00000000..29451bc8 --- /dev/null +++ b/site/src/content/docs/schema/settings.md @@ -0,0 +1,85 @@ +--- +title: "Settings" +description: "Choices about a run — sources, spec output, and assets" +--- + +Controls how a run behaves. Changing a setting produces **different** output, never incorrect output: a different team reading the same library may set every one of these differently and each result is correct. + +Authored in `config/settings.yaml`. Members are grouped by concern, and each concern carries its own `directory`. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `author` | `string` | — | Author recorded in generated spec metadata | +| `data` | `object` | — | Source acquisition and the directory holding fetched, computed, and authored data | +| `spec` | `object` | — | The generated spec — where it lands, how it is split, what it contains, how values are serialized | +| `assets` | `object` | — | Shared resources every code output points at: icons, images, generated CSS, fonts | + +## `data` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `directory` | `string` | — | Directory holding fetched downloads, computed caches, extracted assets, and authored inputs | +| `sources` | `object` | — | Sources the workspace reads from, keyed by source name | + +### `data.sources.` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `key` | `string` | *(required)* | Figma file key the source reads from | +| `fetch` | `string[]` | — | Artifacts to download (e.g. `file`, `variables`, `styles`, `icons`) | + +## `spec` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `directory` | `string` | — | Directory the generated spec is written to | +| [`format`](/settings/output-format/) | `'JSON' \| 'YAML'` | `'JSON'` | Serialization format | +| [`keys`](/guides/key-formatting/) | `'SAFE' \| 'CAMEL' \| 'SNAKE' \| 'KEBAB' \| 'PASCAL' \| 'TRAIN'` | `'SAFE'` | Key casing style | +| [`layout`](/guides/data-layout/) | `'LAYOUT' \| 'PARENT_CHILDREN' \| 'BOTH'` | `'LAYOUT'` | Element hierarchy representation | +| [`tokens`](/settings/tokens/) | `'TOKEN' \| 'TOKEN_NAME' \| 'TOKEN_FIGMA_EXTENSIONS' \| 'FIGMA_NAME' \| 'CUSTOM' \| 'FIGMA_SYNTAX_WEB' \| 'FIGMA_SYNTAX_IOS' \| 'FIGMA_SYNTAX_ANDROID'` | `'TOKEN'` | Token reference output format | +| [`color`](/settings/color/) | `ColorFormat` | `'HEX'` | Color value output format | +| [`variantDepth`](/guides/variant-depth/) | `1 \| 2 \| 3 \| 9999` | `9999` | Maximum variant nesting depth (9999 = unlimited) | +| [`details`](/guides/variant-layering/) | `'FULL' \| 'LAYERED'` | `'LAYERED'` | Output detail level | +| [`collapsePrimitiveWrapper`](/settings/collapse-primitive-wrapper/) | `boolean` | `false` | Strip plain container wrappers around a single text/glyph child and promote the leaf to spec root | +| [`invalidVariants`](/settings/invalid-variants/) | `boolean` | `false` | Include variants marked invalid | +| [`invalidCombinations`](/guides/invalid-variant-combinations/) | `boolean` | `true` | Include `invalidVariantCombinations` list | +| [`emptyVariants`](/settings/empty-variants/) | `boolean` | `false` | Include variants with no element overrides | +| [`defaultSlotContent`](/guides/default-slot-content/) | `boolean` | `false` | **Pro.** Emit the component's default slot content into `Component.slotContentExamples`. Ignored on the free tier | +| `splitComponents` | `boolean` | `true` | Write one file per component rather than a single combined library file | +| `splitConcerns` | `boolean` | `true` | Write one file per concern (api, styling, variants) | +| `useSubfolders` | `boolean` | `true` | Nest each component's files in a subfolder named for the component | + +## `assets` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `directory` | `string` | — | Directory holding shared assets | + +Grouped by consumer rather than producer: icons, images, generated CSS, and fonts arrive from fetch, from generate, from transform, or from a process outside this tool — and every code output points at them, whatever the platform. + +## DEFAULT_SETTINGS + +A runtime export from `@directededges/specs-schema`. Provides defaults for every setting that has one. Typed as `ResolvedSettings` — all defaulted properties are required: + +```ts +const DEFAULT_SETTINGS: ResolvedSettings = { + spec: { + format: 'JSON', + keys: 'SAFE', + layout: 'LAYOUT', + tokens: 'TOKEN', + color: 'HEX', + variantDepth: 9999, + details: 'LAYERED', + collapsePrimitiveWrapper: false, + invalidVariants: false, + invalidCombinations: true, + emptyVariants: false, + defaultSlotContent: false, + }, +}; +``` + +Directories, `sources`, and `author` carry no default: the consumer supplies them, and this package has no basis for choosing one. They stay optional on `ResolvedSettings` for exactly that reason. + +The three split flags are different. Every downstream command — `transform`, `analyze`, `render` — reads the split layout, so the shape of generated output is not a per-consumer choice; leaving each consumer to pick its own default is how the same workspace ends up emitting two layouts. `DEFAULT_SETTINGS` carries them as `true` and they are required on `ResolvedSettings`. diff --git a/site/src/content/docs/schema/slot-content.md b/site/src/content/docs/schema/slot-content.md index 8fd5e1f3..6bd1b1d7 100644 --- a/site/src/content/docs/schema/slot-content.md +++ b/site/src/content/docs/schema/slot-content.md @@ -28,7 +28,7 @@ interface SlotContent { `SlotContent` entries are stored in named registries and referenced — never inlined — by a [`SlotContentRef`](/schema/slot-content-ref/) (`$slotContent` pointer): -- **`Component.slotContentExamples`** — component-scoped fills, e.g. `"#/components/pill/slotContentExamples/composedLabel"`. Emitted when [`include.defaultSlotContent`](/settings/default-slot-content/) is on. +- **`Component.slotContentExamples`** — component-scoped fills, e.g. `"#/components/pill/slotContentExamples/composedLabel"`. Emitted when [`settings.spec.defaultSlotContent`](/schema/settings/#spec) is on. - **`Composition.slotContent`** — fills bundled alongside a [`Composition`](/schema/composition/)'s primary content, e.g. `"#/compositions/filterResultsPage/slotContent/pageHeader"`. specs-from-figma de-duplicates entries by structural equality across variants and slots — identical fills share a single registry entry. diff --git a/site/src/content/docs/schema/styles/background-color.md b/site/src/content/docs/schema/styles/background-color.md index 1c33db29..4b66307a 100644 --- a/site/src/content/docs/schema/styles/background-color.md +++ b/site/src/content/docs/schema/styles/background-color.md @@ -9,8 +9,8 @@ Background fill color, read from Figma's `fills` ([ADR 009](https://github.com/D | Type | Example | Settings | |---|---|---| -| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`config.format.color`](/settings/color/) | -| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`config.format.color`](/settings/color/) | +| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`settings.spec.color`](/schema/settings/#spec) | +| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`settings.spec.color`](/schema/settings/#spec) | | [`TokenReference`](/schema/token-reference/) | `$token: DS.Color.Surface.Muted`
`$type: color` | [`config.format.token`](/settings/tokens/) | | [`GradientValue`](/schema/gradient-value/) | `type: LINEAR`
`angle: 180`
`stops: [...]` | — | | `null` | `null` | — | diff --git a/site/src/content/docs/schema/styles/fill-color.md b/site/src/content/docs/schema/styles/fill-color.md index 7ba333f0..bfce7e09 100644 --- a/site/src/content/docs/schema/styles/fill-color.md +++ b/site/src/content/docs/schema/styles/fill-color.md @@ -9,8 +9,8 @@ Glyph/vector fill color, read from Figma's `fills` ([ADR 013](https://github.com | Type | Example | Settings | |---|---|---| -| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`config.format.color`](/settings/color/) | -| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`config.format.color`](/settings/color/) | +| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`settings.spec.color`](/schema/settings/#spec) | +| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`settings.spec.color`](/schema/settings/#spec) | | [`TokenReference`](/schema/token-reference/) | `$token: DS.Color.Surface.Muted`
`$type: color` | [`config.format.token`](/settings/tokens/) | | [`GradientValue`](/schema/gradient-value/) | `type: LINEAR`
`angle: 180`
`stops: [...]` | — | | `null` | `null` | — | diff --git a/site/src/content/docs/schema/styles/text-color.md b/site/src/content/docs/schema/styles/text-color.md index a766841e..8bbfe9a1 100644 --- a/site/src/content/docs/schema/styles/text-color.md +++ b/site/src/content/docs/schema/styles/text-color.md @@ -9,8 +9,8 @@ Text fill color, read from Figma's `fills` ([ADR 009](https://github.com/Directe | Type | Example | Settings | |---|---|---| -| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`config.format.color`](/settings/color/) | -| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`config.format.color`](/settings/color/) | +| `string` | `"#FF6600"` (`HEX`)
`"#FF6600FF"` (`HEXA`)
`"rgb(255, 102, 0)"` (`RGB`)
`"rgba(255, 102, 0, 1)"` (`RGBA`)
`"hsla(24, 100%, 50%, 1)"` (`HSLA`)
`"hsb(24, 100%, 100%)"` (`HSB`)
`"oklch(0.7 0.15 50 / 1)"` (`OKLCH`)
`"oklab(0.7 0.1 0.1 / 1)"` (`OKLAB`) | [`settings.spec.color`](/schema/settings/#spec) | +| [`ColorObject`](/schema/color-object/) | `colorSpace: srgb`
`components: [1, 0.4, 0]` | [`settings.spec.color`](/schema/settings/#spec) | | [`TokenReference`](/schema/token-reference/) | `$token: DS.Color.Surface.Muted`
`$type: color` | [`config.format.token`](/settings/tokens/) | | [`GradientValue`](/schema/gradient-value/) | `type: LINEAR`
`angle: 180`
`stops: [...]` | — | | `null` | `null` | — | diff --git a/site/src/content/docs/schema/subcomponents.md b/site/src/content/docs/schema/subcomponents.md index 20fde8df..aece7bf8 100644 --- a/site/src/content/docs/schema/subcomponents.md +++ b/site/src/content/docs/schema/subcomponents.md @@ -70,7 +70,7 @@ The `$ref` is a JSON Pointer into the same spec document. ## Detection -Subcomponent detection is controlled by [`config.processing.subcomponents`](/schema/config.md/#processing): +Subcomponent detection is controlled by [`conventions.figma.subcomponents`](/schema/conventions/#figmasubcomponents): | Option | Type | Default | Description | |--------|------|---------|-------------| diff --git a/site/src/content/docs/schema/token-reference.md b/site/src/content/docs/schema/token-reference.md index 217e75d8..ac09c7ac 100644 --- a/site/src/content/docs/schema/token-reference.md +++ b/site/src/content/docs/schema/token-reference.md @@ -122,4 +122,4 @@ visible: ## Further Reading - [ADR 006 — Unified Token Reference Type](https://github.com/DirectedEdges/specs/blob/main/adr/006-token-references.md) — introduces the DTCG-aligned `TokenReference` type -- [ADR 007 — Consolidate Token Format Configuration](https://github.com/DirectedEdges/specs/blob/main/adr/007-token-reference-config.md) — introduces the `format.tokens` config option controlling token output shape +- [ADR 007 — Consolidate Token Format Configuration](https://github.com/DirectedEdges/specs/blob/main/adr/007-token-reference-config.md) — introduces the token output option now carried as `settings.spec.tokens` diff --git a/site/src/content/docs/schema/variants.md b/site/src/content/docs/schema/variants.md index 4c813b95..caa92928 100644 --- a/site/src/content/docs/schema/variants.md +++ b/site/src/content/docs/schema/variants.md @@ -30,6 +30,6 @@ When a variant's `configuration` matches the current prop values, its `elements` ## Invalid Variants -Variants with `invalid: true` represent prop combinations that exist in Figma but are semantically invalid. They are excluded from output by default (controlled by [`include.invalidVariants`](/schema/config.md/#include) in config). +Variants with `invalid: true` represent prop combinations that exist in Figma but are semantically invalid. They are excluded from output by default (controlled by [`settings.spec.invalidVariants`](/schema/settings/#spec) in config). The top-level `invalidVariantCombinations` array lists prop combinations that should never occur together, separately from the variant list. diff --git a/site/src/content/docs/settings/code-only-props-pattern.md b/site/src/content/docs/settings/code-only-props-pattern.md index b1072b7a..20b9c41c 100644 --- a/site/src/content/docs/settings/code-only-props-pattern.md +++ b/site/src/content/docs/settings/code-only-props-pattern.md @@ -1,9 +1,9 @@ --- -title: "Code-Only Props Pattern" +title: "codeOnlyProps" description: "Naming pattern used to detect the code-only props container layer" --- -Naming pattern used to detect the code-only props container layer. When absent, no code-only prop extraction is performed. A `Code only props` layer is a tiny, clipped container parked at `(0,0)` that holds child layers bound to behavioral component properties with no visual representation — for example, a Text Area's `minRows`/`maxRows` sizing bounds and a `minLength` constraint. +Naming pattern used to detect the code-only props container layer. A library fact, declared in `config/conventions.yaml`: every consumer reading the same library must declare the same layer name — a wrong one leaves code-only props unextracted. Absence means the library has no such convention, and no code-only prop extraction is performed. A `Code only props` layer is a tiny, clipped container parked at `(0,0)` that holds child layers bound to behavioral component properties with no visual representation — for example, a Text Area's `minRows`/`maxRows` sizing bounds and a `minLength` constraint. :::tip[Guide] See the [Code-Only Props](/guides/code-only-props/) guide as well as the [Code-Only Props in Figma](https://nathanacurtis.substack.com/p/code-only-props-in-figma) blog post for how detection and extraction works. @@ -12,9 +12,9 @@ See the [Code-Only Props](/guides/code-only-props/) guide as well as the [Code-O ## Configuration ```yaml -config: - processing: - codeOnlyPropsPattern: 'Code only props' +figma: + codeOnlyProps: + match: 'Code only props' ``` ## Result @@ -42,14 +42,16 @@ When the pattern matches, the container **and all of its children are omitted fr Their numeric values (`"2"`, `"6"`, `"3"`) remain strings here. Pair this with [`inferNumberProps`](/settings/infer-number-props/) to emit them as `NumberProp` (`2`, `6`, `3`) instead. -Without `codeOnlyPropsPattern`, the container and its children are treated as ordinary layout elements — they appear in `anatomy`/`elements` and no props are extracted. +Without a `codeOnlyProps` block, the container and its children are treated as ordinary layout elements — they appear in `anatomy`/`elements` and no props are extracted. ## Options -- **Type**: string -- **Default**: absent (disabled) -- **Effect**: When set, layers whose names match the pattern are treated as code-only prop containers and their children are extracted as props. The matched layer and its children are excluded from layout and element styling evaluation. When absent, code-only prop extraction is skipped entirely. +- **Type**: block with a single `match` string — a literal layer name +- **Default**: absent (no such convention) +- **Effect**: When declared, layers whose names match are treated as code-only prop containers and their children are extracted as props. The matched layer and its children are excluded from layout and element styling evaluation. When absent, code-only prop extraction is skipped entirely. ## Path -`config.processing.codeOnlyPropsPattern` +`figma.codeOnlyProps.match` in `config/conventions.yaml` + +**Legacy name**: in the pre-split `specs.config.yaml`, this option was the scalar `config.processing.codeOnlyPropsPattern`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving that member to `figma.codeOnlyProps.match`. diff --git a/site/src/content/docs/settings/collapse-primitive-wrapper.md b/site/src/content/docs/settings/collapse-primitive-wrapper.md index 18063694..27326d12 100644 --- a/site/src/content/docs/settings/collapse-primitive-wrapper.md +++ b/site/src/content/docs/settings/collapse-primitive-wrapper.md @@ -3,14 +3,13 @@ title: "Collapse Primitive Wrapper" description: "Strip plain container wrappers around a single text or glyph element and promote the leaf to spec root" --- -When enabled, a component whose root is a plain, style-free container holding a single `text` or `glyph` child is collapsed: the wrapper is stripped and the leaf becomes the spec root. This eliminates structural noise for purely typographic or icon primitives — such as Heading, Paragraph, Body, Label, or Icon components — where the container adds no design-system meaning. +A run choice in `config/settings.yaml`. When enabled, a component whose root is a plain, style-free container holding a single `text` or `glyph` child is collapsed: the wrapper is stripped and the leaf becomes the spec root. This eliminates structural noise for purely typographic or icon primitives — such as Heading, Paragraph, Body, Label, or Icon components — where the container adds no design-system meaning. ## Configuration ```yaml -config: - processing: - collapsePrimitiveWrapper: true +spec: + collapsePrimitiveWrapper: true ``` ## Result @@ -59,4 +58,6 @@ Collapse is **all-or-nothing**: if any variant in the component set fails the el ## Path -`config.processing.collapsePrimitiveWrapper` +`spec.collapsePrimitiveWrapper` in `config/settings.yaml` + +Stripping the wrapper is a normalization choice, not a fact about the library — keeping it is faithful to Figma and equally correct. That is why this option lives in `settings.yaml` while its neighbors from the old `processing` block moved to `conventions.yaml`. diff --git a/site/src/content/docs/settings/color.md b/site/src/content/docs/settings/color.md index a0dea507..86f3d9f0 100644 --- a/site/src/content/docs/settings/color.md +++ b/site/src/content/docs/settings/color.md @@ -3,9 +3,9 @@ title: "Color" description: "Control how color values are formatted in the spec output" --- -Controls how color values are serialized wherever a `Color` appears in spec output — including solid fill and stroke styles, text color, shadow color, and gradient stops. By default, colors are emitted as compact hex strings, but you can switch to any CSS functional notation, Figma's native HSB model, or a fully structured object that preserves the original color space without lossy conversion. +A run choice in `config/settings.yaml`. Controls how color values are serialized wherever a `Color` appears in spec output — including solid fill and stroke styles, text color, shadow color, and gradient stops. By default, colors are emitted as compact hex strings, but you can switch to any CSS functional notation, Figma's native HSB model, or a fully structured object that preserves the original color space without lossy conversion. -This is a formatting concern only — it doesn't affect which colors are extracted or how they're resolved from Figma. Token-bound colors are unaffected; `format.color` applies to literal, unbound color values. +This is a formatting concern only — it doesn't affect which colors are extracted or how they're resolved from Figma. Token-bound colors are unaffected; `spec.color` applies to literal, unbound color values. ## Default @@ -27,16 +27,15 @@ This is a formatting concern only — it doesn't affect which colors are extract ## Path -`config.format.color` +`spec.color` in `config/settings.yaml` ### Example ```yaml -config: - format: - color: HEX # Default — 6-digit hex strings +spec: + color: HEX # Default — 6-digit hex strings ``` ## See Also -- [Config schema reference](/schema/config/) - Full configuration documentation +- [Settings schema reference](/schema/settings/) - Full settings documentation diff --git a/site/src/content/docs/settings/data-sources.md b/site/src/content/docs/settings/data-sources.md index b17a9f6a..a95127c1 100644 --- a/site/src/content/docs/settings/data-sources.md +++ b/site/src/content/docs/settings/data-sources.md @@ -3,11 +3,12 @@ title: "sources" description: "Configure which Figma files to fetch and process" --- -`sources` tells the CLI which Figma files to fetch data from and what to download from each one. Each entry is a named alias you choose, mapped to a Figma file key and a list of data types: +`data.sources` in `config/settings.yaml` tells the CLI which Figma files to fetch data from and what to download from each one. A run choice — which files a workspace reads is this workspace's business, which is why sources sit in `settings.yaml` while the conventions describing the library live in `conventions.yaml`. Each entry is a named alias you choose, mapped to a Figma file key and a `fetch` list of artifact kinds: - `file` — the full Figma document (components, frames, nodes). This is typically your component library file. Required for `generate`. - `variables` — Figma variable collections and their values. Used for token resolution during `generate`. - `styles` — Figma styles (color, text, effect). Used for style resolution during `generate`. +- `icons` — the glyph assets matched by [`figma.glyphs.match`](/settings/glyph-name-pattern/), extracted as SVG files. Requires that convention to be declared and the `file` payload to be fetched. Most projects have one source. You'd add a second when your design system spans more than one Figma file: @@ -20,22 +21,23 @@ The `generate` command resolves variables and styles across all configured sourc ## Example ```yaml -sources: - library: - key: vtOioqf0hbfCzjj5iRgG3p - data: ['file','variables','styles'] - foundations: - key: n488on7ZWi67JDiFwoNul2 - data: ['variables','styles'] +data: + sources: + library: + key: vtOioqf0hbfCzjj5iRgG3p + fetch: ['file','variables','styles'] + foundations: + key: n488on7ZWi67JDiFwoNul2 + fetch: ['variables','styles'] ``` ## Alias -The alias (e.g. `library`, `foundations`) is a name you assign to each source. It determines the filenames the CLI writes to `dataDirectory`: +The alias (e.g. `library`, `foundations`) is a name you assign to each source. It determines the filenames the CLI writes to `data.directory`: -- `${alias}.file.json` (only if `data` includes `file`) -- `${alias}.variables.json` (only if `data` includes `variables`) -- `${alias}.styles.json` (only if `data` includes `styles`) +- `${alias}.file.json` (only if `fetch` includes `file`) +- `${alias}.variables.json` (only if `fetch` includes `variables`) +- `${alias}.styles.json` (only if `fetch` includes `styles`) ## `key` @@ -44,23 +46,26 @@ The Figma file key for this source. Found in the file URL: `figma.com/design/` and renaming its list to `fetch`. ## Branch Keys The `key` field accepts either a main file key or a **branch file key**. To fetch from a Figma branch, replace the key with the branch's key (found in the branch URL: `figma.com/design//...`). ```yaml -sources: - library: - key: BRANCH_FILE_KEY # fetches from the branch, not main - data: ['file', 'variables', 'styles'] +data: + sources: + library: + key: BRANCH_FILE_KEY # fetches from the branch, not main + fetch: ['file', 'variables', 'styles'] ``` Branch data includes unpublished changes — variables, styles, and components that haven't been merged or published to main. See [Fetching Figma Branches](/cli/commands/fetch/#fetching-figma-branches) for implications. diff --git a/site/src/content/docs/settings/default-slot-content.md b/site/src/content/docs/settings/default-slot-content.md index 667aa4b4..dc756bc3 100644 --- a/site/src/content/docs/settings/default-slot-content.md +++ b/site/src/content/docs/settings/default-slot-content.md @@ -6,16 +6,15 @@ description: "Emit the component's structurally-detected default slot content as -When `true`, the generator emits `Component.slotContentExamples` — the **default content placed inside a component's slot layers**, captured structurally and referenced from each slot binding via `$slotContent`. Defaults to `false`, so output for unannotated components is unchanged until you opt in. +A run choice in `config/settings.yaml`. When `true`, the generator emits `Component.slotContentExamples` — the **default content placed inside a component's slot layers**, captured structurally and referenced from each slot binding via `$slotContent`. Defaults to `false`, so output for unannotated components is unchanged until you opt in. -Unlike [`instanceExamples`](/settings/instance-examples/), slot content examples need **no detection config** — they are derived structurally from whatever content sits inside slot layers. This flag is the only control. +Unlike [`instanceExamples`](/settings/instance-examples/), slot content examples need **no detection convention** — they are derived structurally from whatever content sits inside slot layers. This flag is the only control, and because it is a run choice rather than a library fact, it lives in `settings.yaml`. ## Configuration ```yaml -config: - include: - defaultSlotContent: true # emit structurally-detected default slot fills +spec: + defaultSlotContent: true # emit structurally-detected default slot fills ``` ## Result @@ -74,7 +73,7 @@ With `defaultSlotContent: false` (the default), the slot binding carries no `$sl ## Path -`config.include.defaultSlotContent` +`spec.defaultSlotContent` in `config/settings.yaml` ## Licensing @@ -83,5 +82,5 @@ With `defaultSlotContent: false` (the default), the slot binding carries no `$sl ## See Also - [Guide: Default Slot Content](/guides/default-slot-content/) — what it captures and how to author it -- [`processing.instanceExamples`](/settings/instance-examples/) — the separate, presence-driven instance-example feature +- [`instanceExamples`](/settings/instance-examples/) — the separate, presence-driven instance-example feature - [Schema: Component](/schema/component/) — `slotContentExamples` registry shape diff --git a/site/src/content/docs/settings/details.md b/site/src/content/docs/settings/details.md index cdd792fd..daafbdbd 100644 --- a/site/src/content/docs/settings/details.md +++ b/site/src/content/docs/settings/details.md @@ -3,7 +3,7 @@ title: "Details" description: "Control the detail level for variant data" --- -Detail level for variant data. +Detail level for variant data. A run choice in `config/settings.yaml` — both levels describe the same component; they differ only in how much they repeat. ## Options @@ -14,14 +14,13 @@ Detail level for variant data. ## Path -`config.processing.details` +`spec.details` in `config/settings.yaml` ### Example ```yaml -config: - processing: - details: LAYERED +spec: + details: LAYERED ``` ## See Also diff --git a/site/src/content/docs/settings/empty-variants.md b/site/src/content/docs/settings/empty-variants.md index d2bff017..cec63941 100644 --- a/site/src/content/docs/settings/empty-variants.md +++ b/site/src/content/docs/settings/empty-variants.md @@ -3,7 +3,7 @@ title: "Empty Variants" description: "Include layered variants that contain no element overrides" --- -Include layered variants that contain no element overrides. +Include layered variants that contain no element overrides. A run choice in `config/settings.yaml`. ## Options @@ -13,12 +13,11 @@ Include layered variants that contain no element overrides. ## Path -`config.include.emptyVariants` +`spec.emptyVariants` in `config/settings.yaml` ### Example ```yaml -config: - include: - emptyVariants: false # Exclude empty variants (default) +spec: + emptyVariants: false # Exclude empty variants (default) ``` diff --git a/site/src/content/docs/settings/figma-keys.md b/site/src/content/docs/settings/figma-keys.md index 754090ec..1f3a168b 100644 --- a/site/src/content/docs/settings/figma-keys.md +++ b/site/src/content/docs/settings/figma-keys.md @@ -1,11 +1,11 @@ --- -title: "Figma Keys" +title: "naming" description: "Declare the naming convention your Figma file uses, so formatted keys stay reversible" --- The naming convention your Figma file uses for layer names and component property names. -Where [`keys`](/settings/keys/) controls what the spec *emits*, `figmaKeys` describes what the Figma file *contains*. Declaring it gives every formatted key a defined name to reverse back into when a spec is rendered into Figma. +Where [`keys`](/settings/keys/) controls what the spec *emits*, `naming` describes what the Figma file *contains*. That is why the two live in different files: `keys` is a run choice in `config/settings.yaml`, while `naming` is a fact about the library, declared in `config/conventions.yaml` — a wrong declaration makes name reversal undefined, not merely different. Declaring it gives every formatted key a defined name to reverse back into when a spec is rendered into Figma. ## Options @@ -17,17 +17,24 @@ Where [`keys`](/settings/keys/) controls what the spec *emits*, `figmaKeys` desc Only the two conventions observed in real Figma files are accepted as declarations. This is deliberately narrower than `keys`. +**Legacy name**: in the pre-split `specs.config.yaml`, this option was `config.format.figmaKeys`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving that member to `figma.naming`. + ## Path -`config.format.figmaKeys` +`figma.naming` in `config/conventions.yaml` ### Example ```yaml -config: - format: - figmaKeys: TITLE # Figma layer names are Title Case - keys: CAMEL # spec emits camelCase +# config/conventions.yaml +figma: + naming: TITLE # Figma layer names are Title Case +``` + +```yaml +# config/settings.yaml +spec: + keys: CAMEL # spec emits camelCase ``` ## Opting in diff --git a/site/src/content/docs/settings/folders.md b/site/src/content/docs/settings/folders.md index b4fb4033..522ec0e7 100644 --- a/site/src/content/docs/settings/folders.md +++ b/site/src/content/docs/settings/folders.md @@ -3,7 +3,9 @@ title: "Folders" description: "Configure input and output directories for fetched data and generated specs" --- -## `dataDirectory` +Each concern in `config/settings.yaml` — `data`, `spec`, and `assets` — carries its own `directory`. All are run choices: a different team could point them anywhere and still be correct. + +## `data.directory` Directory where `fetch` writes downloaded payloads, and where `generate` loads them from. @@ -11,12 +13,11 @@ Directory where `fetch` writes downloaded payloads, and where `generate` loads t - **CLI override**: `--data-dir` flag on `fetch`, `scan`, and `generate` commands ```yaml -dataDirectory: ./data +data: + directory: ./data ``` -> **Backward compatibility**: The deprecated `sourceDirectory` field still works as an alias for `dataDirectory`. If both are present, `dataDirectory` takes precedence. Using `sourceDirectory` will emit a deprecation warning. - -## `outputDirectory` +## `spec.directory` Default directory where `generate` commands write their output files. @@ -25,5 +26,19 @@ Default directory where `generate` commands write their output files. - **Note**: The `generate` command's `--format` flag still controls output format (YAML vs JSON); this controls the directory only. ```yaml -outputDirectory: ./specs +spec: + directory: ./specs ``` + +## `assets.directory` + +Directory holding shared resources every code output points at, whatever the platform — icons, images, generated CSS, fonts. + +```yaml +assets: + directory: ./assets +``` + +## Legacy names + +In the pre-split `specs.config.yaml`, these were the root-level `dataDirectory` and `outputDirectory` members (and, before that, `sourceDirectory` as a deprecated alias for `dataDirectory`). That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving both to `data.directory` and `spec.directory`. diff --git a/site/src/content/docs/settings/glyph-name-pattern.md b/site/src/content/docs/settings/glyph-name-pattern.md index 3308f3cd..09858bd5 100644 --- a/site/src/content/docs/settings/glyph-name-pattern.md +++ b/site/src/content/docs/settings/glyph-name-pattern.md @@ -1,9 +1,9 @@ --- -title: "Glyph Name Pattern" +title: "glyphs" description: "Naming pattern used to detect glyph content assets" --- -Naming pattern used to detect glyph content assets (e.g. icon glyphs). When absent, no glyph detection is performed. +Naming pattern used to detect glyph content assets (e.g. icon glyphs). A library fact, declared in `config/conventions.yaml`: every consumer reading the same library must declare the same pattern — a wrong or missing one leaves icon assets undetected. Absence means the library has no glyph naming convention, and no glyph detection is performed. :::tip[Guide] See [Icon Glyphs](/guides/glyph-name-pattern/) for naming strategies and worked examples. @@ -12,9 +12,9 @@ See [Icon Glyphs](/guides/glyph-name-pattern/) for naming strategies and worked ## Configuration ```yaml -config: - processing: - glyphNamePattern: 'DS Icon Glyph / {i}' +figma: + glyphs: + match: 'DS Icon Glyph / {i}' ``` ## Result @@ -41,16 +41,18 @@ When the pattern matches, the layer is typed as a `glyph` in the anatomy and its } ``` -Without `glyphNamePattern`, the layer is treated as an ordinary element and no `content` glyph name is extracted. +Without a `glyphs` block, the layer is treated as an ordinary element and no `content` glyph name is extracted. ## Options -- **Type**: string -- **Default**: absent (disabled) -- **Effect**: When set, layers whose names match the pattern are detected as glyph assets. When absent, glyph detection is skipped entirely. +- **Type**: block with a single `match` string +- **Default**: absent (no glyph convention) +- **Effect**: When declared, layers whose names match the pattern are detected as glyph assets. When absent, glyph detection is skipped entirely. -The pattern must include the `{i}` placeholder, which marks where the glyph name appears in the component name. Internally `{i}` becomes a `(.+)` capture group and the matched text is used as the glyph's `content` value. See [Icon Glyphs](/guides/glyph-name-pattern/) for the full pattern syntax. +The `match` pattern must include the `{i}` placeholder, which marks where the glyph name appears in the component name. Internally `{i}` becomes a `(.+)` capture group and the matched text is used as the glyph's `content` value. See [Icon Glyphs](/guides/glyph-name-pattern/) for the full pattern syntax. ## Path -`config.processing.glyphNamePattern` +`figma.glyphs.match` in `config/conventions.yaml` + +**Legacy name**: in the pre-split `specs.config.yaml`, this option was the scalar `config.processing.glyphNamePattern`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving that member to `figma.glyphs.match`. diff --git a/site/src/content/docs/settings/images.md b/site/src/content/docs/settings/images.md index e7fa0589..2aa5fb87 100644 --- a/site/src/content/docs/settings/images.md +++ b/site/src/content/docs/settings/images.md @@ -1,55 +1,51 @@ --- title: "Images" -description: "Process image fills and image-source props — the processing.images block and its representation triggers" +description: "Process image fills and image-source props — the figma.images block and its representation triggers" --- -Image processing is controlled by one config block: `processing.images`. Its **presence** is the on-switch (like `subcomponents`), and each member is an **independent representation trigger**. Absent by default, so components are unchanged unless you opt in. +Image processing is controlled by one block: `figma.images`. A library fact, declared in `config/conventions.yaml` — how a library expresses images is a property of the library, and a wrong declaration loses images rather than merely reshaping them. The block's **presence** is the on-switch (like `subcomponents`), and each member is an **independent representation trigger**. Absent by default, so components are unchanged unless the library declares the convention. -## `processing.images` +## `figma.images` Three triggers, combinable freely: -- **`backgroundImage`** — detect `IMAGE`-type fills on container elements and emit them as [`Styles.backgroundImage`](/schema/styles/). When paired with `imageComponent`, this doubles as the fallback for fills outside the designated component. -- **`imageComponent`** — designate an image component by name: instances of it are the image primitive, and their image routes through the source prop (`sourceProps[0]`) via `propConfigurations`. Requires a non-empty `sourceProps`. +- **`backgroundImage`** — the library expresses images as `IMAGE`-type fills on container elements, emitted as [`Styles.backgroundImage`](/schema/styles/). When paired with `match`, this doubles as the fallback for fills outside the designated component. +- **`match`** — the name of the library's designated image component: instances of it are the image primitive, and their image routes through the source prop (`sourceProps[0]`) via `propConfigurations`. Requires a non-empty `sourceProps`. - **`sourceProps`** — code-only prop names (exact, raw Figma names — the same convention as subcomponent and glyph patterns) that re-type from `StringProp` to [`ImageProp`](/schema/props/) on any component. The **first** entry is the designated image component's own source prop. Background fills only: ```yaml -config: - processing: - images: - backgroundImage: true +figma: + images: + backgroundImage: true ``` -Component with background-fill fallback — image props route through `dsImage`; any image fill outside it still emits as `backgroundImage`: +Component with background-fill fallback — image props route through `DS Image`; any image fill outside it still emits as `backgroundImage`: ```yaml -config: - processing: - images: - backgroundImage: true - imageComponent: dsImage - sourceProps: [source, image] +figma: + images: + backgroundImage: true + match: DS Image + sourceProps: [source, image] ``` Component only — the designated component is the sole image representation; stray fills are not detected: ```yaml -config: - processing: - images: - imageComponent: dsImage - sourceProps: [source] +figma: + images: + match: DS Image + sourceProps: [source] ``` Typed image props only — re-type `image`-named code-only props without detecting fills or designating a component: ```yaml -config: - processing: - images: - sourceProps: [image] +figma: + images: + sourceProps: [image] ``` ## Result @@ -84,12 +80,12 @@ When the `images` block is absent, none of this is emitted. ## Properties -`processing.images`: +`figma.images`: | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| -| `backgroundImage` | `boolean` | No | `false` | Detect image fills on containers as `Styles.backgroundImage`; the fallback for stray fills when `imageComponent` is set | -| `imageComponent` | `string` | No | — | Designated image component name (e.g. `dsImage`). Requires a non-empty `sourceProps` — `sourceProps[0]` is its source prop | +| `backgroundImage` | `boolean` | No | `false` | Detect image fills on containers as `Styles.backgroundImage`; the fallback for stray fills when `match` is set | +| `match` | `string` | No | — | Designated image component name (e.g. `DS Image`). Requires a non-empty `sourceProps` — `sourceProps[0]` is its source prop | | `sourceProps` | `string[]` | No | — | Raw Figma code-only prop names that re-type to `ImageProp` on any component | ## Object Fit @@ -102,9 +98,11 @@ Each `images` entry is an object holding the Figma identity in `$extensions['com The REST runtime resolves entries via a second call (Get Image Fills, whose S3 URLs expire ~14 days), downloading the bytes into emitted files — never persisting the URL or embedding base64. The Figma plugin cannot write files or embed raw bytes on the asset (saved-data limits), so it emits identity-only entries and duplicates detected images into the Foundations section's Images subsection for human reference. -## Paths +## Path + +`figma.images` in `config/conventions.yaml` -- `config.processing.images` +**Legacy name**: in the pre-split `specs.config.yaml`, this block was `config.processing.images` and the designated component was named by `imageComponent`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving `imageComponent` to `match`. ## See Also diff --git a/site/src/content/docs/settings/index.mdx b/site/src/content/docs/settings/index.mdx index 8ed845ac..077fa882 100644 --- a/site/src/content/docs/settings/index.mdx +++ b/site/src/content/docs/settings/index.mdx @@ -1,17 +1,27 @@ --- -title: "Settings" +title: "Configuration" --- import { Tabs, TabItem } from '@astrojs/starlight/components'; import Figure from '../../../components/Figure.astro'; import pluginSettingsFormat from '../../../assets/plugin-settings-format.png'; -Settings give you technical control over how specs are created — the same settings surface drives both the Figma plugin and the CLI, so a component produces the same structured output no matter which one generates it. +Configuration gives you technical control over how specs are created — the same options drive both the Figma plugin and the CLI, so a component produces the same structured output no matter which one generates it. + +A workspace authors three files in a `config/` directory, one per question: + +| File | Question it answers | Kind of value | +|------|--------------------|---------------| +| `config/conventions.yaml` | What is true about this Figma library? | **Facts.** Every consumer reading the same library declares the same values — a wrong value produces *incorrect* output (undetected glyphs, unclassified states) | +| `config/settings.yaml` | What do I want out of this run? | **Choices.** A different team could set any of these differently and still be correct — a different value produces merely *different* output | +| `config/pipeline.yaml` | What work should run over the specs? | **Work.** Transformers and analyses to run | + +The distinction decides where an option lives. Declaring a convention is also what enables its processing: a declared `glyphs` pattern turns glyph detection on, and an absent block means the library has no such convention — there is no separate on-switch. Because conventions describe the library rather than any one workspace, a library can publish its `conventions.yaml` once and every consuming workspace adopts it verbatim. -The plugin's **Settings** tab exposes the same options as the CLI's config file, right next to the canvas where you're generating specs. +The plugin's **Settings** tab exposes the same options as the CLI's config files, right next to the canvas where you're generating specs.
-The CLI reads settings from `specs.config.yaml`, layered with command-line flags and convention-based fallbacks, so a project's output stays consistent across every `specs` invocation. +The CLI reads configuration from the workspace's `config/` directory, layered with command-line flags and convention-based fallbacks, so a project's output stays consistent across every `specs` invocation. ## Priority System -The CLI applies settings using three priority levels: +The CLI applies configuration using three priority levels: 1. **CLI flags** (highest) - Explicit overrides for individual commands -2. **Config file** - Project defaults via `specs.config.yaml` +2. **Config files** - Project defaults via `config/conventions.yaml`, `config/settings.yaml`, and `config/pipeline.yaml` 3. **Fallbacks** (lowest) - Convention-based defaults when no sources are configured ### CLI Flags -CLI flags always override settings from the config file: +CLI flags always override values from the config files: ```bash -# Config has format.output: JSON +# settings.yaml has spec.format: JSON # Flag overrides to YAML specs generate data/library.file.json -c Button --format yaml ``` **Available flag overrides**: -- `--format` → overrides `config.format.output` +- `--format` → overrides `spec.format` in `settings.yaml` - `--variables` → overrides the variables file path for that run - `--styles` → overrides the styles file path for that run -### Config File +### Config Files -Settings from `specs.config.yaml` apply when no CLI flag is provided. Use `--config` to point at a project-specific file instead of the default location: +Values from the `config/` directory apply when no CLI flag is provided. Use `--config` to point at a different directory instead of the default location: ```bash specs generate data/library.file.json \ -c "Button" \ - --config ./configs/mobile.config.yaml \ + --config ./configs/mobile \ -o specs/mobile/button.yaml ``` @@ -108,7 +118,10 @@ Convention-based defaults when no sources are configured and no flags are provid ``` project/ -├── specs.config.yaml # Config (if exists) +├── config/ +│ ├── conventions.yaml # Library facts (if any declared) +│ ├── settings.yaml # Run choices (if exists) +│ └── pipeline.yaml # Work to run (if exists) ├── data/ │ ├── library.file.json # Main file (specified in command) │ └── foundations/ # Auto-discovery directory @@ -116,67 +129,101 @@ project/ │ └── styles.json # Auto-discovered ``` -## Settings File +## Configuration Files ### Location -The CLI looks for a settings file in these locations (in order): +The CLI looks for configuration in these locations (in order): -1. `./specs.config.yaml` (current directory) -2. `./specs.config.json` (current directory) -3. `~/.specs/config.yaml` (user home directory) -4. Custom path via `--config ` flag +1. Custom path via `--config ` flag — a `config/` directory holding the split files +2. `./config/` (current directory) — any of `conventions.yaml`, `settings.yaml`, `pipeline.yaml` (`.json` also accepted; each file is optional and defaults independently) + +Nothing else is read. A pre-split `specs.config.yaml` / `specs.config.json` (or `~/.specs/config.yaml`) is detected but never loaded — finding one is an error, not a fallback. See [Legacy single file](#legacy-single-file). + +`specs init` scaffolds all three files with inline documentation. ### Format -The settings file can be written in YAML or JSON: +Each file answers one question, written in YAML or JSON: + +```yaml +# config/conventions.yaml — facts about the Figma library +figma: + naming: SENTENCE # the file's layer/property naming convention + + glyphs: + match: 'DS Icon Glyph / {i}' + + codeOnlyProps: + match: 'Code only props' + + subcomponents: + match: + - '{C} / _ / {S}' + + states: + hover: + prop: state + value: hover + disabled: + prop: disabled +``` + +```yaml +# config/settings.yaml — choices about this run +author: Your Name + +data: + # Where `specs fetch` writes payloads, and where `generate` loads them from + directory: ./data + # Figma file keys and which payloads to fetch/load + sources: + library: + key: REPLACE_WITH_LIBRARY_FILE_KEY + fetch: ['file','variables','styles'] + foundations: + key: REPLACE_WITH_FOUNDATIONS_FILE_KEY + fetch: ['variables','styles'] + +spec: + # Default location for generated spec files (can override with -o flag) + directory: ./specs + format: YAML + keys: SAFE + layout: LAYOUT + tokens: TOKEN + variantDepth: 2 + details: LAYERED + invalidVariants: false + invalidCombinations: true +``` ```yaml -# specs.config.yaml - -# Where `specs fetch` writes payloads, and where `generate` loads them from -dataDirectory: ./data - -# Default location for generated spec files (can override with -o flag) -outputDirectory: ./specs - -# Figma file keys and which payloads to fetch/load -sources: - library: - key: REPLACE_WITH_LIBRARY_FILE_KEY - data: ['file','variables','styles'] - foundations: - key: REPLACE_WITH_FOUNDATIONS_FILE_KEY - data: ['variables','styles'] - -# Processing and output settings (shared with the Figma plugin) -config: - format: - output: YAML - keys: SAFE - figmaKeys: SENTENCE # opts in to key preservation; defaults to NONE - layout: LAYOUT - tokens: TOKEN - - processing: - subcomponents: - match: - - '{C} / _ / {S}' - variantDepth: 2 - details: LAYERED - - include: - invalidVariants: false - invalidCombinations: true +# config/pipeline.yaml — work to run over the specs +transformers: + - name: contract + - name: css ``` +### Legacy single file + +A pre-split `specs.config.yaml` (or `.json`, or `~/.specs/config.yaml`) is no longer read (ADR-071). When the CLI finds one it stops with an error rather than falling back to defaults — a defaults-only run would generate specs missing everything your configuration declares: + +``` +specs.config.yaml is no longer read (ADR-071). + Run `specs migrate config` to write config/conventions.yaml, config/settings.yaml and config/pipeline.yaml from it. + Docs: https://specs.directededges.com/settings/ +``` + +Run [`specs migrate config`](/cli/commands/migrate/) once to convert: it writes the three `config/` files from your legacy file, then renames the source to `specs.config.yaml.migrated` so discovery stops finding it. Review the generated files, then delete the renamed original. Each option page notes its legacy path. + ## Validation -The CLI validates every setting value and provides helpful error messages: +The CLI validates every configuration value and provides helpful error messages: ```bash $ specs generate data/library.file.json -c Button -Warning: Invalid format.keys: 'invalid'. Using default: SAFE. +Warning: Invalid spec.keys: 'invalid'. Using default: SAFE. Valid values: SAFE, CAMEL, SNAKE, KEBAB, PASCAL, TRAIN ``` @@ -184,24 +231,26 @@ Invalid values fall back to defaults with warnings, so builds continue. ## Best Practices -### 1. Version Control Your Settings File +### 1. Version Control Your Config Directory -Commit `specs.config.yaml` to share settings across the team: +Commit `config/` to share configuration across the team: ```bash -git add specs.config.yaml -git commit -m "Add Specs CLI configuration" +git add config/ +git commit -m "Add Specs configuration" ``` -### 2. Environment-Specific Settings Files +`conventions.yaml` in particular is worth sharing beyond the team: it describes the library, not the workspace, so every workspace reading the same library should carry the same one. + +### 2. Environment-Specific Settings -Use a different settings file for each environment: +Use a different config directory for each environment: ``` project/ -├── specs.config.yaml # Default (development) -├── .specs.production.yaml # Production -└── .specs.staging.yaml # Staging +├── config/ # Default (development) +├── configs/production/ # Production +└── configs/staging/ # Staging ``` ```bash @@ -209,26 +258,32 @@ project/ specs generate data/library.file.json -c Button # Production -specs generate data/library.file.json -c Button --config .specs.production.yaml +specs generate data/library.file.json -c Button --config configs/production ``` +The conventions file is typically identical across environments — it is the settings that differ. + ### 3. Document Custom Patterns -Add comments to explain project-specific settings: +Add comments to explain library-specific conventions: ```yaml -config: - processing: +# config/conventions.yaml +figma: + subcomponents: # Match direct children and underscore-nested subcomponents - subcomponents: - match: - - '{C} / {S}' - - '{C} / _ / {S}' - exclude: - - '{C} / Examples / {S}' + match: + - '{C} / {S}' + - '{C} / _ / {S}' + exclude: + - '{C} / Examples / {S}' +``` - # Only 2 levels: size + variant (not size + variant + state) - variantDepth: 2 +```yaml +# config/settings.yaml +spec: + # Only 2 levels: size + variant (not size + variant + state) + variantDepth: 2 ``` ### 4. Consistent Key Format @@ -236,13 +291,16 @@ config: Choose one key format and stick with it: ```yaml -config: - format: - keys: CAMEL # All keys in camelCase for consistency +# config/settings.yaml +spec: + keys: CAMEL # All keys in camelCase for consistency ``` ## See Also +- [Conventions schema reference](/schema/conventions/) - The full `conventions.yaml` shape +- [Settings schema reference](/schema/settings/) - The full `settings.yaml` shape +- [Pipeline schema reference](/schema/pipeline/) - The full `pipeline.yaml` shape - [CLI Overview](/cli/) - CLI command options - [Getting Started](/cli/getting-started/) - Installation and setup diff --git a/site/src/content/docs/settings/infer-number-props.md b/site/src/content/docs/settings/infer-number-props.md index 241c39fd..d7c6e0a0 100644 --- a/site/src/content/docs/settings/infer-number-props.md +++ b/site/src/content/docs/settings/infer-number-props.md @@ -3,7 +3,7 @@ title: "Infer Number Props" description: "Automatically emit numeric code-only props as NumberProp instead of StringProp" --- -When enabled, TEXT code-only props whose default and all examples parse as valid numbers (no leading zeros) are emitted as `NumberProp` instead of `StringProp`. The example below uses a Text Area that exposes [code-only props](/settings/code-only-props/) whose values are purely numeric strings — `minRows` = `"2"`, `maxRows` = `"6"`, `minLength` = `"3"`. +When enabled, TEXT code-only props whose default and all examples parse as valid numbers (no leading zeros) are emitted as `NumberProp` instead of `StringProp`. A library fact, declared in `config/conventions.yaml`: it states that the library authors numeric props as Figma `TEXT` props — a library that does, but leaves this off, gets worse typing for genuinely numeric props, not different typing. The example below uses a Text Area that exposes [code-only props](/settings/code-only-props-pattern/) whose values are purely numeric strings — `minRows` = `"2"`, `maxRows` = `"6"`, `minLength` = `"3"`. :::tip[Guide] See [Number Inference](/guides/number-inference/) for how inference works and when to use it. @@ -12,9 +12,8 @@ See [Number Inference](/guides/number-inference/) for how inference works and wh ## Configuration ```yaml -config: - processing: - inferNumberProps: true # Infer numeric code-only props as NumberProp +figma: + inferNumberProps: true # Infer numeric code-only props as NumberProp ``` ## Result @@ -53,4 +52,4 @@ Text code-only props whose values aren't purely numeric (e.g. `value` = `"{Value ## Path -`config.processing.inferNumberProps` +`figma.inferNumberProps` in `config/conventions.yaml` diff --git a/site/src/content/docs/settings/instance-examples.md b/site/src/content/docs/settings/instance-examples.md index a97e1ec4..003c4148 100644 --- a/site/src/content/docs/settings/instance-examples.md +++ b/site/src/content/docs/settings/instance-examples.md @@ -6,50 +6,47 @@ description: "Detect named example frames that demonstrate a configured whole-co -Instance examples are real-world usages of a component placed in your Figma file — instances with their props and slots filled in (for example, an alert with a title, body, and two actions). When detection is configured, those instances are harvested into `Component.instanceExamples` and emitted. +Instance examples are real-world usages of a component placed in your Figma file — instances with their props and slots filled in (for example, an alert with a title, body, and two actions). When detection is configured, those instances are harvested into `Component.instanceExamples` and emitted. A library fact, declared in `config/conventions.yaml`: where a library keeps its examples and how it marks them are properties of the library — a wrong declaration yields no examples, or foreign ones. A candidate qualifies primarily by **identity**: it must be an *instance of the component being generated* (one of its variants). Naming is not the relevance test — that's what `match` is for, and `match` is optional. This means example instances can be named anything; they don't need to reference the component name. -The **presence** of `processing.instanceExamples` is the on-switch — the same opt-in model as [`subcomponents`](/settings/subcomponents/). There is no separate `include` flag: when the block is present (and the license is Pro), examples are detected *and* emitted. When it is absent, no detection runs. +The **presence** of `figma.instanceExamples` is the on-switch — the same opt-in model as [`subcomponents`](/settings/subcomponents/). There is no separate flag: when the block is present (and the license is Pro), examples are detected *and* emitted. When it is absent, no detection runs. ## Configuration Simplest setup — every instance of the component inside a frame named "Ready-made examples" is an example. No name patterns; identity plus the parent filter do the scoping: ```yaml -config: - processing: - instanceExamples: - scope: PAGE - parentNames: - - Ready-made examples +figma: + instanceExamples: + scope: PAGE + parentNames: + - Ready-made examples ``` Examples on a dedicated page, inside an "Examples" frame, narrowed by name and with deprecated ones excluded: ```yaml -config: - processing: - instanceExamples: - scope: FILE - parentNames: - - Examples - match: - - "{C} / *" - exclude: - - "* / Deprecated / *" +figma: + instanceExamples: + scope: FILE + parentNames: + - Examples + match: + - "{C} / *" + exclude: + - "* / Deprecated / *" ``` Examples alongside the component, narrowed by name only: ```yaml -config: - processing: - instanceExamples: - scope: PAGE - match: - - "{C} – *" - - "{C} Example *" +figma: + instanceExamples: + scope: PAGE + match: + - "{C} – *" + - "{C} Example *" ``` ## Result @@ -84,13 +81,13 @@ components: alertWithOpenDrawer: … ``` -When `processing.instanceExamples` is absent (or the license is not Pro), the registry is omitted entirely. +When `figma.instanceExamples` is absent (or the license is not Pro), the registry is omitted entirely. ## Properties | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| -| `scope` | `"PAGE"` \| `"FILE"` | No | `PAGE` | Search boundary. `PAGE` = the component's Figma page only; `FILE` = all pages in the file (for teams with a dedicated "Examples" page) | +| `scope` | `"PAGE"` \| `"FILE"` | No | `PAGE` | Where the library keeps its examples. `PAGE` = the component's Figma page only; `FILE` = all pages in the file (for teams with a dedicated "Examples" page) | | `match` | `string[]` | No | — | Optional name patterns narrowing which instances qualify, using the `{C}` (component name) placeholder. Absence = every in-scope instance of the component qualifies | | `exclude` | `string[]` | No | — | Name patterns to exclude. Same `{C}` syntax as `match` | | `parentNames` | `string[]` | No | — | Immediate-parent frame or section names a candidate must be contained within. Absence means no parent-name filtering | @@ -105,11 +102,11 @@ The relevance test is **identity**: a candidate qualifies when it's an instance ## Path -`config.processing.instanceExamples` +`figma.instanceExamples` in `config/conventions.yaml` ## Licensing -Instance example detection and output requires a [Pro license](/overview/licensing/). On the free tier `processing.instanceExamples` is silently ignored — no detection runs and nothing is emitted. The Figma plugin hides these controls until a Pro license is active. +Instance example detection and output requires a [Pro license](/overview/licensing/). On the free tier `figma.instanceExamples` is silently ignored — no detection runs and nothing is emitted. The Figma plugin hides these controls until a Pro license is active. ## See Also diff --git a/site/src/content/docs/settings/invalid-combinations.md b/site/src/content/docs/settings/invalid-combinations.md index dec29432..a0c299ae 100644 --- a/site/src/content/docs/settings/invalid-combinations.md +++ b/site/src/content/docs/settings/invalid-combinations.md @@ -9,7 +9,7 @@ description: "Calculate and include invalid property combinations" See [Invalid Variant Combinations](/guides/invalid-variant-combinations/) for what invalid combinations are, why they matter, and worked examples. ::: -Calculate and include invalid property combinations. +Calculate and include invalid property combinations. A run choice in `config/settings.yaml`. ## Options @@ -19,12 +19,11 @@ Calculate and include invalid property combinations. ## Path -`config.include.invalidCombinations` +`spec.invalidCombinations` in `config/settings.yaml` ### Example ```yaml -config: - include: - invalidCombinations: true # Show invalid combinations (default) +spec: + invalidCombinations: true # Show invalid combinations (default) ``` diff --git a/site/src/content/docs/settings/invalid-variants.md b/site/src/content/docs/settings/invalid-variants.md index 7db03ef8..650d9103 100644 --- a/site/src/content/docs/settings/invalid-variants.md +++ b/site/src/content/docs/settings/invalid-variants.md @@ -3,7 +3,7 @@ title: "Invalid Variants" description: "Include invalid variant data in output" --- -Include invalid variant data in output. +Include invalid variant data in output. A run choice in `config/settings.yaml`. ## Options @@ -13,12 +13,11 @@ Include invalid variant data in output. ## Path -`config.include.invalidVariants` +`spec.invalidVariants` in `config/settings.yaml` ### Example ```yaml -config: - include: - invalidVariants: false # Exclude invalid variants +spec: + invalidVariants: false # Exclude invalid variants ``` diff --git a/site/src/content/docs/settings/keys.md b/site/src/content/docs/settings/keys.md index 569a4659..3c8049f1 100644 --- a/site/src/content/docs/settings/keys.md +++ b/site/src/content/docs/settings/keys.md @@ -3,7 +3,7 @@ title: "Keys" description: "Transform property and element key names to a consistent naming convention" --- -Key name transformation strategy. +Key name transformation strategy. A run choice in `config/settings.yaml` — any value produces a correct spec, just with differently formatted keys. ## Options @@ -31,14 +31,13 @@ Input: `Background color` or `background-color` ## Path -`config.format.keys` +`spec.keys` in `config/settings.yaml` ### Example ```yaml -config: - format: - keys: CAMEL # Transform keys to camelCase +spec: + keys: CAMEL # Transform keys to camelCase ``` Every value other than `SAFE` is a lossy projection of the Figma name. Names that cannot be reconstructed from the formatted key are preserved in `$extensions.com.figma.name` on the definition, so the spec stays reversible into Figma. diff --git a/site/src/content/docs/settings/layout.md b/site/src/content/docs/settings/layout.md index 710d0abe..43e2cc88 100644 --- a/site/src/content/docs/settings/layout.md +++ b/site/src/content/docs/settings/layout.md @@ -3,14 +3,13 @@ title: "Layout" description: "Choose how element hierarchy is represented in the spec output" --- -Layout representation format. +Layout representation format. A run choice in `config/settings.yaml` — each representation carries the same hierarchy in a different shape. ## Configuration ```yaml -config: - format: - layout: LAYOUT +spec: + layout: LAYOUT ``` ## Result @@ -55,7 +54,7 @@ The same `DS Alert` hierarchy — a `root` containing `decorativeIcon` and `chil ## Path -`config.format.layout` +`spec.layout` in `config/settings.yaml` ## See Also diff --git a/site/src/content/docs/settings/output-format.md b/site/src/content/docs/settings/output-format.md index d4bf484d..56eded26 100644 --- a/site/src/content/docs/settings/output-format.md +++ b/site/src/content/docs/settings/output-format.md @@ -1,9 +1,9 @@ --- -title: "Output Format" +title: "format" description: "Control the serialization format for generated specs" --- -Output serialization format. +Output serialization format. A run choice in `config/settings.yaml` — a different team could pick the other format and still be correct. ## Options @@ -16,12 +16,11 @@ Output serialization format. ## Path -`config.format.output` +`spec.format` in `config/settings.yaml` ### Example ```yaml -config: - format: - output: YAML # Generate YAML by default +spec: + format: YAML # Generate YAML by default ``` diff --git a/site/src/content/docs/settings/output.md b/site/src/content/docs/settings/output.md index 47b18866..511bf0de 100644 --- a/site/src/content/docs/settings/output.md +++ b/site/src/content/docs/settings/output.md @@ -3,43 +3,44 @@ title: "Output" description: "Control where and how generated specifications are written" --- -Controls where and how to write generated specifications. Configured via the `output` field in `specs.config.yaml` or CLI flags. +Controls where and how to write generated specifications. Run choices, configured via the `spec` block in `config/settings.yaml` or CLI flags — any split arrangement carries the same spec data. + +The split layout is the default: one folder per component, holding one file per concern. Downstream commands — `transform`, `analyze`, `render` — read that layout, so most workspaces never set these at all. ```yaml -output: - splitComponents: false # Create separate file per component - splitConcerns: false # Separate API, variants, and examples - useSubfolders: false # Use component subdirectories - defaultFormat: yaml # Output format (yaml|json) +spec: + splitComponents: true # One file per component (default) + splitConcerns: true # Separate API, variants, and examples (default) + useSubfolders: true # Nest each component in its own folder (default) ``` ## Output Modes -The CLI supports four output modes based on flag combinations: +Four output modes, reached by turning parts of the default split off: -| Mode | `--split-components` | `--split-concerns` | Output Structure | -|------|---------------------|-------------------|------------------| -| **Single-file** | - | - | `library.yaml` (all components) | -| **Per-component** | yes | - | `button.yaml`, `alert.yaml`, ... | -| **Per-concern** | - | yes | `api.yaml` + `variants.yaml` (+ `examples.yaml` if any examples) | -| **Combined** | yes | yes | `button/api.yaml`, `button/variants.yaml` (+ `button/examples.yaml` if examples), ... | +| Mode | `splitComponents` | `splitConcerns` | Output Structure | +|------|-------------------|-----------------|------------------| +| **Combined** (default) | true | true | `button/api.yaml`, `button/variants.yaml` (+ `button/examples.yaml` if examples), ... | +| **Per-component** | true | false | `button.yaml`, `alert.yaml`, ... | +| **Per-concern** | false | true | `api.yaml` + `variants.yaml` (+ `examples.yaml` if any examples) | +| **Single-file** | false | false | `library.yaml` (all components) | ## `splitComponents` -Create separate file per component. +Write one file per component rather than a single combined library file. - **Type**: boolean -- **Default**: `false` (single library file) -- **CLI Flag**: `--split-components` +- **Default**: `true` +- **CLI Flag**: `--combine-as-library` turns it off ```yaml -output: +spec: splitComponents: true useSubfolders: false # button.yaml, alert.yaml (flat) ``` ```yaml -output: +spec: splitComponents: true useSubfolders: true # button/button.yaml, alert/alert.yaml ``` @@ -51,12 +52,12 @@ File naming converts display names to camelCase (e.g., `"DS Alert"` → `dsAlert Separate API specification, variant configuration, and examples. - **Type**: boolean -- **Default**: `false` (complete component data) -- **CLI Flag**: `--split-concerns` +- **Default**: `true` +- **CLI Flag**: `--combine-concerns` turns it off ```yaml -output: - splitConcerns: true +spec: + splitConcerns: false ``` **API file** (`api.yaml`): @@ -91,22 +92,14 @@ Example output is a [Pro feature](/settings/default-slot-content/) — on the fr ## `useSubfolders` -Create component subdirectories when splitting by component. +Nest each component's files in a subfolder named for the component. - **Type**: boolean -- **Default**: `false` (flat structure) +- **Default**: `true` - **Effect**: Only applies when `splitComponents: true` -- **CLI Flag**: `--use-subfolders` +- **CLI Flag**: `--no-subfolders` turns it off -**Without subfolders** (flat): -``` -specs/ -├── button.yaml -├── alert.yaml -└── card.yaml -``` - -**With subfolders**: +**With subfolders** (default): ``` specs/ ├── button/ @@ -117,31 +110,22 @@ specs/ └── card.yaml ``` -## `defaultFormat` - -Default output format for stdout only. - -- **Type**: string -- **Default**: `yaml` -- **Options**: `yaml`, `json` -- **Override**: CLI `--format` flag takes precedence -- **Note**: File output is always YAML. This setting controls stdout format only. -- **Note**: Different from `config.format.output` (controls serialization, not file format) - -```yaml -output: - defaultFormat: yaml # stdout format (files use YAML) +**Without subfolders** (flat): +``` +specs/ +├── button.yaml +├── alert.yaml +└── card.yaml ``` ## Combined Mode -Using both `splitComponents` and `splitConcerns` creates component directories with concern files: +The default. Both `splitComponents` and `splitConcerns` on gives component directories of concern files: ```yaml -output: +spec: splitComponents: true splitConcerns: true - useSubfolders: false # Component dirs created automatically ``` ``` @@ -162,12 +146,16 @@ specs/ Output configuration follows the standard [priority system](/settings/#priority-system): -1. **CLI flags** (highest): `--split-components`, `--split-concerns`, `--use-subfolders` -2. **Config file**: `output` field in `specs.config.yaml` -3. **Defaults** (lowest): Single-file mode, YAML format +1. **CLI flags** (highest): `--combine-as-library`, `--combine-concerns`, `--no-subfolders` +2. **Config file**: `spec` block in `config/settings.yaml` +3. **Defaults** (lowest): the full split layout, YAML format + +Each flag only ever turns a split off, so an absent flag defers to the configured value rather than overriding it. ```bash -# Config has splitComponents: false -# CLI overrides to true -specs generate --split-components +# Config leaves splitConcerns at its default of true +# CLI overrides to false for this run +specs generate --combine-concerns ``` + +In the pre-split `specs.config.yaml`, these flags lived in a root-level `output` block and defaulted to `false`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving them to `spec`. Because the defaults inverted, the migration writes all three out explicitly so a migrated workspace keeps emitting what it emits today; delete those three lines to adopt the new default. diff --git a/site/src/content/docs/settings/slot-constraints.md b/site/src/content/docs/settings/slot-constraints.md index be34852f..8d79c26a 100644 --- a/site/src/content/docs/settings/slot-constraints.md +++ b/site/src/content/docs/settings/slot-constraints.md @@ -3,7 +3,7 @@ title: "Slot Constraints" description: "Consolidate slot constraints from code-only props into the slot property" --- -Consolidate slot constraints (`anyOf`, `minChildren`, `maxChildren`) into the slot property. Constraints are read from two sources — Figma's native `slotSettings` API (when the slot has native settings configured) and code-only props (the legacy naming convention). Both sources produce the same output fields. +Consolidate slot constraints (`anyOf`, `minChildren`, `maxChildren`) into the slot property. A library fact, declared in `config/conventions.yaml`: it states that the library authors slot constraints as code-only props — a library that does, but leaves this off, loses declared constraint data. Constraints are read from two sources — Figma's native `slotSettings` API (when the slot has native settings configured) and code-only props (the legacy naming convention). Both sources produce the same output fields. :::tip[Guide] See the [Slot Constraints](/guides/slot-constraints/) guide as well as the [Figma Slots for Repeating Items](https://nathanacurtis.substack.com/p/figma-slots-for-repeating-items) blog post for how constraint consolidation works. @@ -12,9 +12,8 @@ See the [Slot Constraints](/guides/slot-constraints/) guide as well as the [Figm ## Configuration ```yaml -config: - processing: - slotConstraints: true +figma: + slotConstraints: true ``` ## Result @@ -55,4 +54,4 @@ config: ## Path -`config.processing.slotConstraints` +`figma.slotConstraints` in `config/conventions.yaml` diff --git a/site/src/content/docs/settings/states.md b/site/src/content/docs/settings/states.md index ef249dae..30034e33 100644 --- a/site/src/content/docs/settings/states.md +++ b/site/src/content/docs/settings/states.md @@ -3,7 +3,7 @@ title: "States" description: "Classify Figma variant props as browser-driven or consumer-controlled states for deterministic CSS and contract output" --- -`processing.states` classifies your library's Figma variant props as semantic states, enabling two downstream behaviors: +`figma.states` classifies your library's Figma variant props as semantic states. A library fact, declared in `config/conventions.yaml`: which prop expresses which state concept is an agreement no rule can recover — a wrong entry lands a concept on the wrong prop, and an unclassified prop emits as a `data-*` attribute. Declaring it enables two downstream behaviors: - The [`css` transformer](/cli/transforms/css/) emits real CSS pseudo-classes and ARIA attribute selectors instead of `data-*` attributes for classified props. - The [`contract` transformer](/cli/transforms/contract/) omits browser-driven props from generated Props interfaces. @@ -35,7 +35,7 @@ props: default: false ``` -`processing.states` acts on these props during `specs transform` — to determine CSS selector strategy and contract inclusion. The `api.yaml` itself is not modified. +`figma.states` acts on these props during `specs transform` — to determine CSS selector strategy and contract inclusion. The `api.yaml` itself is not modified. ### State concepts @@ -65,25 +65,24 @@ Each concept resolves to a canonical CSS selector and determines whether the pro ### Mapping Props to Concepts -Declare mappings under `processing.states` in your [specs configuration](/settings/). Use `prop` to name the Figma variant prop and `value` for the specific enum value that activates the concept. - -```yaml title="Partial specs.config.yaml" -config: - processing: - states: - active: - prop: state - # Figma value "pressed" → active concept → :active on web - value: pressed - disabled: - # "is" prefix convention → disabled concept → :disabled / aria-disabled - prop: isDisabled +Declare mappings under `figma.states` in [`config/conventions.yaml`](/settings/). Use `prop` to name the Figma variant prop and `value` for the specific enum value that activates the concept. + +```yaml title="Partial config/conventions.yaml" +figma: + states: + active: + prop: state + # Figma value "pressed" → active concept → :active on web + value: pressed + disabled: + # "is" prefix convention → disabled concept → :disabled / aria-disabled + prop: isDisabled ``` Figma naming conventions don't need to match the concept name. Many design systems name their pointer-down state `pressed` rather than `active` because `pressed` is platform-neutral — it maps to `:active` on web, `UIControlState.highlighted` on iOS, and press `Indication` in Compose. Naming it `active` in Figma would embed a web-specific term into a shared design language. Similarly, a library using `isDisabled` as its boolean prop convention is still expressing the `disabled` concept. :::tip Setting up for the first time? -Run the [**CSS States Setup** skill](https://github.com/DirectedEdges/specs/blob/main/packages/cli/src/transforms/Css.states-setup.md) in Claude Code — it scans your specs output directory, matches variant props against the concept table, and proposes a ready-to-paste `processing.states` block. +Run the [**CSS States Setup** skill](https://github.com/DirectedEdges/specs/blob/main/packages/cli/src/transforms/Css.states-setup.md) in Claude Code — it scans your specs output directory, matches variant props against the concept table, and proposes a ready-to-paste `figma.states` block. ::: ### CSS transform @@ -125,29 +124,28 @@ interface TextInputProps { ## Configuration ```yaml -config: - processing: - states: - # Concept key → { prop, value?, contract? } - # value: the Figma variant value that activates this concept (defaults to "true" for booleans) - # contract: rarely needed — derived from the concept - hover: - prop: state - value: hover - active: - prop: state - value: pressed # Figma uses cross-platform name "pressed"; concept maps to :active - focus-within: - prop: focused # boolean prop; value defaults to "true" - disabled: - prop: isDisabled # library uses "is" prefix convention - readonly: - prop: readOnly - invalid: - prop: validation - value: invalid # only one enum value maps to this concept - expanded: - prop: expanded +figma: + states: + # Concept key → { prop, value?, contract? } + # value: the Figma variant value that activates this concept (defaults to "true" for booleans) + # contract: rarely needed — derived from the concept + hover: + prop: state + value: hover + active: + prop: state + value: pressed # Figma uses cross-platform name "pressed"; concept maps to :active + focus-within: + prop: focused # boolean prop; value defaults to "true" + disabled: + prop: isDisabled # library uses "is" prefix convention + readonly: + prop: readOnly + invalid: + prop: validation + value: invalid # only one enum value maps to this concept + expanded: + prop: expanded ``` ## Properties @@ -159,14 +157,14 @@ config: | `contract` | `"omit"` \| `"keep"` | No | concept default | Override the concept's default contract behavior. Rarely needed. | -Run [`specs transform css`](/cli/commands/transform/) to regenerate stylesheets after updating this config. Absence of `processing.states` is safe — all variant props continue to emit as `data-*` selectors. +Run [`specs transform css`](/cli/commands/transform/) to regenerate stylesheets after updating this declaration. Absence of `figma.states` is safe — all variant props continue to emit as `data-*` selectors. ## Path -`config.processing.states` +`figma.states` in `config/conventions.yaml` ## See Also - [`css` transformer](/cli/transforms/css/) — CSS output affected by this classification - [`contract` transformer](/cli/transforms/contract/) — Props interface affected by `contract: omit` -- [`subcomponents`](/settings/subcomponents/) — another presence-driven `processing` option +- [`subcomponents`](/settings/subcomponents/) — another presence-driven convention diff --git a/site/src/content/docs/settings/subcomponents.md b/site/src/content/docs/settings/subcomponents.md index 750d1ca6..340ccb45 100644 --- a/site/src/content/docs/settings/subcomponents.md +++ b/site/src/content/docs/settings/subcomponents.md @@ -3,20 +3,19 @@ title: "Subcomponents" description: "Configure subcomponent discovery, matching patterns, and exclusions" --- -Subcomponent discovery configuration. When present, enables subcomponent detection. When absent, subcomponents are not detected. +Subcomponent organization and naming. A library fact, declared in `config/conventions.yaml`: where a library keeps its subcomponents and how it names them are properties of the library, and a wrong declaration leaves subcomponents undiscovered. When present, the block enables subcomponent detection. When absent, the library declares no subcomponent convention and none are detected. ## Configuration ```yaml -config: - processing: - subcomponents: - scope: PAGE - match: - - '{C} / {S}' - - '{C} / _ / {S}' - exclude: - - '{C} / Examples / {S}' +figma: + subcomponents: + scope: PAGE + match: + - '{C} / {S}' + - '{C} / _ / {S}' + exclude: + - '{C} / Examples / {S}' ``` ## Result @@ -47,7 +46,7 @@ Without `subcomponents`, the registry is absent and these nested components are | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| -| `scope` | `"NESTED"` \| `"PAGE"` | No | `NESTED` | Where to search. `NESTED` = component anatomy only; `PAGE` = also search the Figma page | +| `scope` | `"NESTED"` \| `"PAGE"` | No | `NESTED` | Where the library keeps subcomponents. `NESTED` = component anatomy only; `PAGE` = also search the Figma page | | `match` | `string[]` | Yes | — | Template patterns using `{C}` (component name) and `{S}` (subcomponent name) placeholders | | `exclude` | `string[]` | No | — | Template patterns to exclude from matches. Same `{C}/{S}` syntax | @@ -55,7 +54,7 @@ An asset must match at least one `match` pattern to be considered a subcomponent ## Path -`config.processing.subcomponents` +`figma.subcomponents` in `config/conventions.yaml` ## See Also diff --git a/site/src/content/docs/settings/tokens.md b/site/src/content/docs/settings/tokens.md index 955227f3..af548144 100644 --- a/site/src/content/docs/settings/tokens.md +++ b/site/src/content/docs/settings/tokens.md @@ -7,14 +7,13 @@ description: "Control how design token references are serialized in the spec out Style properties in a spec frequently reference design tokens — the colors, spacing, and typography defined as Figma variables or published styles. The `tokens` option controls **how those references are serialized**, ranging from a minimal name string to a rich object carrying full Figma provenance. -Different consumers need different levels of detail: a documentation site just needs the token name, a code generator needs a structured reference with a type hint, a Figma-native tool needs raw variable IDs, and a team with a bespoke token system needs to inject its own mapping entirely. One format can't satisfy all of these — so `tokens` selects a single **profile** applied uniformly to every token reference in the output, both variables and published named styles. +Different consumers need different levels of detail: a documentation site just needs the token name, a code generator needs a structured reference with a type hint, a Figma-native tool needs raw variable IDs, and a team with a bespoke token system needs to inject its own mapping entirely. One format can't satisfy all of these — so `tokens` selects a single **profile** applied uniformly to every token reference in the output, both variables and published named styles. A run choice in `config/settings.yaml`: every profile serializes the same references correctly, just at different levels of detail. ## Configuration ```yaml -config: - format: - tokens: TOKEN # Default — DTCG-aligned objects +spec: + tokens: TOKEN # Default — DTCG-aligned objects ``` **Default:** `TOKEN`. @@ -103,7 +102,7 @@ specs applyCustomTokens mapping.json # 2. Inject $custom objects into the da specs generate # 3. Generate — uses $custom objects verbatim ``` -The `applyCustomTokens` command auto-discovers variables/styles files from `dataDirectory` and `sources` in this config, or accepts explicit `-v`/`-s` paths. See [`applyCustomTokens` command](/cli/commands/apply-custom-tokens/) for the full mapping file format and pipeline details. +The `applyCustomTokens` command auto-discovers variables/styles files from `data.directory` and `data.sources` in `config/settings.yaml`, or accepts explicit `-v`/`-s` paths. See [`applyCustomTokens` command](/cli/commands/apply-custom-tokens/) for the full mapping file format and pipeline details. ### FIGMA_SYNTAX_WEB / FIGMA_SYNTAX_IOS / FIGMA_SYNTAX_ANDROID @@ -128,7 +127,7 @@ When a token has no code syntax defined for the chosen platform, the profile **f ## Path -`config.format.tokens` +`spec.tokens` in `config/settings.yaml` ## See Also diff --git a/site/src/content/docs/settings/transform.md b/site/src/content/docs/settings/transform.md index d2d65038..a6c3c7cd 100644 --- a/site/src/content/docs/settings/transform.md +++ b/site/src/content/docs/settings/transform.md @@ -5,22 +5,22 @@ description: "Configure which transformers specs transform runs" -The `config.transform` block controls which transformers `specs transform` runs. It is optional — omitting it entirely means the CLI default applies. +The `transformers` list in `config/pipeline.yaml` controls which transformers `specs transform` runs. Pipeline entries name *work to run* — neither a fact about the library nor a choice about output shape — so they live in their own artifact, apart from `conventions.yaml` and `settings.yaml`. The list is optional — omitting it entirely means the CLI default applies. ## Configuration ```yaml -config: - transformers: - - name: contract - - name: css - - name: react - - name: stories +# config/pipeline.yaml +transformers: + - name: contract + - name: css + - name: react + - name: stories ``` ## `transformers` -An array of `{ name }` entries identifying which transformers to run. Names must match a registered transformer. The order of entries is the run order. When this block is absent, the CLI default (`contract`) runs. +An array of `{ name }` entries identifying which transformers to run. Names must match a registered transformer. The order of entries is the run order. When this list is absent, the CLI default (`contract`) runs. `pipeline.yaml` also accepts an `analyses` list of the same shape, naming analyses for `specs analyze` — see the [Pipeline schema reference](/schema/pipeline/). ## Available Transformers @@ -35,11 +35,13 @@ An array of `{ name }` entries identifying which transformers to run. Names must ## Default -Omitting `config.transform` entirely is equivalent to running `specs transform contract`. No configuration is required to use the default transformer. +Omitting `transformers` entirely is equivalent to running `specs transform contract`. No configuration is required to use the default transformer. ## Path -`config.transform` +`transformers` in `config/pipeline.yaml` + +**Legacy name**: in the pre-split `specs.config.yaml`, this list was `config.transformers`. That file is no longer read — [`specs migrate config`](/cli/commands/migrate/) converts it, moving the list to `pipeline.yaml`'s `transformers`. ## See Also diff --git a/site/src/content/docs/settings/variant-depth.md b/site/src/content/docs/settings/variant-depth.md index cd99c7ca..c9dfa744 100644 --- a/site/src/content/docs/settings/variant-depth.md +++ b/site/src/content/docs/settings/variant-depth.md @@ -3,7 +3,7 @@ title: "Variant Depth" description: "Control how many variant property dimensions are expanded" --- -Maximum variant property depth to process. +Maximum variant property depth to process. A run choice in `config/settings.yaml` — deeper depth means more thorough (and slower) variant analysis, not more correct output. ## Options @@ -12,14 +12,13 @@ Maximum variant property depth to process. ## Path -`config.processing.variantDepth` +`spec.variantDepth` in `config/settings.yaml` ### Example ```yaml -config: - processing: - variantDepth: 3 # Process up to 3 levels of variant properties +spec: + variantDepth: 3 # Process up to 3 levels of variant properties ``` ## See Also