diff --git a/.github/agents/Specs.adr.accept.agent.md b/.github/agents/Specs.adr.accept.agent.md index b04f9520..3c510ee5 100644 --- a/.github/agents/Specs.adr.accept.agent.md +++ b/.github/agents/Specs.adr.accept.agent.md @@ -17,12 +17,25 @@ You **MUST** consider the user input before proceeding (if not empty). - Use `BRANCH` directly as `ADR_NAME` (e.g., `011-icon-glyph-as-content`). - The branch name must match an ADR name pattern (starts with a number sequence followed by a hyphen, e.g., `011-`). If no ADR number is found, halt: "Branch does not appear to be an ADR branch." -2. **Load context**: - - **REQUIRED**: Read `$REPO_ROOT/adr/$ADR_NAME.md` — confirm Status is `DRAFT` (if already `ACCEPTED`, report and halt) +2. **Check for an existing PR — do this before any other work.** An ADR branch may already be in review from an earlier session; this command must never open a second PR for the same branch. + + ```bash + gh pr list --head "$BRANCH" --state all --json number,title,state,baseRefName,url + ``` + + - **An OPEN PR exists** → set `EXISTING_PR` to its number and `$RELEASE_BRANCH` to its `baseRefName`. This run is an *update*, not a creation. Skip step 7 (release-branch determination) — the existing PR's base is authoritative. Report the PR number and base to the user before continuing. + - **A MERGED PR exists** → halt: "This ADR branch was already merged in PR #N. Accepting again would need a new branch." + - **A CLOSED (unmerged) PR exists** → do not reopen or replace it silently. Report it and ask the user whether to open a new PR or reopen that one. + - **No PR exists** → normal path; `EXISTING_PR` is unset. + +3. **Load context**: + - **REQUIRED**: Read `$REPO_ROOT/adr/$ADR_NAME.md` — confirm Status is `DRAFT`. + - If already `ACCEPTED` **and** `EXISTING_PR` is set with no uncommitted changes, halt: "ADR is already accepted and PR #N is open — nothing to do." + - If already `ACCEPTED` and there *are* uncommitted changes, this is a follow-up edit: skip the status flip in step 5, and continue so the changes are validated, committed, and pushed to the existing PR. - Confirm that `types/`, `schema/`, `package.json`, and `CHANGELOG.md` have been modified by the implement agent (check git status or file timestamps) - - If no changes are detected, halt: "Run the implement agent first." + - If no changes are detected **and** `EXISTING_PR` is unset, halt: "Run the implement agent first." -3. **Re-run validation gates**: +4. **Re-run validation gates**: - Run: `tsc -p tsconfig.build.json --noEmit` - If exit code ≠ 0: halt and display errors. Do not set ACCEPTED. - Run: `scripts/validate-schema.sh` @@ -30,29 +43,44 @@ You **MUST** consider the user input before proceeding (if not empty). - Run: `tsc --noEmit --strict tests/*.test-d.ts` (if `tests/*.test-d.ts` files exist) - If exit code ≠ 0: halt and display errors. Do not set ACCEPTED. -4. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$ADR_NAME.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. +5. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$ADR_NAME.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. Skip if step 3 determined the ADR is already `ACCEPTED`. + +6. **Update INDEX**: Read `adr/INDEX.md` and move this ADR's row from **Draft** to **Accepted**. Treat this as two discrete edits, not one "move" — every stale row in this file got there because the accepted row was added and the draft row was left behind. + + 1. **Delete** the ADR's row from the **Draft** table. Skip only if it genuinely has no draft row (older ADRs created before INDEX tracking). + 2. **Insert** its row into the **Accepted** table, in descending number order, with: + - the **Title** matching the final ADR heading (it may have changed during implementation) + - a **Highlights** summary (max 144 characters) describing the key change as accepted + 3. **Verify**: the ADR number must now appear exactly once in the file. Run + `grep -c "^| |" adr/INDEX.md` and confirm it returns `1`. If it returns `2`, step 1 did not happen — delete the draft row before continuing. + + A draft row left in place is not cosmetic: the draft table is how a reader finds unfinished work, and a stale row carries the pre-acceptance title, so the same ADR appears twice under two different names. + + The file also has a **Superseded** table, for an ADR closed in favour of a later decision rather than accepted. Move the row there instead, and name what replaced it in the "Superseded by" column — an ADR abandoned without that pointer reads as merely unfinished. -5. **Update INDEX**: Read `adr/INDEX.md` and update the entry for this ADR: - - Move the row from the **Draft** table to the **Accepted** table (descending by number). - - Update the **Title** to match the final ADR heading (it may have changed during implementation). - - Add a **Highlights** summary (max 144 characters) describing the key change as accepted. - - If no entry exists in the Draft table (older ADR created before INDEX tracking), add the row directly to the Accepted table. + Re-check this after any merge or rebase. A draft row claimed on the release branch while the ADR branch was in flight will reappear when the branches reconcile, leaving a duplicate that neither side authored. -6. **Determine release branch**: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). +7. **Determine release branch** *(skip entirely if `EXISTING_PR` is set — use that PR's base)*: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). 1. Find active in-flight release branches with `git branch -r --list 'origin/release/*'`. 2. **Default: use the existing active release branch.** ADR branches are started from the current release branch, so the active branch is the correct target. Do not cross-reference the ADR's semver version against the branch name — the branch name reflects where the release *started*, not the final published version. 3. If exactly one release branch exists, use it as `$RELEASE_BRANCH` without asking. 4. If multiple release branches exist, pick the one the ADR branch was based on (`git merge-base --fork-point` or ask the user). 5. Only if **no** release branch exists at all, create one from `main` following the `release/-` convention, naming every package the release will publish. -7. **Create PR**: Commit any uncommitted changes (the status flip and INDEX update), push `$BRANCH`, and open a PR into the release branch using `gh pr create --base $RELEASE_BRANCH`. +8. **Push, and create the PR only if there isn't one**: Commit any uncommitted changes (the status flip and INDEX update) and push `$BRANCH`. + - **`EXISTING_PR` set** → the push updates that PR. Do **not** run `gh pr create`. Read the PR's current body (`gh pr view $EXISTING_PR --json body`) and, if it no longer describes what is on the branch, offer to update it with `gh pr edit $EXISTING_PR --body-file ` — ask first, since the user may have written it by hand. + - **`EXISTING_PR` unset** → open a PR into the release branch with `gh pr create --base $RELEASE_BRANCH`. + - Pass long PR bodies via `--body-file`, not an inline heredoc — bodies containing backticks and quotes break shell parsing. -8. **Report**: Confirm all gates passed, the ADR is ACCEPTED, and the PR has been created. List the next steps: +9. **Report**: Confirm all gates passed, the ADR is ACCEPTED, and state whether the PR was **created** or an **existing PR was updated** (with its number). List the next steps: - Review and merge the PR into the release branch - When all ADRs for the release are complete, merge `$RELEASE_BRANCH` into `main` and `npm publish` ## Key rules - This command only flips the ADR status — it does not apply any code changes. -- Status MUST only move to `ACCEPTED` after all three validation gates pass in step 3. +- Status MUST only move to `ACCEPTED` after all three validation gates pass in step 4. +- **Never open a second PR for a branch that already has one.** Step 2 runs before everything else for this reason. This command is re-runnable: an ADR branch may already be in review from an earlier session, and re-running must update that PR, not duplicate it. +- Verify repo and PR state by querying it — never infer from the conversation or assume a branch is fresh. +- **An ADR appears in exactly one INDEX table.** Accepting means deleting the draft row as well as adding the accepted one; verify with the grep in step 6 rather than assuming the edit landed. - Use absolute paths for all file operations. diff --git a/.github/agents/Specs.adr.implement.agent.md b/.github/agents/Specs.adr.implement.agent.md index f090b355..260ec37e 100644 --- a/.github/agents/Specs.adr.implement.agent.md +++ b/.github/agents/Specs.adr.implement.agent.md @@ -62,9 +62,20 @@ You **MUST** consider the user input before proceeding (if not empty). - Create or update `tests/[type-name].test-d.ts` for each changed type using `tsd`-style assertions or `@ts-expect-error` patterns - Run: `tsc --noEmit --strict tests/*.test-d.ts` to confirm test files compile - If tests fail: halt and report - - **All gates have now passed. Steps 10–12 are REQUIRED before reporting completion. Do not skip to step 13.** - -10. **Update docs**: + - **All gates have now passed. Steps 10–13 are REQUIRED before reporting completion. Do not skip to step 14.** + +10. **Write the ADR summary**: Add or update the `**Summary**:` line in the ADR's metadata block, directly beneath `**Status**`. Write it now rather than at draft time — it must describe what was actually implemented, which often differs from the original draft. + - **One sentence, present tense, roughly 15–18 words.** The new property, type, or config option is the grammatical subject. + - Anchor the addition to the neighbouring fields it joins, not to the gap it filled. + - Never open with "The schema", and never include a past-tense problem clause ("could not", "was dropped", "had nowhere to land"). + - Keep identifiers in backticks. + - Examples of the target voice: + - A `strokeDashPattern` property adds dashes to strokes already supported with color, weight and alignment. + - A `glyph` element type, `IconProp` and `glyphNamePattern` emit icons as first-class by applying Figma conventions. + - Images are supported by `backgroundImage` style, `ImageProp` and binding in components and examples. + - **Gate**: read the ADR back and confirm the `**Summary**:` line is present and no longer a placeholder. The docs site publishes this line verbatim with no fallback — a missing summary leaves a blank row in the published index. + +11. **Update docs**: - Docs live in `site/src/content/docs/`. Schema type pages are under `site/src/content/docs/schema/` (e.g., `schema/styles.md` for `Styles`, `schema/config.md` for `Config`). Individual config option pages are under `site/src/content/docs/config/` (e.g., `config/tokens.md`, `config/keys.md`). - For each property added, removed, or renamed in the ADR: update the relevant doc page's Properties table, Values table, and "Relating properties to values" section to reflect the new state. - For new dedicated types (e.g., `LayoutMode`, `WrapAlignment`, `ItemSpacing`): add a row to the Values table describing the type and its valid values. @@ -72,7 +83,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Do not create new doc pages for types that are only used as field values on an existing documented type — document them inline in the parent type's page. - If no doc file exists for the changed type, skip this step. -11. **Update CHANGELOG.md**: +12. **Update CHANGELOG.md**: - The release branch scaffolds an `## [X.Y.Z] - Unreleased` heading with empty sections. Add entries into the existing scaffold — do **not** replace `Unreleased` with a date (the date is set at release time). If no scaffold heading exists, prepend one using `Unreleased` as the date. - **Format**: one top-level bullet per user-visible change; no sub-bullets; no bold; no code blocks; no wrapping prose paragraphs - **Entry line**: `` `Parent.field` `` — one-phrase description; aim for ≤ 12 words; omit implementation detail (class names, file paths, method names) @@ -80,12 +91,12 @@ You **MUST** consider the user input before proceeding (if not empty). - **Consolidation**: When a new type exists only to serve a property, merge into one property-first bullet — e.g. `` `Styles.mainAxisAlignment` — typed as `MainAxisAlignment` (`'START' | 'END' | 'CENTER' | 'SPACE_BETWEEN'`) or `null`; description ``. Do not list the type as a separate bullet. - **Sections**: use `### Added`, `### Changed`, `### Removed` as needed; add `### Migration` (MAJOR or rename only) - **Migration line**: `` `Parent.old` → `Parent.new` ``: one sentence; imperative; describe what to read instead and how to handle the new type - - **Gate**: After writing, verify the new entry is present in the file. If CHANGELOG.md does not contain the new version heading, halt and report — do not proceed to step 12. + - **Gate**: After writing, verify the new entry is present in the file. If CHANGELOG.md does not contain the new version heading, halt and report — do not proceed to step 13. -12. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. - - **Gate**: After writing, read `package.json` back and confirm the `"version"` field matches the ADR's `NEW` version. If it does not match, halt and report — do not proceed to step 13. +13. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. + - **Gate**: After writing, read `package.json` back and confirm the `"version"` field matches the ADR's `NEW` version. If it does not match, halt and report — do not proceed to step 14. -13. **Report**: List every file modified (with one-line description each). The list **must** include `CHANGELOG.md` and `package.json` — if either is absent from the list, halt: steps 11–12 were not completed. State that the author should review the diff and accept the ADR once satisfied. Remind the author that this ADR branch (`$BRANCH`) targets the release branch (`$RELEASE_BRANCH`), not `main`. +14. **Report**: List every file modified (with one-line description each). The list **must** include the ADR file, `CHANGELOG.md`, and `package.json` — if any is absent from the list, halt: steps 10, 12, or 13 were not completed. State that the author should review the diff and accept the ADR once satisfied. Remind the author that this ADR branch (`$BRANCH`) targets the release branch (`$RELEASE_BRANCH`), not `main`. ## Key rules diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 5221b96c..eabc3fd8 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -26,12 +26,9 @@ jobs: - name: Install site dependencies working-directory: site run: npm ci - - name: Generate releases page - working-directory: site - run: node scripts/build-releases.mjs - name: Build site working-directory: site - run: npx astro build + run: npm run build - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.gitignore b/.gitignore index ec3f02e8..4b380a18 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tests/tmp/ # Generated at build time from package CHANGELOGs site/src/content/docs/overview/releases.mdx +site/src/content/docs/adr/ diff --git a/adr/001-metadata.license.md b/adr/001-metadata.license.md index eae47003..55650150 100644 --- a/adr/001-metadata.license.md +++ b/adr/001-metadata.license.md @@ -3,6 +3,7 @@ **Branch**: `001-license-check` **Created**: 2026-02-24 **Status**: ACCEPTED +**Summary**: A `license` field inside `metadata.generator` records the license state that produced a component's output. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/002-effects-shadows-blurs.md b/adr/002-effects-shadows-blurs.md index f1646ccc..6927d3a8 100644 --- a/adr/002-effects-shadows-blurs.md +++ b/adr/002-effects-shadows-blurs.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0` **Created**: 2026-02-24 **Status**: ACCEPTED +**Summary**: An `effects` property with `Shadow`, `Blur` and `Effects` types replaces `effectStyleId` to describe shadows and blurs. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/003-gradients.md b/adr/003-gradients.md index e103955e..28e7b602 100644 --- a/adr/003-gradients.md +++ b/adr/003-gradients.md @@ -3,6 +3,7 @@ **Branch**: `003-gradients` **Created**: 2026-02-25 **Status**: ACCEPTED +**Summary**: Gradient types and a `ColorStyle` alias let `backgroundColor`, `textColor` and `strokes` carry linear, radial and angular gradients. **Deciders**: Nathan Curtis **Supersedes**: *(none)* diff --git a/adr/004-aspect-ratio.md b/adr/004-aspect-ratio.md index 2ea9b63f..b6fb733f 100644 --- a/adr/004-aspect-ratio.md +++ b/adr/004-aspect-ratio.md @@ -3,6 +3,7 @@ **Branch**: `004-aspect-ratio` **Created**: 2026-02-25 **Status**: ACCEPTED +**Summary**: An `aspectRatio` property carries an `x`/`y` ratio object on styles alongside width and height. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/005-typography-composite.md b/adr/005-typography-composite.md index 4bba77f6..f775bd9d 100644 --- a/adr/005-typography-composite.md +++ b/adr/005-typography-composite.md @@ -3,6 +3,7 @@ **Branch**: `005-typography-composite` **Created**: 2026-02-26 **Status**: ACCEPTED +**Summary**: A composite `typography` property replaces fourteen flat text keys including `fontSize`, `lineHeight`, `letterSpacing` and `textStyleId`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/006-token-references.md b/adr/006-token-references.md index c390b9ec..1050ab62 100644 --- a/adr/006-token-references.md +++ b/adr/006-token-references.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0/006-token-references` **Created**: 2026-02-28 **Status**: ACCEPTED +**Summary**: A single `TokenReference` type replaces `VariableStyle` and `FigmaStyle` for variables, named styles and composite references. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/007-token-reference-config.md b/adr/007-token-reference-config.md index e0068546..e38a1659 100644 --- a/adr/007-token-reference-config.md +++ b/adr/007-token-reference-config.md @@ -3,6 +3,7 @@ **Branch**: `007-token-reference-config` **Created**: 2026-03-01 **Status**: ACCEPTED +**Summary**: A `format.tokens` option replaces `variables`, `simplifyVariables` and `simplifyStyles` with one token output format. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 006: Unified Token Reference Type)* diff --git a/adr/008-prop-bindings.md b/adr/008-prop-bindings.md index c82b6db4..6e2f1b01 100644 --- a/adr/008-prop-bindings.md +++ b/adr/008-prop-bindings.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0` **Created**: 2026-03-02 **Status**: ACCEPTED +**Summary**: A `PropBinding` type keyed `$binding` replaces `ReferenceValue` unions wherever a value is bound to a prop. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/009-color-values.md b/adr/009-color-values.md index f2637a7f..5e9b0237 100644 --- a/adr/009-color-values.md +++ b/adr/009-color-values.md @@ -2,6 +2,7 @@ **Created**: 2026-03-02 **Status**: ACCEPTED +**Summary**: A DTCG `ColorValue` object replaces the hex string in `ColorStyle`, aligning color values with token standards. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/010-sides-and-corners.md b/adr/010-sides-and-corners.md index 74b1bd5f..3b247d7f 100644 --- a/adr/010-sides-and-corners.md +++ b/adr/010-sides-and-corners.md @@ -3,6 +3,7 @@ **Branch**: `010-sides-and-corners` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: Composite `Sides` and `Corners` types replace flat padding, stroke weight and corner radius properties. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/011-icon-glyph-as-content.md b/adr/011-icon-glyph-as-content.md index 2cd24b9f..bcbff239 100644 --- a/adr/011-icon-glyph-as-content.md +++ b/adr/011-icon-glyph-as-content.md @@ -3,6 +3,7 @@ **Branch**: `011-icon-glyph-as-content` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: An `ElementType` union and `iconNamePattern` option constrain element types and detect icons by Figma naming. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/012-element-type-references.md b/adr/012-element-type-references.md index 6314e765..ac2d1ebe 100644 --- a/adr/012-element-type-references.md +++ b/adr/012-element-type-references.md @@ -3,6 +3,7 @@ **Branch**: `012-element-type-references` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: An `ElementTypeRef` widens `AnatomyElement.type`, letting an element type point at a shared definition. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/013-icon-fillColor.md b/adr/013-icon-fillColor.md index f181257a..bb6c871a 100644 --- a/adr/013-icon-fillColor.md +++ b/adr/013-icon-fillColor.md @@ -3,6 +3,7 @@ **Branch**: `013-icon-fillColor` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: A `fillColor` property colors icon elements alongside `backgroundColor`, `textColor` and `strokes`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/014-prop-examples.md b/adr/014-prop-examples.md index 9aacf10b..42fc0e7d 100644 --- a/adr/014-prop-examples.md +++ b/adr/014-prop-examples.md @@ -3,6 +3,7 @@ **Branch**: `014-prop-examples` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: An `examples` array on `TextProp` and `IconProp` carries sample values, and `default` becomes optional. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/015-anyprop-oneOf-discrimination.md b/adr/015-anyprop-oneOf-discrimination.md index 834b84a3..f93bc8fd 100644 --- a/adr/015-anyprop-oneOf-discrimination.md +++ b/adr/015-anyprop-oneOf-discrimination.md @@ -3,6 +3,7 @@ **Branch**: `015-anyprop-oneOf-discrimination` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: A single `StringProp` merges `TextProp` and `IconProp`, restoring valid `oneOf` discrimination in `AnyProp`. **Deciders**: Nathan Curtis (author), *(collaborators TBD)* **Supersedes**: ADR 017 (`017-icon-or-glyph-element-name`) — prop rename portion only. ADR 017 renamed `IconProp` → `GlyphProp`; this ADR merges both `TextProp` and `IconProp` into `StringProp`, making the intermediate `GlyphProp` rename moot. The element type rename (`icon` → `glyph`) and other non-prop changes from ADR 017 remain in effect. diff --git a/adr/016-element-content.md b/adr/016-element-content.md index fc51eee6..f7c932c8 100644 --- a/adr/016-element-content.md +++ b/adr/016-element-content.md @@ -3,6 +3,7 @@ **Branch**: `016-element-content` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `content` property replaces `text`, identifying literal or bound content on any element type. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/017-icon-or-glyph-element-name.md b/adr/017-icon-or-glyph-element-name.md index ba04ae1f..fe2d12d2 100644 --- a/adr/017-icon-or-glyph-element-name.md +++ b/adr/017-icon-or-glyph-element-name.md @@ -3,6 +3,7 @@ **Branch**: `017-icon-or-glyph-element-name` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `glyph` element type, `GlyphProp` and `glyphNamePattern` emit icons as first-class by applying Figma conventions. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/018-conditional-visible-binding.md b/adr/018-conditional-visible-binding.md index c58de6e5..89ba8e90 100644 --- a/adr/018-conditional-visible-binding.md +++ b/adr/018-conditional-visible-binding.md @@ -3,6 +3,7 @@ **Branch**: `018-conditional-visible-binding` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `Conditional` type with condition expressions lets `visible` test a prop value rather than mirror it. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/019-nullable-prop-defaults.md b/adr/019-nullable-prop-defaults.md index 6b861e01..033ddcb5 100644 --- a/adr/019-nullable-prop-defaults.md +++ b/adr/019-nullable-prop-defaults.md @@ -3,6 +3,7 @@ **Branch**: `019-nullable-prop-defaults` **Created**: 2026-03-11 **Status**: ACCEPTED +**Summary**: `StringProp.default` accepts `null`, expressing props whose absent value is meaningful. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/022-nullable-slot-props.md b/adr/022-nullable-slot-props.md index cc6a55af..ffd49a09 100644 --- a/adr/022-nullable-slot-props.md +++ b/adr/022-nullable-slot-props.md @@ -2,7 +2,8 @@ **Branch**: `022-nullable-slot-props` **Created**: 2026-03-12 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: `SlotProp` gains `nullable` and a nullable `default`, matching the other prop types. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 019 to cover `SlotProp`)* diff --git a/adr/023-schema-compliance-fixes.md b/adr/023-schema-compliance-fixes.md index 6efb30df..24d302c5 100644 --- a/adr/023-schema-compliance-fixes.md +++ b/adr/023-schema-compliance-fixes.md @@ -3,6 +3,7 @@ **Branch**: `023-schema-compliance-fixes` **Created**: 2026-03-13 **Status**: ACCEPTED +**Summary**: `SlotProp.default` becomes optional and pattern properties admit `$`-prefixed keys, bringing types and schema back into compliance. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 022 for `SlotProp.default` optionality)* diff --git a/adr/026-platform-extensions.md b/adr/026-platform-extensions.md index e72cab76..164548cc 100644 --- a/adr/026-platform-extensions.md +++ b/adr/026-platform-extensions.md @@ -3,6 +3,7 @@ **Branch**: `026-platform-extensions` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: An `$extensions` object on every prop type unifies platform-specific metadata, replacing the Figma-only `x-platform` field. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/027-code-only-props.md b/adr/027-code-only-props.md index 7a82ab2e..c8f7847a 100644 --- a/adr/027-code-only-props.md +++ b/adr/027-code-only-props.md @@ -3,6 +3,7 @@ **Branch**: `027-code-only-props` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: A `codeOnlyPropsPattern` option and `FigmaCodeOnlySource` extension emit props that exist in code but not Figma. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **Depends on**: [026-platform-extensions](./026-platform-extensions.md) (`$extensions` pattern) diff --git a/adr/028-slot-constraints.md b/adr/028-slot-constraints.md index f5fe6abf..dab96933 100644 --- a/adr/028-slot-constraints.md +++ b/adr/028-slot-constraints.md @@ -3,6 +3,7 @@ **Branch**: `028-slot-constraints` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: `minItems`, `maxItems` and `anyOf` on `SlotProp`, with a `slotConstraints` option, describe what a slot accepts. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/029-number-prop.md b/adr/029-number-prop.md index 662a1417..61eeb0a7 100644 --- a/adr/029-number-prop.md +++ b/adr/029-number-prop.md @@ -3,6 +3,7 @@ **Branch**: `029-number-prop` **Created**: 2026-03-17 **Status**: ACCEPTED +**Summary**: A `NumberProp` type and `inferNumberProps` option carry numeric properties alongside boolean, string, enum and slot props. **Deciders**: (author) **Supersedes**: *(none)* diff --git a/adr/030-subcomponent-refs.md b/adr/030-subcomponent-refs.md index 36965ea4..8cb9af0c 100644 --- a/adr/030-subcomponent-refs.md +++ b/adr/030-subcomponent-refs.md @@ -3,6 +3,7 @@ **Branch**: `030-subcomponent-refs` **Created**: 2026-03-23 **Status**: ACCEPTED +**Summary**: A `SubcomponentRef` lets `instanceOf` point at a subcomponent definition rather than repeat its name. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/031-subcomponent-search-scope.md b/adr/031-subcomponent-search-scope.md index f75e2b05..3db7d903 100644 --- a/adr/031-subcomponent-search-scope.md +++ b/adr/031-subcomponent-search-scope.md @@ -3,6 +3,7 @@ **Branch**: `031-subcomponent-search-scope` **Created**: 2026-03-24 **Status**: ACCEPTED +**Summary**: A `processing.subcomponents` object with `scope`, `match` and `exclude` replaces `subcomponentNamePattern` for finding subcomponents. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/032-typography-leading-trim-enum.md b/adr/032-typography-leading-trim-enum.md index a08dc475..d4a6b625 100644 --- a/adr/032-typography-leading-trim-enum.md +++ b/adr/032-typography-leading-trim-enum.md @@ -3,6 +3,7 @@ **Branch**: `032-leading-trim-enum` **Created**: 2026-03-25 **Status**: ACCEPTED +**Summary**: `Typography.leadingTrim` is a string enum, matching the values Figma actually reports. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/033-typography-font-token-reference.md b/adr/033-typography-font-token-reference.md index b2881d31..1a7aa00f 100644 --- a/adr/033-typography-font-token-reference.md +++ b/adr/033-typography-font-token-reference.md @@ -3,6 +3,7 @@ **Branch**: `033-font-token-reference` **Created**: 2026-03-25 **Status**: ACCEPTED +**Summary**: `Typography.fontFamily` and `fontStyle` accept a `TokenReference` and drop `number`, matching how fonts are tokenized. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/034-empty-variants.md b/adr/034-empty-variants.md index bdf45673..68466ad2 100644 --- a/adr/034-empty-variants.md +++ b/adr/034-empty-variants.md @@ -2,7 +2,8 @@ **Branch**: `034-empty-variants` **Created**: 2026-03-30 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: An `emptyVariants` include option replaces `variantNames`, and the remaining `Config.include` fields become optional. **Deciders**: nathanacurtis (author) **Supersedes**: *(none)* diff --git a/adr/035-optional-config-defaults.md b/adr/035-optional-config-defaults.md index a9bd8b8f..98538093 100644 --- a/adr/035-optional-config-defaults.md +++ b/adr/035-optional-config-defaults.md @@ -3,6 +3,7 @@ **Branch**: `035-optional-config-defaults` **Created**: 2026-04-10 **Status**: ACCEPTED +**Summary**: Every `Config` property carrying a default becomes optional, so a config states only what it changes. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/036-remove-variant-name-baseline.md b/adr/036-remove-variant-name-baseline.md index 47514771..c6635536 100644 --- a/adr/036-remove-variant-name-baseline.md +++ b/adr/036-remove-variant-name-baseline.md @@ -3,6 +3,7 @@ **Branch**: `036-remove-variant-name-baseline` **Created**: 2026-04-13 **Status**: ACCEPTED +**Summary**: `Variant.name` and `Variant.baseline` are removed, leaving a variant identified by its prop configuration alone. **Deciders**: nathanacurtis (author) **Supersedes**: *(none)* diff --git a/adr/037-item-spacing.md b/adr/037-item-spacing.md index 23a0180e..f7a38e8a 100644 --- a/adr/037-item-spacing.md +++ b/adr/037-item-spacing.md @@ -3,6 +3,7 @@ **Branch**: `037-item-spacing` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: An `ItemSpacing` type gives `itemSpacing` a bi-axial shape, replacing the separate `counterAxisSpacing` property. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/038-layout-mode-enum.md b/adr/038-layout-mode-enum.md index 488d60c1..f676d3e7 100644 --- a/adr/038-layout-mode-enum.md +++ b/adr/038-layout-mode-enum.md @@ -3,6 +3,7 @@ **Branch**: `038-layout-mode-enum` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: A `LayoutMode` enum types `layoutMode` as `NONE`, `HORIZONTAL` or `VERTICAL` rather than an open string. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/039-wrap-alignment.md b/adr/039-wrap-alignment.md index 1278f342..b1a73038 100644 --- a/adr/039-wrap-alignment.md +++ b/adr/039-wrap-alignment.md @@ -3,6 +3,7 @@ **Branch**: `039-wrap-alignment` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: `wrap` and `wrapAlignment` with a `WrapAlignment` enum replace `layoutWrap` and `counterAxisAlignContent`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/040-layout-alignment.md b/adr/040-layout-alignment.md index 2a01bafa..8ac56735 100644 --- a/adr/040-layout-alignment.md +++ b/adr/040-layout-alignment.md @@ -3,6 +3,7 @@ **Branch**: `040-layout-alignment` **Created**: 2026-04-23 **Status**: ACCEPTED +**Summary**: `mainAxisAlignment` and `crossAxisAlignment` with named enums replace `primaryAxisAlignItems` and `counterAxisAlignItems`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/041-layout-positioning.md b/adr/041-layout-positioning.md index 469e0ee8..624d70f2 100644 --- a/adr/041-layout-positioning.md +++ b/adr/041-layout-positioning.md @@ -3,6 +3,7 @@ **Branch**: `041-layout-positioning` **Created**: 2026-04-27 **Status**: ACCEPTED +**Summary**: `Position` and `PositionOffset` types describe constraint-based placement, replacing `x`, `y` and `layoutPositioning`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 5ad3a4da..61f40061 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -2,7 +2,8 @@ **Branch**: `042-composition-type` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `Composition` type names composed content, carrying `anatomy`, `elements` and `layout` with optional title and description. **Deciders**: Nathan Curtis (author) **Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-049)* **Extended by**: ADR-046 (Slots and Slot References), ADR-047 (Component Slot Examples), ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/043-custom-color-format-config.md b/adr/043-custom-color-format-config.md index bd923fb4..e9a19ace 100644 --- a/adr/043-custom-color-format-config.md +++ b/adr/043-custom-color-format-config.md @@ -3,6 +3,7 @@ **Branch**: `043-custom-color-format-config` **Created**: 2026-05-01 **Status**: ACCEPTED +**Summary**: A `format.color` option with a `ColorFormat` type emits color as hex, RGB, HSL, OKLCH or object. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/046-slots-and-slot-references.md b/adr/046-slots-and-slot-references.md index 8ea432b3..8c54ba68 100644 --- a/adr/046-slots-and-slot-references.md +++ b/adr/046-slots-and-slot-references.md @@ -2,7 +2,8 @@ **Branch**: `046-slots-and-slot-references` **Created**: 2026-05-18 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `SlotContent` triplet and `SlotContentRef` pointer let compositions fill slots by reference. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) **Extended by**: ADR-047 (Component Slot Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/047-component-slot-examples.md b/adr/047-component-slot-examples.md index bbdb9fd9..1f403845 100644 --- a/adr/047-component-slot-examples.md +++ b/adr/047-component-slot-examples.md @@ -2,7 +2,8 @@ **Branch**: `047-component-slot-examples` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `slotContentExamples` registry and `SlotBinding` carry example slot fills, referenced rather than duplicated across variants. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references) **Extended by**: ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/048-component-instance-examples.md b/adr/048-component-instance-examples.md index fefbe880..942da185 100644 --- a/adr/048-component-instance-examples.md +++ b/adr/048-component-instance-examples.md @@ -2,7 +2,8 @@ **Branch**: `048-component-instance-examples` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: An `instanceExamples` registry carries real instances of a component, stored once and referenced by variants. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples) **Extended by**: ADR-049 (Prop Configurations and Bindings) diff --git a/adr/049-prop-configurations-bindings.md b/adr/049-prop-configurations-bindings.md index be9f93af..473d4daa 100644 --- a/adr/049-prop-configurations-bindings.md +++ b/adr/049-prop-configurations-bindings.md @@ -2,7 +2,8 @@ **Branch**: `049-prop-configurations-bindings` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: `PropConfigurations` values accept `PropBinding` and `SlotContentRef`, so a configuration can forward a prop or fill a slot. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-008 — Introduce PropBinding](008-prop-binding), [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/050-examples-config.md b/adr/050-examples-config.md index 56c3ae0f..c39c34d6 100644 --- a/adr/050-examples-config.md +++ b/adr/050-examples-config.md @@ -2,7 +2,8 @@ **Branch**: `042-composition-type` **Created**: 2026-05-19 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `processing.instanceExamples` block and `include.defaultSlotContent` govern which examples a spec carries. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/051-platform-token-syntax.md b/adr/051-platform-token-syntax.md index c569aa20..13db1705 100644 --- a/adr/051-platform-token-syntax.md +++ b/adr/051-platform-token-syntax.md @@ -3,6 +3,7 @@ **Branch**: `051-platform-token-syntax` **Created**: 2026-05-20 **Status**: ACCEPTED +**Summary**: Three `FIGMA_SYNTAX_WEB`, `FIGMA_SYNTAX_IOS` and `FIGMA_SYNTAX_ANDROID` token profiles emit platform code syntax alongside the default `TOKEN`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/052-slotting-nested-instances.md b/adr/052-slotting-nested-instances.md index c1500bfd..cfde063c 100644 --- a/adr/052-slotting-nested-instances.md +++ b/adr/052-slotting-nested-instances.md @@ -3,6 +3,7 @@ **Branch**: `examples-slots-instances` **Created**: 2026-05-22 **Status**: ACCEPTED — supersedes the deferred `Element.overrides` design (retained below as Option C, *rejected*) +**Summary**: A reserved `$nested` key in `PropConfigurations` carries path-addressed configurations that reach into nested instances. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-046 — Slots & Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples), [ADR-049 — PropConfigurations Bindings](049-prop-configurations-bindings), [ADR-050 — Examples Config](050-examples-config) diff --git a/adr/053-transform-emit-config.md b/adr/053-transform-emit-config.md index 94b6c4ff..e05968db 100644 --- a/adr/053-transform-emit-config.md +++ b/adr/053-transform-emit-config.md @@ -2,7 +2,8 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `transform` config block with `TransformEntry` drives the `specs transform` command's code artifact output. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **RFC**: [RFC 001: Component Dictionary](../rfc/001-component-dictionary/README.md) diff --git a/adr/054-workspace-schema.md b/adr/054-workspace-schema.md index fab5cb62..2f4e6d1d 100644 --- a/adr/054-workspace-schema.md +++ b/adr/054-workspace-schema.md @@ -2,7 +2,8 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: A `workspace.schema.json` file describes `specs.config.yaml`, covering sources, output and the `config` block. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **Follows**: [ADR 053](053-transform-emit-config.md) diff --git a/adr/055-processing-states.md b/adr/055-processing-states.md index a6612547..5870691a 100644 --- a/adr/055-processing-states.md +++ b/adr/055-processing-states.md @@ -3,6 +3,7 @@ **Branch**: `055-processing-states` **Created**: 2026-06-05 **Status**: ACCEPTED +**Summary**: A `processing.states` list of `VariantStateEntry` classifies variant props as browser-driven or consumer-controlled. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/056-slot-children-constraints.md b/adr/056-slot-children-constraints.md index fd131f55..4e2c05be 100644 --- a/adr/056-slot-children-constraints.md +++ b/adr/056-slot-children-constraints.md @@ -3,6 +3,7 @@ **Branch**: `adr/056-slot-children-constraints` **Created**: 2026-06-12 **Status**: ACCEPTED +**Summary**: `SlotProp.minChildren` and `maxChildren` replace `minItems` and `maxItems`, aligning with Figma's slot settings. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/057-generator-version-string.md b/adr/057-generator-version-string.md index b3bd8202..a084af60 100644 --- a/adr/057-generator-version-string.md +++ b/adr/057-generator-version-string.md @@ -3,6 +3,7 @@ **Branch**: `057-generator-version-string` **Created**: 2026-06-15 **Status**: ACCEPTED +**Summary**: `Metadata.generator.version` is typed `string`, matching the semver values every producer already emits. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/058-wrapper-collapse.md b/adr/058-wrapper-collapse.md index 44dc266d..2be1ab46 100644 --- a/adr/058-wrapper-collapse.md +++ b/adr/058-wrapper-collapse.md @@ -3,6 +3,7 @@ **Branch**: `058-wrapper-collapse` **Created**: 2026-06-16 **Status**: ACCEPTED +**Summary**: A `collapsePrimitiveWrapper` processing option strips plain wrappers, promoting a single text or glyph child to the spec root. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/059-border-style.md b/adr/059-border-style.md index e13b7e6b..b825a84f 100644 --- a/adr/059-border-style.md +++ b/adr/059-border-style.md @@ -3,6 +3,7 @@ **Branch**: `059-border-style` **Created**: 2026-06-26 **Status**: ACCEPTED +**Summary**: A `strokeDashPattern` property adds dashes to strokes already supported with color, weight and alignment. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/060-subcomponent-source-metadata.md b/adr/060-subcomponent-source-metadata.md index 7afea082..888ddbef 100644 --- a/adr/060-subcomponent-source-metadata.md +++ b/adr/060-subcomponent-source-metadata.md @@ -3,6 +3,7 @@ **Branch**: `060-subcomponent-source-metadata` **Created**: 2026-06-27 **Status**: ACCEPTED +**Summary**: A `Subcomponent.source` carrying `pageId`, `nodeId` and `nodeType` resolves a subcomponent back to its Figma node. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/062-text-truncation.md b/adr/062-text-truncation.md index 88af3da3..ceff452d 100644 --- a/adr/062-text-truncation.md +++ b/adr/062-text-truncation.md @@ -2,7 +2,8 @@ **Branch**: `062-text-truncation` **Created**: 2026-07-09 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: `textOverflow` and `maxLines` properties add truncation to styles already covering typography and layout. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/063-image-content.md b/adr/063-image-content.md index 4a087b11..3d962053 100644 --- a/adr/063-image-content.md +++ b/adr/063-image-content.md @@ -3,6 +3,7 @@ **Branch**: `063-image-content` **Created**: 2026-07-14 **Status**: ACCEPTED +**Summary**: Images are supported by `backgroundImage` style, `ImageProp` and binding in components and examples. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/064-text-align-horizontal.md b/adr/064-text-align-horizontal.md index db6658b6..95adb36d 100644 --- a/adr/064-text-align-horizontal.md +++ b/adr/064-text-align-horizontal.md @@ -3,6 +3,7 @@ **Branch**: `064-text-align-horizontal` **Created**: 2026-08-03 **Status**: ACCEPTED +**Summary**: A `TextAlignHorizontal` enum types `textAlignHorizontal` as `START`, `CENTER`, `END` or `JUSTIFY`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/065-code-only-text-prop-nullable.md b/adr/065-code-only-text-prop-nullable.md index 88351a3c..d3667ecf 100644 --- a/adr/065-code-only-text-prop-nullable.md +++ b/adr/065-code-only-text-prop-nullable.md @@ -2,7 +2,8 @@ **Branch**: `065-code-only-text-prop-nullable` **Created**: 2026-08-05 -**Status**: DRAFT +**Status**: ACCEPTED +**Summary**: `NumberProp` gains `nullable`, and JSDoc documents the default each prop type applies when `nullable` is absent. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR-019 and ADR-022)* diff --git a/adr/066-lossless-key-formatting.md b/adr/066-lossless-key-formatting.md new file mode 100644 index 00000000..d3e048f9 --- /dev/null +++ b/adr/066-lossless-key-formatting.md @@ -0,0 +1,635 @@ +# ADR: Lossless key formatting — safe key grammar and Figma name preservation + +**Branch**: `066-lossless-key-formatting` +**Created**: 2026-08-10 +**Status**: ACCEPTED +**Summary**: A `format.figmaKeys` option and safe key grammar preserve Figma layer names losslessly through `com.figma.name`. +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none — extends ADR-058)* + +--- + +## Context + +`config.format.keys` selects a naming convention (`SAFE`, `CAMEL`, `SNAKE`, `KEBAB`, `PASCAL`, `TRAIN`) that is applied to every authored key in a spec: `anatomy` keys, `props` keys, and every reference to them — `elements`, `propConfigurations`, `children`, `slotContent`, `instanceExamples`, `compositions`. + +Every format other than `SAFE` is a lossy projection. The formatted key is derived by splitting the source name into `[A-Za-z0-9]` words and re-joining them, which discards: + +- **Separator identity** — `Icon leading`, `Icon-leading`, and `Icon_leading` all become `iconLeading` / `icon-leading` / `icon_leading`. The source separator is unrecoverable. +- **Casing** — `URL field` and `Url field` both become `urlField`; `Icon Leading` and `Icon leading` are indistinguishable after formatting. +- **Non-alphanumeric characters** — `&`, `+`, `/`, `.`, `()`, and every non-ASCII letter are deleted outright, taking their word boundary with them: `Cut & paste` becomes `cutPaste`, and nothing in the spec records that a `&` was ever there. +- **Word boundaries adjacent to digits** — `Icon 2 leading` collapses to `icon2Leading`, and no rule recovers the boundary. + +Because the schema has no record of the name a key was derived from, a spec produced with any format other than `SAFE` cannot be rendered back into Figma faithfully. Figma layer names and component property names are the identity used to match existing nodes; a formatted key that no longer reconstructs the Figma name causes the render direction to create duplicates or fail to bind. + +Three things are missing from the contract: + +1. A stated **source name shape** on the Figma side, so producers and consumers agree on what an unformatted key looks like and what a formatted key reverses to. +2. A stated **safe key grammar** — the set of names that survive every `format.keys` value unchanged in meaning. +3. A place to record the **Figma name** whenever a key falls outside that grammar. + +The precedent for the third already exists: `AnatomyElement.$extensions['com.figma'].originalName` records the pre-collapse layer name for wrapper-collapsed elements (ADR-058, shipped in `0.28.0`). This ADR generalizes that field to all lossy key derivations, adds the equivalent to props, and renames it to match its widened meaning. + +--- + +## Decision Drivers + +- **Round-trip fidelity**: a spec must carry enough information to reconstruct the Figma name of every anatomy element and prop, under any `format.keys` value. +- **Mechanically verifiable contract** (Constitution IV): "which keys are safe" must be expressible in JSON Schema, not only in prose. +- **Quiet by default**: the preservation field must not appear on well-formed specs. A catalog that follows the safe grammar emits no extensions at all. +- **Formats must stay fit for purpose**: `CAMEL` and `PASCAL` exist to produce code identifiers. No widening of the safe character set may compromise that. +- **No logic in this package** (Constitution II): the schema declares the grammar and the fields; deriving and emitting them is producer behavior. +- **Types and schema symmetry** (Constitution I): every added field lands in both `types/` and `schema/`. +- **Minimal, intentional public API** (Constitution III): prefer one field with one meaning over parallel fields or a deprecated alias carried forward. +- **References must stay resolvable**: `elements`, `propConfigurations`, and other key references continue to point at formatted keys — no reference-site duplication of source names. + +--- + +## Options Considered + +### Decision 1 — Where the Figma name is preserved + +#### Option A: `$extensions['com.figma']` on the definition *(Selected)* + +Record the Figma name once, on the `anatomy` element definition and on the prop definition. Reference sites (`elements`, `propConfigurations`, …) continue to use the formatted key and resolve the Figma name through the definition. + +**Pros**: +- Reuses the field ADR-058 already established for exactly this purpose on `AnatomyElement`. +- Single source of truth — no chance of divergent values at reference sites. +- Absent on well-formed specs, satisfying "quiet by default". +- Optional field on both types — no reference-site schema churn. + +**Cons / Trade-offs**: +- Consumers must dereference the definition to recover the Figma name for a reference. + +--- + +#### Option B: A parallel key-map block on `Component` *(Rejected)* + +A top-level `keyMap: Record` mapping formatted key → Figma name. + +**Rejected because**: it introduces a second naming surface outside `$extensions`, with no namespace, and its entries are ambiguous across the anatomy and prop key spaces (a component may have an anatomy element and a prop that format to the same key). It also grows a block on every component rather than staying quiet by default. + +--- + +#### Option C: Emit unformatted keys and store the formatted key in extensions *(Rejected)* + +Invert the relationship — keys stay as Figma names, and the `format.keys` result is recorded in `$extensions`. + +**Rejected because**: `format.keys` exists so consumers can read keys in their own platform's convention directly. Moving the formatted value into extensions defeats the feature and breaks every existing consumer of formatted output. + +--- + +### Decision 2 — What that field is called + +#### Option A: Rename `originalName` → `name` *(Selected)* + +Within `$extensions['com.figma']`, the field becomes `name`. `AnatomyElement` is renamed; `FigmaPropExtension` gains the field under the new spelling. + +```yaml +# Before (ADR-058) +$extensions: + com.figma: + originalName: Leading icon + +# After +$extensions: + com.figma: + name: Leading icon +``` + +**Pros**: +- The namespace already carries the qualifier — `com.figma.name` reads as "this element's name in Figma", which is precisely the contract. +- `original` becomes inaccurate once the field generalizes. Under ADR-058 it meant *pre-collapse*; here it also means *pre-format*. "Original relative to what?" has two answers, and the shared answer is simply "in Figma". +- One field, one meaning, two reasons it may be present — rather than a qualifier that describes only one of them. +- Satisfies Constitution III: the smallest accurate public name. + +**Cons / Trade-offs**: +- Breaking rename of a field shipped in `0.28.0`. See Migration — the real footprint is three call sites in one repo. + +--- + +#### Option B: Add `name`, deprecate `originalName` *(Rejected)* + +Ship both spellings, remove `originalName` at the next MAJOR. + +**Rejected because**: it buys a compatibility window no consumer needs (see Migration) at the cost of a period where either spelling may appear, forcing every reader to check both and every producer to choose. The ADR would ship ambiguity to avoid four line edits. + +--- + +#### Option C: Keep `originalName` *(Rejected)* + +**Rejected because**: the field's meaning is being redefined by this ADR regardless. Retaining a qualifier that is accurate for only one of its two triggers embeds the confusion permanently to avoid a one-time rename. + +--- + +### Decision 3 — How the source-side name shape is declared + +Reversal needs a stated target: given `iconLeading`, a renderer can only reconstruct the Figma name if it knows what convention that file's names follow. Three ways to supply it. + +#### Option A: New `format.figmaKeys` config field, defaulting to `NONE` *(Selected)* + +Add a sibling to `format.keys` declaring the naming convention the Figma file itself uses, so `format.keys` describes the *output* convention and `format.figmaKeys` describes the *source* convention. Reversal is defined as: format the key back into `format.figmaKeys`. + +```yaml +format: + figmaKeys: SENTENCE # what the Figma file uses — new, defaults to NONE + keys: KEBAB # what the spec emits — existing +``` + +The accepted values are `NONE`, `SENTENCE`, and `TITLE`. `SENTENCE` and `TITLE` are the two conventions observed in practice for Figma layer and component-property names — deliberately narrower than `format.keys`; values are added when a real file requires them, not in anticipation. + +`NONE` is the default and means **no source convention is declared**. Under `NONE` the producer asserts nothing about the file's names, so the safe key grammar is not evaluated, no `name` is emitted for format divergence, and no reversal target exists. The ADR-058 wrapper-collapse trigger for `name` is unaffected — it does not depend on a declared convention. + +Everything this ADR adds is therefore **opt-in**: a catalog gets the grammar check, the divergence extension, and reversible rendering by declaring `figmaKeys: SENTENCE | TITLE`, and keeps today's *behavior* by leaving it alone. + +`NONE` is not byte-identical to today's output. The `originalName` → `name` rename applies unconditionally, so any catalog with a wrapper-collapsed element emits a differently-spelled extension under `NONE` too. What `NONE` preserves is the behavior — which names are formatted, which extensions are triggered — not the bytes. + +Opting in is a real trade, not a free upgrade. A catalog that declares `SENTENCE` buys lossless round-tripping and pays for it in spec complexity: every name outside the safe grammar, and every name already written in the destination format, grows a `$extensions` block. On a catalog with inconsistent Figma naming that is a lot of new noise in the output. That cost is the reason the default is `NONE` — the author decides whether the fidelity is worth the specs it produces. + +**Pros**: +- Reversal becomes a declared, symmetric pair rather than a hardcoded assumption. +- Files authored in `Title Case` become losslessly reversible without emitting `name` on every key. +- Defaulting to `NONE` makes the checks and preservation behavior opt-in. No existing catalog changes output, and no catalog is told its layer names are "unsafe" against a convention it never claimed. +- Both non-`NONE` values use single-space separators, so the safe grammar's character rules stay uniform and only its casing clause varies. + +**Cons / Trade-offs**: +- The default is the *un*safe setting: a catalog gets lossy keys until someone opts in. This is deliberate — the alternative is asserting a convention on the author's behalf and emitting extensions against an assumption they never made. +- A file with mixed conventions still diverges from whatever single value is declared, so `name` remains necessary as the escape hatch. +- A future file using an unlisted convention needs a schema change to declare it, rather than picking an already-present enum value. + +--- + +#### Option B: Hardcode sentence case in the schema documentation *(Rejected)* + +Declare in prose that Figma names are always assumed sentence case, with no config surface. + +**Rejected because**: it is wrong for real files. A library whose layer names are `Title Case` would emit `name` on every anatomy element and every prop, making the "quiet by default" driver unachievable for that catalog and burying genuine problems in noise. + +--- + +#### Option C: Infer the source convention per component from the observed names *(Rejected)* + +**Rejected because**: inference is producer logic with no stable contract — the same catalog could infer differently as components are added, and the schema would have no way to state what a spec's keys reverse to. It also violates Constitution II if expressed here. + +--- + +### Decision 4 — How wide the safe character set is + +#### Option A: ASCII letters and digits only *(Selected)* + +The safe set is `[A-Za-z0-9]` plus the single-space word separator. Every other character — `&`, `+`, `/`, `.`, `()`, punctuation, non-ASCII letters — is unsafe and routes to `com.figma.name`. + +**Pros**: +- Keeps `CAMEL` and `PASCAL` fit for purpose. A preserved `&` would emit `cut&Paste` as an anatomy key — valid JSON, unusable as the code identifier those formats exist to produce. +- Avoids ambiguous reversal. Transliterating `&` → `and` cannot be undone: `cutAndPaste` may have come from `Cut & paste` or from a literal `Cut and paste`. +- One rule covers all six output formats, rather than a per-format safe set consumers must track. + +**Cons / Trade-offs**: +- Common authoring characters (`&`, `+`, `/`) always trigger the extension, so catalogs using them are not extension-free. + +--- + +#### Option B: Widen the safe set to retain `&` and `+` *(Rejected)* + +Preserve selected symbols through formatting rather than deleting them. + +**Rejected because**: it violates the "formats must stay fit for purpose" driver. These characters can only survive `SAFE`; under `CAMEL`, `PASCAL`, `SNAKE`, `KEBAB`, and `TRAIN` they either produce illegal identifiers or must be dropped anyway — meaning the safe set would have to be defined per format, and no single grammar could describe it. + +--- + +#### Option C: Transliterate symbols to words (`&` → `and`, `+` → `plus`) *(Rejected)* + +**Rejected because**: it is lossy in the direction this ADR exists to fix. The transliteration is not injective, so the render direction cannot tell a transliterated key from a literal one, and it silently rewrites author intent into English-specific words. + +--- + +### Decision 5 — Names already written in the destination format + +A real Figma file is rarely uniform. A library that is largely `Sentence case` may still carry property names authored as code identifiers — `isDisabled`, `hasIcon` — because they were named for the engineers consuming them. Under `figmaKeys: SENTENCE`, `keys: CAMEL`, `isDisabled` is *unsafe* by the grammar (its first character is lowercase), yet `format.keys` leaves it exactly as it found it, and it is already the name the author wants on both sides. + +The forward direction is not the problem — `CAMEL` is idempotent over an already-camel name. The problem is **reversal**: a renderer told to reconstruct the Figma name by formatting `isDisabled` into `SENTENCE` produces `Is disabled` and renames a property the author deliberately spelled `isDisabled`. + +#### Option A: Detect names already authored as keys, retain them, and record `name` *(Selected)* + +A source name that fails the declared `figmaKeys` grammar but is itself a well-formed key in the declared `format.keys` convention is **already in the destination format**. It is retained verbatim, and `com.figma.name` is emitted so reversal restores it rather than re-deriving it. Names in some *other* key convention are not retained — they are formatted and recorded like any divergent name. The Decision states the tests exactly. + +```yaml +# figmaKeys: SENTENCE, keys: CAMEL +props: + iconLeading: # from "Icon leading" — SENTENCE-safe, formatted, quiet + type: glyph + isDisabled: # authored as "isDisabled" in Figma — already CAMEL + type: boolean + $extensions: + com.figma: + name: isDisabled # reversal is identity, not "Is disabled" +``` + +**Pros**: +- Answers the case directly: mixed-convention files stop being rewritten on render. +- Requires no new field — the escape hatch this ADR already adds carries it. +- The detection is a pure predicate over two declared grammars, not inference about the file. + +**Cons / Trade-offs**: +- **Detection alone cannot make it quiet.** `iconLeading` (formatted from `Icon leading`) and `isDisabled` (authored as-is) are both valid `CAMEL` and both invalid `SENTENCE`. Nothing in the emitted key distinguishes them, so the retained case *must* record `name`. Under a lossy `format.keys`, a catalog with many authored-key names will not be extension-free. +- Only meaningful when a convention is declared. Under `figmaKeys: NONE` there is no grammar to fail, so there is nothing to detect and no reversal to protect. + +--- + +#### Option B: Format everything, as today *(Rejected)* + +**Rejected because**: it makes the render direction actively destructive on exactly the files most likely to adopt it — the ones already naming properties for their code consumers. Renaming `isDisabled` to `Is disabled` is a silent, unrequested edit to the author's file. + +--- + +#### Option C: Pass through silently, without `name` *(Rejected)* + +**Rejected because**: it is undecidable at read time, as Option A's trade-off shows. A consumer seeing `isDisabled` cannot tell whether the Figma name is `isDisabled` or `Is disabled`, which is the precise ambiguity this ADR exists to eliminate. Quiet output is a driver, but not at the cost of the round-trip guarantee. + +This is not the same ambiguity as `figmaKeys: NONE`. Under `NONE` no convention is declared, so a consumer knows reversal is undefined and has no license to reconstruct anything — the spec makes no promise it cannot keep. Option C declares `SENTENCE`, invites a consumer to reverse into it, and then silently exempts some keys from that promise. Undeclared is safe; declared-and-selectively-violated is not. + +--- + +## Decision + +### The source convention — `format.figmaKeys` + +`format.figmaKeys` declares the naming convention the Figma file uses for layer names and component-property names. It is the target of reversal: a renderer reconstructs a Figma name by re-formatting the spec key into `format.figmaKeys`. + +| Value | Shape | Example | +|-------|-------|---------| +| `NONE` *(default)* | No convention declared — grammar not evaluated, no reversal target | — | +| `SENTENCE` | First word capitalized, rest lowercase, single spaces | `Icon leading` | +| `TITLE` | Every word capitalized, single spaces | `Icon Leading` | + +`SENTENCE` and `TITLE` are source conventions only; neither is accepted by `format.keys`, which describes what the spec emits. + +`NONE` is the default, and everything below is gated on a value other than `NONE`: + +- The safe key grammar is not evaluated. +- `com.figma.name` is not emitted for format divergence. The ADR-058 wrapper-collapse trigger is independent of `figmaKeys` and continues to emit it. +- Authored-key retention (below) does not apply — there is no grammar for a name to fail. +- Reversal is undefined. A renderer reading a spec produced under `NONE` has no declared target and MUST fall back to `com.figma.name` where present and the formatted key otherwise — today's behavior. + +Declaring `SENTENCE` or `TITLE` opts the catalog into all of it. + +### The safe key grammar + +*Applies only when `format.figmaKeys` is `SENTENCE` or `TITLE`.* + +A name is **round-trip safe** when it satisfies both the character rules and the casing rule for the declared `format.figmaKeys`. + +Character and word rules, identical for both `figmaKeys` values: + +- ASCII letters and digits only — no `&`, `+`, `/`, `.`, parentheses, punctuation, or non-ASCII letters. +- Exactly one space between words. No leading, trailing, or repeated spaces. +- A word is either all letters or all digits. A digit run is always its own word — `Badge count 2` is three words; `Badge count2` is not safe. +- The name does not begin with a digit — the first word is always a letter word. + +Casing rule, per declared convention: + +| `figmaKeys` | Pattern | +|-------------|---------| +| `SENTENCE` | `^[A-Z][a-z]*( ([a-z]+\|[0-9]+))*$` | +| `TITLE` | `^[A-Z][a-z]*( ([A-Z][a-z]*\|[0-9]+))*$` | + +### The word-splitting rule + +Reversal is only deterministic if formatting and reversal split words the same way. Both directions split on: + +- **Spaces**, in the source name. +- **Case transitions**, lower→upper (`iconLeading` → `icon` + `Leading`). +- **Letter↔digit transitions**, in both directions (`badgeCount2` → `badge` + `count` + `2`). + +The letter↔digit clause is what makes `Badge count 2` safe: it formats to `badgeCount2` and splits back to `badge` / `count` / `2`, recovering the boundary. It is also why `Badge count2` is *not* safe — it formats to the same `badgeCount2`, which reverses to `Badge count 2`, not to the authored name. The grammar excludes mixed letter-digit words precisely so that no two safe names collapse onto one key by this route. + +Names satisfying the grammar reconstruct identically from the output of any `format.keys` value. Names that do not satisfy it require `com.figma.name` to be recoverable. + +### Names already authored as keys + +*Applies only when `format.figmaKeys` is `SENTENCE` or `TITLE`.* + +Every Figma name is decided by two tests, in order: + +1. **Does it match the origin convention** — the safe key grammar for the declared `figmaKeys`? If so, format it and emit nothing. The key reverses back to the name unaided. +2. **Does it match the destination convention** — a well-formed key in the declared `format.keys`? If so, **retain it verbatim** and record `com.figma.name`. +3. **Otherwise** — it matches neither. Format it, and record `com.figma.name`. + +Only the *destination* format retains. A `PascalCase` name with a `KEBAB` destination is not a match: it is reformatted and recorded, exactly like any other divergent name. Retention exists because formatting cannot *convert* between key conventions — it splits on spaces, hyphens, and underscores, not case transitions — so a name already in the destination convention can only be damaged by re-running it through the formatter, never improved. + +Test 1 runs first so that a name satisfying both tests stays quiet. `Label` is `SENTENCE`-safe *and* a well-formed `PASCAL` key; formatting and retention produce the same key, so the origin test wins and no extension is emitted for a name that reverses correctly on its own. + +`SAFE` has no destination pattern — it emits names as authored, so no consumer reverses a `SAFE` key and test 2 does not apply. `SAFE` is still lossy for the characters it strips (`.`, `[`, `]`, `\`, quotes), so a name it changes is recorded on that basis alone. + +```yaml +# figmaKeys: SENTENCE, keys: KEBAB +Icon leading → icon-leading # matches origin: formatted, no extension +is-disabled → is-disabled # matches destination KEBAB: retained, name: is-disabled +isDisabled → isdisabled # matches neither: formatted, name: isDisabled +IsDisabled → isdisabled # PASCAL is not the destination: formatted, name: IsDisabled +URL field → url-field # matches neither: formatted, name: URL field + +# figmaKeys: SENTENCE, keys: CAMEL +isDisabled → isDisabled # now matches the destination: retained, name: isDisabled +is-disabled → isDisabled # KEBAB is not the destination: formatted, name: is-disabled +``` + +The retained case always emits `name`. `iconLeading` and `isDisabled` are both valid `CAMEL` and both invalid `SENTENCE`, so the emitted key carries no signal a consumer could use to tell a formatted key from a natively-authored one — the extension is the only thing that distinguishes them. + +```yaml +# figmaKeys: SENTENCE + +# Safe — survives every format.keys value +Icon leading # → iconLeading | icon_leading | icon-leading | IconLeading | Icon-Leading +Label +Badge count 2 # → badgeCount2 — the digit is its own word, boundary recovered +Icon 2 leading # → icon2Leading — same rule + +# Unsafe — requires $extensions.com.figma.name +Icon-leading # separator is not a space +Icon Leading # casing diverges from SENTENCE (safe under figmaKeys: TITLE) +URL field # inner capitals lost +Badge count2 # mixed letter-digit word — reverses to "Badge count 2" +2 icons # name begins with a digit +Cut & paste # symbol deleted, word boundary lost +Size (large) # parentheses deleted +Étiquette # non-ASCII letter deleted +``` + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Anatomy.ts` | Renamed `FigmaAnatomyElementExtension.originalName` → `name`, widened to record the Figma name whenever the anatomy key diverges from it (format projection or wrapper collapse) | MAJOR | +| `Props.ts` | Added optional field `name` to `FigmaPropExtension` | MINOR | +| `Config.ts` | Added optional field `format.figmaKeys` to `Config`, required field `format.figmaKeys` to `ResolvedConfig`, and `figmaKeys: 'NONE'` to `DEFAULT_CONFIG` | MINOR | +| `Config.ts` | Expanded `format.keys` documentation to reference `format.figmaKeys` and the safe key grammar | PATCH | + +**Example — anatomy extension** (`types/Anatomy.ts`): + +```yaml +# Before +FigmaAnatomyElementExtension: + originalName?: string # pre-collapse layer name only (ADR-058) + +# After +FigmaAnatomyElementExtension: + name?: string # the element's name in Figma — present when the key diverges from it +``` + +**Example — prop extension** (`types/Props.ts`): + +```yaml +# Before +FigmaPropExtension: + type?: string + source?: FigmaCodeOnlySource + +# After +FigmaPropExtension: + type?: string + source?: FigmaCodeOnlySource + name?: string # optional — present only when the prop key diverges from the Figma property name +``` + +**Example — config** (`types/Config.ts`): + +```yaml +# Before +Config.format: + output?: 'JSON' | 'YAML' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + +# After +Config.format: + output?: 'JSON' | 'YAML' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + figmaKeys?: 'NONE' | 'SENTENCE' | 'TITLE' # optional — defaults to NONE +``` + +**Example — emitted spec under `format.keys: KEBAB`, `format.figmaKeys: SENTENCE`**: + +```yaml +anatomy: + icon-leading: # safe — derived from "Icon leading", no extension emitted + type: glyph + url-field: + type: text + $extensions: + com.figma: + name: URL field +props: + is-disabled: # safe — derived from "Is disabled" + type: boolean + default: false + cut-paste: + type: boolean + default: false + $extensions: + com.figma: + name: Cut & paste +elements: + url-field: # references remain in formatted key space + text: Email address +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Renamed `AnatomyElement.$extensions['com.figma'].originalName` → `name`, with widened description | MAJOR | +| `component.schema.json` | Added property `name` to `FigmaPropExtension` | MINOR | +| `component.schema.json` | Added definitions `SafeKeySentence` and `SafeKeyTitle` with their `pattern`s and descriptions | MINOR | +| `component.schema.json` | Expanded descriptions of `Anatomy` and `Props` to reference the safe key definitions | PATCH | +| `workspace.schema.json` | Added property `figmaKeys` under `format`, enum `[NONE, SENTENCE, TITLE]`, default `NONE` | MINOR | + +**Example — new definitions** (`schema/component.schema.json`): + +```yaml +SafeKeySentence: + type: string + pattern: "^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$" + description: >- + A round-trip-safe Figma name under format.figmaKeys SENTENCE: ASCII letters + and digits, single-space word separators, sentence case, each word all + letters or all digits, never digit-initial. Names matching this pattern + reconstruct identically from the output of any format.keys value; names + that do not require $extensions.com.figma.name. + +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. +``` + +```yaml +# New property under #/definitions/FigmaPropExtension/properties +name: + type: string + description: >- + The Figma component-property name, recorded when the prop key diverges from + it under format.keys. Absent when the name matches the safe key grammar. +``` + +```yaml +# New property under #/definitions/.../format/properties (workspace.schema.json) +figmaKeys: + type: string + enum: [NONE, SENTENCE, TITLE] + default: NONE + description: >- + Naming convention the Figma file uses for layer names and component + property names. Reversal target for format.keys. NONE (the default) + declares no convention — the safe key grammar is not evaluated, no + com.figma.name is emitted for format divergence, and reversal is undefined. +``` + +### Notes + +- `format.figmaKeys` describes the source, `format.keys` describes the output. They are independent; `figmaKeys: SENTENCE` with `keys: SAFE` is the identity case where keys are emitted as authored. +- `figmaKeys: NONE` (the default) preserves today's behavior, though not today's bytes — the `originalName` → `name` rename applies under it. It is the "I have not told you what my file looks like" value, not a claim that the file has no convention. +- `SafeKeySentence` and `SafeKeyTitle` are definitions, not applied constraints on `Anatomy`/`Props` property names. They describe the *Figma-side* name shape, while the keys in a document are in `format.keys` space. +- `name` remains optional on both surfaces, so a catalog whose names all satisfy the grammar emits no *format-divergence* extensions. Wrapper collapse still emits `name` on its own trigger, independent of `figmaKeys` and of the grammar. +- Reference sites are deliberately unchanged. A consumer resolving `elements.url-field` looks up `anatomy['url-field']` and reads `name` there. +- On `AnatomyElement`, `name` has two triggers — wrapper-collapse promotion and format divergence — that are not distinguished. When both apply, the pre-collapse Figma name is the correct value for both purposes. + +### Migration + +`originalName` shipped in `0.28.0`. Its consumers are: + +| Location | Role | Risk | +|----------|------|------| +| `specs-from-figma` — anatomy element construction | Writes the field | Compile error on rename — caught | +| `figma-from-specs` — collapsed-root name recovery | Reads the field | **Reads via an inline structural type, not the published type — will not fail compilation. Silently yields `undefined` if missed.** | +| `specs-plugin-2` | — | No references | +| `specs-testing` workspace specs | Generated `api.yaml` output | Regenerated, not authored | + +The read site in `figma-from-specs` is the one hazard: because it declares the extension shape inline rather than importing `FigmaAnatomyElementExtension`, the rename is invisible to `tsc` there. Implementation MUST update it explicitly rather than relying on the compiler. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: + - `FigmaAnatomyElementExtension.name` (`types/Anatomy.ts`) ↔ `#/definitions/AnatomyElement/properties/$extensions/properties/com.figma/properties/name` + - `FigmaPropExtension.name` (`types/Props.ts`) ↔ `#/definitions/FigmaPropExtension/properties/name` + - `Config['format']['figmaKeys']` and `ResolvedConfig['format']['figmaKeys']` (`types/Config.ts`) ↔ `format.figmaKeys` in `schema/workspace.schema.json` + - `SafeKeySentence` and `SafeKeyTitle` (`#/definitions`) have no TypeScript counterpart — a `pattern` constraint on a string is not expressible as a distinct TypeScript type, and introducing a branded type would be logic, not a declaration (Constitution II). This asymmetry is justified and intentional; the type-side contract remains `string`. + +--- + +## Coverage inventory — every named space in a spec + +Two fields (`AnatomyElement.$extensions['com.figma'].name` and `FigmaPropExtension.name`) are claimed to cover all Figma-derived naming in a spec. This inventory is the basis of that claim: every keyed space in the schema, what its keys are, and why it is or is not covered. + +| Space | Type | Keys are | Status | +|-------|------|----------|--------| +| `anatomy` | `Record` | Figma layer names, formatted | **Covered** — `name` on the definition | +| `props` | `Record` | Figma component-property names, formatted | **Covered** — `name` on the definition | +| `elements` | `Record` | References to anatomy keys | **Covered by reference** — resolves through `anatomy` (Decision 1) | +| `propConfigurations` | `Record` | References to prop keys | **Covered by reference** — resolves through `props` | +| `children`, `slotContent` refs, `layout` | key references | References to anatomy keys | **Covered by reference** | +| `compositions[].anatomy` / `.elements` | nested `Anatomy` / `Elements` | Figma layer names, formatted | **Covered recursively** — same `AnatomyElement` type (`Composition.ts:26-27`) | +| `slotContentExamples[].anatomy` / `.elements` | nested `Anatomy` / `Elements` | Figma layer names, formatted | **Covered recursively** — same `AnatomyElement` type (`SlotContent.ts:12-13`) | +| `props..options` values | string values | Figma variant option values, formatted | **Not covered — gap.** No per-option preservation surface | +| `subcomponents` | `Record` | Formatted from `sub.title` | **Not needed** — unformatted `title` sits alongside | +| `title` (component, composition, instance example) | `string` | Verbatim Figma node name, never formatted | **Not needed** — nothing was projected | +| `instanceExamples` | `Record` | Authored identifiers (`^[a-zA-Z0-9_-]+$`), not Figma names | **Out of scope** — carries its own `title` for the label | +| `compositions` (registry keys) | `Record` | Authored identifiers | **Out of scope** — carries its own `title` | +| `slotContentExamples` (registry keys) | `Record` | Authored registry identifiers | **Out of scope** | +| `images` | `Record` | Authored / content-derived identifiers | **Out of scope** | +| `config.format.states` | `Record` | Config-declared state names | **Out of scope** — config vocabulary, not a Figma name | + +Three consequences of this inventory are normative: + +- **Reference sites need no schema change.** Because every reference is in formatted key space and resolves to a definition, adding `name` in two places covers every use of those names throughout the document. This is the whole reason Decision 1 selected the definition site. +- **Producers MUST recurse.** `Anatomy` appears at the top level, inside every `Composition`, and inside every `SlotContent`. A producer that evaluates only the top-level `anatomy` map silently leaves composed and slot-filled elements unprotected, and because the nested type is identical, nothing in the type system catches the omission. Implementation must walk all three. +- **The out-of-scope rows are out of scope because nothing was projected**, not because the loss is tolerated. They hold authored identifiers or verbatim names, so there is no source name to reconstruct. If a producer ever derives one of these keys from a Figma name, it moves into the covered set and needs its own decision. + +This inventory is derived from the schema types and the render-direction write sites. It has not been confirmed against a full sweep of what `specs-from-figma` emits into each registry. + +--- + +## Key application surfaces in `figma-from-specs` + +Audited on `feat/figma-from-specs`. Every place the render direction either formats a key or writes a spec key back into Figma as a name, and whether `AnatomyElement.name` / `FigmaPropExtension.name` covers it. + +| Surface | Site | Direction | Covered by this ADR | +|---------|------|-----------|---------------------| +| Anatomy layer names | `Elements/Elements.ts:231` — `childNode.name = name` | Spec key written to Figma **verbatim, unformatted** | **Yes** — anatomy `name` becomes the value to use here, replacing the raw key | +| Collapsed-root leaf name | `Elements/Elements.ts:122,131` — reads `originalName` | Extension read | **Yes** — the ADR-058 site; rename to `name` (see Migration) | +| Component property names | `Props/Props.ts:48,50,53` — `addComponentProperty(name, …)` | Spec prop key written to Figma **verbatim** | **Yes** — prop `name` becomes the value to use | +| Boolean-pairing property names | `Props/Props.ts:63` — `addComponentProperty(pair.booleanPropName, …)` | Derived from a prop key | **Yes**, transitively — resolves through the same prop `name` | +| Code-only property names | `Props/CodeOnlyProps.ts:76-77,188,206` — property, container, and layer names | Derived from prop keys and `$extensions.com.figma.source` | **Partly** — the property name resolves through prop `name`, but the layer/pattern names at `:188,206` come from the code-only source block and are not a key-format surface | +| Prop-configuration property matching | `PropConfigurations/PropConfigurations.ts:103` — `matchFormatted` | Formats the *Figma* name forward and compares | **Yes** — becomes a `name` lookup with the format comparison as fallback | +| **Variant option values** | `PropConfigurations/PropConfigurations.ts:72` — `formatKey(option, fmt) === specVal` | Formats Figma enum *values* forward and compares | **No — gap.** Enum option values pass through `format.keys` too, and neither `name` field records them. See below | +| Glyph content keys | `Elements/GlyphElement.ts:32` — `formatKey(rawName, glyphKeyFormat) === contentKey` | Formats a glyph library name forward and compares | **No — out of scope.** Matches against a live glyph manifest, not a spec key; the source name is external to the component | +| Subcomponent titles | `Subcomponents/Subcomponents.ts:28,42` — `formatKey(sub.title, keyFormat)` | Formats a title into key space to resolve a manifest entry | **Not needed.** `title` is stored unformatted and stays available to reverse from | +| Component / variant frame names | `Component.ts:83,108,114`, `Variants/Variants.ts:78` | Writes `title` back as the Figma name | **Not needed** — `title` is never in key space (see note below) | + +### What this ADR deliberately leaves untouched + +The contract covers **anatomy element keys and prop keys**. Three other naming surfaces are explicitly out of scope, for two different reasons. + +**Variant option values — a real gap, not fixed here.** + +`props..options` entries are formatted by `format.keys`, and the render direction matches them back by re-formatting the live Figma enum value (`PropConfigurations.ts:72`). `Cut & paste` as an *option value* is exactly as lossy as it is as a key, and `FigmaPropExtension.name` records only the property name — there is no per-option preservation surface. Adding one means a keyed structure under the prop extension, which is a separate decision about the prop extension's shape and is not made here. + +**Component titles and subcomponent titles — already lossless, no change needed.** + +`title` is not in key space, which is a fact about the read direction: `specs-from-figma` sets `title: this.node.name` verbatim (`Component/Component.ts:283`), so the Figma component name reaches the spec unformatted and no `format.keys` value touches it. Nothing needs preserving, because nothing was ever projected. + +In the write direction, `figma-from-specs` formats a title only at the *consumption* site — `Subcomponents.ts:28,42` derives a key from `sub.title` to resolve a manifest entry — and the unformatted `title` remains alongside. Frame naming writes `title` back as-is. + +So titles already do what `com.figma.name` does for keys — they record the original name — and they do it in the schema's own field rather than an extension. No `name` extension is added for titles, and none is needed. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | When `format.figmaKeys` is not `NONE`, must evaluate each anatomy element name and prop name against the safe key grammar, apply the authored-key retention test, and emit `name` on divergence or retention; existing `originalName` write site breaks under every value including `NONE` | Rename the emitted extension key unconditionally; add the grammar and retention evaluation gated on `format.figmaKeys`; align the word splitter with the stated letter↔digit rule; recompile | +| `specs-cli` | Emits and validates specs carrying the new optional field; the new config key is accepted in workspace config | Recompile against the new schema version; surface `format.figmaKeys` in config resolution and documentation | +| `specs-plugin-2` | Same emission path as the CLI in the plugin runtime; the render direction reconstructs Figma names by formatting into `format.figmaKeys`, preferring `name` where present | Recompile; use `name` in preference to the formatted key when matching Figma nodes | + +--- + +## Semver Decision + +**Version bump**: none — lands in the in-flight `0.30.0` (`MAJOR`-class change, released as a pre-1.0 minor) + +**Justification**: Renaming `originalName` → `name` removes a published field name, which the constitution classes as `MAJOR`. The package is pre-1.0 and already releases breaking narrowing changes as minors — `0.29.0` narrowed `Styles.textAlignHorizontal` from `Style` to a string enum. `0.30.0` is unreleased and is the version this ADR lands in, so it absorbs the change and `package.json` is unchanged. All other changes are additive: `FigmaPropExtension.name` and `Config.format.figmaKeys` are optional, and the safe-key definitions are referenced only from descriptions. `ResolvedConfig.format.figmaKeys` is required, and adding a required field to a published type is `MAJOR`-class in its own right — a caller assembling a `ResolvedConfig` literal rather than spreading `DEFAULT_CONFIG` loses compilation. It is absorbed by the same pre-1.0 `0.30.0` as the rename, and `DEFAULT_CONFIG` supplies `NONE` so the spread path is unaffected. + +--- + +## Consequences + +- The schema states, for the first time, what an unformatted Figma-side name looks like — declared per workspace via `format.figmaKeys`. +- No existing catalog changes behavior. `figmaKeys` defaults to `NONE`, so the grammar check, the divergence extension, and reversal are opt-in; a catalog adopts them by naming its convention. Output is not byte-identical — the `originalName` → `name` rename applies regardless of `figmaKeys`. +- Solving the problem costs spec complexity, and the author chooses whether to pay it. Opting in adds a `$extensions` block to every name outside the safe grammar and every name already written in the destination format. On a catalog with inconsistent Figma naming, that is substantial noise in exchange for fidelity — which is why it is opt-in rather than the default. +- The default being `NONE` means most catalogs stay lossy until someone opts in. Adoption depends on the value being visible, which is what the analyze report below is for. +- Key formatting becomes a declared pair — `figmaKeys` in, `keys` out — so reversal is defined by config rather than inferred. +- The set of names that survive every `format.keys` value is defined by two mechanically checkable patterns rather than by the behavior of one producer. +- **When `figmaKeys` is declared**, specs produced with a lossy `format.keys` value carry enough information to reconstruct Figma names, making the spec → Figma direction lossless for anatomy and prop identity. Under the `NONE` default they do not, and reversal stays undefined. +- Well-formed catalogs stay quiet — no format-divergence `$extensions` appear unless a name falls outside the safe grammar, so the field doubles as a signal that a Figma name needs attention. Wrapper collapse emits `name` on its own trigger, so a grammar-conforming catalog is not necessarily extension-free. +- Two distinct Figma names can still format to the same key — `Cut & paste` and `Cut paste` both reach `cutPaste`, and a retained name can collide with a formatted one. This is pre-existing behavior and is not changed here: a keyed record holds one entry per key, so one name wins and the other's `name` is not recorded. The loss belongs to the author who named two things the same in key space, not to the system. The safe grammar is collision-free among safe names, and `analyze keys` is the place to surface the rest. +- Consumers that match Figma nodes by name MUST prefer `com.figma.name` over the formatted key when it is present; ignoring it reintroduces the divergence this ADR removes. +- Names outside the safe grammar remain fully supported — they are recorded, not rejected. Narrowing them is an authoring recommendation, not a validation gate. +- `&`, `+`, and `/` will always trigger the extension. Catalogs wanting extension-free output must avoid them in layer and property names. +- `format.figmaKeys` starts deliberately narrow at `SENTENCE | TITLE`; encountering a file with another convention is a schema change, not a config choice. +- The grammar makes naming quality **reportable**, which suggests a follow-on `specs analyze keys` report: run the safe key grammar across a catalog and surface every anatomy element, prop, and option whose Figma name falls outside it — grouped by cause (separator, casing, symbol, mixed letter-digit word, digit-initial, non-ASCII) and by the convention the name *would* be safe under. Two audiences: + - **Design-system teams** get a list of layer and property names to fix in Figma, ranked by how many components each affects. + - **Adopters of `figmaKeys`** get the evidence for which value to declare — a catalog that is 94% `SENTENCE`-safe and 3% `TITLE`-safe has an obvious answer, and the residue is the exact list of names that will emit `com.figma.name`. + The report is also the honest way to run under `figmaKeys: NONE`: no extension is emitted, but the divergence is still measurable and can be shown. +- Deriving the retention and divergence signals is producer behavior (Constitution II). The schema declares the grammar; `analyze keys` is CLI work, out of scope for this ADR. +- Two fields cover every Figma-derived name in a spec, at every nesting depth, because reference sites resolve through definitions — see the Coverage inventory for the full enumeration and the recursion requirement it places on producers. +- The contract covers anatomy and prop *identity* only, and three surfaces stay as they are: + - **Variant option values** pass through `format.keys` with no preservation field and remain lossy. Closing this needs a per-option surface on the prop extension — a separate decision. + - **Component titles** are already the verbatim Figma node name and are never formatted. + - **Subcomponent titles** are likewise verbatim; they are formatted only when read, and the unformatted title remains alongside. + Titles therefore already record the original name in a first-class field, which is why no extension is added for them. diff --git a/adr/069-clips-content.md b/adr/069-clips-content.md new file mode 100644 index 00000000..c2950a8c --- /dev/null +++ b/adr/069-clips-content.md @@ -0,0 +1,237 @@ +# ADR: Rename `clipContent` to `clipsContent` + +**Branch**: `069-clips-content` +**Created**: 2026-08-14 +**Status**: ACCEPTED +**Summary**: A `clipsContent` boolean style replaces `clipContent`, joining `visible` and `locked` as element state consumers map to `overflow`. +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`Styles` declares a boolean style named `clipContent`: + +```yaml +# types/Styles.ts — current +Styles: + clipContent: Style +``` + +The name matches nothing. The property describes whether an element cuts off content that overflows its box, and the key the data actually carries is `clipsContent`. Because the declared key and the carried key differ by one character, every consumer of the declared key has been inert since it shipped: + +- The `styles.schema.json` property `clipContent` never validates against emitted spec output. +- The `StyleKey` union member `'clipContent'` never selects a real style. +- The CSS mapping rule for `clipContent → overflow` never fires, so `overflow: hidden` / `overflow: visible` is never emitted. + +The defect is a typo. But correcting a typo still means publishing a field name, and a published field name is a contract with every consumer — so this ADR treats both halves as open questions rather than assuming the answer: + +1. **The construct** — is an on/off boolean the right way to model clipping at all, or should this be an enum? +2. **The name** — given the construct, what should the field be called? + +The risk to guard against is that `clipsContent` happens to be Figma's spelling. Constitution VI is explicit that Figma is the data source, not the naming authority. Arriving at Figma's spelling is acceptable; arriving at it *because* it is Figma's spelling is not. Each decision below is therefore settled on code-platform evidence and on terms the schema already uses, and each records what would have changed the answer. + +--- + +## Cross-platform survey + +Both decisions draw on the same evidence, gathered once here. + +| Platform | API | Construct | Name shape | +|----------|-----|-----------|------------| +| UIKit | `UIView.clipsToBounds: Bool` | Boolean | third-person verb + boundary | +| SwiftUI | `.clipped()`, `.clipShape(_:)` | Modifier presence (on/off) | past participle | +| Android View | `ViewGroup.clipChildren`, `View.clipToOutline` | Boolean | verb + object / verb + boundary | +| Jetpack Compose | `Modifier.clip(shape)`, `Modifier.clipToBounds()` | Modifier presence (on/off) | verb + boundary | +| CSS | `overflow: visible \| hidden \| clip \| scroll \| auto` | Enum | overflow noun | +| React Native | `overflow: 'visible' \| 'hidden' \| 'scroll'` | Enum (CSS subset) | overflow noun | +| Flutter | `clipBehavior: Clip` (`none`, `hardEdge`, `antiAlias`, `antiAliasWithSaveLayer`) | Enum | verb + behavior noun | +| Figma | `clipsContent: boolean` | Boolean | third-person verb + object | + +--- + +## Decision Drivers + +- **Construct neutrality**: the shape must be the one code platforms agree on, and must not carry values this schema has no way to populate. +- **Name neutrality**: every naming tiebreak must resolve on code-platform evidence or on vocabulary the schema already uses — never on "Figma spells it this way". +- **Falsifiability**: each decision states what evidence would have produced a different answer, so a reviewer can check the reasoning rather than trust it. +- **Type ↔ schema symmetry**: the type, the schema property, and the `StyleKey` union must move together (Constitution IV). +- **Breaking-change discipline**: renaming a named field within an exported type is breaking and MUST be versioned accordingly (Constitution III). + +--- + +## Decision 1 — Construct: boolean or enum? + +### Option A: Keep the boolean (`Style` / `BooleanStyleValue`) *(Selected)* + +Model clipping as an on/off style, unchanged from today. + +**Pros**: +- **Constitution VI rule 1 applies — 2+ code platforms agree, independent of Figma.** Four of the seven code platforms surveyed model clipping as on/off: UIKit and Android View as literal booleans, SwiftUI and Compose as modifier presence. Figma's agreement is a fifth data point, not the basis. +- **The three enums degenerate to a boolean in this domain.** Their extra values fall into two buckets, neither of which this schema can express: + - *Scrolling* — CSS/React Native `scroll` and `auto` describe a scrolling affordance, a runtime interaction behavior. The schema has no scroll concept anywhere in `Styles`, and a static design surface produces no scroll state to extract. + - *Rasterization quality* — Flutter's `hardEdge` / `antiAlias` / `antiAliasWithSaveLayer` describe how the clip is rendered, a render-time performance trade-off with no design-surface counterpart. + + Remove what the schema cannot populate and CSS `overflow` collapses to `visible` vs `hidden`, and Flutter's `Clip` collapses to `none` vs everything-else. Both are booleans wearing an enum's clothes. +- **Widening at the consumer boundary is lossless; narrowing is not.** A boolean projects cleanly onto every enum — the existing CSS mapping already does exactly this (`true → overflow: hidden`, `false → overflow: visible`). An enum in the schema would instead force platforms with a boolean to narrow, and would advertise values no extractor can ever produce. +- Reuses the existing `BooleanStyleValue` schema definition, so the value stays token-bindable like other boolean styles (`visible`, `locked`, `wrap`). + +**Cons / Trade-offs**: +- If the schema ever models scrolling containers, clipping and scrollability will need to be reconciled — a boolean cannot express `overflow: scroll`. That is a genuine future cost, but it is a *new capability* deserving its own ADR, not a reason to pre-build an enum whose extra values would sit permanently unpopulated. + +**What would have changed this**: if a majority of code platforms modeled clipping with a value set that survives the design-surface filter — three or more distinct, extractable clipping modes rather than on/off — the enum would win. + +--- + +### Option B: CSS-shaped `overflow` enum *(Rejected)* + +Replace the boolean with `overflow: 'VISIBLE' | 'HIDDEN' | 'CLIP'`. + +**Rejected because**: it violates the construct-neutrality driver from the opposite direction — it adopts the *web's* model as the neutral one. CSS and React Native are one platform family, not two independent votes, so this is closer to a single-platform preference than the rule-1 consensus Option A has. It also imports `scroll`/`auto` semantics the schema cannot populate, and collides with the existing `textOverflow` style (`CLIP` | `ELLIPSIS`) — two unrelated `overflow`-named properties with a shared `CLIP` value that means different things. + +--- + +### Option C: Flutter-shaped `clipBehavior` enum *(Rejected)* + +Adopt a named enum modeled on Flutter's `Clip`. + +**Rejected because**: its distinctions are rasterization quality, which is a rendering implementation concern rather than a design decision, and nothing on a design surface determines which value to emit. It is a single-platform model with no second platform agreeing. + +--- + +## Decision 2 — Name: what should the boolean be called? + +Given a boolean, the survey offers three naming shapes. Two axes are in play — what the name points at (the clipped object vs. the clipping boundary), and the verb form. + +### Option A: `clipsContent` *(Selected)* + +**Pros**: +- **Object-naming beats boundary-naming on schema-internal grounds.** The platforms split evenly on this axis — Android `clipChildren` and Figma `clipsContent` name the clipped object; UIKit `clipsToBounds` and Compose `clipToBounds` name the boundary. The tiebreak is not Figma's vote, it is that **`Styles` has no `bounds` concept to point at.** The schema models extent as `width`/`height`/`min*`/`max*` with no bounds object, so a `*ToBounds` name would reference a term the schema never defines. `content` is already the schema's word for what an element contains. +- **`content` generalizes where `children` does not.** Android's `clipChildren` names child views specifically, but clipping applies equally to overflowing text and paint on elements with no children. `content` covers all three; `children` would be wrong for a clipped `TEXT` element. +- **The verb form is settled by internal consistency, not by Figma.** Booleans in `Styles` read as state predicates describing the element — `visible`, `locked`, `wrap` — not as commands. `clipsContent` reads as a predicate ("this element clips content"); the current `clipContent` reads as an imperative instruction, which is why it looks out of place beside its neighbors. UIKit's `clipsToBounds` independently confirms the third-person form for a boolean clip flag. + +**Cons / Trade-offs**: +- The result is spelled the same as Figma's property. Every tiebreak above was decided on code-platform or in-schema evidence, and the coincidence is what it is — but the reasoning, not the match, is what should be reviewed. + +**What would have changed this**: if `Styles` modeled a bounds or frame object, `clipsToBounds` would carry two code platforms (UIKit, Compose) plus an in-schema referent and would win. It does not, so it cannot. + +--- + +### Option B: `clipsToBounds` *(Rejected)* + +**Rejected because**: it names a referent the schema does not define. `Styles` has no bounds object, so the name would point at a concept a consumer cannot look up — trading a Figma-shaped name for a UIKit-shaped one without improving neutrality. + +--- + +### Option C: Keep `clipContent`, translate in the transformer *(Rejected)* + +Leave the schema name alone and rename `clipsContent` → `clipContent` on the way out. + +**Rejected because**: Constitution VI's rationale for making the transformer translate is to spare consumers a *deliberate* naming decision. This name was not decided, it was mistyped. Paying a permanent translation step to preserve a typo inverts the rule. It also keeps the imperative verb form that reads wrong beside `visible` and `locked`. + +--- + +## Decision + +Model clipping as a boolean, and rename the field, the schema property, and the `StyleKey` member to `clipsContent`. + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Styles.ts` | Renamed field `clipContent` → `clipsContent` on `Styles` | MAJOR | +| `Styles.ts` | Renamed `StyleKey` union member `'clipContent'` → `'clipsContent'` | MAJOR | + +**Example — new shape** (`types/Styles.ts`): +```yaml +# Before +Styles: + effects: TokenReference | Effects + clipContent: Style + cornerRadius: Style | Corners + +# After +Styles: + effects: TokenReference | Effects + clipsContent: Style + cornerRadius: Style | Corners +``` + +```yaml +# StyleKey — before +StyleKey: + - effects + - clipContent + - cornerRadius + +# StyleKey — after +StyleKey: + - effects + - clipsContent + - cornerRadius +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `styles.schema.json` | Renamed property `clipContent` → `clipsContent` | MAJOR | + +**Example — new shape** (`schema/styles.schema.json`): +```yaml +# Before — under #/definitions/Styles/properties +clipContent: + $ref: "#/definitions/BooleanStyleValue" + description: "Clip content" + +# After +clipsContent: + $ref: "#/definitions/BooleanStyleValue" + description: "Whether the element clips content that overflows its box" +``` + +### Notes + +- The construct is unchanged: the type stays `Style`, the schema `$ref` stays `BooleanStyleValue`, and the property stays optional. Only the key changes. Decision 1 records that the boolean was re-examined and re-affirmed rather than inherited by default. +- No deprecation alias is introduced. An alias would keep alive a name that has never resolved to a value, and would require consumers to handle two keys for one concept. +- The description is rewritten from `"Clip content"` — an imperative fragment restating the old field name — to a statement of what the boolean means. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: `Styles.clipsContent` ↔ `#/definitions/Styles/properties/clipsContent`; the `StyleKey` union member `'clipsContent'` names the same key. All three rename in one change; no drift is introduced. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-cli` | The CSS mapping keyed on the old name stops being dead code once the key matches | Update the key used by the style-to-CSS mapping and its mapping documentation; recompile | +| `specs-from-figma` | Emitted output already uses `clipsContent`; it now validates against the schema | Recompile against the new types; confirm no code references the old key | +| `specs-plugin-2` | Consumes the same style key surface | Recompile; update any reference to the old key | +| Docs site | A styles reference page is published under the old name | Rename the page and update references to the new key | + +--- + +## Semver Decision + +**Target version**: `0.30.0` — the version of the active release branch (`release/schema-0.30.0+cli-0.27.0`). This ADR ships within that release and proposes no bump of its own. + +**Change class**: `MAJOR`-class (breaking). Renaming a named field within an exported type and renaming a schema property are both breaking changes under Constitution III and the Versioning rule ("`MAJOR` for any breaking change to a type signature, field name, field presence, or schema structure"). It MUST be called out as breaking in `CHANGELOG.md` under the release's entry. + +**Naming governance citation**: Constitution VI **rule 1** — 2+ code platforms agree. For the construct, UIKit, Android View, SwiftUI, and Compose agree on on/off. For the object-vs-boundary naming axis the code platforms tie, and the tiebreak is drawn from the schema's own vocabulary rather than from Figma. Rule 3 (defer to Figma) is **not** invoked anywhere in this ADR. + +--- + +## Consequences + +- `clipsContent` resolves against real data for the first time, so the style appears in spec output and the schema validates it. +- Consumers that map this style — including the CSS `overflow` mapping — begin producing output where they previously produced nothing. +- The boolean construct is now on the record as examined and justified, so a future proposal to widen it to an enum has a documented bar to clear: it must name extractable clipping modes beyond on/off. +- Scrolling remains unrepresentable in `Styles`. If the schema ever gains scroll semantics, the relationship between clipping and scrollability needs its own ADR. +- The name `clipContent` is gone with no alias; any consumer referencing it fails at compile time rather than silently matching nothing. +- Published spec documents produced before this change carry no `clipContent` key to migrate, since the key was never emitted. diff --git a/adr/INDEX.md b/adr/INDEX.md index 8b1cb36f..31015db1 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -5,36 +5,9 @@ | # | Title | Highlights | |---|-------|------------| | 070 | Explicit `position: ABSOLUTE` for Children of Non-Auto-Layout Parents | | -| 069 | Rename `clipContent` to `clipsContent` | | -| 066 | Lossless Key Formatting — Safe Key Grammar and Original-Name Preservation | | -| 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | -| 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | | -| 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | | -| 061 | Schema Entry Points for Concern-Split Output | | -| 059 | Border Style and Dash Pattern — `borderStyle` and `borderDashPattern` on `Styles` | | -| 058 | Wrapper Collapse Config Flag — `processing.wrapperCollapse` | | -| 057 | Fix `Metadata.generator.version` type: `number` → `string` | | -| 056 | Rename `SlotProp.minItems`/`maxItems` → `minChildren`/`maxChildren` | Align with Figma-native `slotSettings` field names; `anyOf` populated from `preferredValues` when `allowPreferredValuesOnly` is true | -| 055 | Variant State Classification via `processing.states` | | -| 054 | Workspace Schema File | | -| 053 | Transform Command and Configuration | | -| 052 | Deeply-Nested Slot Content — Path-Anchored Overrides Across Non-Slot Instance Boundaries | | -| 051 | Platform Code-Syntax Token Profiles | | -| 050 | Examples Config | Add `include.defaultSlotContent` (default false); add `processing.instanceExamples` with scope, match, exclude, parentNames — instance-example output is presence-driven (no `include.instanceExamples` flag), mirroring `subcomponents` | -| 049 | Nested Slot Compositions | Recursion follow-on to ADR-047: fill nested instances' slots from a parent context (reserved, draft on `042-composition-type` branch) | -| 048 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding` (reserved, draft on `042-composition-type` branch) | -| 047 | Slot Content — Component.slotContentExamples and SlotBinding | Add `Component.slotContentExamples: Record` + `SlotBinding` extending `PropBinding` with optional `examples?: SlotContentRef[]` (Figma authoring default at index 0); widens `Children` to `string[] \| SlotBinding` (reserved, draft on `042-composition-type` branch) | -| 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample` and `InstanceExamples`; add `Component.instanceExamples?` named record (reserved, draft on `042-composition-type` branch) | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | -| 043 | Custom Color Format Configuration | | -| 042 | Composition as a First-Class Type | | -| 041 | Layout Positioning — Constraint-Based Naming | | -| 035 | Make Config Properties with Defaults Optional | | -| 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | -| 025 | Flowing Content into a Nested Instance's Slot | Model parent components that flow defined content into a nested child instance's slot _(branch)_ | | 024 | Component Extends Relationship | Add `extends` field to express base/derived component relationships and prop/variant inheritance _(branch)_ | -| 022 | Add Nullable Support to SlotProp | Fix type-schema drift: add `nullable?: boolean` and widen `default` to `string \| null` on SlotProp | | 021 | Rename `invalidVariantCombinations` to `invalidPropConfigurations` | Shorten verbose field name and fix misleading "Variant" terminology — it describes prop configs, not variants _(branch)_ | | 020 | Classify Props by External vs Internal Ownership | Add ownership metadata to props distinguishing public API, dual-purpose, and interaction-driven properties _(branch)_ | @@ -42,15 +15,30 @@ | # | Title | Highlights | |---|-------|------------| +| 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` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | Narrow from `Style` to `TextAlignHorizontal \| null` (`'START' \| 'CENTER' \| 'END' \| 'JUSTIFY'`); Figma `LEFT`/`RIGHT`/`JUSTIFIED` remapped | | 063 | Image Content — `backgroundImage` fill, an `images` registry, `ImageProp`, and `ImageBinding` | Add `backgroundImage` fills, a `Component.images` registry (Figma identity + optional `src`), `ImageProp`/`ImageBinding`, and presence-switched `processing.images` | +| 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | Add `textOverflow` (`TextOverflow \| null`) and `maxLines` to `Styles`; not token-bindable | +| 061 | Schema Entry Points for Concern-Split Output | Split output across `root`, `components`, `component`, and `styles` schema entry points | | 060 | Subcomponent Figma Source Identity — `Subcomponent.source` | Add optional `SubcomponentSource` (`pageId`, `nodeId`, `nodeType`) to `Subcomponent`; enables reverse-direction tools to resolve `SubcomponentRef` to Figma nodes | | 059 | Stroke Dash Pattern — `strokeDashPattern` on `Styles` | Add `StrokeDashPattern { dash, gap }` structural type; presence = dashed stroke, null/absent = solid; not token-bindable | | 058 | Collapsing Wrapped Primitives — `processing.collapsePrimitiveWrapper` | Add optional boolean to `Config.processing` (default false); strips plain container wrappers around a single text/glyph child and promotes the leaf to spec root | | 057 | Fix `Metadata.generator.version` type: `number` → `string` | Corrects type mismatch — field holds semver strings (e.g. `"1.10.0"`) in all producers; was incorrectly typed as `number` | +| 056 | Rename `SlotProp.minItems`/`maxItems` → `minChildren`/`maxChildren` | Align with Figma-native `slotSettings` field names; `anyOf` populated from `preferredValues` when `allowPreferredValuesOnly` is true | | 055 | Variant State Classification via `processing.states` | Add `VariantStateEntry` type; add `Config.processing.states` — classifies Figma variant props as browser-driven or consumer-controlled for CSS selector and contract output | +| 054 | Workspace Schema File | Add `workspace.schema.json` describing `specs.config.yaml` — sources, output, and the `config` block | +| 053 | Transform Command and Configuration | Add `config.transformers` and the `specs transform` command for generating code artifacts from specs | +| 052 | Deeply-Nested Slot Content — Path-Anchored Overrides Across Non-Slot Instance Boundaries | Add the reserved `$nested` key on `PropConfigurations` for path-addressed overrides across instance boundaries | | 051 | Platform Code-Syntax Token Profiles | Add `FIGMA_SYNTAX_WEB`/`_IOS`/`_ANDROID` to `Config.format.tokens`, emitting per-platform Figma code syntax with fallback to `TOKEN` | +| 050 | Examples Config | Add `include.defaultSlotContent` (default false); add `processing.instanceExamples` with scope, match, exclude, parentNames | +| 049 | Nested Slot Compositions | Fill nested instances’ slots from a parent context; `PropConfigurationValue` accepts `SlotContentRef` under slot-prop keys | +| 048 | PropConfigurations PropBinding | Widen `PropConfigurationValue` to accept `PropBinding`, so a nested prop can pass through to a parent prop | +| 047 | Slot Content — Component.slotContentExamples and SlotBinding | Add `Component.slotContentExamples` and `SlotBinding` with `examples?: SlotContentRef[]`; widen `Children` | +| 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample`/`InstanceExamples` and `Component.instanceExamples` for documented whole-component usages | | 043 | Custom Color Format Configuration | Add `Config.format.color` with 9-format enum (HEX default); rename `ColorValue` → `ColorObject`; widen color types with `string` arm | +| 042 | Composition as a First-Class Type | Add `Composition` and `SlotContent` as first-class types carrying their own anatomy, elements, and layout | | 041 | Layout Positioning — Constraint-Based Naming | Replace `x`/`y`/`layoutPositioning` with constraint-based `position`, `start`, `end`, `top`, `bottom`, center offsets | | 040 | Replace `primaryAxisAlignItems` and `counterAxisAlignItems` with `mainAxisAlignment` and `crossAxisAlignment` | Rename to platform-neutral names; add `MainAxisAlignment` and `CrossAxisAlignment` enums; not token-bindable | | 039 | Replace `layoutWrap` and `counterAxisAlignContent` with `wrap` and `wrapAlignment` | Rename to platform-neutral names; add `WrapAlignment` enum (`START \| SPACE_BETWEEN`); not token-bindable | @@ -58,6 +46,7 @@ | 037 | Consolidate Item Spacing into a Bi-Axial Model | Replace `itemSpacing` + `counterAxisSpacing` with single `itemSpacing: Style \| ItemSpacing` using `{ horizontal, vertical }` | | 036 | Remove `name` and `baseline` from `Variant` | Remove unused `name` and `baseline` optional fields from `Variant` type and schema (breaking) | | 035 | Make Config Properties with Defaults Optional | Make 5 required Config properties optional with defaults; add `ResolvedConfig` type for fully-resolved shape | +| 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | | 033 | Typography fontFamily/fontStyle — Remove Number, Add TokenReference | Fix font fields: remove impossible `number` branch, add `TokenReference` for variable-bound font properties | | 032 | Typography leadingTrim — Correct to String Enum | Fix `leadingTrim` from incorrect `number \| "mixed"` to correct `"NONE" \| "CAP_HEIGHT" \| "mixed"` string enum | | 031 | Subcomponent Search Scope Config | Replace `subcomponentNamePattern` with structured `processing.subcomponents` object (`scope`, `match[]`, `exclude[]`) | @@ -67,6 +56,7 @@ | 027 | Code-Only Props | Surface Figma code-only props (a11y, semantics) in `props` with `$extensions` source kind `codeOnlyProp` | | 026 | Unify Platform-Specific Properties Under `$extensions` | Standardize on DTCG `$extensions` with reverse-domain keys for all platform metadata; remove `x-platform` | | 023 | Fix Schema Compliance Gaps | Fix 58 schema violations: optional SlotProp.default, `$`-prefix patternProperties, hex in ColorStyleValue, schema URL | +| 022 | Add Nullable Support to SlotProp | Fix type-schema drift: add `nullable?: boolean` and widen `default` to `string \| null` on SlotProp | | 019 | Allow null in StringProp.default | Widen `StringProp.default` from `string` to `string \| null` so nullable props can express `null` as their default | | 018 | Conditional Visible Binding | Add `Conditional` type with `if`/`condition`/`then`/`else` for declarative visibility derived from nullable props | | 017 | Rename `icon` Element Type to `glyph` | Rename `icon` to `glyph` in ElementType, `IconProp` to `GlyphProp`, `iconNamePattern` to `glyphNamePattern` | @@ -86,3 +76,9 @@ | 003 | Gradient Support for Color Style Properties | Add `GradientValue` (LINEAR/RADIAL/ANGULAR) discriminated union and `ColorStyle` type for gradient fills | | 002 | Replace `effectStyleId` with `effects` | Remove `effectStyleId`; add grouped `effects` key with `Shadow`, `Blur`, `Effects` types | | 001 | Surface License State in Component Output | Add optional `generator.license` (`status`, `level`) to Metadata for downstream entitlement gating | + +## Superseded + +| # | Title | Superseded by | +|---|-------|---------------| +| 025 | Flowing Content into a Nested Instance's Slot | ADR-042 composition and ADR-047 slot content, which model nested fills as first-class content rather than per-element flow | diff --git a/adr/adr-template.md b/adr/adr-template.md index 8426e020..21488c77 100644 --- a/adr/adr-template.md +++ b/adr/adr-template.md @@ -3,6 +3,7 @@ **Branch**: `[###-short-name]` **Created**: [DATE] **Status**: DRAFT +**Summary**: *(written at implementation — see `/specs.adr.implement`)* **Deciders**: [Author] (author), [name], [name] **Supersedes**: *(none, or link to prior ADR)* diff --git a/package-lock.json b/package-lock.json index 95286cd0..df52242e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "packages/cli" ], "dependencies": { - "@directededges/specs-from-figma": "file:../specs-from-figma" + "@directededges/specs-from-figma": "file:../specs-from-figma/packages/specs-from-figma" }, "devDependencies": { "typescript": "^5.3.3", @@ -18,11 +18,20 @@ } }, "../specs-from-figma": { + "name": "specs-from-figma-monorepo", + "dev": true, + "optional": true, + "peer": true, + "workspaces": [ + "packages/*" + ] + }, + "../specs-from-figma/packages/specs-from-figma": { "name": "@directededges/specs-from-figma", - "version": "0.28.0", + "version": "0.29.0", "license": "PolyForm-Internal-Use-1.0.0", "dependencies": { - "@directededges/specs-schema": "^0.29.0", + "@directededges/specs-schema": "file:../../../specs/packages/schema", "fs-extra": "^11.3.3", "yaml": "^2.8.0" }, @@ -32,13 +41,15 @@ "@figma/rest-api-spec": "^0.36.0", "@types/fs-extra": "^11.0.4", "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.25.5", "eslint": "^9.39.5", "javascript-obfuscator": "^5.3.0", "typescript": "^5.3.2", "typescript-eslint": "^8.64.0", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "ws": "^8.21.0" } }, "node_modules/@directededges/specs-cli": { @@ -46,7 +57,7 @@ "link": true }, "node_modules/@directededges/specs-from-figma": { - "resolved": "../specs-from-figma", + "resolved": "../specs-from-figma/packages/specs-from-figma", "link": true }, "node_modules/@directededges/specs-schema": { @@ -890,6 +901,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "dev": true, @@ -1606,6 +1627,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.3", "license": "ISC", @@ -1621,14 +1663,15 @@ }, "packages/cli": { "name": "@directededges/specs-cli", - "version": "0.26.0", + "version": "0.27.0", "license": "MIT", "dependencies": { - "@directededges/specs-from-figma": "^0.28.0", - "@directededges/specs-schema": "^0.29.0", + "@directededges/specs-from-figma": "file:../../../specs-from-figma/packages/specs-from-figma", + "@directededges/specs-schema": "file:../../../specs/packages/schema", "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2", + "ws": "^8.21.1", "yaml": "^2.3.4" }, "bin": { @@ -1637,6 +1680,7 @@ "devDependencies": { "@types/fs-extra": "^11.0.4", "@types/node": "^20.10.5", + "@types/ws": "^8.18.1", "esbuild": "^0.20.0", "typescript": "^5.3.3", "vitest": "^3.0.0" @@ -2070,7 +2114,7 @@ }, "packages/schema": { "name": "@directededges/specs-schema", - "version": "0.29.0", + "version": "0.30.0", "license": "CC-BY-4.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/package.json b/package.json index 86dc1a22..b96124ff 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,6 @@ "vitest": "^3.1.1" }, "dependencies": { - "@directededges/specs-from-figma": "file:../specs-from-figma" + "@directededges/specs-from-figma": "^0.29.0" } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index fd265207..73fff7a7 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,12 +5,58 @@ 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). -## Unreleased +## [0.27.0] - 2026-08-17 + +Specs can now go back into Figma. `specs render` takes a spec you already have and builds +the component it describes in a connected Figma file — the reverse of `specs generate`, and +the other half of a round trip you can run and re-run as a spec changes. ### Added +- **`specs render` — build a component in Figma from a spec.** Point it at a spec file, a + component folder, or a directory of them; with no argument it renders everything in your + configured `outputDirectory`. Variants, styles, props, slots, subcomponents, and icons are + all reconstructed, with tokens bound to the file's own variables and styles. + - `--watch` re-renders on every save, so you can edit a spec and watch the component change. + - `--overwrite` replaces a component of the same name. Without it, a name collision is an + error rather than a silent deletion. + - `--page ` renders onto a specific page instead of whichever one is open — worth using + in any script, since the open page can move underneath you. + - `--strict` fails the run when an element cannot be resolved, instead of producing a + component with content missing. + - `--timing` reports where the time went, phase by phase. +- **`specs bridge` — the connection render works over.** `start`, `stop`, and `status` for a + local server the Figma plugin connects to. Figma cannot be reached from outside, so the + plugin opens the connection and the CLI talks through it. Enable the CLI Bridge in the + plugin, and `specs bridge status` will name the file it is connected to. Several Figma + files can be connected at once; `--file ` picks one, and in an interactive + terminal you are offered a numbered list instead of an error. +- **`specs cache` — the lookup tables render needs.** Every component, icon, style, and + variable name in your fetched data, resolved to what Figma needs to place it. Built from + `specs fetch` output, refreshed with `--force`, or rebuilt in place with + `specs render --refresh-cache`. A stale cache fails the render rather than rendering + something subtly wrong. +- **`specs generate --from-bridge` — read a spec from what's selected in Figma.** A third + source for `generate` alongside a fetched file and a manifest, with no REST fetch and no + Figma token: select a component in a connected file and generate its spec directly. Your + config governs the result, exactly as it does for a fetched generate, and the run leaves + the Figma file untouched. Output follows the same `--output` / `--split-components` / + `--split-concerns` resolution as every other source. +- **`specs fetch --only ` now narrows by data kind as well as by source.** `--only icons` re-downloads just the icon SVGs, deriving them from the file payload already on disk rather than pulling the whole file again; `--only variables,styles` skips the file entirely. A source alias still works as before, and the two combine (`--only library,icons`). A name that matches neither is an error listing both the configured aliases and the available kinds, rather than being quietly ignored. +- `format.figmaKeys` in the generated `specs.config.yaml` template — commented out at its `NONE` default, documenting the opt-in that enables the safe key grammar and Figma name preservation (ADR-066) +- `specs analyze keys` — reports Figma layer and property names a formatted key cannot reconstruct, written to `_analysis/keys.yaml`. Organized `byComponent` as a designer's checklist — each component splitting into `props` and `anatomy`, with an empty surface omitted — then `byCause` for systemic problems and `byName` for a name repeated across the library. Requires `format.figmaKeys` to declare a convention; empty under the `NONE` default (ADR-066) - **`specs fetch` icons** — a fourth data kind alongside `file`, `variables`, and `styles`: fetch derives the library's icon glyphs from the downloaded file payload (no `scan` required) and downloads their SVG assets with stable kebab-case slugs. +### Changed + +- CSS `overflow` output — keyed on the renamed `Styles.clipsContent`, so `overflow: hidden` / `overflow: visible` is emitted for the first time (ADR-069) + +### Dependency updates + +- **`@directededges/specs-schema` ^0.30.0** — specs can now record the name a designer sees in Figma alongside a formatted key, so a key that cannot reconstruct its original name no longer loses it. Number properties gained the same platform-extension capability every other property type already had, and the style that records whether an element clips its content is now spelled `clipsContent`. +- **`@directededges/specs-from-figma` ^0.29.0** — generated specs carry Figma names wherever a key diverges, and a name already well-formed in your key convention is kept as authored instead of being reformatted. Several sources of phantom variants are gone: slot content no longer records measured sizes or withdrawn host bindings as design intent, an invalid variant combination now names the combination that is actually missing, and elements that clip their content report it for the first time — expect regenerated specs to gain `clipsContent: true` on containers that clip, which is common rather than rare. + + ## [0.26.0] - 2026-08-07 Two new `specs analyze` reports help you understand a design system at scale. `specs analyze dependencies` maps how components compose one another — instances, slot constraints, and example compositions — so you can answer "what's the blast radius of changing this component?" and "which props does anyone actually use?" `specs analyze styling` now also flags tokens nobody references, surfacing dead variables and styles you can safely retire. diff --git a/packages/cli/package.json b/packages/cli/package.json index 2f392757..a5dd491b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.26.0", + "version": "0.27.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", @@ -15,21 +15,24 @@ "docs" ], "scripts": { - "build": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --banner:js='#!/usr/bin/env node' && chmod +x dist/specs.js", + "build": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --banner:js='#!/usr/bin/env node' && chmod +x dist/specs.js && npm run build:bridge-server", + "build:bridge-server": "esbuild src/bridge/server.ts --bundle --platform=node --target=node18 --format=esm --packages=external --outfile=dist/bridge-server.js && chmod +x dist/bridge-server.js", "watch": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --watch", "test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests" }, "dependencies": { - "@directededges/specs-schema": "^0.29.0", - "@directededges/specs-from-figma": "^0.28.0", + "@directededges/specs-schema": "^0.30.0", + "@directededges/specs-from-figma": "^0.29.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", - "yaml": "^2.3.4", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "ws": "^8.21.1", + "yaml": "^2.3.4" }, "devDependencies": { - "@types/node": "^20.10.5", "@types/fs-extra": "^11.0.4", + "@types/node": "^20.10.5", + "@types/ws": "^8.18.1", "esbuild": "^0.20.0", "typescript": "^5.3.3", "vitest": "^3.0.0" diff --git a/packages/cli/src/Cache/Cache.ts b/packages/cli/src/Cache/Cache.ts new file mode 100644 index 00000000..8d098814 --- /dev/null +++ b/packages/cli/src/Cache/Cache.ts @@ -0,0 +1,383 @@ +/** + * Render lookup caches. + * + * Render resolves a spec's references — component ids, style names, token names, glyph + * names — against data fetched from Figma. That data arrives as whole API responses: a + * file payload can be hundreds of megabytes, of which render needs a few hundred small + * entries. Re-deriving those entries per render meant parsing the whole payload every + * time, which cost seconds before any Figma work began. + * + * These caches are those entries, extracted once and written to `/cache/` as + * four merged YAML files — one per concern, covering every fetched library: + * + * | file | maps | built from | + * |-------------------|-----------------------------------|------------------------| + * | `components.yaml` | node id → published key + name | `.file.json` | + * | `styles.yaml` | style name → key + type | `.file.json` | + * | `variables.yaml` | token name → key, id, published | `.variables.json` | + * | `icons.yaml` | glyph name → node id + key | `.file.json` | + * + * Merged rather than per-library, because render wants one lookup, not N. Each entry + * records the alias it came from: node ids are file-scoped, so knowing an entry's origin + * is what lets render decide whether an id is usable at all — and it lets one library be + * rebuilt without re-parsing the rest. + * + * Every file carries a `sources` block naming the payload each alias was built from, with + * its size and mtime. That block is the staleness check: a `stat` per source, and any + * mismatch means the cache no longer describes the data on disk. Render validates and + * fails; `specs cache` rebuilds. + * + * @packageDocumentation + */ + +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { parse, stringify } from 'yaml'; +import { buildVariablesIndex } from '../utilities/variablesIndex.js'; +import { collectGlyphComponents } from '../utilities/glyphComponents.js'; + +/** The four caches, by concern. Order is display order for reporting. */ +export const CACHE_CONCERNS = ['components', 'styles', 'variables', 'icons'] as const; +export type CacheConcern = (typeof CACHE_CONCERNS)[number]; + +/** What one alias's slice of a cache was built from — the staleness check's input. */ +export interface CacheSource { + /** Payload file name, relative to the data directory. */ + from: string; + bytes: number; + mtime: string; + /** icons.yaml only: the pattern glyph names were extracted with. A config edit to the + * pattern changes what the entries mean, with no change to any fetched file. */ + glyphNamePattern?: string; +} + +export interface CacheFile { + /** Alias → the payload that alias's entries came from. */ + sources: Record; + entries: Record; +} + +/** + * Component/component-set node id → its published key and raw Figma name. Keyless nodes are + * omitted: without a key there is nothing to record beyond the id the caller already holds. + * + * The name is what lets the bridge resolve an `instanceOf` value naming a component the + * workspace has no spec for. A spec records `instanceOf` as a formatted key, and that + * transform is lossy — "DS Link/On overlay/M" and "DS Link On Overlay M" format + * identically — so the match is made by formatting these names the same way rather than + * by inverting the key. Stored raw, and formatted at manifest-build time, so a change to + * `format.keys` in config needs no cache rebuild. + */ +export interface ComponentsEntry { key: string; name: string; file: string } +export interface StylesEntry { key: string; type: 'FILL' | 'TEXT' | 'EFFECT'; file: string } +export interface VariablesEntry { key: string; id: string; published: boolean; file: string } +export interface IconsEntry { id: string; key?: string; file: string } + +export type AnyCacheEntry = ComponentsEntry | StylesEntry | VariablesEntry | IconsEntry; + +/** One alias's contribution to all four caches. */ +interface AliasSlice { + components: { source: CacheSource | null; entries: Record }; + styles: { source: CacheSource | null; entries: Record }; + variables: { source: CacheSource | null; entries: Record }; + icons: { source: CacheSource | null; entries: Record }; +} + +export interface CacheOptions { + dataDir: string; + /** Aliases declared in config, in declaration order. */ + aliases: string[]; + glyphNamePattern?: string; + /** Rebuild every alias, whether or not its provenance still matches. */ + force?: boolean; +} + +export interface CacheReport { + /** Aliases whose entries were re-derived. */ + rebuilt: string[]; + /** Aliases whose cached provenance still matched the payload on disk. */ + current: string[]; + /** Aliases declared in config with no fetched payload — skipped, not an error here. */ + unfetched: string[]; + /** Entry counts per concern, after the rebuild. */ + counts: Record; +} + +// ── Paths ───────────────────────────────────────────────────────────────────── + +export function cacheDir(dataDir: string): string { + return join(dataDir, 'cache'); +} + +export function cachePath(dataDir: string, concern: CacheConcern): string { + return join(cacheDir(dataDir), `${concern}.yaml`); +} + +// ── Read / write ────────────────────────────────────────────────────────────── + +export function readCacheFile(dataDir: string, concern: CacheConcern): CacheFile | null { + const path = cachePath(dataDir, concern); + if (!existsSync(path)) return null; + try { + const parsed = parse(readFileSync(path, 'utf8')) as CacheFile | null; + if (!parsed || typeof parsed !== 'object') return null; + return { sources: parsed.sources ?? {}, entries: parsed.entries ?? {} }; + } catch { + return null; // unreadable is indistinguishable from absent, and both mean "rebuild" + } +} + +function writeCacheFile(dataDir: string, concern: CacheConcern, data: CacheFile): void { + mkdirSync(cacheDir(dataDir), { recursive: true }); + const header = + `# Generated by \`specs cache\` — derived from fetched Figma data, safe to delete.\n` + + `# Rebuild with \`specs cache\`. Do not edit by hand.\n`; + writeFileSync(cachePath(dataDir, concern), header + stringify(data, { lineWidth: 0 }), 'utf8'); +} + +// ── Provenance ──────────────────────────────────────────────────────────────── + +function sourceOf(dataDir: string, fileName: string, glyphNamePattern?: string): CacheSource | null { + const path = join(dataDir, fileName); + if (!existsSync(path)) return null; + const stat = statSync(path); + return { + from: fileName, + bytes: stat.size, + mtime: stat.mtime.toISOString(), + ...(glyphNamePattern ? { glyphNamePattern } : {}), + }; +} + +/** True when a recorded source still describes the file on disk. A payload that has been + * re-fetched, or a glyph pattern that has been edited in config, fails this. */ +function matches(recorded: CacheSource | undefined, current: CacheSource | null): boolean { + if (!recorded || !current) return false; + return recorded.from === current.from + && recorded.bytes === current.bytes + && recorded.mtime === current.mtime + && recorded.glyphNamePattern === current.glyphNamePattern; +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +function readJson(path: string): Record | null { + try { + return JSON.parse(readFileSync(path, 'utf8')) as Record; + } catch { + return null; + } +} + +/** + * Derive one alias's entries for all four concerns. The file payload is parsed once and + * feeds three of them; the variables payload is separate and much smaller. + */ +function buildAliasSlice(alias: string, dataDir: string, glyphNamePattern?: string): AliasSlice { + const empty: AliasSlice = { + components: { source: null, entries: {} }, + styles: { source: null, entries: {} }, + variables: { source: null, entries: {} }, + icons: { source: null, entries: {} }, + }; + + const fileName = `${alias}.file.json`; + const fileSource = sourceOf(dataDir, fileName); + if (fileSource) { + const data = readJson(join(dataDir, fileName)); + if (data) { + const refs = { + ...((data.components as Record | undefined) ?? {}), + ...((data.componentSets as Record | undefined) ?? {}), + }; + for (const [id, meta] of Object.entries(refs)) { + if (meta?.key) empty.components.entries[id] = { key: meta.key, name: meta.name ?? '', file: alias }; + } + empty.components.source = fileSource; + + const styles = (data.styles as Record | undefined) ?? {}; + for (const meta of Object.values(styles)) { + const type = meta.styleType; + if ((type === 'EFFECT' || type === 'TEXT' || type === 'FILL') && meta.name && meta.key) { + empty.styles.entries[meta.name] = { key: meta.key, type, file: alias }; + } + } + empty.styles.source = fileSource; + + // An unset pattern means this workspace has no glyph convention — the cache is + // written empty rather than skipped, so "no glyphs" stays distinguishable from + // "never built". + empty.icons.source = sourceOf(dataDir, fileName, glyphNamePattern); + if (glyphNamePattern) { + for (const glyph of collectGlyphComponents(data.document, glyphNamePattern)) { + if (empty.icons.entries[glyph.name]) continue; // first occurrence wins, as scan does + const key = refs[glyph.id]?.key; + empty.icons.entries[glyph.name] = key ? { id: glyph.id, key, file: alias } : { id: glyph.id, file: alias }; + } + } + } + } + + const variablesName = `${alias}.variables.json`; + const variablesSource = sourceOf(dataDir, variablesName); + if (variablesSource) { + const data = readJson(join(dataDir, variablesName)); + if (data) { + const index = buildVariablesIndex((data.meta ? data : { meta: data }) as Parameters[0]); + for (const [name, entry] of Object.entries(index)) { + empty.variables.entries[name] = { ...entry, file: alias }; + } + empty.variables.source = variablesSource; + } + } + + return empty; +} + +// ── Refresh ─────────────────────────────────────────────────────────────────── + +/** + * Bring the caches in line with the fetched data on disk. + * + * An alias whose recorded provenance still matches is left alone — its entries are copied + * forward untouched — so refreshing after fetching one library parses that library only. + * An alias with no fetched payload is skipped rather than reported as an error: not having + * fetched something yet is a normal state for this command, and only render is in a + * position to call it a problem. + */ +export function refreshCache(options: CacheOptions): CacheReport { + const { dataDir, aliases, glyphNamePattern, force } = options; + + const existing = { + components: readCacheFile(dataDir, 'components'), + styles: readCacheFile(dataDir, 'styles'), + variables: readCacheFile(dataDir, 'variables'), + icons: readCacheFile(dataDir, 'icons'), + }; + + const next: { [K in CacheConcern]: CacheFile } = { + components: { sources: {}, entries: {} }, + styles: { sources: {}, entries: {} }, + variables: { sources: {}, entries: {} }, + icons: { sources: {}, entries: {} }, + }; + + const report: CacheReport = { + rebuilt: [], + current: [], + unfetched: [], + counts: { components: 0, styles: 0, variables: 0, icons: 0 }, + }; + + for (const alias of aliases) { + const hasFile = existsSync(join(dataDir, `${alias}.file.json`)); + const hasVariables = existsSync(join(dataDir, `${alias}.variables.json`)); + if (!hasFile && !hasVariables) { + report.unfetched.push(alias); + continue; + } + + const currentSources = { + components: sourceOf(dataDir, `${alias}.file.json`), + styles: sourceOf(dataDir, `${alias}.file.json`), + variables: sourceOf(dataDir, `${alias}.variables.json`), + icons: sourceOf(dataDir, `${alias}.file.json`, glyphNamePattern), + }; + + const stale = force || CACHE_CONCERNS.some(concern => { + const current = currentSources[concern]; + if (!current) return false; // that payload isn't fetched — nothing to be stale about + return !matches(existing[concern]?.sources[alias], current); + }); + + if (!stale) { + for (const concern of CACHE_CONCERNS) { + const from = existing[concern]; + if (!from?.sources[alias]) continue; + next[concern].sources[alias] = from.sources[alias]; + for (const [key, entry] of Object.entries(from.entries)) { + if ((entry as { file?: string }).file === alias) next[concern].entries[key] = entry; + } + } + report.current.push(alias); + continue; + } + + const slice = buildAliasSlice(alias, dataDir, glyphNamePattern); + for (const concern of CACHE_CONCERNS) { + const built = slice[concern]; + if (!built.source) continue; + next[concern].sources[alias] = built.source; + Object.assign(next[concern].entries, built.entries); + } + report.rebuilt.push(alias); + } + + for (const concern of CACHE_CONCERNS) { + writeCacheFile(dataDir, concern, next[concern]); + report.counts[concern] = Object.keys(next[concern].entries).length; + } + + return report; +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +export interface CacheProblem { + concern: CacheConcern; + alias: string; + reason: 'missing' | 'stale'; +} + +/** + * Check the caches against the payloads on disk, without writing anything. Every declared + * alias must be present and current in every cache whose payload it has fetched — render + * calls this and refuses to run on any problem, because a stale lookup binds a spec to the + * wrong variable or drops an instance, which surfaces far from its cause. + */ +export function validateCache(options: Omit): CacheProblem[] { + const { dataDir, aliases, glyphNamePattern } = options; + const problems: CacheProblem[] = []; + + const files = { + components: readCacheFile(dataDir, 'components'), + styles: readCacheFile(dataDir, 'styles'), + variables: readCacheFile(dataDir, 'variables'), + icons: readCacheFile(dataDir, 'icons'), + }; + + for (const alias of aliases) { + const currentSources = { + components: sourceOf(dataDir, `${alias}.file.json`), + styles: sourceOf(dataDir, `${alias}.file.json`), + variables: sourceOf(dataDir, `${alias}.variables.json`), + icons: sourceOf(dataDir, `${alias}.file.json`, glyphNamePattern), + }; + + for (const concern of CACHE_CONCERNS) { + const current = currentSources[concern]; + if (!current) continue; // payload not fetched for this alias — nothing to validate + const file = files[concern]; + if (!file || !file.sources[alias]) { + problems.push({ concern, alias, reason: 'missing' }); + continue; + } + if (!matches(file.sources[alias], current)) { + problems.push({ concern, alias, reason: 'stale' }); + } + } + } + + return problems; +} + +/** One line per problem, plus the command that fixes them. */ +export function describeProblems(problems: CacheProblem[]): string { + const lines = problems.map(p => ` - ${p.concern}.yaml: "${p.alias}" is ${p.reason}`); + return [ + `Render cache is not usable:`, + ...lines, + ``, + `Run \`specs cache\` to rebuild it.`, + ].join('\n'); +} diff --git a/packages/cli/src/Config/ConfigTemplates.ts b/packages/cli/src/Config/ConfigTemplates.ts index 96be015e..7fea335d 100644 --- a/packages/cli/src/Config/ConfigTemplates.ts +++ b/packages/cli/src/Config/ConfigTemplates.ts @@ -62,6 +62,13 @@ config: # 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 diff --git a/packages/cli/src/Render/SpecLoader.ts b/packages/cli/src/Render/SpecLoader.ts new file mode 100644 index 00000000..e5e3177f --- /dev/null +++ b/packages/cli/src/Render/SpecLoader.ts @@ -0,0 +1,147 @@ +/** + * Loads a render input from disk, resolving two supported shapes: + * - a single spec file (.yaml/.yml/.json) — combined component data, as + * produced by `generate` without --split-concerns + * - a split-concerns component folder — api.(yaml|json) + variants.(yaml|json) + * + optional examples.(yaml|json), as produced by + * `generate --split-components --split-concerns` + * - a parent directory of those — see `findComponentFolders`, which collects + * every component folder beneath it for a batch render + * + * Format (JSON vs YAML) is detected per-file by extension. The result is + * always a single merged, in-memory component spec — the bridge/plugin never + * sees the on-disk shape. + */ + +import fs from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +export interface LoadedSpec { + spec: Record; + /** Absolute path used for workspace (specsDir/dataDir) derivation. */ + resolvePath: string; +} + +const CONCERN_EXTENSIONS = ['.yaml', '.yml', '.json']; + +function parseByExtension(filePath: string, raw: string): Record { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.json') return JSON.parse(raw); + if (ext === '.yaml' || ext === '.yml') return parseYaml(raw); + throw new Error(`Unsupported spec file extension "${ext}" (expected .yaml, .yml, or .json): ${filePath}`); +} + +function readSpecFile(filePath: string): Record { + const raw = fs.readFileSync(filePath, 'utf8'); + return parseByExtension(filePath, raw); +} + +function findConcernFile(dir: string, name: string): string | null { + for (const ext of CONCERN_EXTENSIONS) { + const candidate = path.join(dir, `${name}${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Recursively recombines api/variants/examples concern data (as split by + * `splitComponentByConcern` in the generate pipeline) back into one component + * object. Each concern's `metadata` is identical at the source, so last-write + * wins is safe. `subcomponents` are merged per-key rather than overwritten, + * since each concern only carries its own slice of subcomponent fields. + */ +function mergeConcerns( + api: Record, + variants: Record, + examples?: Record +): Record { + const merged: Record = { ...api, ...variants, ...(examples ?? {}) }; + + const subKeys = new Set([ + ...Object.keys(api.subcomponents ?? {}), + ...Object.keys(variants.subcomponents ?? {}), + ...Object.keys(examples?.subcomponents ?? {}), + ]); + + if (subKeys.size > 0) { + merged.subcomponents = {}; + for (const key of subKeys) { + merged.subcomponents[key] = mergeConcerns( + api.subcomponents?.[key] ?? {}, + variants.subcomponents?.[key] ?? {}, + examples?.subcomponents?.[key] + ); + } + } else { + delete merged.subcomponents; + } + + return merged; +} + +function loadSplitConcernsFolder(dir: string): Record { + const apiPath = findConcernFile(dir, 'api'); + const variantsPath = findConcernFile(dir, 'variants'); + const examplesPath = findConcernFile(dir, 'examples'); + + if (!apiPath || !variantsPath) { + throw new Error( + `Expected api.(yaml|json) and variants.(yaml|json) in split-concerns component folder: ${dir}` + ); + } + + const api = readSpecFile(apiPath); + const variants = readSpecFile(variantsPath); + const examples = examplesPath ? readSpecFile(examplesPath) : undefined; + + return mergeConcerns(api, variants, examples); +} + +/** A directory is a component folder when it carries both required concerns. */ +export function isComponentFolder(dir: string): boolean { + return Boolean(findConcernFile(dir, 'api') && findConcernFile(dir, 'variants')); +} + +/** + * How far below a parent directory component folders are looked for. 1 covers + * the flat `specs/deButton/` layout; 2 also covers one level of grouping, + * `specs/forms/deInput/`. Deeper nesting is intentionally not scanned — a batch + * render should stay predictable about what it will touch. + */ +const MAX_SCAN_DEPTH = 2; + +/** + * Collect every component folder at or beneath `dir`, sorted by path for a + * deterministic render order. If `dir` is itself a component folder it is the + * only result — scanning never descends into a component. + */ +export function findComponentFolders(dir: string, maxDepth = MAX_SCAN_DEPTH): string[] { + if (isComponentFolder(dir)) return [dir]; + if (maxDepth < 1) return []; + + const found: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + found.push(...findComponentFolders(path.join(dir, entry.name), maxDepth - 1)); + } + return found.sort(); +} + +/** + * Load a render input (single spec file or split-concerns component folder) + * into a single in-memory spec object, ready to send to the bridge. + */ +export function loadSpec(rawPath: string): LoadedSpec { + const absPath = path.resolve(rawPath); + + if (!fs.existsSync(absPath)) { + throw new Error(`Spec path not found: ${absPath}`); + } + + const isDirectory = fs.statSync(absPath).isDirectory(); + const spec = isDirectory ? loadSplitConcernsFolder(absPath) : readSpecFile(absPath); + + return { spec, resolvePath: absPath }; +} diff --git a/packages/cli/src/analyzers/Keys.ts b/packages/cli/src/analyzers/Keys.ts new file mode 100644 index 00000000..b33c7fbd --- /dev/null +++ b/packages/cli/src/analyzers/Keys.ts @@ -0,0 +1,271 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import type { Transformer, TransformerContext } from '../Types/Transformer.js'; + +/** + * Reports Figma layer and property names that a formatted key cannot reconstruct + * (ADR-066), so they can be tidied at the source. + * + * Reads generated specs, and therefore sees a name only where the producer recorded it + * in `$extensions['com.figma'].name`. That happens when `format.figmaKeys` declares a + * source convention — under the `NONE` default nothing diverges and this report is + * empty, which is correct: no convention was declared, so no name departed from one. + * + * A recorded name is NOT by itself a naming problem. The field has two independent + * triggers: format divergence (ADR-066) and wrapper-collapse provenance (ADR-058), and + * the latter fires on the `root` key regardless of `figmaKeys` or of how well-formed + * the name is. `Text` on a collapsed root is perfectly safe under SENTENCE and + * round-trips unaided. So each recorded name is re-tested against the safe key grammar + * here, and only genuine failures are reported. + */ + +type Surface = 'anatomy' | 'prop'; + +type DeclaredConvention = 'SENTENCE' | 'TITLE'; + +/** + * The safe key grammar. These mirror the `SafeKeySentence` and `SafeKeyTitle` + * definitions in `component.schema.json`, which remain the contract — kept as literals + * here because the CLI bundles to a single file and reading the schema JSON at runtime + * would make the report depend on a resolvable package path. If the schema patterns + * change, change these. + */ +const SAFE_KEY_PATTERNS: Record = { + SENTENCE: /^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$/, + TITLE: /^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$/, +}; + +/** Why a Figma name falls outside the safe key grammar. First match wins. */ +type Cause = + | 'separator' + | 'symbol' + | 'non-ascii' + | 'mixed-letter-digit' + | 'digit-initial' + | 'casing' + | 'already-a-key'; + +interface NameEntry { + key: string; + figmaName: string; + cause: Cause; +} + +/** Empty surfaces are omitted rather than emitted as `[]`, to keep the checklist quiet. */ +interface ComponentEntry { + divergent: number; + props?: NameEntry[]; + anatomy?: NameEntry[]; +} + +interface CauseEntry { + cause: Cause; + occurrences: number; + names: string[]; +} + +interface NameFrequencyEntry { + figmaName: string; + occurrences: number; + components: string[]; + cause: Cause; +} + +interface KeysAggregate { + summary: { + totalComponents: number; + componentsWithDivergence: number; + totalNames: number; + divergentNames: number; + causeDistribution: Record; + }; + byComponent: Record; + byCause: CauseEntry[]; + byName: NameFrequencyEntry[]; +} + +interface Collected extends NameEntry { + component: string; + surface: Surface; +} + +export class KeysAnalyzer implements Transformer { + readonly name = 'keys'; + + private readonly _divergent: Collected[] = []; + 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. */ + private _convention: DeclaredConvention | undefined; + + async run(apiYaml: Record, context: TransformerContext): Promise { + const { componentKey, outputFormat } = context; + this._outputFormat = outputFormat; + this._components.add(componentKey); + this._convention = declaredConvention(apiYaml) ?? this._convention; + + this.collect(componentKey, apiYaml); + + const subcomponents = (apiYaml.subcomponents ?? {}) as Record; + for (const [subName, subRaw] of Object.entries(subcomponents)) { + this.collect(`${componentKey}.${subName}`, subRaw as Record); + } + } + + /** + * Walks a component's anatomy and props. Compositions and slot content carry their own + * nested anatomy, so those are walked too — the producer records names at every depth. + */ + private collect(component: string, comp: Record): void { + this.collectAnatomy(component, comp.anatomy); + this.collectProps(component, comp.props); + + const slotContent = (comp.slotContentExamples ?? {}) as Record; + for (const entry of Object.values(slotContent)) { + this.collectAnatomy(component, (entry as Record)?.anatomy); + } + + const compositions = (comp.compositions ?? {}) as Record; + for (const entry of Object.values(compositions)) { + this.collectAnatomy(component, (entry as Record)?.anatomy); + } + } + + private collectAnatomy(component: string, anatomy: unknown): void { + for (const [key, raw] of Object.entries((anatomy ?? {}) as Record)) { + this._totalNames++; + this.record(component, key, 'anatomy', figmaNameOf(raw)); + } + } + + private collectProps(component: string, props: unknown): void { + for (const [key, raw] of Object.entries((props ?? {}) as Record)) { + this._totalNames++; + this.record(component, key, 'prop', figmaNameOf(raw)); + } + } + + /** + * Records a name only if it actually fails the declared grammar. A recorded name that + * passes was written for the other reason the field exists — wrapper-collapse + * provenance — and is not a naming problem: it reconstructs from its key unaided. + */ + private record(component: string, key: string, surface: Surface, figmaName: string | undefined): void { + if (!figmaName || !this._convention) return; + if (SAFE_KEY_PATTERNS[this._convention].test(figmaName)) return; + this._divergent.push({ component, key, figmaName, surface, cause: causeOf(figmaName) }); + } + + async finalize(outputDir: string, analysisDir?: string): Promise { + if (this._totalNames === 0) return; + + const outDir = analysisDir ?? path.join(outputDir, '_analysis'); + await fs.ensureDir(outDir); + + const aggregate = this.buildAggregate(); + const ext = this._outputFormat === 'JSON' ? 'json' : 'yaml'; + const content = this._outputFormat === 'JSON' + ? JSON.stringify(aggregate, null, 2) + '\n' + : yaml.stringify(aggregate, { lineWidth: 120 }); + await fs.writeFile(path.join(outDir, `keys.${ext}`), content, 'utf-8'); + } + + private buildAggregate(): KeysAggregate { + const surfaces = new Map(); + for (const entry of this._divergent) { + const bucket = surfaces.get(entry.component) + ?? surfaces.set(entry.component, { props: [], anatomy: [] }).get(entry.component)!; + const list = entry.surface === 'prop' ? bucket.props : bucket.anatomy; + list.push({ key: entry.key, figmaName: entry.figmaName, cause: entry.cause }); + } + const byComponent: Record = {}; + for (const [component, { props, anatomy }] of surfaces) { + props.sort((a, b) => a.figmaName.localeCompare(b.figmaName)); + anatomy.sort((a, b) => a.figmaName.localeCompare(b.figmaName)); + byComponent[component] = { + divergent: props.length + anatomy.length, + ...(props.length ? { props } : {}), + ...(anatomy.length ? { anatomy } : {}), + }; + } + + const causeDistribution: Record = {}; + const causeNames = new Map>(); + for (const entry of this._divergent) { + causeDistribution[entry.cause] = (causeDistribution[entry.cause] ?? 0) + 1; + (causeNames.get(entry.cause) ?? causeNames.set(entry.cause, new Set()).get(entry.cause)!).add(entry.figmaName); + } + const byCause: CauseEntry[] = [...causeNames.entries()] + .map(([cause, names]) => ({ cause, occurrences: causeDistribution[cause], names: [...names].sort() })) + .sort((a, b) => b.occurrences - a.occurrences); + + // A name wrong in twelve components is one decision, not twelve — which is what a + // per-component checklist cannot show on its own. + const frequency = new Map; occurrences: number; cause: Cause }>(); + for (const entry of this._divergent) { + const record = frequency.get(entry.figmaName) + ?? { components: new Set(), occurrences: 0, cause: entry.cause }; + record.components.add(entry.component); + record.occurrences++; + frequency.set(entry.figmaName, record); + } + const byName: NameFrequencyEntry[] = [...frequency.entries()] + .map(([figmaName, r]) => ({ + figmaName, + occurrences: r.occurrences, + components: [...r.components].sort(), + cause: r.cause, + })) + .sort((a, b) => b.occurrences - a.occurrences || a.figmaName.localeCompare(b.figmaName)); + + return { + summary: { + totalComponents: this._components.size, + componentsWithDivergence: Object.keys(byComponent).length, + totalNames: this._totalNames, + divergentNames: this._divergent.length, + causeDistribution, + }, + byComponent: Object.fromEntries(Object.entries(byComponent).sort(([a], [b]) => a.localeCompare(b))), + byCause, + byName, + }; + } +} + +function figmaNameOf(raw: unknown): string | undefined { + const extensions = (raw as Record)?.$extensions as Record | undefined; + const figma = extensions?.['com.figma'] as Record | undefined; + const name = figma?.name; + return typeof name === 'string' ? name : undefined; +} + +/** + * Classifies why a name diverged. Ordered most-specific first: a name with both a + * symbol and odd casing is reported as a symbol problem, because that is the edit to + * make. `already-a-key` is last — it is not a defect, just a name authored in the + * spec's own convention rather than as a Figma display name. + */ +function causeOf(name: string): Cause { + if (/[\-_]/.test(name) && !/\s/.test(name)) return 'already-a-key'; + if (/[^\x20-\x7E]/.test(name)) return 'non-ascii'; + if (/[^A-Za-z0-9 ]/.test(name)) return 'symbol'; + if (/\s\s|^\s|\s$|[\t\n]/.test(name)) return 'separator'; + if (/[A-Za-z][0-9]|[0-9][A-Za-z]/.test(name)) return 'mixed-letter-digit'; + if (/^[0-9]/.test(name)) return 'digit-initial'; + // Characters and word shape are all fine, so the only rule left to fail is casing. + // Reached only for names that already failed the grammar, so this is a verdict rather + // than a catch-all — a well-formed name never gets here. + return 'casing'; +} + +/** The convention the spec was generated under, from its own `metadata.config`. */ +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; + return value === 'SENTENCE' || value === 'TITLE' ? value : undefined; +} diff --git a/packages/cli/src/analyzers/index.ts b/packages/cli/src/analyzers/index.ts index 66930428..a33f0024 100644 --- a/packages/cli/src/analyzers/index.ts +++ b/packages/cli/src/analyzers/index.ts @@ -1,5 +1,6 @@ import type { Transformer } from '../Types/Transformer.js'; import { DependenciesAnalyzer } from './Dependencies.js'; +import { KeysAnalyzer } from './Keys.js'; import { PropsAnalyzer } from './Props.js'; import { StylingAnalyzer } from './Styling.js'; @@ -7,6 +8,7 @@ const ALL_ANALYZERS: Transformer[] = [ new PropsAnalyzer(), new StylingAnalyzer(), new DependenciesAnalyzer(), + new KeysAnalyzer(), ]; const BY_NAME = new Map(ALL_ANALYZERS.map(a => [a.name, a])); diff --git a/packages/cli/src/bridge/client.ts b/packages/cli/src/bridge/client.ts new file mode 100644 index 00000000..24804b69 --- /dev/null +++ b/packages/cli/src/bridge/client.ts @@ -0,0 +1,103 @@ +// Thin HTTP client for talking to a running bridge server (see bridge/server.ts). + +import type { ResolvedConfig } from '@directededges/specs-schema'; +import { HTTP_PORT } from './config.js'; + +export interface BridgeConnection { + fileKey: string; + fileName?: string; + connected: boolean; +} + +export interface BridgeStatus { + connections: BridgeConnection[]; +} + +export async function getBridgeStatus(timeoutMs = 1500): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`http://localhost:${HTTP_PORT}/status`, { signal: controller.signal }); + return (await res.json()) as BridgeStatus; + } finally { + clearTimeout(timer); + } +} + +export interface RenderRequestBody { + specPath?: string; + /** Pre-parsed, pre-merged component spec. When present, the bridge relays + * it as-is instead of reading/parsing specPath itself. specPath is still + * required alongside it to derive the workspace (specsDir/dataDir). */ + spec?: Record; + pageId?: string; + fileKey?: string; + /** 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. + */ + config?: ResolvedConfig; +} + +// Render reports success/failure only — the round-trip spec read is a second, +// explicit call (`specs generate --from-bridge`), not a render side effect. +export interface RenderResponse { + success: boolean; + nodeId?: string; + /** Non-fatal render degradations reported by the plugin (font fallbacks, skipped keys). */ + warnings?: string[]; + error?: unknown; + /** Phase durations inside the plugin render. */ + timings?: { total: number; phases: Array<{ label: string; ms: number; count: number }> }; + /** Phase durations on the bridge — manifest builds and the plugin round-trip. */ + bridgeTimings?: Array<{ label: string; ms: number }>; + payloadKB?: number; +} + +export async function postRender(body: RenderRequestBody): Promise { + const res = await fetch(`http://localhost:${HTTP_PORT}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as RenderResponse; +} + +export interface GenerateFromSelectionRequestBody { + fileKey?: string; + /** 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. + */ + config?: ResolvedConfig; + /** + * 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. + */ + remove?: boolean; +} + +export interface GenerateFromSelectionResponse { + success: boolean; + nodeId?: string; + name?: string; + specData?: unknown; + error?: unknown; +} + +export async function postGenerateFromSelection(body: GenerateFromSelectionRequestBody = {}): Promise { + const res = await fetch(`http://localhost:${HTTP_PORT}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as GenerateFromSelectionResponse; +} diff --git a/packages/cli/src/bridge/config.ts b/packages/cli/src/bridge/config.ts new file mode 100644 index 00000000..ac96f4ea --- /dev/null +++ b/packages/cli/src/bridge/config.ts @@ -0,0 +1,32 @@ +// Bridge configuration constants and workspace resolution utilities. + +import { existsSync, statSync } from 'fs'; +import { resolve, dirname } from 'path'; + +export const WS_PORT = 9001; +export const HTTP_PORT = 9002; + +// Default render target page — the "Specs 2 Testing" render page. +// Override per-request via body.pageId or CLI --pageId. +export const DEFAULT_PAGE_ID = '1462-365'; + +/** + * Walk up from `fromPath` to find the workspace home directory: + * the nearest ancestor that contains both a `specs/` and `data/` subdirectory. + * + * Returns the workspace home path, or null if no such directory is found. + */ +export function resolveWorkspaceDir(fromPath: string): string | null { + let dir = existsSync(fromPath) && statSync(fromPath).isDirectory() + ? fromPath + : dirname(fromPath); + + while (true) { + if (existsSync(resolve(dir, 'specs')) && existsSync(resolve(dir, 'data'))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) return null; // filesystem root + dir = parent; + } +} diff --git a/packages/cli/src/bridge/connections.ts b/packages/cli/src/bridge/connections.ts new file mode 100644 index 00000000..c81d4981 --- /dev/null +++ b/packages/cli/src/bridge/connections.ts @@ -0,0 +1,66 @@ +// Tracks connected plugin sockets, keyed by Figma file key. Extracted from +// server.ts so the connection-resolution logic (ambiguous-file detection, +// stale-close handling) can be unit tested without binding real network ports. + +export interface ConnectionLike { + readyState: number; +} + +export interface Connection { + ws: TSocket; + fileKey: string; + fileName?: string; +} + +export class ConnectionRegistry { + private connections = new Map>(); + + register(fileKey: string, ws: TSocket, fileName?: string): void { + this.connections.set(fileKey, { ws, fileKey, fileName }); + } + + unregister(fileKey: string): void { + this.connections.delete(fileKey); + } + + get size(): number { + return this.connections.size; + } + + /** + * Resolve which connection a request targets. + * - Explicit fileKey: must match a connected plugin. + * - No fileKey, exactly one connection: use it. + * - No fileKey, zero or 2+ connections: throw — never guess. + */ + resolve(fileKey?: string | null): Connection { + if (fileKey) { + const conn = this.connections.get(fileKey); + if (!conn) { + throw new Error(`No plugin connected for file "${fileKey}".`); + } + return conn; + } + + if (this.connections.size === 0) { + throw new Error('No plugin connected. Enable the CLI Bridge in Specs 2 first.'); + } + + if (this.connections.size > 1) { + const list = [...this.connections.values()] + .map((c) => c.fileName ? `${c.fileKey} (${c.fileName})` : c.fileKey) + .join(', '); + throw new Error(`Multiple plugins connected — specify --file . Connected: ${list}`); + } + + return [...this.connections.values()][0]; + } + + list(): Array<{ fileKey: string; fileName?: string; connected: boolean }> { + return [...this.connections.values()].map((c) => ({ + fileKey: c.fileKey, + fileName: c.fileName, + connected: c.ws.readyState === 1, + })); + } +} diff --git a/packages/cli/src/bridge/pickConnection.ts b/packages/cli/src/bridge/pickConnection.ts new file mode 100644 index 00000000..1f6bca8c --- /dev/null +++ b/packages/cli/src/bridge/pickConnection.ts @@ -0,0 +1,77 @@ +// Resolves which connected Figma file a bridge request should target when the +// caller didn't pass --file explicitly. With 2+ connections and an interactive +// terminal, prompts the user to pick one instead of failing immediately — +// non-interactive callers (scripts, CI) keep today's fail-loud behavior so +// they never hang waiting on stdin. + +import { createInterface } from 'readline'; +import { getBridgeStatus, type BridgeConnection } from './client.js'; + +export function isAmbiguous(connections: BridgeConnection[]): boolean { + return connections.length >= 2; +} + +/** Converts a 1-based answer string to a 0-based index, or null if out of range/unparseable. */ +export function parseSelection(answer: string, count: number): number | null { + const idx = parseInt(answer.trim(), 10) - 1; + return Number.isInteger(idx) && idx >= 0 && idx < count ? idx : null; +} + +function promptForConnection(connections: BridgeConnection[]): Promise { + return new Promise((resolve) => { + console.log('\nMultiple Figma files are connected:'); + connections.forEach((c, i) => { + console.log(` ${i + 1}) ${c.fileName ?? c.fileKey} (${c.fileKey})`); + }); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`Select a file [1-${connections.length}]: `, (answer) => { + rl.close(); + const idx = parseSelection(answer, connections.length); + if (idx === null) { + console.error('Invalid selection.'); + resolve(undefined); + return; + } + resolve(connections[idx].fileKey); + }); + }); +} + +export interface ResolveFileKeyOptions { + /** Override interactivity detection (for tests). Defaults to real stdin/stdout TTY state. */ + isTTY?: boolean; + /** Override the status fetch (for tests). Defaults to getBridgeStatus(). */ + getStatus?: typeof getBridgeStatus; + /** Override the prompt (for tests). Defaults to a readline-based picker. */ + prompt?: (connections: BridgeConnection[]) => Promise; +} + +/** + * Returns the fileKey to target for a bridge request. + * - An explicit fileKey passes through unchanged. + * - Non-interactive (no TTY): returns undefined — the bridge server's own + * ambiguity/no-connection error surfaces from the actual request. + * - Interactive with fewer than 2 connections: returns undefined — resolve() + * on the server picks the sole connection, or reports "none connected". + * - Interactive with 2+ connections: prompts the user to choose. + */ +export async function resolveFileKey(explicit: string | undefined, opts: ResolveFileKeyOptions = {}): Promise { + if (explicit) return explicit; + + const isTTY = opts.isTTY ?? (!!process.stdin.isTTY && !!process.stdout.isTTY); + if (!isTTY) return undefined; + + const getStatus = opts.getStatus ?? getBridgeStatus; + let status; + try { + status = await getStatus(); + } catch { + return undefined; + } + + if (!isAmbiguous(status.connections)) return undefined; + + const prompt = opts.prompt ?? promptForConnection; + return prompt(status.connections); +} diff --git a/packages/cli/src/bridge/pidfile.ts b/packages/cli/src/bridge/pidfile.ts new file mode 100644 index 00000000..81590410 --- /dev/null +++ b/packages/cli/src/bridge/pidfile.ts @@ -0,0 +1,51 @@ +// Pidfile management for the daemonized bridge server process. + +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const SPECS_HOME = join(homedir(), '.specs'); +export const BRIDGE_PID_FILE = join(SPECS_HOME, 'bridge.pid'); +export const BRIDGE_LOG_FILE = join(SPECS_HOME, 'bridge.log'); + +export function ensureSpecsHome(): void { + if (!existsSync(SPECS_HOME)) mkdirSync(SPECS_HOME, { recursive: true }); +} + +export function readPid(): number | null { + if (!existsSync(BRIDGE_PID_FILE)) return null; + const raw = readFileSync(BRIDGE_PID_FILE, 'utf8').trim(); + const pid = Number(raw); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + +export function writePid(pid: number): void { + ensureSpecsHome(); + writeFileSync(BRIDGE_PID_FILE, String(pid), 'utf8'); +} + +export function clearPidFile(): void { + if (existsSync(BRIDGE_PID_FILE)) unlinkSync(BRIDGE_PID_FILE); +} + +/** True if a process with this pid is currently alive. */ +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Reads the pidfile and checks liveness. Cleans up a stale pidfile + * (recorded pid no longer running) automatically. + */ +export function getRunningPid(): number | null { + const pid = readPid(); + if (pid === null) return null; + if (isAlive(pid)) return pid; + clearPidFile(); + return null; +} diff --git a/packages/cli/src/bridge/requestTracker.ts b/packages/cli/src/bridge/requestTracker.ts new file mode 100644 index 00000000..9bd738e4 --- /dev/null +++ b/packages/cli/src/bridge/requestTracker.ts @@ -0,0 +1,45 @@ +// Correlation-ID based request/response matching, so responses route to the +// right caller even with multiple connections and requests in flight. +// Extracted from server.ts for unit testability. + +import { randomUUID } from 'crypto'; + +export class RequestTracker { + private pending = new Map void; reject: (e: unknown) => void }>(); + + /** + * Register a new pending request. Returns its requestId (to send alongside + * the outgoing message) and a promise that resolves/rejects when `resolve()` + * is called with that ID, or rejects on timeout if it never is. + */ + create(timeoutMs: number, timeoutMessage: string): { requestId: string; promise: Promise } { + const requestId = randomUUID(); + const promise = new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + setTimeout(() => { + if (this.pending.has(requestId)) { + this.pending.delete(requestId); + reject(new Error(timeoutMessage)); + } + }, timeoutMs); + }); + return { requestId, promise }; + } + + /** Resolve a pending request by ID. Returns false if the ID is unknown (already resolved, timed out, or never registered). */ + resolve(requestId: string, value: T): boolean { + const pending = this.pending.get(requestId); + if (!pending) return false; + this.pending.delete(requestId); + pending.resolve(value); + return true; + } + + has(requestId: string): boolean { + return this.pending.has(requestId); + } + + get size(): number { + return this.pending.size; + } +} diff --git a/packages/cli/src/bridge/server.ts b/packages/cli/src/bridge/server.ts new file mode 100644 index 00000000..b427c1b9 --- /dev/null +++ b/packages/cli/src/bridge/server.ts @@ -0,0 +1,680 @@ +#!/usr/bin/env node +// Bridge server — persistent WebSocket server for the Specs 2 CLI bridge. +// Stays running until Ctrl+C (or SIGTERM from `specs bridge stop`). Accepts +// multiple renderComponent/generateFromSelection commands per session, and +// multiple simultaneously connected Figma files (each with its own plugin +// connection). +// +// Ports: +// 9001 — WebSocket, for plugin connections (ui.html) — one per open Figma file +// 9002 — HTTP, control endpoint for the CLI (`specs render` / `specs generate`) or scripts +// +// HTTP API: +// POST http://localhost:9002/render +// Body: { "specPath": "/abs/path/to/spec.yaml", "pageId": "1462-365", "fileKey": "..." } +// fileKey is optional when exactly one plugin is connected; required (and +// validated) when more than one is connected. +// Response: { "success": true, "nodeId": "..." } (round-trip spec read is +// an explicit second call — POST /generate — not a render side effect) +// POST http://localhost:9002/generate +// Body: { "fileKey": "..." } (fileKey optional under the same single-connection rule) +// Generates a spec from the plugin's current Figma selection — no REST fetch needed. +// Response: { "success": true, "nodeId": "...", "name": "...", "specData": {...} } +// GET http://localhost:9002/status +// Response: { "connections": [{ "fileKey": "...", "fileName": "...", "connected": true }] } +// +// CLI (one-shot render, then stays running): +// node bridge-server.js [--workspace /path/to/workspace] --render path/to/spec.yaml [--pageId 1462-365] +// +// Workspace resolution (in order of precedence): +// 1. --workspace CLI flag +// 2. WORKSPACE_DIR env var +// 3. Derived per-request from the specPath (walks up to find specs/ + data/ sibling dirs) +// 4. SPECS_DIR / DATA_DIR env vars as explicit overrides for non-standard layouts +// +// Multi-connection protocol: +// Each plugin connection sends { type: 'hello', fileKey, fileName? } immediately +// on connect. The server tracks connections in a Map keyed by fileKey — this +// replaces a single "activeSocket" variable, which had a real bug: with only +// one tracked socket, an older connection's close event would null out a +// newer, still-live connection's reference. Every request (getPageId, +// renderComponent) carries a generated requestId; the plugin must echo it +// 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 { WebSocketServer, type WebSocket } from 'ws'; +import { createServer } from 'http'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { resolve as pathResolve, isAbsolute, basename } from 'path'; +import { parse } from 'yaml'; +import { WS_PORT, HTTP_PORT, DEFAULT_PAGE_ID, resolveWorkspaceDir } from './config.js'; +import { ConnectionRegistry, type Connection } from './connections.js'; +import { RequestTracker } from './requestTracker.js'; +import { countUnpublished, type VariablesIndex } from '../utilities/variablesIndex.js'; +import { formatKey } from '../utilities/formatKey.js'; +import { + readCacheFile, validateCache, describeProblems, + type ComponentsEntry, type StylesEntry, type VariablesEntry, type IconsEntry, +} from '../Cache/Cache.js'; + +/** id = same-file node id (fast path); key = published cross-file key (fallback import). */ +type ComponentEntry = { id: string; key?: string }; +type Manifest = Record; +type GlyphManifest = Record; +/** Token name → the handles that name resolves to. A spec references a variable by name only, + * so this is what gives the name meaning on the render side. */ +type VariablesManifest = VariablesIndex; + +interface RenderResult { + success: boolean; + nodeId?: string; + error?: string; + /** Phase durations reported by the plugin render (see figma-from-specs Timings.ts). */ + timings?: { total: number; phases: Array<{ label: string; ms: number; count: number }> }; + /** Phase durations measured here, before and around the plugin round-trip. */ + bridgeTimings?: Array<{ label: string; ms: number }>; + /** Size of the render payload sent over the socket, in KB. */ + payloadKB?: number; +} + +interface GenerateResult { + success: boolean; + nodeId?: string; + name?: string; + specData?: unknown; + error?: string; +} + +// ── Startup workspace (optional) ────────────────────────────────────────────── +// If set, all requests use this workspace. If not set, workspace is derived per-request. + +const workspaceIdx = process.argv.indexOf('--workspace'); +const startupWorkspaceDir: string | null = + process.env.WORKSPACE_DIR ?? + (workspaceIdx !== -1 ? process.argv[workspaceIdx + 1] : null); + +if (startupWorkspaceDir) { + console.log(` Workspace : ${startupWorkspaceDir}`); +} else { + console.log(` Workspace : (derived per-request from spec path)`); +} + +// SPECS_DIR / DATA_DIR env vars override workspace derivation for non-standard layouts. +const envSpecsDir = process.env.SPECS_DIR ?? null; +const envDataDir = process.env.DATA_DIR ?? null; + +/** + * Resolve the specs and data directories for a given spec or manifest path. + * Precedence: explicit env overrides → startup workspace → per-request derivation. + */ +function resolveDirs(fromPath: string): { specsDir: string; dataDir: string; aliases: string[]; glyphNamePattern?: string } { + const workspaceDir = startupWorkspaceDir ?? resolveWorkspaceDir(fromPath); + if (!workspaceDir && (!envSpecsDir || !envDataDir)) { + throw new Error( + `Cannot determine workspace for "${fromPath}". ` + + `Pass --workspace , set WORKSPACE_DIR, or ensure the path is inside a directory containing specs/ and data/.` + ); + } + 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. + const { aliases, glyphNamePattern } = resolveSources(workspaceDir as string); + return { specsDir, dataDir, aliases, glyphNamePattern }; +} + +console.log(`\nSpecs 2 — CLI bridge`); +console.log(` WebSocket : ws://localhost:${WS_PORT} (plugin)`); +console.log(` HTTP : http://localhost:${HTTP_PORT}/render (control)`); +console.log(` Enable the CLI Bridge in the Specs 2 plugin to connect.`); +console.log(` Ctrl+C to stop.\n`); + +// ── WebSocket server (plugin connections) ───────────────────────────────────── + +const wss = new WebSocketServer({ port: WS_PORT }); + +// Keyed by Figma file key. One entry per connected plugin instance. +const registry = new ConnectionRegistry(); + +// Pending requests keyed by a generated requestId, so responses route to the +// right caller regardless of which connection they came from. +const requests = new RequestTracker(); + +wss.on('connection', (ws: WebSocket) => { + // This connection's fileKey isn't known until its 'hello' message arrives. + let thisFileKey: string | null = null; + console.log('Plugin socket opened, awaiting hello…'); + + ws.on('message', (data: Buffer) => { + let msg: Record; + try { msg = JSON.parse(data.toString()); } catch { return; } + + if (msg.type === 'hello') { + thisFileKey = msg.fileKey as string; + const fileName = msg.fileName as string | undefined; + registry.register(thisFileKey, ws, fileName); + console.log(`Plugin connected: ${thisFileKey}${fileName ? ` (${fileName})` : ''}`); + return; + } + + if (msg.type === 'pageId-result' || msg.type === 'renderComponent-result' || msg.type === 'generateFromSelection-result' || msg.type === 'removeNode-result') { + const requestId = msg.requestId as string | undefined; + if (!requestId) return; // no way to route this response + + if (msg.type === 'pageId-result') { + requests.resolve(requestId, msg.pageId as string); + return; + } + + if (msg.type === 'generateFromSelection-result') { + if (msg.success) { + const nodeId = msg.nodeId as string; + const name = msg.name as string | undefined; + console.log(`✓ Generated from selection. nodeId: ${nodeId}`); + requests.resolve(requestId, { success: true, nodeId, name, specData: msg.specData ?? null }); + } else { + console.error(`✗ Generate from selection failed: ${msg.error}`); + requests.resolve(requestId, { success: false, error: msg.error as string }); + } + return; + } + + // renderComponent-result + if (msg.success) { + const nodeId = msg.nodeId as string; + const warnings = Array.isArray(msg.warnings) && msg.warnings.length > 0 ? (msg.warnings as string[]) : undefined; + console.log(`✓ Rendered in Figma. nodeId: ${nodeId}`); + for (const w of warnings ?? []) console.warn(` ⚠ ${w}`); + const timings = msg.timings as RenderResult['timings']; + requests.resolve(requestId, { success: true, nodeId, ...(warnings ? { warnings } : {}), ...(timings ? { timings } : {}) }); + } else { + console.error(`✗ Render failed: ${msg.error}`); + requests.resolve(requestId, { success: false, error: msg.error as string }); + } + } + }); + + ws.on('close', () => { + if (thisFileKey) { + registry.unregister(thisFileKey); + console.log(`Plugin disconnected: ${thisFileKey}`); + } else { + console.log('Plugin socket closed before hello.'); + } + }); + + ws.on('error', (e: Error) => console.error('Socket error:', e.message)); +}); + +wss.on('error', (e: NodeJS.ErrnoException) => { + console.error(`WebSocket server error: ${e.message}`); + if (e.code === 'EADDRINUSE') console.error(`Port ${WS_PORT} already in use.`); + process.exit(1); +}); + +// ── HTTP control server ─────────────────────────────────────────────────────── + +/** Callers prefix what they print with "Error: ", so report the bare message. */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +const http = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ connections: registry.list() })); + return; + } + + if (req.method === 'POST' && req.url === '/generate') { + let genBody = ''; + req.on('data', (chunk) => { genBody += chunk; }); + req.on('end', () => { + let params: { fileKey?: string; nodeId?: string; config?: ResolvedConfig; 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) + // 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) { + try { await sendRemoveNode(result.nodeId, params.fileKey); } catch { /* reported by the caller's next read */ } + } + return result; + }) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: errorMessage(err) })); + }); + }); + return; + } + + if (req.method !== 'POST' || req.url !== '/render') { + res.writeHead(405); + res.end(JSON.stringify({ error: 'Only POST /render, POST /generate, or GET /status is supported.' })); + return; + } + + 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 }; + 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; + + if (!specArg) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'specPath is required.' })); + return; + } + + // Single spec path: must be absolute (workspace derivation requires a real path) + if (!specArg || !isAbsolute(specArg)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'specPath must be absolute when no startup workspace is configured.' })); + return; + } + + sendRender(specArg, pageId, fileKey, preParsedSpec, overwrite, config) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: errorMessage(err) })); + }); + }); +}); + +http.listen(HTTP_PORT); +http.on('error', (e: NodeJS.ErrnoException) => { + console.error(`HTTP server error: ${e.message}`); + if (e.code === 'EADDRINUSE') console.error(`Port ${HTTP_PORT} already in use.`); + process.exit(1); +}); + +// ── Shared utilities ────────────────────────────────────────────────────────── + +// ── Manifest builders ───────────────────────────────────────────────────────── +// +// Every library-side lookup a render needs is read from the caches under +// {dataDir}/cache/ (see src/Cache/Cache.ts). They are built by `specs fetch` and +// `specs cache` from the fetched payloads, so no payload is parsed here — a file +// payload can be hundreds of megabytes and used to be parsed three times per render. +// Only the spec-side half of the instance manifest is derived at render time, since +// it reflects the workspace's specs rather than the library. + +/** + * Build a flat manifest of spec key → component entry by scanning specsDir. + * Also layers in subcomponent ref aliases from the current spec. Published + * cross-file keys come from the components cache, which records them per node id + * across every fetched library. + */ +function buildManifest(spec: Record, specsDir: string, dataDir: string, keyFormat?: string): Manifest { + const manifest: Manifest = {}; + + const cache = readCacheFile(dataDir, 'components'); + const entryFor = (id: string): ComponentEntry => { + const key = cache?.entries[id]?.key; + return key ? { id, key } : { id }; + }; + + const specFiles = collectSpecFiles(specsDir); + // Deduplicate by key: first nodeId found wins (variants.yaml before api.yaml, etc.) + for (const { key, path } of specFiles) { + if (manifest[key]) continue; // already have a nodeId for this key + try { + const s = parse(readFileSync(path, 'utf8')) as { metadata?: { source?: { nodeId?: string } } }; + const nodeId = s.metadata?.source?.nodeId; + if (nodeId) manifest[key] = entryFor(nodeId); + } catch { + // Skip unreadable or non-spec files silently + } + } + + // Layer 2: alias subcomponent ref keys from the current spec. + const specTyped = spec as { + subcomponents?: Record; + components?: Record }>; + }; + const subcomponents = specTyped.subcomponents ?? Object.values(specTyped.components ?? {})[0]?.subcomponents; + if (subcomponents) { + for (const [refKey, sub] of Object.entries(subcomponents)) { + if (sub.source?.nodeId) { + manifest[refKey] = entryFor(sub.source.nodeId); + } else if (sub.title) { + const titleKey = toCamelCase(sub.title); + if (manifest[titleKey]) manifest[refKey] = manifest[titleKey]; + } + } + } + + // Layer 3: the components the workspace has no spec for. + // + // A spec names an instance's component with a formatted key, and that transform is lossy + // — "DS Link/On overlay/M/False/Rest/Start" formats to "dsLinkOnOverlayMFalseRestStart" + // with the separators gone — so the name cannot be reconstructed from the key. It can be + // recognised, though: format the library's own component names the same way and compare. + // These resolve by published key on the render side, which is what makes a component from + // another file placeable at all. + // + // Only names this spec actually references are added, so the payload does not grow by the + // library's entire component list. Spec-derived entries always win — a workspace component + // is the more specific answer, and its node id is local to the file being rendered into. + const referenced = collectComponentNames(spec); + if (referenced.size > 0 && cache) { + const byFormattedName = new Map(); + for (const [id, entry] of Object.entries(cache.entries)) { + if (!entry.name) continue; + const formatted = formatKey(entry.name, keyFormat); + // First occurrence wins. Distinct components can format to the same key (the transform + // is lossy), and nothing in the payload says which one a spec meant. + if (!byFormattedName.has(formatted)) byFormattedName.set(formatted, { id, key: entry.key }); + } + let added = 0; + for (const name of referenced) { + if (manifest[name]) continue; + const found = byFormattedName.get(name); + if (found) { manifest[name] = found; added++; } + } + if (added > 0) console.log(` Manifest: +${added} from library components with no spec`); + } + + console.log(` Manifest: ${Object.keys(manifest).length} entries`); + return manifest; +} + +/** + * Every component name a spec references, at any depth — variants, examples, slot content, + * subcomponents. `$ref` forms are skipped: those already resolve within the spec. + * + * Two surfaces name components. `instanceOf` names the component an element instantiates. + * A `propConfigurations` value names one too when the property behind it is an instance + * swap — an icon on an avatar, say — and the spec gives no clue which of its values those + * are, since it records the property's value, not its Figma type. So every string value is + * offered up: a name that matches no library component simply finds nothing, and one that + * does is only ever used by a swap that asked for it. + */ +function collectComponentNames(node: unknown, acc = new Set()): Set { + if (!node || typeof node !== 'object') return acc; + if (Array.isArray(node)) { for (const item of node) collectComponentNames(item, acc); return acc; } + for (const [key, value] of Object.entries(node as Record)) { + if (key === 'instanceOf' && typeof value === 'string') acc.add(value); + else if (key === 'propConfigurations' && value && typeof value === 'object') { + for (const configured of Object.values(value as Record)) { + if (typeof configured === 'string') acc.add(configured); + } + collectComponentNames(value, acc); // $nested configs carry their own values + } + else collectComponentNames(value, acc); + } + return acc; +} + +/** + * Glyph name → component entry, straight from the icons cache. The cache is built by + * matching the configured glyphNamePattern against the fetched file, so render depends + * on no scan output: a scan manifest is generated and then authored, which makes it a + * poor thing to resolve against. + */ +function buildGlyphManifest(dataDir: string): GlyphManifest { + const cache = readCacheFile(dataDir, 'icons'); + const result: GlyphManifest = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + result[name] = entry.key ? { id: entry.id, key: entry.key } : { id: entry.id }; + } + console.log(` Glyph manifest: ${Object.keys(result).length} glyphs`); + return result; +} + +/** Style name → published key, for the style types a spec can reference. */ +function buildStylesManifest(dataDir: string): Record { + const cache = readCacheFile(dataDir, 'styles'); + const result: Record = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + result[name] = entry.key; + } + console.log(` Styles manifest: ${Object.keys(result).length} entries`); + return result; +} + +/** Token name → the handles it resolves to. */ +function buildVariablesManifest(dataDir: string): VariablesManifest { + const cache = readCacheFile(dataDir, 'variables'); + const index: VariablesManifest = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + index[name] = { key: entry.key, id: entry.id, published: entry.published }; + } + + const names = Object.keys(index).length; + if (names === 0) { + console.warn(` Variables manifest: no named variables cached — variable binding disabled`); + return index; + } + + const unpublished = countUnpublished(index); + const suffix = unpublished > 0 ? ` (${unpublished} not importable from the library — id fallback only)` : ''; + console.log(` Variables manifest: ${names} token names${suffix}`); + return index; +} + +/** + * Collect all spec YAML files from a specs directory. + * Handles both flat layouts (specsDir/foo.yaml) and subdirectory layouts + * (specsDir/foo/variants.yaml, specsDir/foo/api.yaml, etc.). + * Returns an array of { key, path } where key is the camelCase component name. + */ +function collectSpecFiles(specsDir: string): Array<{ key: string; path: string }> { + let entries: string[]; + try { + entries = readdirSync(specsDir); + } catch (e) { + throw new Error(`Cannot read specs directory "${specsDir}": ${(e as Error).message}`); + } + + const files: Array<{ key: string; path: string }> = []; + for (const entry of entries) { + const entryPath = pathResolve(specsDir, entry); + let stat; + try { stat = statSync(entryPath); } catch { continue; } + + if (stat.isFile() && entry.endsWith('.yaml')) { + // Flat layout: specsDir/foo.yaml → key "foo" + files.push({ key: basename(entry, '.yaml'), path: entryPath }); + } else if (stat.isDirectory()) { + // Subdirectory layout: specsDir/foo/variants.yaml → key "foo" + // Prefer variants.yaml; also collect api.yaml and others for nodeId scanning. + let subEntries: string[]; + try { subEntries = readdirSync(entryPath); } catch { continue; } + for (const sub of subEntries) { + if (sub.endsWith('.yaml')) { + files.push({ key: entry, path: pathResolve(entryPath, sub) }); + } + } + } + } + + return files; +} + + +function toCamelCase(str: string): string { + return str + .replace(/[^a-zA-Z0-9 ]/g, '') + .split(' ') + .filter(Boolean) + .map((w, i) => i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1).toLowerCase()) + .join(''); +} + +// ── 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. + */ +function resolveSources(workspaceDir: string): { aliases: string[]; glyphNamePattern?: string } { + const configPath = pathResolve(workspaceDir, 'specs.config.yaml'); + try { + const config = parse(readFileSync(configPath, 'utf8')) as { + sources?: Record; + config?: { processing?: { glyphNamePattern?: string } }; + }; + const sources = config?.sources; + const aliases = sources && typeof sources === 'object' ? Object.keys(sources) : []; + return { aliases, glyphNamePattern: config?.config?.processing?.glyphNamePattern }; + } catch { + // No config or unreadable — no aliases, which render reports as an unusable cache + return { aliases: [] }; + } +} + +// ── Shared render logic ─────────────────────────────────────────────────────── + +/** + * Ask a specific connection's plugin for the current Figma page ID. + */ +function getCurrentPageId(conn: Connection): Promise { + const { requestId, promise } = requests.create(5000, 'Timed out waiting for pageId-result.'); + conn.ws.send(JSON.stringify({ type: 'getPageId', requestId })); + return promise as Promise; +} + +/** + * Ask a specific connection's plugin to generate a spec from its current Figma + * selection, or from an explicit nodeId (the plugin selects that node first). + */ +/** Testing utility: delete a rendered node so a catalogue sweep leaves the page as it found it. */ +async function sendRemoveNode(nodeId: string, fileKey?: string): Promise<{ success: boolean; error?: string }> { + const conn = registry.resolve(fileKey); + const { requestId, promise } = requests.create(30000, 'Timed out waiting for removeNode-result.'); + conn.ws.send(JSON.stringify({ type: 'removeNode', requestId, nodeId })); + return promise as Promise<{ success: boolean; error?: string }>; +} + +async function sendGenerateFromSelection(fileKey?: string, nodeId?: string, config?: ResolvedConfig): 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 })); + 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 { + const conn = registry.resolve(fileKey); + + let spec: Record; + if (preParsedSpec) { + spec = preParsedSpec; + } else { + try { + spec = parse(readFileSync(specPath, 'utf8')); + } catch (e) { + throw new Error(`Error reading spec: ${(e as Error).message}`); + } + } + + const dirs = resolveDirs(specPath); + const { specsDir, dataDir, aliases, glyphNamePattern } = dirs; + + // Render resolves against the caches only. A missing or stale cache is fatal rather + // than rebuilt here: rebuilding parses every fetched payload, which is exactly the + // per-render cost the caches exist to remove — and silently rendering against data + // that no longer matches what was fetched binds specs to the wrong variables. + const problems = validateCache({ dataDir, aliases, glyphNamePattern }); + if (problems.length > 0) throw new Error(describeProblems(problems)); + + const bridgeTimings: Array<{ label: string; ms: number }> = []; + const timed = (label: string, fn: () => T): T => { + const start = Date.now(); + 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; + const manifest = timed('instance manifest', () => buildManifest(spec, specsDir, dataDir, keyFormat)); + const glyphIdManifest = timed('glyph manifest', () => buildGlyphManifest(dataDir)); + const stylesManifest = timed('styles manifest', () => buildStylesManifest(dataDir)); + const variablesManifest = timed('variables manifest', () => buildVariablesManifest(dataDir)); + + // Resolve page ID: use provided value, or ask this connection's plugin for its current page. + let pageId: string; + if (rawPageId) { + pageId = rawPageId.replace(/-/g, ':'); + } else { + pageId = await getCurrentPageId(conn); + console.log(` Page ID: ${pageId} (from plugin current page, ${conn.fileKey})`); + } + + const specTyped = spec as { components?: Record }; + const componentName = Object.values(specTyped.components ?? {})[0]?.title + ?? Object.keys(specTyped.components ?? {})[0] + ?? specPath; + + console.log(`Rendering: ${componentName} → page ${pageId} (${conn.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 sentAt = Date.now(); + conn.ws.send(payload); + const result = await (promise as Promise); + bridgeTimings.push({ label: 'plugin round-trip', ms: Date.now() - sentAt }); + return { ...result, bridgeTimings, payloadKB: Math.round(payload.length / 1024) }; +} + +// ── CLI: --render flag ──────────────────────────────────────────────────────── +// +// node bridge-server.js --render /abs/path/to/spec.yaml [--pageId 1462-365] +// Debug shortcut — only works when exactly one plugin is connected. + +const renderIdx = process.argv.indexOf('--render'); +if (renderIdx !== -1) { + const specArg = process.argv[renderIdx + 1]; + const pageIdIdx = process.argv.indexOf('--pageId'); + const rawPageId = pageIdIdx !== -1 ? process.argv[pageIdIdx + 1] : DEFAULT_PAGE_ID; + + if (!specArg) { console.error('--render requires a spec path'); process.exit(1); } + if (!isAbsolute(specArg)) { console.error('--render path must be absolute'); process.exit(1); } + + const tryRender = (): void => { + if (registry.size > 0) { + sendRender(specArg, rawPageId) + .catch((e) => console.error((e as Error).message)); + } else { + console.log('Waiting for plugin to connect…'); + setTimeout(tryRender, 1000); + } + }; + setTimeout(tryRender, 500); +} diff --git a/packages/cli/src/commands/AnalyzeCommand.ts b/packages/cli/src/commands/AnalyzeCommand.ts index 6797e06e..f11d77b6 100644 --- a/packages/cli/src/commands/AnalyzeCommand.ts +++ b/packages/cli/src/commands/AnalyzeCommand.ts @@ -19,7 +19,7 @@ interface AnalyzeOptions { export const Analyze = new Command('analyze') .description('Run analysis passes over component specs and write aggregate reports to _analysis/') - .argument('[analyzers...]', 'Analyzer names to run (props, styling, dependencies)') + .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)') diff --git a/packages/cli/src/commands/ApplyCustomTokensCommand.ts b/packages/cli/src/commands/ApplyCustomTokensCommand.ts index c485dfd2..cc9b1e8c 100644 --- a/packages/cli/src/commands/ApplyCustomTokensCommand.ts +++ b/packages/cli/src/commands/ApplyCustomTokensCommand.ts @@ -12,6 +12,8 @@ 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'; const ERROR_CODES = { SUCCESS: 0, @@ -24,6 +26,7 @@ type MinimalConfig = { dataDirectory?: string; sourceDirectory?: string; // deprecated alias sources?: Record; + config?: { processing?: { glyphNamePattern?: string } }; }; function findConfigFile(cwd: string): string | null { @@ -285,6 +288,17 @@ export const ApplyCustomTokens = new Command('applyCustomTokens') console.log(` ${unmatchedIds.size} mapping entry ID(s) did not match any variable or style`); } + // 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 report = refreshCache({ + dataDir: path.resolve(configDir, config.dataDirectory), + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + }); + reportCache(report); + } + process.exit(ERROR_CODES.SUCCESS); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/BridgeCommand.ts b/packages/cli/src/commands/BridgeCommand.ts new file mode 100644 index 00000000..ce748649 --- /dev/null +++ b/packages/cli/src/commands/BridgeCommand.ts @@ -0,0 +1,124 @@ +/** + * Bridge Command + * + * Manages the local CLI bridge — a background process that relays + * `specs render` requests to a connected Specs 2 Figma plugin over + * WebSocket. See bridge/server.ts for the actual server implementation. + */ + +import { Command } from 'commander'; +import { spawn } from 'child_process'; +import { openSync, closeSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { + ensureSpecsHome, + getRunningPid, + isAlive, + writePid, + clearPidFile, + BRIDGE_PID_FILE, + BRIDGE_LOG_FILE, +} from '../bridge/pidfile.js'; +import { getBridgeStatus } from '../bridge/client.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, +}; + +function resolveBridgeServerScript(): string { + // dist/specs.js and dist/bridge-server.js are sibling build outputs. + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, 'bridge-server.js'); +} + +export const Bridge = new Command('bridge') + .description('Manage the local CLI bridge used by `specs render` to reach a running Figma plugin'); + +Bridge.command('start') + .description('Start the bridge server in the background') + .option('--workspace ', 'Pin a workspace directory instead of deriving it per-request') + .action(async (options: { workspace?: string }) => { + const runningPid = getRunningPid(); + if (runningPid !== null) { + console.log(`Bridge already running (pid ${runningPid}).`); + console.log('Run `specs bridge status` to check plugin connection.'); + process.exit(ERROR_CODES.SUCCESS); + } + + ensureSpecsHome(); + const serverScript = resolveBridgeServerScript(); + const args = options.workspace ? ['--workspace', options.workspace] : []; + + const logFd = openSync(BRIDGE_LOG_FILE, 'a'); + const child = spawn('node', [serverScript, ...args], { + detached: true, + stdio: ['ignore', logFd, logFd], + }); + closeSync(logFd); + + if (!child.pid) { + console.error('Error: failed to start the bridge server.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + child.unref(); + writePid(child.pid); + + console.log(`Bridge started (pid ${child.pid}).`); + console.log(' WebSocket : ws://localhost:9001 (plugin)'); + console.log(' HTTP : http://localhost:9002/render (control)'); + console.log(` Logs : ${BRIDGE_LOG_FILE}`); + console.log('Enable the CLI Bridge in the Specs 2 plugin to connect.'); + }); + +Bridge.command('stop') + .description('Stop the background bridge server') + .action(async () => { + const pid = getRunningPid(); + if (pid === null) { + console.log('Bridge is not running.'); + process.exit(ERROR_CODES.SUCCESS); + } + + process.kill(pid, 'SIGTERM'); + + const deadline = Date.now() + 3000; + while (Date.now() < deadline && isAlive(pid)) { + await new Promise((r) => setTimeout(r, 100)); + } + + if (isAlive(pid)) { + process.kill(pid, 'SIGKILL'); + } + + clearPidFile(); + console.log('Bridge stopped.'); + }); + +Bridge.command('status') + .description('Check whether the bridge server (and a connected plugin) are running') + .action(async () => { + const pid = getRunningPid(); + if (pid === null) { + console.log('Bridge is not running.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + try { + const { connections } = await getBridgeStatus(); + if (connections.length === 0) { + console.log(`Bridge running (pid ${pid}). No plugin connected.`); + } else { + console.log(`Bridge running (pid ${pid}). ${connections.length} plugin${connections.length === 1 ? '' : 's'} connected:`); + for (const c of connections) { + console.log(` ${c.fileKey}${c.fileName ? ` (${c.fileName})` : ''} — ${c.connected ? 'connected' : 'disconnected'}`); + } + } + process.exit(ERROR_CODES.SUCCESS); + } catch { + console.log(`Bridge running (pid ${pid}), but the control port isn't responding.`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); diff --git a/packages/cli/src/commands/CacheCommand.ts b/packages/cli/src/commands/CacheCommand.ts new file mode 100644 index 00000000..1ce77972 --- /dev/null +++ b/packages/cli/src/commands/CacheCommand.ts @@ -0,0 +1,66 @@ +/** + * Cache Command + * + * Builds the render lookup caches under `{dataDirectory}/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. + */ + +import { Command } from 'commander'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; +import { refreshCache, type CacheReport } from '../Cache/Cache.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, + INVALID_ARGS: 2, +}; + +/** Shared by this command and every command that refreshes the cache as a final step. */ +export function reportCache(report: CacheReport): void { + const { rebuilt, current, unfetched, counts } = report; + if (rebuilt.length > 0) console.log(` Cache rebuilt: ${rebuilt.join(', ')}`); + if (current.length > 0) console.log(` Cache current: ${current.join(', ')}`); + // Not an error here — only render is in a position to insist a source be fetched. + if (unfetched.length > 0) console.log(` Not fetched, skipped: ${unfetched.join(', ')}`); + console.log( + ` Entries: ${counts.components} components, ${counts.styles} styles, ` + + `${counts.variables} variables, ${counts.icons} icons` + ); +} + +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('--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.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const aliases = Object.keys(config.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`.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const report = refreshCache({ + dataDir: config.dataDirectory, + aliases, + glyphNamePattern: config.config?.processing?.glyphNamePattern, + force: options.force, + }); + + reportCache(report); + console.log('✓ Cache written.'); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); diff --git a/packages/cli/src/commands/FetchCommand.ts b/packages/cli/src/commands/FetchCommand.ts index c33be79a..871c1614 100644 --- a/packages/cli/src/commands/FetchCommand.ts +++ b/packages/cli/src/commands/FetchCommand.ts @@ -12,6 +12,10 @@ 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'; const ERROR_CODES = { SUCCESS: 0, @@ -33,41 +37,7 @@ type MinimalConfig = { config?: { processing?: { glyphNamePattern?: string } }; }; -/** - * Walk the file document for COMPONENT nodes whose name matches the - * glyphNamePattern ("DS Icon asset / {i}" — {i} captures the icon name). - * Duplicate slugs keep the first occurrence and suffix later ones with the - * node id so nothing is silently dropped. - */ -export function collectGlyphComponents(document: unknown, pattern: string): Array<{ id: string; name: string; slug: string }> { - const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{i\\\}/g, '(.+)'); - const regex = new RegExp(`^${escaped}$`); - const found: Array<{ id: string; name: string; slug: string }> = []; - const walk = (node: unknown): void => { - if (!node || typeof node !== 'object') return; - const n = node as { id?: string; name?: string; type?: string; children?: unknown[] }; - if (n.type === 'COMPONENT' && typeof n.name === 'string' && typeof n.id === 'string') { - const match = n.name.match(regex); - if (match) found.push({ id: n.id, name: match[1] ?? n.name, slug: '' }); - } - for (const child of n.children ?? []) walk(child); - }; - walk(document); - - const seen = new Set(); - for (const glyph of found) { - // Kebabize camelCase too, matching the scaffold's glyphUrl slugging. - const base = glyph.name - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - glyph.slug = seen.has(base) ? `${base}-${glyph.id.replace(':', '-')}` : base; - seen.add(base); - } - return found; -} +export { collectGlyphComponents }; async function streamToString(stream: ReadableStream | null): Promise { if (!stream) return ''; @@ -115,6 +85,12 @@ function loadConfig(configPath?: string): { configPath: string | null; config: M }; } +const FETCH_KINDS: readonly FetchKind[] = ['file', 'variables', 'styles', 'icons']; + +function isFetchKind(value: string): value is FetchKind { + return (FETCH_KINDS as readonly string[]).includes(value); +} + function splitOnly(value?: string): string[] { if (!value) return []; return value @@ -243,53 +219,6 @@ export function formatAuthError(status: number, alias: string, kind: string, con ].join('\n'); } -function isInteractive(): boolean { - return Boolean(process.stdout.isTTY); -} - -function renderInlineStatus(text: string): void { - if (!isInteractive()) { - console.log(text); - return; - } - - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - process.stdout.write(text); -} - -function clearInlineStatus(): void { - if (!isInteractive()) return; - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); -} - -function formatElapsed(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remaining = seconds % 60; - return `${minutes}m ${remaining}s`; -} - -function startSpinner(text: string): () => string { - const start = Date.now(); - if (!isInteractive()) { - console.log(text); - return () => formatElapsed(Date.now() - start); - } - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let i = 0; - const id = setInterval(() => { - const elapsed = formatElapsed(Date.now() - start); - renderInlineStatus(`${frames[i++ % frames.length]} ${text} (${elapsed})`); - }, 80); - return () => { - clearInterval(id); - clearInlineStatus(); - return formatElapsed(Date.now() - start); - }; -} export interface FetchOptions { config?: string; @@ -305,7 +234,7 @@ export const Fetch = new Command('fetch') .option('--config ', 'Path to config file (specs.config.yaml)') .option('--data-dir ', 'Override data directory (default: config dataDirectory or ./data)') .option('--outDir ', 'Deprecated: use --data-dir') - .option('--only ', 'Fetch only the given file alias(es) from sources.files') + .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') .option('--verbose', 'Enable detailed logging', false) .action(async (options: FetchOptions) => { @@ -338,14 +267,48 @@ export const Fetch = new Command('fetch') process.exit(ERROR_CODES.INVALID_ARGS); } - const onlyAliases = splitOnly(options.only); - const selected = onlyAliases.length > 0 ? fileEntries.filter(f => onlyAliases.includes(f.alias)) : fileEntries; + // `--only` narrows two independent axes: which source files to fetch, and which kinds + // of data to fetch for them. A value naming a data kind narrows the kind; anything + // else is read as a file alias. Both can be given together (`--only library,icons`). + const onlyValues = splitOnly(options.only); + const onlyKinds = onlyValues.filter(isFetchKind); + const onlyAliases = onlyValues.filter(v => !isFetchKind(v)); + + // An alias sharing a data kind's name would be silently unreachable, so say so + // rather than guess which the caller meant. + const shadowed = fileEntries.map(f => f.alias).filter(isFetchKind); + if (shadowed.length > 0 && onlyKinds.some(k => shadowed.includes(k))) { + console.error(`Error: --only "${onlyKinds.filter(k => shadowed.includes(k)).join(', ')}" is both a data kind and a source alias.`); + console.error('Rename the source alias, or drop --only and let config decide.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } - if (onlyAliases.length > 0 && selected.length === 0) { - console.error(`Error: --only did not match any configured aliases: ${onlyAliases.join(', ')}`); + // Every name has to mean something. A typo alongside a valid alias would otherwise + // fetch more than was asked for and say nothing — the opposite of what --only is for. + const unmatched = onlyAliases.filter(a => !fileEntries.some(f => f.alias === a)); + if (unmatched.length > 0) { + console.error(`Error: --only ${unmatched.join(', ')} — not a source alias or a data kind.`); + console.error(`Aliases: ${fileEntries.map(f => f.alias).join(', ') || '(none)'}`); + console.error(`Kinds: ${FETCH_KINDS.join(', ')}`); process.exit(ERROR_CODES.INVALID_ARGS); } + const selected = onlyAliases.length > 0 ? fileEntries.filter(f => onlyAliases.includes(f.alias)) : fileEntries; + + // A kind the caller asked for that no selected source is configured to fetch would + // otherwise do nothing at all and say nothing about why. + if (onlyKinds.length > 0) { + const available = new Set(selected.flatMap(f => f.fetch)); + const unavailable = onlyKinds.filter(k => !available.has(k)); + if (unavailable.length === onlyKinds.length) { + console.error(`Error: --only ${onlyKinds.join(', ')} — no selected source is configured to fetch ${unavailable.length === 1 ? 'it' : 'them'}.`); + console.error(`Configured data for ${selected.map(f => `${f.alias}: [${f.fetch.join(', ')}]`).join('; ')}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + const wants = (kind: FetchKind): boolean => onlyKinds.length === 0 || onlyKinds.includes(kind); + await fs.ensureDir(outDir); if (options.verbose) { @@ -355,7 +318,7 @@ export const Fetch = new Command('fetch') } for (const entry of selected) { - for (const kind of entry.fetch.filter(k => k !== 'icons')) { + for (const kind of entry.fetch.filter(k => k !== 'icons' && wants(k))) { const url = kind === 'file' ? `https://api.figma.com/v1/files/${entry.key}${options.geometry ? '?geometry=paths' : ''}` @@ -430,7 +393,7 @@ export const Fetch = new Command('fetch') // Icons run after the other kinds: glyph components are derived from // 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')) { + if (entry.fetch.includes('icons') && wants('icons')) { const pattern = config.config?.processing?.glyphNamePattern; if (!pattern) { console.error(`Error: sources.${entry.alias}.data includes "icons" but config.processing.glyphNamePattern is not set`); @@ -496,6 +459,20 @@ export const Fetch = new Command('fetch') } clearInlineStatus(); + + // 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 report = refreshCache({ + dataDir, + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + }); + reportCache(report); + } + console.log('✓ Fetch complete'); process.exit(ERROR_CODES.SUCCESS); } catch (error) { diff --git a/packages/cli/src/commands/GenerateCommand.ts b/packages/cli/src/commands/GenerateCommand.ts index 443ceec9..2c21a480 100644 --- a/packages/cli/src/commands/GenerateCommand.ts +++ b/packages/cli/src/commands/GenerateCommand.ts @@ -4,8 +4,10 @@ * Auto-detects source type: * - JSON file → file mode (single component with -c) * - Markdown manifest → manifest mode (multiple components from checkboxes) + * - --from-bridge → bridge mode (current Figma selection via CLI bridge) * - * Both modes use Components.fromRestApi() batch API. + * File/manifest modes use Components.fromRestApi() batch API. Bridge mode + * gets an already-generated spec from the plugin over the bridge — no REST fetch. */ import { Command } from 'commander'; @@ -15,6 +17,7 @@ import yaml from 'yaml'; import { Components } from '@directededges/specs-from-figma'; import type { ProgressEvent, RestLicenseInput } from '@directededges/specs-from-figma'; import { ConfigLoader } from '../Config/ConfigLoader.js'; +import type { CLIConfig } from '../Types/CLIConfig.js'; import { loadFoundations } from '../utilities/loadFoundations.js'; import { ManifestParser } from '../utilities/ManifestParser.js'; import { ManifestParserV2 } from '../utilities/ManifestParserV2.js'; @@ -27,6 +30,9 @@ import { CombinedFileWriter } from '../Writers/CombinedFileWriter.js'; import type { FileWriter, WriteResult } from '../Writers/FileWriter.js'; import type { OutputFormat } from '../Types/OutputConfig.js'; import { ImageFillsResolver, IMAGES_DIR_NAME } from '../utilities/ImageFillsResolver.js'; +import { postGenerateFromSelection } from '../bridge/client.js'; +import { formatKey } from '../utilities/formatKey.js'; +import { resolveFileKey } from '../bridge/pickConnection.js'; declare const __SPECS_CLI_VERSION__: string; @@ -67,6 +73,10 @@ interface GenerateOptions { splitConcerns?: boolean; useSubfolders?: boolean; getImages?: boolean; + fromBridge?: boolean; + file?: string; + node?: string; + remove?: boolean; } /** @@ -82,6 +92,166 @@ function resolveFileSourceAlias(sources: NonNullable }>, + errors: Array<{ component: string; error: string }>, + isManifest: boolean, + options: GenerateOptions, + config: CLIConfig, + modelConfig: CLIConfig['config'] +): Promise { + // ------------------------------------------------------------------- + // File mode stdout (no -o) + // ------------------------------------------------------------------- + if (!isManifest && !options.output && !config.outputDirectory) { + 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'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const componentData = processedComponents[0].spec; + const outputFormat = options.format + ? options.format.toLowerCase() + : modelConfig.format.output.toLowerCase(); + + const formattedOutput = outputFormat === 'yaml' + ? yaml.stringify(componentData) + : JSON.stringify(componentData, null, 2); + + console.log(formattedOutput); + process.exit(ERROR_CODES.SUCCESS); + return; + } + + // ------------------------------------------------------------------- + // File output via manifest + writer + // ------------------------------------------------------------------- + const resolvedFormat: OutputFormat = options.format + ? options.format.toLowerCase() as OutputFormat + : modelConfig.format.output.toLowerCase() as OutputFormat; + + 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, + defaultFormat: resolvedFormat + }; + + let outputPath: string; + if (options.output) { + outputPath = path.resolve(options.output); + } else if (config.outputDirectory) { + outputPath = path.resolve(config.outputDirectory); + } else { + // Should not reach here — handled above for file mode stdout + process.exit(ERROR_CODES.INVALID_ARGS); + return; + } + + // When in single-file mode and outputPath is an existing directory, + // append a default filename so we don't try to open a directory as a file + const isSingleFileMode = !outputConfig.splitComponents && !outputConfig.splitConcerns; + if (isSingleFileMode && fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) { + outputPath = path.join(outputPath, `library.${resolvedFormat}`); + } + + const baseDir = outputConfig.splitComponents || outputConfig.splitConcerns + ? outputPath + : path.dirname(outputPath); + + const outputFileName = (!outputConfig.splitComponents && !outputConfig.splitConcerns) + ? path.basename(outputPath) + : undefined; + + // ------------------------------------------------------------------- + // Image resolution (ADR-063, --get-images): add src to unresolved + // registry entries — files written under {baseDir}/_images/, referenced + // relative to the spec file that points at them. Runs before the + // manifest so writers serialize the resolved registry values. + // ------------------------------------------------------------------- + if (options.getImages) { + const hashes = ImageFillsResolver.collectUnresolvedHashes(processedComponents); + if (hashes.size === 0) { + console.log(modelConfig.processing.images + ? 'Note: --get-images found no unresolved image placeholders' + : 'Note: --get-images has no effect — processing.images is not configured'); + } else { + // Reuse hash-named files already present in _images/ — only the + // remainder needs the token, the API call, and downloads. + const files = await ImageFillsResolver.findExisting(hashes, baseDir); + const missing = new Set([...hashes].filter(hash => !files.has(hash))); + + if (missing.size > 0) { + const token = process.env.FIGMA_TOKEN; + if (!token) { + 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; + if (!fileKey) { + console.error('Error: --get-images requires a configured source file key (sources..key in specs.config.yaml)'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + console.log(`Requesting image download URLs from Figma (${missing.size} image(s))...`); + const urls = await ImageFillsResolver.fetchImageUrls(fileKey, token); + process.stdout.write(`Images downloading (0/${missing.size})`); + const downloaded = await ImageFillsResolver.downloadAndWrite(missing, urls, baseDir, (completed, total) => { + process.stdout.write(`\rImages downloading (${completed}/${total})`); + if (completed === total) process.stdout.write('\n'); + }); + for (const [hash, filename] of downloaded) files.set(hash, filename); + } + + // Spec files sit one level below baseDir when components get their own + // folders (subfolders, or the component+concern combined layout). + const inComponentFolders = !!outputConfig.splitComponents && (!!outputConfig.useSubfolders || !!outputConfig.splitConcerns); + const relativePrefix = inComponentFolders ? `../${IMAGES_DIR_NAME}/` : `${IMAGES_DIR_NAME}/`; + const resolvedCount = ImageFillsResolver.applyResolvedSources(processedComponents, files, relativePrefix); + const reused = hashes.size - missing.size; + console.log(`✓ Resolved ${resolvedCount} image reference(s) into ${files.size} file(s) under ${IMAGES_DIR_NAME}/ (${reused} reused, ${missing.size} downloaded)`); + } + } + + const manifest = new FileManifest(processedComponents, outputConfig, baseDir, outputFileName); + + // Select appropriate writer + let writer: FileWriter; + if (outputConfig.splitConcerns && !outputConfig.splitComponents) { + writer = new ConcernFileWriter(); + } else if (outputConfig.splitComponents && !outputConfig.splitConcerns) { + writer = new ComponentFileWriter(outputConfig.useSubfolders); + } else if (!outputConfig.splitComponents && !outputConfig.splitConcerns) { + writer = new SingleFileWriter(); + } else { + writer = new CombinedFileWriter(); + } + + const writeResult: WriteResult = await writer.write(manifest); + + if (writeResult.warnings.length > 0) { + const isOverwriteWarning = (warning: string) => warning.includes('Overwriting existing file'); + const overwriteCount = writeResult.warnings.filter(isOverwriteWarning).length; + if (overwriteCount > 0) { + console.log('Warning: Overwrote existing file(s)'); + } + writeResult.warnings.filter(warning => !isOverwriteWarning(warning)).forEach(warning => console.log(warning)); + } + + if (writeResult.errors.length > 0) { + writeResult.errors.forEach(error => console.error(`Error: ${error}`)); + process.exit(ERROR_CODES.FILE_ERROR); + } + + process.exit(errors.length > 0 ? ERROR_CODES.GENERAL_ERROR : ERROR_CODES.SUCCESS); +} + 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)') @@ -97,6 +267,10 @@ export const Generate = new Command('generate') .option('--split-concerns', 'Separate API, variants, and examples into different files') .option('--use-subfolders', 'Organize component files in subdirectories (requires --split-components)') .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)') + .option('--node ', 'With --from-bridge: generate from this node id instead of the current selection') + .option('--remove', 'With --from-bridge: delete the node once its spec has been read (round-trip testing — leaves the Figma page as it was found)') .option('--verbose', 'Enable detailed logging', false) .action(async (source: string | undefined, options: GenerateOptions) => { try { @@ -109,6 +283,51 @@ export const Generate = new Command('generate') console.log(`[CLI] Using config from: ${options.config}`); } + // --------------------------------------------------------------- + // BRIDGE MODE (--from-bridge): bypass REST fetch entirely — + // the plugin has already generated the spec from the current + // selection; just relay it through the same output writers. + // --------------------------------------------------------------- + if (options.fromBridge) { + if (source) { + console.error('Error: --from-bridge does not take a source argument (it reads the current Figma selection).'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + 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 }); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + console.error('Error: bridge is not running.'); + console.error(' Start it with: specs bridge start'); + } else { + console.error(`Error: ${err.message}`); + } + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + if (!result.success) { + const msg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); + console.error(`Error: ${msg}`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + if (!result.specData) { + console.error('Error: Bridge returned success but no spec data.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + 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); + return; + } + // Use dataDirectory for loading data files (flag > config > default) const sourceDir = options.dataDir ? path.resolve(options.dataDir) @@ -223,8 +442,34 @@ export const Generate = new Command('generate') } libraryJson = await fs.readJSON(sourceFile); - componentIds = selectedComponents.map(c => c.id); - componentNames = new Map(selectedComponents.map(c => [c.id, c.name])); + + // `--component` used to apply only in file mode, so asking for one component here + // silently generated the whole catalogue — a slow surprise, and one that looks like + // the flag worked. Match on the Figma name, the id, or the formatted key the output + // is written under, since that is the name a caller has in front of them. + let chosen = selectedComponents; + if (options.component) { + const wanted = options.component; + chosen = selectedComponents.filter(c => + c.id === wanted || c.name === wanted || formatKey(c.name, modelConfig.format.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)) + .filter(k => k.toLowerCase().includes(wanted.toLowerCase())) + .slice(0, 5); + if (near.length > 0) { + console.error('Did you mean:'); + for (const k of near) console.error(` ${k}`); + } else { + console.error(`Tip: ${selectedComponents.length} components are available — omit --component to generate all of them.`); + } + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + componentIds = chosen.map(c => c.id); + componentNames = new Map(chosen.map(c => [c.id, c.name])); if (options.verbose) { console.log(`[CLI] File loaded: ${libraryJson.name || path.basename(sourceFile)}`); @@ -417,152 +662,7 @@ export const Generate = new Command('generate') process.exit(ERROR_CODES.GENERAL_ERROR); } - // --------------------------------------------------------------- - // File mode stdout (no -o) - // --------------------------------------------------------------- - if (!isManifest && !options.output && !config.outputDirectory) { - 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'); - process.exit(ERROR_CODES.INVALID_ARGS); - } - const componentData = processedComponents[0].spec; - const outputFormat = options.format - ? options.format.toLowerCase() - : modelConfig.format.output.toLowerCase(); - - const formattedOutput = outputFormat === 'yaml' - ? yaml.stringify(componentData) - : JSON.stringify(componentData, null, 2); - - console.log(formattedOutput); - process.exit(ERROR_CODES.SUCCESS); - return; - } - - // --------------------------------------------------------------- - // File output via manifest + writer - // --------------------------------------------------------------- - const resolvedFormat: OutputFormat = options.format - ? options.format.toLowerCase() as OutputFormat - : modelConfig.format.output.toLowerCase() as OutputFormat; - - 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, - defaultFormat: resolvedFormat - }; - - let outputPath: string; - if (options.output) { - outputPath = path.resolve(options.output); - } else if (config.outputDirectory) { - outputPath = path.resolve(config.outputDirectory); - } else { - // Should not reach here — handled above for file mode stdout - process.exit(ERROR_CODES.INVALID_ARGS); - return; - } - - // When in single-file mode and outputPath is an existing directory, - // append a default filename so we don't try to open a directory as a file - const isSingleFileMode = !outputConfig.splitComponents && !outputConfig.splitConcerns; - if (isSingleFileMode && fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) { - outputPath = path.join(outputPath, `library.${resolvedFormat}`); - } - - const baseDir = outputConfig.splitComponents || outputConfig.splitConcerns - ? outputPath - : path.dirname(outputPath); - - const outputFileName = (!outputConfig.splitComponents && !outputConfig.splitConcerns) - ? path.basename(outputPath) - : undefined; - - // --------------------------------------------------------------- - // Image resolution (ADR-063, --get-images): add src to unresolved - // registry entries — files written under {baseDir}/_images/, referenced - // relative to the spec file that points at them. Runs before the - // manifest so writers serialize the resolved registry values. - // --------------------------------------------------------------- - if (options.getImages) { - const hashes = ImageFillsResolver.collectUnresolvedHashes(processedComponents); - if (hashes.size === 0) { - console.log(modelConfig.processing.images - ? 'Note: --get-images found no unresolved image placeholders' - : 'Note: --get-images has no effect — processing.images is not configured'); - } else { - // Reuse hash-named files already present in _images/ — only the - // remainder needs the token, the API call, and downloads. - const files = await ImageFillsResolver.findExisting(hashes, baseDir); - const missing = new Set([...hashes].filter(hash => !files.has(hash))); - - if (missing.size > 0) { - const token = process.env.FIGMA_TOKEN; - if (!token) { - 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; - if (!fileKey) { - console.error('Error: --get-images requires a configured source file key (sources..key in specs.config.yaml)'); - process.exit(ERROR_CODES.INVALID_ARGS); - } - - console.log(`Requesting image download URLs from Figma (${missing.size} image(s))...`); - const urls = await ImageFillsResolver.fetchImageUrls(fileKey, token); - process.stdout.write(`Images downloading (0/${missing.size})`); - const downloaded = await ImageFillsResolver.downloadAndWrite(missing, urls, baseDir, (completed, total) => { - process.stdout.write(`\rImages downloading (${completed}/${total})`); - if (completed === total) process.stdout.write('\n'); - }); - for (const [hash, filename] of downloaded) files.set(hash, filename); - } - - // Spec files sit one level below baseDir when components get their own - // folders (subfolders, or the component+concern combined layout). - const inComponentFolders = !!outputConfig.splitComponents && (!!outputConfig.useSubfolders || !!outputConfig.splitConcerns); - const relativePrefix = inComponentFolders ? `../${IMAGES_DIR_NAME}/` : `${IMAGES_DIR_NAME}/`; - const resolvedCount = ImageFillsResolver.applyResolvedSources(processedComponents, files, relativePrefix); - const reused = hashes.size - missing.size; - console.log(`✓ Resolved ${resolvedCount} image reference(s) into ${files.size} file(s) under ${IMAGES_DIR_NAME}/ (${reused} reused, ${missing.size} downloaded)`); - } - } - - const manifest = new FileManifest(processedComponents, outputConfig, baseDir, outputFileName); - - // Select appropriate writer - let writer: FileWriter; - if (outputConfig.splitConcerns && !outputConfig.splitComponents) { - writer = new ConcernFileWriter(); - } else if (outputConfig.splitComponents && !outputConfig.splitConcerns) { - writer = new ComponentFileWriter(outputConfig.useSubfolders); - } else if (!outputConfig.splitComponents && !outputConfig.splitConcerns) { - writer = new SingleFileWriter(); - } else { - writer = new CombinedFileWriter(); - } - - const writeResult: WriteResult = await writer.write(manifest); - - if (writeResult.warnings.length > 0) { - const isOverwriteWarning = (warning: string) => warning.includes('Overwriting existing file'); - const overwriteCount = writeResult.warnings.filter(isOverwriteWarning).length; - if (overwriteCount > 0) { - console.log('Warning: Overwrote existing file(s)'); - } - writeResult.warnings.filter(warning => !isOverwriteWarning(warning)).forEach(warning => console.log(warning)); - } - - if (writeResult.errors.length > 0) { - writeResult.errors.forEach(error => console.error(`Error: ${error}`)); - process.exit(ERROR_CODES.FILE_ERROR); - } - - - process.exit(errors.length > 0 ? ERROR_CODES.GENERAL_ERROR : ERROR_CODES.SUCCESS); + await writeGeneratedOutput(processedComponents, errors, isManifest, options, config, modelConfig); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/RenderCommand.ts b/packages/cli/src/commands/RenderCommand.ts new file mode 100644 index 00000000..3eb39770 --- /dev/null +++ b/packages/cli/src/commands/RenderCommand.ts @@ -0,0 +1,345 @@ +/** + * Render Command + * + * Sends a spec to the local CLI bridge, which relays it to a connected + * Specs 2 Figma plugin to create or update the matching component live in + * Figma. See `specs bridge` to start/stop the bridge. + */ + +import type { ResolvedConfig } from '@directededges/specs-schema'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'path'; +import { createInterface } from 'readline'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; +import { postRender, type RenderResponse } from '../bridge/client.js'; +import { resolveFileKey } from '../bridge/pickConnection.js'; +import { findComponentFolders, isComponentFolder, loadSpec } from '../Render/SpecLoader.js'; +import { startSpinner } from '../utilities/spinner.js'; +import { refreshCache } from '../Cache/Cache.js'; +import { reportCache } from './CacheCommand.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, + INVALID_ARGS: 2, +}; + +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)') + .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)') + .option('--watch', 'Watch the spec path and re-render on every change (implies --overwrite)') + .option('--strict', 'Fail the render when an instance element cannot be resolved, instead of rendering a component with missing content') + .option('--timing', 'Print a phase-by-phase timing report for the render (bridge manifests, then plugin render phases)') + .option('--refresh-cache', 'Rebuild the render lookup caches from fetched data before rendering (see `specs cache`)') + .action(async (specPath: string | undefined, options: { config?: string; file?: string; page?: string; overwrite?: boolean; watch?: boolean; strict?: boolean; timing?: boolean; refreshCache?: boolean; verbose?: boolean }) => { + // Ahead of everything else: a stale cache is a hard failure on the bridge, and this + // 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.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + reportCache(refreshCache({ + dataDir: config.dataDirectory, + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + })); + } + if (options.watch) { + if (!specPath) { + console.error('Error: --watch requires a spec path.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + await watchAndRender(specPath, options); + return; + } + + // Zero-arg resolution: the configured outputDirectory, 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); + 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.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + try { + const absSpecPath = path.resolve(specPath); + if (!fs.existsSync(absSpecPath)) { + console.error(`Error: Spec path not found: ${absSpecPath}`); + 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; + try { + workspaceConfig = new ConfigLoader().load(options.config).config; + } catch { + workspaceConfig = undefined; + } + const withConfig = { ...options, workspaceConfig }; + + const isBatchDir = fs.statSync(absSpecPath).isDirectory() && !isComponentFolder(absSpecPath); + if (isBatchDir) { + await renderBatchDirectory(absSpecPath, withConfig); + } else { + await renderSpecPath(absSpecPath, withConfig); + } + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + console.error('Error: bridge is not running.'); + console.error(' Start it with: specs bridge start'); + } else { + console.error(`Error: ${err.message}`); + } + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); + +// Shared by the one-shot render path and each watch-triggered re-render. +// Throws on failure; caller decides whether that's fatal (one-shot) or just +// 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 } +): Promise { + const { spec, resolvePath } = loadSpec(specPath); + // The component, not the path it came from: the full path is noise on every line of a + // batch, and the name is what identifies the render in progress. `resolvePath` is a + // component folder or a spec file, so the extension comes off either way. + const name = path.basename(resolvePath, path.extname(resolvePath)); + const fileKey = await resolveFileKey(options.file); + + // The spinner holds one line while Figma works, and is erased when it stops — the + // outcome prints over it rather than under it, so a batch reads as one line per + // component instead of two. + const stopSpinner = startSpinner(`Rendering: ${name}`); + const startedAt = Date.now(); + let result: RenderResponse; + try { + result = await postRender({ specPath: resolvePath, spec, fileKey, pageId: options.page, overwrite: options.overwrite, config: options.workspaceConfig }); + } finally { + stopSpinner(); + } + const elapsed = Date.now() - startedAt; + + if (!result.success) { + const msg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); + throw new Error(`Render failed: ${msg}`); + } + + // Success is the whole contract — reading the produced component's spec is + // an explicit second call (`specs generate --from-bridge`), not a side effect. + // Render warnings are withheld for now. They are dominated by known, tracked defects + // (see the sub-issues of #281) and by degradations a user cannot act on, so printing a + // wall of them per render buries the outcome rather than informing it. The INCOMPLETE + // count below still reports the one case that means content is actually missing. + const SHOW_RENDER_WARNINGS = false; + if (SHOW_RENDER_WARNINGS) for (const w of result.warnings ?? []) console.warn(` ⚠ ${w}`); + + // A render that could not place an instance produced a component missing + // content, so "✓ Rendered" on its own overstates what happened. Say it plainly + // — a per-warning line scrolls past in a batch, a count does not. + const dropped = countDroppedInstances(result.warnings); + if (dropped > 0) { + console.warn( + ` ⚠ INCOMPLETE: ${dropped} instance element(s) could not be resolved and were not rendered. ` + + 'The component exists in Figma but is missing content.' + ); + if (options.strict) { + throw new Error( + `Render incomplete: ${dropped} instance element(s) not rendered (--strict). ` + + 'Drop --strict to accept an incomplete render.' + ); + } + } + + // Matches the shape fetch sets for per-item work: `✓ Verb: subject (detail)`. + console.log(`✓ Rendered: ${name} (${(elapsed / 1000).toFixed(1)}s, nodeId: ${result.nodeId})`); + if (options.timing) printTimingReport(result, elapsed); + return dropped; +} + +/** + * Warnings that mean content is missing from the rendered component, as opposed to + * cosmetic degradations (font fallbacks, skipped style keys) which leave it complete. + * Matched on render's own message text — see `Elements.createChild`. + */ +/** + * Attribute a render's wall-clock time to bridge phases (manifest builds, which + * parse the fetched file data) and plugin render phases. Plugin phases that run + * concurrently — one row per variant, say — sum above the render's own total; + * the count column is what makes that readable. + */ +function printTimingReport(result: RenderResponse, elapsed: number): void { + const row = (label: string, ms: number, count?: number): void => { + const share = elapsed > 0 ? `${Math.round((ms / elapsed) * 100)}%`.padStart(4) : ' -'; + const times = count !== undefined && count > 1 ? ` ×${count}` : ''; + console.log(` ${label.padEnd(24)} ${String(ms).padStart(6)}ms ${share}${times}`); + }; + + console.log('\n Timing'); + const bridge = [...(result.bridgeTimings ?? [])].sort((a, b) => b.ms - a.ms); + if (bridge.length > 0) { + console.log(' Bridge:'); + for (const t of bridge) row(t.label, t.ms); + } + if (result.payloadKB !== undefined) { + console.log(` ${'payload'.padEnd(24)} ${String(result.payloadKB).padStart(6)}KB`); + } + const phases = [...(result.timings?.phases ?? [])].sort((a, b) => b.ms - a.ms); + if (phases.length > 0) { + console.log(` Plugin (${result.timings?.total}ms total):`); + for (const p of phases) row(p.label, p.ms, p.count); + } + console.log(` ${'TOTAL'.padEnd(24)} ${String(elapsed).padStart(6)}ms`); +} + +function countDroppedInstances(warnings?: string[]): number { + // Matched on the message an unresolved *instance element* produces. `VariableResolver` + // says "no manifest entry for" too, about a token — counting those reports missing + // content where only a binding was lost, which reads as a far more broken render. + return (warnings ?? []).filter((w) => w.includes('instance element') && w.includes('no manifest entry for')).length; +} + +function confirm(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${question} [y/N]: `, (answer) => { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +/** + * Render every component folder found beneath a directory, sequentially and in + * path order. One failure doesn't abort the run; the exit code reflects the total. + */ +async function renderBatchDirectory( + absDir: string, + options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConfig?: ResolvedConfig }, + // 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 } = {} +): Promise { + const folders = findComponentFolders(absDir); + + if (folders.length === 0) { + const msg = `no component folders found in ${absDir}\nTip: a component folder holds api.(yaml|json) and variants.(yaml|json), at most two levels deep.`; + if (watch) throw new Error(msg); + console.error(`Error: ${msg}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + console.log(`Found ${folders.length} component${folders.length === 1 ? '' : 's'} in ${path.relative(process.cwd(), absDir) || '.'}:`); + for (const folder of folders) console.log(` - ${path.relative(absDir, folder)}`); + + // --overwrite deletes each existing same-titled component before re-rendering, + // so a multi-component sweep is worth confirming when someone is there to ask. + if (!watch && options.overwrite && folders.length > 1 && process.stdin.isTTY && process.stdout.isTTY) { + const ok = await confirm(`\nOverwrite ${folders.length} components in the connected Figma file?`); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + // Resolve the target file once, so an ambiguous bridge doesn't prompt per component. + const resolved = { ...options, file: (await resolveFileKey(options.file)) ?? options.file }; + + let written = 0; + const incomplete: string[] = []; + const failures: string[] = []; + + for (const folder of folders) { + try { + const dropped = await renderSpecPath(folder, resolved); + written++; + if (dropped > 0) incomplete.push(`${path.relative(absDir, folder)} (${dropped})`); + } catch (e) { + const name = path.relative(absDir, folder); + failures.push(name); + console.error(` ✗ ${name}: ${(e as Error).message}`); + } + } + + console.log(`\nDone: ${written} rendered in Figma, ${failures.length} failed.`); + // Incomplete renders succeeded, so they are not failures — but a sweep that + // silently produced components missing content is exactly what this reports. + if (incomplete.length > 0) { + console.warn(`⚠ ${incomplete.length} rendered with missing content: ${incomplete.join(', ')}`); + } + if (failures.length > 0 && !watch) process.exit(ERROR_CODES.GENERAL_ERROR); +} + +const WATCH_DEBOUNCE_MS = 300; + +async function watchAndRender( + specPath: string, + options: { file?: string; workspaceConfig?: ResolvedConfig } +): Promise { + const absSpecPath = path.resolve(specPath); + if (!fs.existsSync(absSpecPath)) { + console.error(`Error: Spec path not found: ${absSpecPath}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const isDir = fs.statSync(absSpecPath).isDirectory(); + const watchTarget = isDir ? absSpecPath : path.dirname(absSpecPath); + const isBatchDir = isDir && !isComponentFolder(absSpecPath); + + let rendering = false; + let pending = false; + let debounceTimer: NodeJS.Timeout | undefined; + + const runRender = async () => { + if (rendering) { + pending = true; + return; + } + rendering = true; + try { + if (isBatchDir) { + await renderBatchDirectory(absSpecPath, { ...options, overwrite: true }, { watch: true }); + } else { + await renderSpecPath(absSpecPath, { ...options, overwrite: true }); + } + } catch (e) { + console.error(`✗ ${(e as Error).message}`); + } finally { + rendering = false; + if (pending) { + pending = false; + void runRender(); + } + } + }; + + const scheduleRender = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(runRender, WATCH_DEBOUNCE_MS); + }; + + console.log(`Watching ${path.relative(process.cwd(), watchTarget) || '.'} for changes...`); + fs.watch(watchTarget, { recursive: true }, scheduleRender); + + await runRender(); + await new Promise(() => {}); // keep the process alive until Ctrl+C +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f5d09b6c..c55c66fb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,17 +24,20 @@ import { Command } from 'commander'; import { Generate } from './commands/GenerateCommand.js'; 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 { 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__; // Backward compatibility: export Scan also as Audit export const Audit = Scan; -export { Generate, Scan, Fetch, Init, ApplyCustomTokens, Transform, Analyze }; +export { Generate, Scan, Fetch, Init, ApplyCustomTokens, Transform, Analyze, Render, Bridge }; export const commands = { Init, @@ -44,6 +47,8 @@ export const commands = { ApplyCustomTokens, Transform, Analyze, + Render, + Bridge, }; export function createProgram(): Command { @@ -58,9 +63,12 @@ export function createProgram(): Command { program.addCommand(Generate); program.addCommand(Scan); program.addCommand(Fetch); + program.addCommand(Cache); program.addCommand(ApplyCustomTokens); program.addCommand(Transform); program.addCommand(Analyze); + program.addCommand(Render); + program.addCommand(Bridge); // Deprecated alias: 'audit' → 'scan' const auditAlias = new Command('audit') diff --git a/packages/cli/src/transforms/Css.mapping.md b/packages/cli/src/transforms/Css.mapping.md index 280c37f3..5ecae94f 100644 --- a/packages/cli/src/transforms/Css.mapping.md +++ b/packages/cli/src/transforms/Css.mapping.md @@ -144,8 +144,8 @@ if present, else `currentColor`. Gradients deferred to phase 2. | Spec key | CSS property | Notes | |----------|-------------|-------| | `visible: false` | `display: none` | Only emitted when explicitly false. | -| `clipContent: true` | `overflow: hidden` | | -| `clipContent: false` | `overflow: visible` | | +| `clipsContent: true` | `overflow: hidden` | | +| `clipsContent: false` | `overflow: visible` | | ### Transform diff --git a/packages/cli/src/transforms/css/styleToCSS.ts b/packages/cli/src/transforms/css/styleToCSS.ts index 89df73d7..b648bff1 100644 --- a/packages/cli/src/transforms/css/styleToCSS.ts +++ b/packages/cli/src/transforms/css/styleToCSS.ts @@ -265,9 +265,9 @@ export function styleToCSS(styles: Record, tokensFormat = 'TOKE // ── Overflow ───────────────────────────────────────────────────────────────── - if ('clipContent' in styles && styles.clipContent !== undefined) { - if (styles.clipContent === true) decls.push('overflow: hidden'); - else if (styles.clipContent === false) decls.push('overflow: visible'); + if ('clipsContent' in styles && styles.clipsContent !== undefined) { + if (styles.clipsContent === true) decls.push('overflow: hidden'); + else if (styles.clipsContent === false) decls.push('overflow: visible'); } // ── Transform ──────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/utilities/formatKey.ts b/packages/cli/src/utilities/formatKey.ts new file mode 100644 index 00000000..578ba61c --- /dev/null +++ b/packages/cli/src/utilities/formatKey.ts @@ -0,0 +1,76 @@ +/** + * Spec key formatting, ported from `Utilities.formatKey` in specs-from-figma. + * + * The bridge needs to recognise a spec's `instanceOf` value — which is a formatted key — + * among the library's component names, which are raw Figma names. Since the transform is + * lossy (`"DS Link/On overlay/M"` and `"DS Link On Overlay M"` format identically), + * there is no inverse; the only way to match is to apply the same transform forward to + * every candidate name and compare in formatted space. + * + * This is a deliberate duplicate rather than an import: `formatKey` is internal to + * specs-from-figma and not part of its public surface. `tests/unit/utilities/formatKey.test.ts` + * pins the behaviour against cases taken from that implementation — the two must stay in + * lockstep, and drift shows up as a lookup that silently finds nothing. + */ + +export type FormatKeys = 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + +const UNSAFE_CHARS_REGEX = /["\\\n\r\t\b\f.[\]]/g; +const WORD_SEPARATOR_REGEX = /[\s\-_]+/g; +const NON_ALPHANUMERIC_REGEX = /[^a-zA-Z0-9]+/g; + +/** + * Convert a raw Figma name to the spec key form named by `format`. + * + * @param str - the raw name + * @param format - the workspace's `format.keys` setting; SAFE when unset, matching the source + */ +export function formatKey(str: string, format: FormatKeys | string = 'SAFE'): string { + // SAFE keeps the original string minus unsafe characters; every other format works from + // words, so separators (including "/") become boundaries rather than being deleted. + const cleanedUnsafeRemoved = str.replace(UNSAFE_CHARS_REGEX, ''); + + const cleanedForSplit = str + .replace(UNSAFE_CHARS_REGEX, ' ') + .replace(NON_ALPHANUMERIC_REGEX, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const words = cleanedForSplit + .split(WORD_SEPARATOR_REGEX) + .filter(word => word.length > 0) + .map(word => word.replace(NON_ALPHANUMERIC_REGEX, '')) + .filter(word => word.length > 0); + + if (words.length === 0) return ''; + + switch (format?.toUpperCase()) { + case 'SAFE': + return cleanedUnsafeRemoved; + + case 'CAMEL': + // A single word only has its first character lowered, so an already-camelCase name + // ("effectStyle") survives intact. + if (words.length === 1) { + const w = words[0]; + return w.charAt(0).toLowerCase() + w.slice(1); + } + return words[0].toLowerCase() + + words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(''); + + case 'SNAKE': + return words.map(w => w.toLowerCase()).join('_'); + + case 'KEBAB': + return words.map(w => w.toLowerCase()).join('-'); + + case 'PASCAL': + return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(''); + + case 'TRAIN': + return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('-'); + + default: + return cleanedUnsafeRemoved; + } +} diff --git a/packages/cli/src/utilities/glyphComponents.ts b/packages/cli/src/utilities/glyphComponents.ts new file mode 100644 index 00000000..6bce7713 --- /dev/null +++ b/packages/cli/src/utilities/glyphComponents.ts @@ -0,0 +1,45 @@ +/** + * Glyph component discovery from a fetched file document. + * + * Shared by `fetch` (which downloads an SVG per glyph) and the cache builder + * (which records each glyph's node id and published key), so both read the + * same names out of the same pattern. + * + * @packageDocumentation + */ + +/** + * Walk the file document for COMPONENT nodes whose name matches the + * glyphNamePattern ("DS Icon asset / {i}" — {i} captures the icon name). + * Duplicate slugs keep the first occurrence and suffix later ones with the + * node id so nothing is silently dropped. + */ +export function collectGlyphComponents(document: unknown, pattern: string): Array<{ id: string; name: string; slug: string }> { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{i\\\}/g, '(.+)'); + const regex = new RegExp(`^${escaped}$`); + const found: Array<{ id: string; name: string; slug: string }> = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const n = node as { id?: string; name?: string; type?: string; children?: unknown[] }; + if (n.type === 'COMPONENT' && typeof n.name === 'string' && typeof n.id === 'string') { + const match = n.name.match(regex); + if (match) found.push({ id: n.id, name: match[1] ?? n.name, slug: '' }); + } + for (const child of n.children ?? []) walk(child); + }; + walk(document); + + const seen = new Set(); + for (const glyph of found) { + // Kebabize camelCase too, matching the scaffold's glyphUrl slugging. + const base = glyph.name + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .toLowerCase(); + glyph.slug = seen.has(base) ? `${base}-${glyph.id.replace(':', '-')}` : base; + seen.add(base); + } + return found; +} diff --git a/packages/cli/src/utilities/spinner.ts b/packages/cli/src/utilities/spinner.ts new file mode 100644 index 00000000..6b2ac68c --- /dev/null +++ b/packages/cli/src/utilities/spinner.ts @@ -0,0 +1,62 @@ +/** + * Inline progress for commands that wait on something slow — a download, a render. + * + * The spinner occupies one line and is erased when it stops, so the caller prints the + * outcome over it rather than under it: one line per unit of work, start to finish. + * + * Outside a TTY (a pipe, CI, a log file) there is no cursor to move, so the text prints + * once and the elapsed time is still returned. Nothing writes escape codes into a file. + * + * @packageDocumentation + */ + +import readline from 'readline'; + +export function isInteractive(): boolean { + return Boolean(process.stdout.isTTY); +} + +export function renderInlineStatus(text: string): void { + if (!isInteractive()) { + console.log(text); + return; + } + + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(text); +} + +export function clearInlineStatus(): void { + if (!isInteractive()) return; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); +} + +export function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return `${minutes}m ${remaining}s`; +} + +/** Start spinning, and return a stop function that erases the line and reports elapsed time. */ +export function startSpinner(text: string): () => string { + const start = Date.now(); + if (!isInteractive()) { + console.log(text); + return () => formatElapsed(Date.now() - start); + } + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + const id = setInterval(() => { + const elapsed = formatElapsed(Date.now() - start); + renderInlineStatus(`${frames[i++ % frames.length]} ${text} (${elapsed})`); + }, 80); + return () => { + clearInterval(id); + clearInlineStatus(); + return formatElapsed(Date.now() - start); + }; +} diff --git a/packages/cli/src/utilities/variablesIndex.ts b/packages/cli/src/utilities/variablesIndex.ts new file mode 100644 index 00000000..8cf603fc --- /dev/null +++ b/packages/cli/src/utilities/variablesIndex.ts @@ -0,0 +1,76 @@ +/** + * Variables index construction from a fetched variables JSON file. + * + * A spec references a variable by name and nothing else. This index is what gives that name + * meaning: it maps each token name onto the handles needed to reach the actual variable — the + * library key, and the id it has in the file it was fetched from. The fetched + * `/v1/files/:key/variables/local` response is the only place a variable's name, key and id + * appear together, which is why the mapping is built here rather than recorded in the spec. + * + * @packageDocumentation + */ + +/** The handles a token name resolves to. `key` imports the variable from the team library and + * is authoritative for any file. `id` identifies it within the file it was fetched from, and + * is the fallback when a library import is unavailable. `published` is false for a variable + * hidden from publishing, which cannot be imported by key at all. */ +export interface VariableEntry { + key: string; + id: string; + published: boolean; +} + +/** Token name (`collectionName/variableName`) → the handles that name resolves to. */ +export type VariablesIndex = Record; + +interface VariablesJson { + meta?: { + variables?: Record; + variableCollections?: Record; + }; +} + +/** + * Build the token-name index from parsed variables JSON. + * + * A variable with no name is omitted: the name is the only thing a spec can reference it by, + * so an unnamed variable is unreachable regardless of what else is known about it. A variable + * with no key is kept — its id still resolves when rendering into the file it came from. + * + * @param data - Parsed contents of a `*.variables.json` file + * @returns Index keyed by token name + */ +export function buildVariablesIndex(data: VariablesJson | null | undefined): VariablesIndex { + const vars = data?.meta?.variables; + const cols = data?.meta?.variableCollections; + if (!vars || !cols) return {}; + + const colNames: Record = {}; + for (const [id, col] of Object.entries(cols)) { + if (col.name) colNames[id] = col.name; + } + + const index: VariablesIndex = {}; + for (const [id, varDef] of Object.entries(vars)) { + if (!varDef.name) continue; + const colName = (varDef.variableCollectionId ? colNames[varDef.variableCollectionId] : undefined) ?? ''; + const tokenName = colName ? `${colName}/${varDef.name}` : varDef.name; + index[tokenName] = { + key: varDef.key ?? '', + id, + published: varDef.key != null && varDef.hiddenFromPublishing !== true, + }; + } + + return index; +} + +/** Count of names that cannot be imported from the library, for operator-facing logging. */ +export function countUnpublished(index: VariablesIndex): number { + return Object.values(index).filter(e => !e.published).length; +} diff --git a/packages/cli/tests/unit/analyzers/Keys.test.ts b/packages/cli/tests/unit/analyzers/Keys.test.ts new file mode 100644 index 00000000..75c6b309 --- /dev/null +++ b/packages/cli/tests/unit/analyzers/Keys.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import yaml from 'yaml'; +import { KeysAnalyzer } from '../../../src/analyzers/Keys.js'; + +type KeysYaml = { + summary: { + totalComponents: number; + componentsWithDivergence: number; + totalNames: number; + divergentNames: number; + causeDistribution: Record; + }; + byComponent: Record; + anatomy?: Array<{ key: string; figmaName: string; cause: string }>; + }>; + byCause: Array<{ cause: string; occurrences: number; names: string[] }>; + byName: Array<{ figmaName: string; occurrences: number; components: string[]; cause: string }>; +}; + +/** An anatomy or prop entry carrying a recorded Figma name. */ +const withName = (type: string, figmaName: string) => ({ + type, + $extensions: { 'com.figma': { name: figmaName } }, +}); + +describe('KeysAnalyzer', () => { + let outputDir: string; + let analysisDir: string; + + beforeEach(async () => { + outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'keys-test-')); + analysisDir = path.join(outputDir, '_analysis'); + }); + + afterEach(async () => { + await fs.remove(outputDir); + }); + + /** + * @param figmaKeys The convention the specs declare in `metadata.config`. Defaults to + * SENTENCE; pass undefined to model a catalog generated under the NONE default. + */ + async function runAnalyzer( + components: Record>, + figmaKeys: 'SENTENCE' | 'TITLE' | undefined = 'SENTENCE', + ) { + const a = new KeysAnalyzer(); + for (const [componentKey, apiYaml] of Object.entries(components)) { + const compDir = path.join(outputDir, componentKey); + await fs.ensureDir(compDir); + const spec = figmaKeys + ? { ...apiYaml, metadata: { config: { format: { keys: 'CAMEL', figmaKeys } } } } + : apiYaml; + await a.run(spec, { outputDir: compDir, componentKey, outputFormat: 'YAML', tokensFormat: 'TOKEN' }); + } + await a.finalize!(outputDir, analysisDir); + const file = path.join(analysisDir, 'keys.yaml'); + if (!await fs.pathExists(file)) return null; + return yaml.parse(await fs.readFile(file, 'utf-8')) as KeysYaml; + } + + it('reports nothing for a catalog generated under the NONE default', async () => { + const result = await runAnalyzer({ + dsBody: { anatomy: { root: withName('text', 'Text') } }, + }, undefined); + expect(result!.summary.divergentNames).toBe(0); + expect(result!.byComponent).toEqual({}); + }); + + it('ignores a recorded name that satisfies the grammar — wrapper-collapse provenance', async () => { + // $extensions['com.figma'].name has two triggers. ADR-058 wrapper collapse records + // the pre-collapse layer name on `root` whether or not it diverges, so a safe name + // like 'Text' or 'Icon glyph' must not be reported as a naming problem. + const result = await runAnalyzer({ + dsBody: { anatomy: { root: withName('text', 'Text') } }, + dsIcon: { anatomy: { root: withName('glyph', 'Icon glyph') } }, + dsText: { anatomy: { root: withName('text', 'Text'), urlField: withName('text', 'URL field') } }, + }); + expect(result!.summary.divergentNames).toBe(1); + expect(Object.keys(result!.byComponent)).toEqual(['dsText']); + expect(result!.byComponent.dsText.anatomy).toEqual([ + { key: 'urlField', figmaName: 'URL field', cause: 'casing' }, + ]); + }); + + it('reports nothing when no name was recorded', async () => { + const result = await runAnalyzer({ + dsButton: { anatomy: { label: { type: 'text' } }, props: { size: { type: 'string' } } }, + }); + expect(result!.summary.divergentNames).toBe(0); + expect(result!.byComponent).toEqual({}); + }); + + it('groups divergent names under their component', async () => { + const result = await runAnalyzer({ + dsAlert: { + anatomy: { root: { type: 'container' }, iconLeading: withName('glyph', 'Icon Leading') }, + props: { fullBleed: { type: 'boolean', $extensions: { 'com.figma': { name: 'Full Bleed' } } } }, + }, + }); + + expect(result!.byComponent.dsAlert.divergent).toBe(2); + expect(result!.byComponent.dsAlert.props).toEqual([ + { key: 'fullBleed', figmaName: 'Full Bleed', cause: 'casing' }, + ]); + expect(result!.byComponent.dsAlert.anatomy).toEqual([ + { key: 'iconLeading', figmaName: 'Icon Leading', cause: 'casing' }, + ]); + }); + + it('omits a surface array entirely when it is empty', async () => { + const result = await runAnalyzer({ + dsBadge: { props: { fullBleed: withName('boolean', 'Full Bleed') } }, + }); + expect(result!.byComponent.dsBadge).not.toHaveProperty('anatomy'); + expect(result!.byComponent.dsBadge.props).toHaveLength(1); + }); + + it('counts every name, not only divergent ones', async () => { + const result = await runAnalyzer({ + dsButton: { + anatomy: { root: { type: 'container' }, label: { type: 'text' }, urlField: withName('text', 'URL field') }, + props: { size: { type: 'string' } }, + }, + }); + expect(result!.summary.totalNames).toBe(4); + expect(result!.summary.divergentNames).toBe(1); + expect(result!.summary.componentsWithDivergence).toBe(1); + expect(result!.summary.totalComponents).toBe(1); + }); + + it('aggregates a repeated name across components — one decision, not many', async () => { + const props = { a11yLabel: withName('string', 'A11y label') }; + const result = await runAnalyzer({ + dsAvatar: { props }, dsBadge: { props }, dsButton: { props }, + }); + + const entry = result!.byName.find(n => n.figmaName === 'A11y label')!; + expect(entry.occurrences).toBe(3); + expect(entry.components).toEqual(['dsAvatar', 'dsBadge', 'dsButton']); + }); + + it('walks anatomy nested in slot content and compositions', async () => { + const result = await runAnalyzer({ + dsCard: { + anatomy: { root: { type: 'container' } }, + slotContentExamples: { basic: { anatomy: { urlField: withName('text', 'URL field') } } }, + compositions: { hero: { anatomy: { cutPaste: withName('container', 'Cut & paste') } } }, + }, + }); + expect(result!.byComponent.dsCard.divergent).toBe(2); + }); + + it('collects subcomponents under a dot-path key', async () => { + const result = await runAnalyzer({ + dsList: { + anatomy: { root: { type: 'container' } }, + subcomponents: { item: { anatomy: { startIcon: withName('glyph', 'Start Icon') } } }, + }, + }); + expect(result!.byComponent['dsList.item'].divergent).toBe(1); + }); + + describe('cause classification', () => { + it.each([ + ['Label ', 'separator'], + ['Cut & paste', 'symbol'], + ['Étiquette', 'non-ascii'], + ['A11y label', 'mixed-letter-digit'], + ['0000 0000', 'digit-initial'], + ['Start Icon', 'casing'], + ['x-figmacollapse', 'already-a-key'], + ])('classifies %s as %s', async (figmaName, expected) => { + const result = await runAnalyzer({ + dsX: { anatomy: { k: withName('text', figmaName as string) } }, + }); + expect(result!.byComponent.dsX.anatomy![0].cause).toBe(expected); + }); + + it('ranks causes by frequency', async () => { + const result = await runAnalyzer({ + dsA: { anatomy: { a: withName('text', 'Start Icon'), b: withName('text', 'End Icon') } }, + dsB: { anatomy: { c: withName('text', 'Cut & paste') } }, + }); + expect(result!.byCause[0]).toEqual({ cause: 'casing', occurrences: 2, names: ['End Icon', 'Start Icon'] }); + expect(result!.byCause[1].cause).toBe('symbol'); + }); + }); +}); diff --git a/packages/cli/tests/unit/bridge/client.test.ts b/packages/cli/tests/unit/bridge/client.test.ts new file mode 100644 index 00000000..4458a7b2 --- /dev/null +++ b/packages/cli/tests/unit/bridge/client.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { postGenerateFromSelection } from '../../../src/bridge/client.js'; + +describe('postGenerateFromSelection', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts to /generate with an empty body when no fileKey is given', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: true, nodeId: '1:2', name: 'Alert', specData: { title: 'Alert' } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await postGenerateFromSelection(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/generate$/), + expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) + ); + expect(result).toEqual({ success: true, nodeId: '1:2', name: 'Alert', specData: { title: 'Alert' } }); + }); + + it('includes fileKey in the request body when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: false, error: 'No plugin connected for file "fileA".' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await postGenerateFromSelection({ fileKey: 'fileA' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/generate$/), + expect.objectContaining({ body: JSON.stringify({ fileKey: 'fileA' }) }) + ); + expect(result.success).toBe(false); + expect(result.error).toContain('fileA'); + }); +}); diff --git a/packages/cli/tests/unit/bridge/connections.test.ts b/packages/cli/tests/unit/bridge/connections.test.ts new file mode 100644 index 00000000..9a319cb6 --- /dev/null +++ b/packages/cli/tests/unit/bridge/connections.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { ConnectionRegistry } from '../../../src/bridge/connections.js'; + +function fakeSocket(readyState = 1) { + return { readyState }; +} + +describe('ConnectionRegistry', () => { + it('resolve() throws when nothing is connected', () => { + const registry = new ConnectionRegistry(); + expect(() => registry.resolve()).toThrow('No plugin connected. Enable the CLI Bridge in Specs 2 first.'); + }); + + it('resolve() returns the sole connection when exactly one is registered', () => { + const registry = new ConnectionRegistry(); + const ws = fakeSocket(); + registry.register('fileA', ws, 'Design System'); + + const conn = registry.resolve(); + expect(conn.fileKey).toBe('fileA'); + expect(conn.fileName).toBe('Design System'); + expect(conn.ws).toBe(ws); + }); + + it('resolve() throws an ambiguity error listing all connections when 2+ are registered and no fileKey given', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket(), 'Design System'); + registry.register('fileB', fakeSocket(), 'Prototype'); + + expect(() => registry.resolve()).toThrow( + 'Multiple plugins connected — specify --file . Connected: fileA (Design System), fileB (Prototype)' + ); + }); + + it('resolve(fileKey) returns the matching connection even when multiple are registered', () => { + const registry = new ConnectionRegistry(); + const wsA = fakeSocket(); + const wsB = fakeSocket(); + registry.register('fileA', wsA, 'Design System'); + registry.register('fileB', wsB, 'Prototype'); + + expect(registry.resolve('fileB').ws).toBe(wsB); + }); + + it('resolve(fileKey) throws when the requested fileKey is not connected', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + + expect(() => registry.resolve('nonexistent')).toThrow('No plugin connected for file "nonexistent".'); + }); + + it('unregister() removes only the specified connection, not others', () => { + // Regression test for the original single-activeSocket bug: an older + // connection's close handler used to null out a newer connection's + // reference. Confirms unregister is scoped to its own fileKey. + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + registry.register('fileB', fakeSocket()); + + registry.unregister('fileA'); + + expect(registry.size).toBe(1); + expect(() => registry.resolve('fileA')).toThrow(); + expect(registry.resolve('fileB').fileKey).toBe('fileB'); + }); + + it('unregister() of an unknown fileKey is a no-op', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + + expect(() => registry.unregister('nonexistent')).not.toThrow(); + expect(registry.size).toBe(1); + }); + + it('register() with an already-used fileKey overwrites the prior entry (reconnect)', () => { + const registry = new ConnectionRegistry(); + const wsOld = fakeSocket(); + const wsNew = fakeSocket(); + registry.register('fileA', wsOld); + registry.register('fileA', wsNew); + + expect(registry.size).toBe(1); + expect(registry.resolve('fileA').ws).toBe(wsNew); + }); + + it('list() reports connected state per readyState and includes fileName when present', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket(1), 'Design System'); + registry.register('fileB', fakeSocket(3)); // 3 = CLOSED + + const list = registry.list().sort((a, b) => a.fileKey.localeCompare(b.fileKey)); + expect(list).toEqual([ + { fileKey: 'fileA', fileName: 'Design System', connected: true }, + { fileKey: 'fileB', fileName: undefined, connected: false }, + ]); + }); +}); diff --git a/packages/cli/tests/unit/bridge/pickConnection.test.ts b/packages/cli/tests/unit/bridge/pickConnection.test.ts new file mode 100644 index 00000000..2d123aea --- /dev/null +++ b/packages/cli/tests/unit/bridge/pickConnection.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import { isAmbiguous, parseSelection, resolveFileKey } from '../../../src/bridge/pickConnection.js'; +import type { BridgeConnection } from '../../../src/bridge/client.js'; + +const twoConnections: BridgeConnection[] = [ + { fileKey: 'fileA', fileName: 'Design System', connected: true }, + { fileKey: 'fileB', fileName: 'Prototype', connected: true }, +]; + +describe('isAmbiguous', () => { + it('is false for 0 or 1 connections', () => { + expect(isAmbiguous([])).toBe(false); + expect(isAmbiguous([twoConnections[0]])).toBe(false); + }); + + it('is true for 2+ connections', () => { + expect(isAmbiguous(twoConnections)).toBe(true); + }); +}); + +describe('parseSelection', () => { + it('converts a valid 1-based answer to a 0-based index', () => { + expect(parseSelection('1', 2)).toBe(0); + expect(parseSelection('2', 2)).toBe(1); + }); + + it('returns null for out-of-range answers', () => { + expect(parseSelection('0', 2)).toBeNull(); + expect(parseSelection('3', 2)).toBeNull(); + }); + + it('returns null for unparseable answers', () => { + expect(parseSelection('abc', 2)).toBeNull(); + expect(parseSelection('', 2)).toBeNull(); + }); + + it('trims whitespace before parsing', () => { + expect(parseSelection(' 2 ', 2)).toBe(1); + }); +}); + +describe('resolveFileKey', () => { + it('returns the explicit fileKey unchanged without checking status', async () => { + const getStatus = vi.fn(); + const result = await resolveFileKey('explicitKey', { getStatus }); + expect(result).toBe('explicitKey'); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it('returns undefined when not interactive (no TTY), without prompting', async () => { + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: false, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('returns undefined when interactive but fewer than 2 connections', async () => { + const getStatus = vi.fn().mockResolvedValue({ connections: [twoConnections[0]] }); + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('prompts and returns the chosen fileKey when interactive with 2+ connections', async () => { + const getStatus = vi.fn().mockResolvedValue({ connections: twoConnections }); + const prompt = vi.fn().mockResolvedValue('fileB'); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBe('fileB'); + expect(prompt).toHaveBeenCalledWith(twoConnections); + }); + + it('returns undefined when the status fetch fails (bridge not running)', async () => { + const getStatus = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tests/unit/bridge/pidfile.test.ts b/packages/cli/tests/unit/bridge/pidfile.test.ts new file mode 100644 index 00000000..37d308ec --- /dev/null +++ b/packages/cli/tests/unit/bridge/pidfile.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('os', () => ({ + homedir: () => '/fake/home', +})); + +const fsMock = { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +}; + +vi.mock('fs', () => fsMock); + +const { ensureSpecsHome, readPid, writePid, clearPidFile, isAlive, getRunningPid, BRIDGE_PID_FILE, BRIDGE_LOG_FILE } = + await import('../../../src/bridge/pidfile.js'); + +describe('pidfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('computes paths under the (mocked) home directory, not a hardcoded location', () => { + expect(BRIDGE_PID_FILE).toBe('/fake/home/.specs/bridge.pid'); + expect(BRIDGE_LOG_FILE).toBe('/fake/home/.specs/bridge.log'); + }); + + describe('ensureSpecsHome', () => { + it('creates the directory when it does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + ensureSpecsHome(); + expect(fsMock.mkdirSync).toHaveBeenCalledWith('/fake/home/.specs', { recursive: true }); + }); + + it('does not create the directory when it already exists', () => { + fsMock.existsSync.mockReturnValue(true); + ensureSpecsHome(); + expect(fsMock.mkdirSync).not.toHaveBeenCalled(); + }); + }); + + describe('readPid', () => { + it('returns null when the pidfile does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + expect(readPid()).toBeNull(); + }); + + it('returns the parsed pid when the file contains a valid integer', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('12345\n'); + expect(readPid()).toBe(12345); + }); + + it('returns null when the file contents are not a valid positive integer', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('not-a-pid'); + expect(readPid()).toBeNull(); + }); + + it('returns null for a negative or zero pid', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('-5'); + expect(readPid()).toBeNull(); + }); + }); + + describe('writePid / clearPidFile', () => { + it('writePid ensures the directory exists and writes the pid as a string', () => { + fsMock.existsSync.mockReturnValue(false); + writePid(999); + expect(fsMock.mkdirSync).toHaveBeenCalled(); + expect(fsMock.writeFileSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid', '999', 'utf8'); + }); + + it('clearPidFile removes the file when it exists', () => { + fsMock.existsSync.mockReturnValue(true); + clearPidFile(); + expect(fsMock.unlinkSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid'); + }); + + it('clearPidFile is a no-op when the file does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + clearPidFile(); + expect(fsMock.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('isAlive', () => { + it('returns true when process.kill(pid, 0) does not throw', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never); + expect(isAlive(123)).toBe(true); + killSpy.mockRestore(); + }); + + it('returns false when process.kill(pid, 0) throws (process not running)', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); + expect(isAlive(123)).toBe(false); + killSpy.mockRestore(); + }); + }); + + describe('getRunningPid', () => { + it('returns null when there is no pidfile', () => { + fsMock.existsSync.mockReturnValue(false); + expect(getRunningPid()).toBeNull(); + }); + + it('returns the pid when the pidfile exists and the process is alive', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('456'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never); + + expect(getRunningPid()).toBe(456); + + killSpy.mockRestore(); + }); + + it('cleans up a stale pidfile and returns null when the recorded pid is not alive', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('456'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); + + expect(getRunningPid()).toBeNull(); + expect(fsMock.unlinkSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid'); + + killSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/tests/unit/bridge/requestTracker.test.ts b/packages/cli/tests/unit/bridge/requestTracker.test.ts new file mode 100644 index 00000000..511b3aea --- /dev/null +++ b/packages/cli/tests/unit/bridge/requestTracker.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RequestTracker } from '../../../src/bridge/requestTracker.js'; + +describe('RequestTracker', () => { + it('create() returns a unique requestId per call', () => { + const tracker = new RequestTracker(); + const a = tracker.create(1000, 'timeout'); + const b = tracker.create(1000, 'timeout'); + expect(a.requestId).not.toBe(b.requestId); + }); + + it('resolve() resolves the matching promise with the given value', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + const resolved = tracker.resolve(requestId, 'hello'); + + expect(resolved).toBe(true); + await expect(promise).resolves.toBe('hello'); + }); + + it('resolve() with an unknown requestId returns false and does not throw', () => { + const tracker = new RequestTracker(); + expect(tracker.resolve('unknown-id', 'value')).toBe(false); + }); + + it('resolve() only fires once per requestId — a second call is a no-op', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + tracker.resolve(requestId, 'first'); + const secondCallResult = tracker.resolve(requestId, 'second'); + + expect(secondCallResult).toBe(false); + await expect(promise).resolves.toBe('first'); + }); + + it('two concurrent requests resolve independently without cross-talk', async () => { + // This is the concrete regression the correlation-ID design replaced a + // single global pending-slot to fix: two in-flight requests (e.g. to two + // different connected Figma files) must not resolve each other. + const tracker = new RequestTracker(); + const first = tracker.create(1000, 'timeout'); + const second = tracker.create(1000, 'timeout'); + + tracker.resolve(second.requestId, 'second-result'); + tracker.resolve(first.requestId, 'first-result'); + + await expect(first.promise).resolves.toBe('first-result'); + await expect(second.promise).resolves.toBe('second-result'); + }); + + describe('timeouts', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('rejects with the given message if never resolved before the timeout', async () => { + const tracker = new RequestTracker(); + const { promise } = tracker.create(1000, 'Timed out waiting for result.'); + + const assertion = expect(promise).rejects.toThrow('Timed out waiting for result.'); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + }); + + it('does not reject if resolved before the timeout fires', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'Timed out.'); + + tracker.resolve(requestId, 'in time'); + await vi.advanceTimersByTimeAsync(1000); + + await expect(promise).resolves.toBe('in time'); + }); + + it('has() and size reflect pending requests and clear after resolve or timeout', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + expect(tracker.has(requestId)).toBe(true); + expect(tracker.size).toBe(1); + + tracker.resolve(requestId, 'done'); + await promise; + + expect(tracker.has(requestId)).toBe(false); + expect(tracker.size).toBe(0); + }); + }); +}); diff --git a/packages/cli/tests/unit/cache/Cache.test.ts b/packages/cli/tests/unit/cache/Cache.test.ts new file mode 100644 index 00000000..f5ed8f78 --- /dev/null +++ b/packages/cli/tests/unit/cache/Cache.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, utimesSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { parse } from 'yaml'; +import { + refreshCache, validateCache, readCacheFile, cachePath, describeProblems, + type ComponentsEntry, type StylesEntry, type VariablesEntry, type IconsEntry, +} from '../../../src/Cache/Cache.js'; + +const PATTERN = 'Icon / {i}'; + +function filePayload(overrides: Record = {}): string { + return JSON.stringify({ + components: { + '1:1': { key: 'componentkey1', name: 'Library Button/Primary/M' }, + '1:2': { name: 'Unpublished' }, // no published key — omitted from the cache + '9:9': { key: 'glyphkey', name: 'Icon / star' }, + }, + componentSets: { + '2:1': { key: 'setkey1', name: 'Library Card' }, + }, + styles: { + s1: { styleType: 'FILL', name: 'Color/Primary', key: 'fillkey' }, + s2: { styleType: 'TEXT', name: 'Type/Body', key: 'textkey' }, + s3: { styleType: 'GRID', name: 'Grid/Ignored', key: 'gridkey' }, + s4: { styleType: 'EFFECT', name: 'Shadow/Low', key: 'effectkey' }, + }, + document: { + id: '0:0', + children: [ + { id: '9:9', type: 'COMPONENT', name: 'Icon / star' }, + { id: '9:8', type: 'COMPONENT', name: 'Not an icon' }, + ], + }, + ...overrides, + }); +} + +function variablesPayload(): string { + return JSON.stringify({ + meta: { + variableCollections: { c1: { name: 'Color' } }, + variables: { + v1: { name: 'Primary', key: 'varkey1', variableCollectionId: 'c1', hiddenFromPublishing: false }, + v2: { name: 'Secret', key: 'varkey2', variableCollectionId: 'c1', hiddenFromPublishing: true }, + }, + }, + }); +} + +describe('Cache', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'specs-cache-')); + writeFileSync(join(dataDir, 'library.file.json'), filePayload()); + writeFileSync(join(dataDir, 'library.variables.json'), variablesPayload()); + }); + + afterEach(() => rmSync(dataDir, { recursive: true, force: true })); + + const build = (aliases = ['library'], force = false) => + refreshCache({ dataDir, aliases, glyphNamePattern: PATTERN, force }); + + describe('building', () => { + it('writes all four caches', () => { + const report = build(); + expect(report.rebuilt).toEqual(['library']); + expect(report.counts).toEqual({ components: 3, styles: 3, variables: 2, icons: 1 }); + }); + + it('records only components that carry a published key', () => { + build(); + const cache = readCacheFile(dataDir, 'components')!; + expect(Object.keys(cache.entries).sort()).toEqual(['1:1', '2:1', '9:9']); + expect(cache.entries['1:1']).toEqual({ key: 'componentkey1', name: 'Library Button/Primary/M', file: 'library' }); + }); + + it('keeps only the style types a spec can reference', () => { + build(); + const cache = readCacheFile(dataDir, 'styles')!; + expect(Object.keys(cache.entries).sort()).toEqual(['Color/Primary', 'Shadow/Low', 'Type/Body']); + expect(cache.entries['Color/Primary']).toEqual({ key: 'fillkey', type: 'FILL', file: 'library' }); + }); + + it('carries the published flag through to variables', () => { + build(); + const cache = readCacheFile(dataDir, 'variables')!; + expect(cache.entries['Color/Primary'].published).toBe(true); + expect(cache.entries['Color/Secret'].published).toBe(false); + }); + + it('extracts glyph names with the pattern and cross-references their keys', () => { + build(); + const cache = readCacheFile(dataDir, 'icons')!; + expect(cache.entries).toEqual({ star: { id: '9:9', key: 'glyphkey', file: 'library' } }); + }); + + it('writes an empty icons cache when nothing matches, so "none" differs from "never built"', () => { + refreshCache({ dataDir, aliases: ['library'], glyphNamePattern: 'Nothing / {i}' }); + const cache = readCacheFile(dataDir, 'icons')!; + expect(cache.entries).toEqual({}); + expect(cache.sources.library).toBeDefined(); + }); + + it('records the raw Figma name, which is what an instanceOf lookup matches against', () => { + build(); + const cache = readCacheFile(dataDir, 'components')!; + // Stored raw and formatted at lookup time, so changing format.keys needs no rebuild. + expect(cache.entries['2:1'].name).toBe('Library Card'); + expect(cache.entries['9:9'].name).toBe('Icon / star'); + }); + + it('tags every entry with the alias it came from', () => { + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey', name: 'Brand Thing' } }, + styles: {}, + document: {}, + })); + build(['library', 'brand']); + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['1:1'].file).toBe('library'); + expect(cache.entries['5:5'].file).toBe('brand'); + expect(Object.keys(cache.sources).sort()).toEqual(['brand', 'library']); + }); + + it('skips an alias that has not been fetched', () => { + const report = build(['library', 'missing']); + expect(report.unfetched).toEqual(['missing']); + expect(report.rebuilt).toEqual(['library']); + }); + }); + + describe('incremental rebuilds', () => { + it('leaves an alias alone when its payload has not changed', () => { + build(); + const report = build(); + expect(report.current).toEqual(['library']); + expect(report.rebuilt).toEqual([]); + expect(report.counts.components).toBe(3); + }); + + it('rebuilds every alias under --force', () => { + build(); + expect(build(['library'], true).rebuilt).toEqual(['library']); + }); + + it('rebuilds only the alias whose payload changed, keeping the other alias intact', () => { + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey', name: 'Brand Thing' } }, styles: {}, document: {}, + })); + build(['library', 'brand']); + + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey2', name: 'Brand Thing' }, '6:6': { key: 'newkey', name: 'New Thing' } }, styles: {}, document: {}, + })); + const report = build(['library', 'brand']); + + expect(report.rebuilt).toEqual(['brand']); + expect(report.current).toEqual(['library']); + + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['5:5'].key).toBe('brandkey2'); + expect(cache.entries['6:6']).toBeDefined(); + expect(cache.entries['1:1'].key).toBe('componentkey1'); // untouched alias survived + }); + + it('drops entries an alias no longer has', () => { + build(); + writeFileSync(join(dataDir, 'library.file.json'), JSON.stringify({ + components: { '1:1': { key: 'componentkey1', name: 'Library Button/Primary/M' } }, styles: {}, document: {}, + })); + build(); + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['2:1']).toBeUndefined(); + }); + }); + + describe('validation', () => { + const validate = (aliases = ['library'], pattern: string | undefined = PATTERN) => + validateCache({ dataDir, aliases, glyphNamePattern: pattern }); + + it('passes on a freshly built cache', () => { + build(); + expect(validate()).toEqual([]); + }); + + it('reports every concern as missing when nothing is built', () => { + expect(validate().map(p => p.concern).sort()).toEqual(['components', 'icons', 'styles', 'variables']); + expect(validate().every(p => p.reason === 'missing')).toBe(true); + }); + + it('reports the concern whose payload changed as stale', () => { + build(); + writeFileSync(join(dataDir, 'library.variables.json'), JSON.stringify({ meta: { variables: {}, variableCollections: {} } })); + expect(validate()).toEqual([{ concern: 'variables', alias: 'library', reason: 'stale' }]); + }); + + it('treats a same-size rewrite as stale, since mtime moved', () => { + build(); + const path = join(dataDir, 'library.file.json'); + const content = readFileSync(path, 'utf8'); + writeFileSync(path, content); + const future = new Date(Date.now() + 10_000); + utimesSync(path, future, future); + const stale = validate().filter(p => p.reason === 'stale').map(p => p.concern).sort(); + expect(stale).toEqual(['components', 'icons', 'styles']); + }); + + it('invalidates icons when the glyph pattern changes, with no file change at all', () => { + build(); + expect(validate(['library'], 'Different / {i}')).toEqual([ + { concern: 'icons', alias: 'library', reason: 'stale' }, + ]); + }); + + it('reports a newly declared but unbuilt alias', () => { + build(); + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ components: {}, styles: {}, document: {} })); + const problems = validateCache({ dataDir, aliases: ['library', 'brand'], glyphNamePattern: PATTERN }); + expect(problems.every(p => p.alias === 'brand' && p.reason === 'missing')).toBe(true); + }); + + it('names the command that fixes the problem', () => { + const message = describeProblems([{ concern: 'variables', alias: 'library', reason: 'stale' }]); + expect(message).toContain('variables.yaml: "library" is stale'); + expect(message).toContain('specs cache'); + }); + }); + + describe('file format', () => { + it('writes a do-not-edit header above valid YAML', () => { + build(); + const raw = readFileSync(cachePath(dataDir, 'styles'), 'utf8'); + expect(raw.startsWith('# Generated by `specs cache`')).toBe(true); + expect(parse(raw).entries['Color/Primary'].key).toBe('fillkey'); + }); + + it('records the payload each alias was built from', () => { + build(); + const cache = readCacheFile(dataDir, 'styles')!; + expect(cache.sources.library.from).toBe('library.file.json'); + expect(cache.sources.library.bytes).toBeGreaterThan(0); + expect(cache.sources.library.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('records the glyph pattern only on the icons cache', () => { + build(); + expect(readCacheFile(dataDir, 'icons')!.sources.library.glyphNamePattern).toBe(PATTERN); + expect(readCacheFile(dataDir, 'styles')!.sources.library.glyphNamePattern).toBeUndefined(); + }); + + it('treats an unreadable cache as absent rather than throwing', () => { + build(); + writeFileSync(cachePath(dataDir, 'components'), ': not : valid : yaml :\n - ['); + expect(readCacheFile(dataDir, 'components')).toBeNull(); + const problems = validateCache({ dataDir, aliases: ['library'], glyphNamePattern: PATTERN }); + expect(problems.some(p => p.concern === 'components' && p.reason === 'missing')).toBe(true); + }); + }); +}); diff --git a/packages/cli/tests/unit/commands/GenerateCommand.test.ts b/packages/cli/tests/unit/commands/GenerateCommand.test.ts index 79ca4cad..b482f31e 100644 --- a/packages/cli/tests/unit/commands/GenerateCommand.test.ts +++ b/packages/cli/tests/unit/commands/GenerateCommand.test.ts @@ -45,9 +45,20 @@ describe('GenerateCommand', () => { expect(options).toContain('--split-components'); expect(options).toContain('--split-concerns'); expect(options).toContain('--use-subfolders'); + expect(options).toContain('--from-bridge'); + expect(options).toContain('--file'); expect(options).toContain('--verbose'); }); + it('--from-bridge and --file are not mandatory (opt-in source mode)', () => { + const fromBridge = Generate.options.find(o => o.long === '--from-bridge'); + const file = Generate.options.find(o => o.long === '--file'); + expect(fromBridge).toBeDefined(); + expect(fromBridge!.mandatory).toBeFalsy(); + expect(file).toBeDefined(); + expect(file!.mandatory).toBeFalsy(); + }); + it('has short aliases for key options', () => { const shorts = Generate.options.map(o => o.short).filter(Boolean); diff --git a/packages/cli/tests/unit/render/SpecLoader.test.ts b/packages/cli/tests/unit/render/SpecLoader.test.ts new file mode 100644 index 00000000..7aba0cc8 --- /dev/null +++ b/packages/cli/tests/unit/render/SpecLoader.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { stringify } from 'yaml'; +import { findComponentFolders, loadSpec } from '../../../src/Render/SpecLoader'; +import { splitComponentByConcern } from '../../../src/Writers/DataTransformers'; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'specloader-test-')); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const COMPONENT = { + title: 'DE Button', + anatomy: { root: { type: 'container' } }, + props: { appearance: { type: 'string', default: 'Filled' } }, + default: { appearance: 'Filled' }, + variants: [{ appearance: 'Filled' }, { appearance: 'Outline' }], + slotContentExamples: { label: 'Click me' }, + metadata: { generatedAt: '2026-01-01T00:00:00.000Z' }, +}; + +describe('loadSpec — single file', () => { + it('parses a combined .yaml spec', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.yaml'); + fs.writeFileSync(file, stringify(COMPONENT), 'utf8'); + + const { spec, resolvePath } = loadSpec(file); + + expect(resolvePath).toBe(path.resolve(file)); + expect(spec.title).toBe('DE Button'); + expect(spec.variants).toEqual(COMPONENT.variants); + }); + + it('parses a combined .json spec', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.json'); + fs.writeFileSync(file, JSON.stringify(COMPONENT), 'utf8'); + + const { spec } = loadSpec(file); + + expect(spec.title).toBe('DE Button'); + expect(spec.props).toEqual(COMPONENT.props); + }); + + it('throws a clear error for an unsupported extension', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.txt'); + fs.writeFileSync(file, 'title: X', 'utf8'); + + expect(() => loadSpec(file)).toThrow(/Unsupported spec file extension/); + }); + + it('throws when the path does not exist', () => { + expect(() => loadSpec('/nonexistent/path/to/spec.yaml')).toThrow(/not found/); + }); +}); + +describe('loadSpec — split-concerns folder', () => { + it('merges api.yaml + variants.yaml + examples.yaml back into one component', () => { + const dir = makeTmpDir(); + const { api, variants, examples } = splitComponentByConcern(COMPONENT); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + fs.writeFileSync(path.join(dir, 'examples.yaml'), stringify(examples), 'utf8'); + + const { spec, resolvePath } = loadSpec(dir); + + expect(resolvePath).toBe(path.resolve(dir)); + expect(spec.title).toBe(COMPONENT.title); + expect(spec.anatomy).toEqual(COMPONENT.anatomy); + expect(spec.props).toEqual(COMPONENT.props); + expect(spec.default).toEqual(COMPONENT.default); + expect(spec.variants).toEqual(COMPONENT.variants); + expect(spec.slotContentExamples).toEqual(COMPONENT.slotContentExamples); + }); + + it('supports mixed extensions across concern files', () => { + const dir = makeTmpDir(); + const { api, variants } = splitComponentByConcern(COMPONENT); + + fs.writeFileSync(path.join(dir, 'api.json'), JSON.stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + + const { spec } = loadSpec(dir); + + expect(spec.title).toBe(COMPONENT.title); + expect(spec.variants).toEqual(COMPONENT.variants); + expect(spec.slotContentExamples).toBeUndefined(); + }); + + it('works without an examples file (optional concern)', () => { + const dir = makeTmpDir(); + const noExamples = { ...COMPONENT, slotContentExamples: undefined }; + const { api, variants } = splitComponentByConcern(noExamples); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + + const { spec } = loadSpec(dir); + + expect(spec.title).toBe(COMPONENT.title); + expect(spec.slotContentExamples).toBeUndefined(); + }); + + it('throws when the folder is missing a required concern file', () => { + const dir = makeTmpDir(); + const { api } = splitComponentByConcern(COMPONENT); + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + + expect(() => loadSpec(dir)).toThrow(/Expected api\.\(yaml\|json\) and variants\.\(yaml\|json\)/); + }); + + it('merges subcomponents recursively', () => { + const dir = makeTmpDir(); + const withSub = { + ...COMPONENT, + subcomponents: { + icon: { + title: 'Icon', + anatomy: {}, + props: { size: { type: 'number' } }, + default: { size: 16 }, + variants: [{ size: 16 }], + metadata: {}, + }, + }, + }; + const { api, variants, examples } = splitComponentByConcern(withSub); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + fs.writeFileSync(path.join(dir, 'examples.yaml'), stringify(examples), 'utf8'); + + const { spec } = loadSpec(dir); + const sub = (spec.subcomponents as any).icon; + + expect(sub.title).toBe('Icon'); + expect(sub.props).toEqual({ size: { type: 'number' } }); + expect(sub.default).toEqual({ size: 16 }); + }); +}); + +describe('findComponentFolders', () => { + function makeComponent(dir: string, name: string): string { + const folder = path.join(dir, name); + fs.mkdirSync(folder, { recursive: true }); + const { api, variants } = splitComponentByConcern(COMPONENT); + fs.writeFileSync(path.join(folder, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(folder, 'variants.yaml'), stringify(variants), 'utf8'); + return folder; + } + + it('returns the directory itself when it is a component folder', () => { + const dir = makeTmpDir(); + const folder = makeComponent(dir, 'deButton'); + + expect(findComponentFolders(folder)).toEqual([folder]); + }); + + it('finds component folders one and two levels down, in path order', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + const input = makeComponent(dir, path.join('forms', 'deInput')); + + expect(findComponentFolders(dir)).toEqual([button, input].sort()); + }); + + it('does not descend past two levels', () => { + const dir = makeTmpDir(); + makeComponent(dir, path.join('a', 'b', 'deTooBuried')); + + expect(findComponentFolders(dir)).toEqual([]); + }); + + it('does not descend into a component folder', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + makeComponent(button, 'nested'); + + expect(findComponentFolders(dir)).toEqual([button]); + }); + + it('ignores dot-directories and folders missing a required concern', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + makeComponent(dir, '.cache'); + fs.mkdirSync(path.join(dir, 'deHalf')); + fs.writeFileSync(path.join(dir, 'deHalf', 'api.yaml'), stringify({}), 'utf8'); + + expect(findComponentFolders(dir)).toEqual([button]); + }); +}); diff --git a/packages/cli/tests/unit/utilities/formatKey.test.ts b/packages/cli/tests/unit/utilities/formatKey.test.ts new file mode 100644 index 00000000..5d8ef3d2 --- /dev/null +++ b/packages/cli/tests/unit/utilities/formatKey.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { formatKey } from '../../../src/utilities/formatKey.js'; + +/** + * `formatKey` is a deliberate duplicate of `Utilities.formatKey` in specs-from-figma, which + * the CLI cannot import. These cases pin the behaviour: if the two drift, the bridge's + * component lookups silently stop matching rather than failing, so the drift has to be + * caught here. + */ +describe('formatKey', () => { + describe('CAMEL', () => { + it('lowercases the first word and TitleCases the rest', () => { + expect(formatKey('Action 1 appearance', 'CAMEL')).toBe('action1Appearance'); + expect(formatKey('Header overlaid', 'CAMEL')).toBe('headerOverlaid'); + }); + + it('treats "/" as a word separator, which is why the transform has no inverse', () => { + expect(formatKey('DS Link/On overlay/M/False/Rest/Start', 'CAMEL')).toBe('dsLinkOnOverlayMFalseRestStart'); + expect(formatKey('DS Toolbar/Android/S/Close/True/Base/Tertiary', 'CAMEL')).toBe('dsToolbarAndroidSCloseTrueBaseTertiary'); + }); + + it('collapses distinct names onto the same key — the reason matching is done forward', () => { + expect(formatKey('DS Link/On overlay/M', 'CAMEL')).toBe(formatKey('DS Link On Overlay M', 'CAMEL')); + expect(formatKey('DS Link-On_overlay M', 'CAMEL')).toBe(formatKey('DS Link/On/overlay/M', 'CAMEL')); + }); + + it('lowercases only the first character of a single word, preserving camelCase input', () => { + expect(formatKey('effectStyle', 'CAMEL')).toBe('effectStyle'); + expect(formatKey('Button', 'CAMEL')).toBe('button'); + }); + + it('lowercases the tail of each subsequent word', () => { + expect(formatKey('DS CARD header', 'CAMEL')).toBe('dsCardHeader'); + }); + }); + + describe('other formats', () => { + const name = 'DS Card /_ / Header'; + + it('SAFE strips unsafe characters and keeps everything else', () => { + expect(formatKey('Card "A" [1].b', 'SAFE')).toBe('Card A 1b'); + expect(formatKey(name, 'SAFE')).toBe(name); + }); + + it('SNAKE, KEBAB, PASCAL and TRAIN work from the same word split', () => { + expect(formatKey('Header overlaid', 'SNAKE')).toBe('header_overlaid'); + expect(formatKey('Header overlaid', 'KEBAB')).toBe('header-overlaid'); + expect(formatKey('Header overlaid', 'PASCAL')).toBe('HeaderOverlaid'); + expect(formatKey('Header overlaid', 'TRAIN')).toBe('Header-Overlaid'); + }); + + it('defaults to SAFE when the format is unset or unknown', () => { + expect(formatKey('Header overlaid')).toBe('Header overlaid'); + expect(formatKey('Header overlaid', 'NONSENSE')).toBe('Header overlaid'); + }); + + it('accepts lowercase format names', () => { + expect(formatKey('Header overlaid', 'camel')).toBe('headerOverlaid'); + }); + }); + + describe('edge cases', () => { + it('returns an empty string when nothing survives the split', () => { + expect(formatKey('///', 'CAMEL')).toBe(''); + expect(formatKey(' ', 'CAMEL')).toBe(''); + expect(formatKey('', 'CAMEL')).toBe(''); + }); + + it('drops non-alphanumeric characters inside words', () => { + expect(formatKey('Size=M, State=Rest', 'CAMEL')).toBe('sizeMStateRest'); + }); + }); +}); diff --git a/packages/cli/tests/unit/utilities/variablesIndex.test.ts b/packages/cli/tests/unit/utilities/variablesIndex.test.ts new file mode 100644 index 00000000..1388f683 --- /dev/null +++ b/packages/cli/tests/unit/utilities/variablesIndex.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { buildVariablesIndex, countUnpublished } from '../../../src/utilities/variablesIndex.js'; + +const VARIABLES_JSON = { + meta: { + variableCollections: { + 'VariableCollectionId:1:1': { name: 'Spacing' }, + 'VariableCollectionId:2:2': { name: 'Color' }, + }, + variables: { + 'VariableID:1:23': { + name: 'md', + key: 'key-spacing-md', + variableCollectionId: 'VariableCollectionId:1:1', + }, + 'VariableID:2:34': { + name: 'brand/primary', + key: 'key-color-brand-primary', + variableCollectionId: 'VariableCollectionId:2:2', + hiddenFromPublishing: true, + }, + }, + }, +}; + +describe('buildVariablesIndex', () => { + it('maps a token name to the key and the id it was fetched with', () => { + const index = buildVariablesIndex(VARIABLES_JSON); + + expect(index['Spacing/md']).toEqual({ + key: 'key-spacing-md', + id: 'VariableID:1:23', + published: true, + }); + }); + + it('marks a variable hidden from publishing as unpublished, keeping its id', () => { + const index = buildVariablesIndex(VARIABLES_JSON); + + expect(index['Color/brand/primary']).toEqual({ + key: 'key-color-brand-primary', + id: 'VariableID:2:34', + published: false, + }); + expect(countUnpublished(index)).toBe(1); + }); + + it('omits an unnamed variable — the name is the only thing a spec can reference', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': { name: 'Spacing' } }, + variables: { 'VariableID:3:45': { key: 'key-nameless', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(Object.keys(index)).toHaveLength(0); + }); + + it('keeps a keyless variable as unpublished, so its id can still resolve in its own file', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': { name: 'Spacing' } }, + variables: { 'VariableID:4:56': { name: 'lg', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(index['Spacing/lg']).toEqual({ key: '', id: 'VariableID:4:56', published: false }); + }); + + it('falls back to a bare name when the collection cannot be named', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': {} }, + variables: { 'VariableID:5:67': { name: 'orphan', key: 'key-orphan', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(index['orphan']).toMatchObject({ key: 'key-orphan', published: true }); + }); + + it('returns an empty index for missing, empty, or malformed input', () => { + expect(buildVariablesIndex(null)).toEqual({}); + expect(buildVariablesIndex(undefined)).toEqual({}); + expect(buildVariablesIndex({})).toEqual({}); + expect(buildVariablesIndex({ meta: { variables: {} } })).toEqual({}); + }); +}); diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index d2511995..d7fd8de3 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,29 @@ 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.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. + +### Added + +- **A spec can state the naming convention its Figma file uses** — `Config.format.figmaKeys` (`NONE` | `SENTENCE` | `TITLE`, default `NONE`) names the convention to reverse `format.keys` back into. `NONE` declares no convention, so the safe key grammar, the divergence extension, and reversal are all opt-in (ADR-066) +- **Properties whose key can't reconstruct their Figma name now carry that name** — `FigmaPropExtension.name` is emitted only when the key alone is insufficient (ADR-066) +- **The key grammar that survives every formatting mode is now defined** — `SafeKeySentence` and `SafeKeyTitle` describe keys that round-trip under any `format.keys` value (ADR-066) +- **Number properties can now carry platform extensions** — `NumberProp.$extensions` was the one prop type with nowhere to record its Figma name; the JSON schema already permitted `$`-prefixed keys, the TypeScript type did not + +### Changed + +- **The Figma layer-name extension now covers key divergence, not just wrapper collapse** — `FigmaAnatomyElementExtension.name`, renamed from `originalName` (ADR-066) +- **The clip flag now reads as a verb, matching the rest of the style vocabulary** — `Styles.clipsContent`, renamed from `clipContent`; type and token-bindability unchanged (ADR-069) + +### Migration + +`Styles.clipContent` → `Styles.clipsContent`: read `clipsContent` instead; the value shape is unchanged. No stored spec carries the old key — it never matched emitted output. + +`FigmaAnatomyElementExtension.originalName` → `FigmaAnatomyElementExtension.name`: read `$extensions['com.figma'].name` instead; the value and its collapse-provenance meaning are unchanged. Consumers that read the extension through an inline structural type will not see a compile error — update those call sites explicitly. + + ## [0.29.0] - 2026-08-07 Numeric properties can now be marked nullable, matching the flexibility already available on strings, slots, and images. Horizontal text alignment now uses logical inline-axis direction (`START`/`END`) instead of physical `LEFT`/`RIGHT`, so specs read correctly regardless of writing direction. diff --git a/packages/schema/package.json b/packages/schema/package.json index 728a899a..3d81f760 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.29.0", + "version": "0.30.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", diff --git a/packages/schema/schema/component.schema.json b/packages/schema/schema/component.schema.json index da2bd23f..a157a9ea 100644 --- a/packages/schema/schema/component.schema.json +++ b/packages/schema/schema/component.schema.json @@ -124,12 +124,22 @@ }, "Anatomy": { "type": "object", - "description": "The structural elements that make up the component. Element names format depends on user settings.", + "description": "The structural elements that make up the component. Keys are formatted per format.keys. A key whose Figma name did not satisfy the safe key grammar (see SafeKeySentence / SafeKeyTitle) carries that name in $extensions['com.figma'].name.", "examples": [], "additionalProperties": { "$ref": "#/definitions/AnatomyElement" } }, + "SafeKeySentence": { + "type": "string", + "pattern": "^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$", + "description": "A round-trip-safe Figma name under format.figmaKeys SENTENCE: ASCII letters and digits, single-space word separators, sentence case, each word either all letters or all digits, never beginning with a digit. Names matching this pattern reconstruct identically from the output of any format.keys value; names that do not require $extensions['com.figma'].name." + }, + "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." + }, "ElementType": { "type": "string", "description": "Element types derived from Figma node analysis.", @@ -213,9 +223,9 @@ "com.figma": { "type": "object", "properties": { - "originalName": { + "name": { "type": "string", - "description": "Original Figma layer name before primitive-wrapper collapse promoted this element to root (ADR-058)." + "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." } }, "additionalProperties": false @@ -231,7 +241,7 @@ }, "Props": { "type": "object", - "description": "", + "description": "Component properties. Keys are formatted per format.keys. A key whose Figma property name did not satisfy the safe key grammar (see SafeKeySentence / SafeKeyTitle) carries that name in $extensions['com.figma'].name.", "examples": [], "additionalProperties": { "$ref": "#/definitions/AnyProp" @@ -302,6 +312,10 @@ "source": { "$ref": "#/definitions/FigmaCodeOnlySource", "description": "Provenance metadata \u2014 present only for props extracted from a code-only container layer" + }, + "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." } }, "additionalProperties": true @@ -1049,6 +1063,9 @@ "type": "number" }, "description": "Sample numeric values demonstrating typical content for this prop" + }, + "$extensions": { + "$ref": "#/definitions/PropExtensions" } }, "required": [ diff --git a/packages/schema/schema/styles.schema.json b/packages/schema/schema/styles.schema.json index e240a103..ca794a4b 100644 --- a/packages/schema/schema/styles.schema.json +++ b/packages/schema/schema/styles.schema.json @@ -18,7 +18,7 @@ "fillColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Glyph fill color. Present on GLYPH element type only. Represented in Figma as fills." }, "textColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Text fill color. Present on TEXT element type only. Represented in Figma as fills." }, "effects": { "$ref": "#/definitions/EffectsStyleValue", "description": "Effect output. TokenReference when the node references a named effects style; Effects when effects are defined inline. Replaces effectStyleId." }, - "clipContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Clip content" }, + "clipsContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Whether the element clips content that overflows its box" }, "cornerRadius": { "$ref": "#/definitions/CornersStyleValue", "description": "Corner radius. Scalar when uniform; Corners object when per-corner values differ." }, "width": { "$ref": "#/definitions/NumberStyleValue", "description": "Width in pixels" }, "height": { "$ref": "#/definitions/NumberStyleValue", "description": "Height in pixels" }, diff --git a/packages/schema/schema/workspace.schema.json b/packages/schema/schema/workspace.schema.json index 4e0a89d9..29f6c1e2 100644 --- a/packages/schema/schema/workspace.schema.json +++ b/packages/schema/schema/workspace.schema.json @@ -217,7 +217,17 @@ "TRAIN" ], "default": "SAFE", - "description": "Key naming convention. Defaults to 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", diff --git a/packages/schema/tests/Anatomy.test-d.ts b/packages/schema/tests/Anatomy.test-d.ts index 6f2ed4f8..332ecebc 100644 --- a/packages/schema/tests/Anatomy.test-d.ts +++ b/packages/schema/tests/Anatomy.test-d.ts @@ -90,14 +90,29 @@ if (typeof element.type === 'string') { const _ref: string = element.type.$ref; } -// AnatomyElement.$extensions carries collapse provenance (ADR-058) +// AnatomyElement.$extensions carries the Figma name (ADR-058 collapse, ADR-066 key divergence) const collapsedRoot: AnatomyElement = { type: 'text', - $extensions: { 'com.figma': { originalName: 'Text' } }, + $extensions: { 'com.figma': { name: 'Text' } }, +}; + +// ADR-066 — a key that could not represent its Figma name losslessly +const divergentKey: AnatomyElement = { + type: 'glyph', + $extensions: { 'com.figma': { name: 'Cut & paste' } }, }; +// name is optional — safe keys emit no extension at all +const safeKey: AnatomyElement = { type: 'text' }; + const badExtension: AnatomyElement = { type: 'text', // @ts-expect-error — unknown extension members are rejected $extensions: { 'com.figma': { layerName: 'Text' } }, }; + +const renamedExtension: AnatomyElement = { + type: 'text', + // @ts-expect-error — originalName was renamed to name in 0.30.0 (ADR-066) + $extensions: { 'com.figma': { originalName: 'Text' } }, +}; diff --git a/packages/schema/tests/Config.test-d.ts b/packages/schema/tests/Config.test-d.ts index 96e97894..05ce1558 100644 --- a/packages/schema/tests/Config.test-d.ts +++ b/packages/schema/tests/Config.test-d.ts @@ -38,6 +38,7 @@ const fullConfig: Config = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'SENTENCE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', @@ -178,11 +179,24 @@ const defaultIsValidConfig: Config = DEFAULT_CONFIG; 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', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' }, + format: { output: 'JSON', keys: 'SAFE', figmaKeys: 'SENTENCE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' }, include: { invalidVariants: false, invalidCombinations: true, emptyVariants: false, defaultSlotContent: false }, transformers: [], }; diff --git a/packages/schema/tests/Props.test-d.ts b/packages/schema/tests/Props.test-d.ts index b9b08108..d69c47a6 100644 --- a/packages/schema/tests/Props.test-d.ts +++ b/packages/schema/tests/Props.test-d.ts @@ -97,6 +97,21 @@ const slotWithExt: SlotProp = { type: 'slot', $extensions: { 'com.figma': { type // $extensions with empty com.figma const boolEmptyFigma: BooleanProp = { type: 'boolean', default: true, $extensions: { 'com.figma': {} } }; +// com.figma.name preserves a Figma property name the key could not represent (ADR-066) +const boolDivergentKey: BooleanProp = { + type: 'boolean', + default: false, + $extensions: { 'com.figma': { type: 'BOOLEAN', name: 'Cut & paste' } }, +}; + +// name is optional — safe keys emit no name at all +const enumSafeKey: EnumProp = { + type: 'string', + default: 'sm', + enum: ['sm', 'md'], + $extensions: { 'com.figma': { type: 'VARIANT' } }, +}; + // $extensions with empty object const boolEmptyExt: BooleanProp = { type: 'boolean', default: true, $extensions: {} }; diff --git a/packages/schema/tests/Styles.test-d.ts b/packages/schema/tests/Styles.test-d.ts index 28435539..2dbd2cd4 100644 --- a/packages/schema/tests/Styles.test-d.ts +++ b/packages/schema/tests/Styles.test-d.ts @@ -585,6 +585,24 @@ const withWrapToken: Styles = { wrap: { $token: 'Layout.Wrap', $type: 'boolean' } satisfies TokenReference, }; +// ─── Styles.clipsContent (boolean Style) ──────────────────────────────────── + +// Boolean values +const withClipsContentTrue: Styles = { clipsContent: true }; +const withClipsContentFalse: Styles = { clipsContent: false }; + +// null is valid (Style includes null) +const withClipsContentNull: Styles = { clipsContent: null }; + +// TokenReference is valid (Style includes TokenReference) +const withClipsContentToken: Styles = { + clipsContent: { $token: 'Layout.ClipsContent', $type: 'boolean' } satisfies TokenReference, +}; + +// The pre-ADR-069 name is no longer a Styles key +// @ts-expect-error: clipContent was renamed to clipsContent +const withOldClipName: Styles = { clipContent: true }; + // ─── Styles.wrapAlignment (WrapAlignment | null) ──────────────────────────── // Valid enum values on Styles diff --git a/packages/schema/types/Anatomy.ts b/packages/schema/types/Anatomy.ts index 9a1d8fe4..cd34e430 100644 --- a/packages/schema/types/Anatomy.ts +++ b/packages/schema/types/Anatomy.ts @@ -30,8 +30,21 @@ export type SubcomponentRef = { * @since 0.28.0 */ export interface FigmaAnatomyElementExtension { - /** Original Figma layer name before primitive-wrapper collapse promoted this element to root (ADR-058). */ - originalName?: string; + /** + * The element's name in Figma. Recorded on any of three triggers: + * - primitive-wrapper collapse promoted this element to root (ADR-058) — always, + * independent of `format.figmaKeys`; + * - the name fell outside the safe key grammar, so `format.keys` could not + * represent it losslessly (ADR-066); + * - 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 no + * source convention is declared, so format divergence is not evaluated and this + * field is absent however the key was derived. + * @since 0.30.0 — renamed from `originalName` + */ + name?: string; } /** diff --git a/packages/schema/types/Config.ts b/packages/schema/types/Config.ts index cdad0c7c..7ac55173 100644 --- a/packages/schema/types/Config.ts +++ b/packages/schema/types/Config.ts @@ -121,8 +121,27 @@ export interface Config { format: { /** Output format. Optional; defaults to JSON. */ output?: 'JSON' | 'YAML'; - /** Key naming convention. Optional; defaults to SAFE. */ + /** + * 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'; /** @@ -209,6 +228,8 @@ export interface ResolvedConfig { 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. */ @@ -241,6 +262,7 @@ export interface ResolvedConfig { * - 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 @@ -262,6 +284,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'NONE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', diff --git a/packages/schema/types/Props.ts b/packages/schema/types/Props.ts index cbd995d7..c5226ce0 100644 --- a/packages/schema/types/Props.ts +++ b/packages/schema/types/Props.ts @@ -35,6 +35,17 @@ export interface FigmaPropExtension { type?: string; /** Provenance metadata — present only for props extracted from a code-only container layer. @since 0.14.0 */ source?: FigmaCodeOnlySource; + /** + * 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 no + * source convention is declared, so divergence is not evaluated and this field is + * absent however the key was derived. + * @since 0.30.0 + */ + name?: string; /** Additional Figma-specific metadata passes through without type enforcement. */ [key: string]: unknown; } @@ -111,6 +122,8 @@ export interface NumberProp { nullable?: boolean; /** Sample numeric values demonstrating typical content for this prop */ examples?: number[]; + /** DTCG §5.2.3 platform-specific extensions. @since 0.30.0 */ + $extensions?: PropExtensions; } /** diff --git a/packages/schema/types/Styles.ts b/packages/schema/types/Styles.ts index 7c60e4ec..633ad8ed 100644 --- a/packages/schema/types/Styles.ts +++ b/packages/schema/types/Styles.ts @@ -15,7 +15,8 @@ export type Styles = Partial<{ /** Glyph fill color. Present on GLYPH element type only. Represented in Figma as fills. @since 0.13.0 */ fillColor: ColorStyle; effects: TokenReference | Effects; - clipContent: Style; + /** Whether the element clips content that overflows its box. @since 0.30.0 */ + clipsContent: Style; /** Corner radius. Scalar when uniform; `Corners` object when per-corner values differ. @since 1.0.0 */ cornerRadius: Style | Corners; width: Style; @@ -343,7 +344,7 @@ export type StyleKey = | 'backgroundImage' | 'fillColor' | 'effects' - | 'clipContent' + | 'clipsContent' | 'cornerRadius' | 'width' | 'height' diff --git a/scripts/check-prerequisites.sh b/scripts/check-prerequisites.sh index 98e387c2..c85f8292 100755 --- a/scripts/check-prerequisites.sh +++ b/scripts/check-prerequisites.sh @@ -74,12 +74,59 @@ EOF esac done -# Source common functions +# Resolve repo root and branch. These were previously sourced from common.sh, which +# was removed with the rest of the spec-kit scaffolding; the helpers this script +# actually used are inlined here so it has no external dependency. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" -# Get feature paths and validate branch -eval $(get_feature_paths) +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT="$(git rev-parse --show-toplevel)" + HAS_GIT="true" +else + # Fall back to the script's parent directory (scripts/ lives at the repo root) + REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/.." && pwd)" + HAS_GIT="false" +fi + +if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + CURRENT_BRANCH="$SPECIFY_FEATURE" +elif [[ "$HAS_GIT" == "true" ]]; then + CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +else + CURRENT_BRANCH="" +fi + +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + # Accept feature branches (###-name) or ADR branches under a release (X.Y.Z/###-name) + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]+\.[0-9]+\.[0-9]+/[0-9]{3}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 0.12.0/001-feature-name" >&2 + return 1 + fi + + return 0 +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +FEATURE_DIR="$REPO_ROOT/specs/$CURRENT_BRANCH" +FEATURE_SPEC="$FEATURE_DIR/spec.md" +IMPL_PLAN="$FEATURE_DIR/plan.md" +TASKS="$FEATURE_DIR/tasks.md" +RESEARCH="$FEATURE_DIR/research.md" +DATA_MODEL="$FEATURE_DIR/data-model.md" +QUICKSTART="$FEATURE_DIR/quickstart.md" +CONTRACTS_DIR="$FEATURE_DIR/contracts" + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 # If paths-only mode, output paths and exit (support JSON + paths-only combined) diff --git a/scripts/validate-schema.sh b/scripts/validate-schema.sh index c8395866..c10a284e 100755 --- a/scripts/validate-schema.sh +++ b/scripts/validate-schema.sh @@ -7,7 +7,7 @@ # # Usage: ./validate-schema.sh [--json] [--schema-dir ] # --json Output results in JSON format -# --schema-dir Override the schema directory (default: /schema) +# --schema-dir Override the schema directory (default: /packages/schema/schema) set -e @@ -23,10 +23,16 @@ for arg in "$@"; do esac done -# Locate repo root and schema dir +# Locate repo root and schema dir. This script lives at /scripts/, so the +# repo root is one level up — it previously assumed the old spec-kit location three +# levels down, which resolved outside the repo entirely. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/../../.." && pwd)" -SCHEMA_DIR="${SCHEMA_DIR:-$REPO_ROOT/schema}" +if git -C "$SCRIPT_DIR" rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +else + REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/.." && pwd)" +fi +SCHEMA_DIR="${SCHEMA_DIR:-$REPO_ROOT/packages/schema/schema}" if [ ! -d "$SCHEMA_DIR" ]; then echo "ERROR: Schema directory not found: $SCHEMA_DIR" >&2 diff --git a/site/.gitignore b/site/.gitignore index ddce69b6..7e5082c2 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .astro/ +src/content/docs/adr/ diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 97ba7614..9e1bdc74 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -20,6 +20,7 @@ export default defineConfig({ Sidebar: './src/components/Sidebar.astro', Hero: './src/components/Hero.astro', Header: './src/components/Header.astro', + PageTitle: './src/components/PageTitle.astro', Footer: './src/components/Footer.astro', }, customCss: ['./src/custom.css'], @@ -63,6 +64,7 @@ export default defineConfig({ { label: 'About Specs', slug: 'overview/aboutspecs' }, { label: 'Getting Started', slug: 'cli/getting-started' }, { label: 'Releases', slug: 'overview/releases' }, + { label: 'Decision Records (ADRs)', slug: 'adr' }, { label: 'Licensing', slug: 'overview/licensing', badge: pro }, { label: 'Specs 2 Figma Plugin', @@ -104,6 +106,9 @@ export default defineConfig({ { label: 'applyCustomTokens', slug: 'cli/commands/apply-custom-tokens' }, { label: 'generate', slug: 'cli/commands/generate' }, { label: 'transform', slug: 'cli/commands/transform' }, + { label: 'bridge', slug: 'cli/commands/bridge', badge: experimental }, + { label: 'cache', slug: 'cli/commands/cache', badge: experimental }, + { label: 'render', slug: 'cli/commands/render', badge: experimental }, { label: 'Transforms', collapsed: true, @@ -125,6 +130,7 @@ export default defineConfig({ { label: 'props', slug: 'cli/analyze/props' }, { label: 'styling', slug: 'cli/analyze/styling' }, { label: 'dependencies', slug: 'cli/analyze/dependencies' }, + { label: 'keys', slug: 'cli/analyze/keys' }, ], }, ], @@ -232,6 +238,7 @@ export default defineConfig({ { label: 'Key Formatting', slug: 'guides/key-formatting' }, { label: 'Layout Positioning', slug: 'guides/layout-positioning' }, { label: 'Number Inference', slug: 'guides/number-inference' }, + { label: 'Render to Figma', slug: 'guides/render-to-figma', badge: experimental }, { label: 'Slot Constraints', slug: 'guides/slot-constraints', badge: pro }, { label: 'Subcomponents', slug: 'guides/subcomponent-scoping', badge: pro }, { label: 'Variant Depth', slug: 'guides/variant-depth' }, diff --git a/site/package.json b/site/package.json index 97c2169c..abab1975 100644 --- a/site/package.json +++ b/site/package.json @@ -3,8 +3,8 @@ "type": "module", "version": "0.0.1", "scripts": { - "prebuild": "node scripts/build-releases.mjs", - "predev": "node scripts/build-releases.mjs", + "prebuild": "node scripts/build-releases.mjs && node scripts/build-adrs.mjs", + "predev": "node scripts/build-releases.mjs && node scripts/build-adrs.mjs", "dev": "astro dev", "start": "astro dev", "build": "astro build", diff --git a/site/scripts/build-adrs.mjs b/site/scripts/build-adrs.mjs new file mode 100644 index 00000000..1f8dfb9f --- /dev/null +++ b/site/scripts/build-adrs.mjs @@ -0,0 +1,200 @@ +/** + * Reads ACCEPTED ADRs from adr/ and emits one Starlight page each into + * site/src/content/docs/adr/, plus an index page. Run before `astro dev` + * or `astro build`. + * + * Usage: node scripts/build-adrs.mjs + */ + +import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const repo = resolve(root, '..'); + +const srcDir = resolve(repo, 'adr'); +const outDir = resolve(root, 'src/content/docs/adr'); + +/** Quote a string for a YAML double-quoted scalar. */ +function yaml(text) { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** `# ADR: Foo Bar` or `# ADR 058: Foo Bar` → `Foo Bar` */ +function extractTitle(md) { + const m = md.match(/^#\s+(?:ADR[\s-]*\d*\s*:\s*)?(.+)$/m); + return m ? m[1].trim() : null; +} + +/** `**Status**: ACCEPTED — because` → `ACCEPTED` */ +function extractStatus(md) { + const m = md.match(/^\*\*Status\*\*:\s*(\S+)/m); + return m ? m[1].replace(/[^A-Z]/gi, '').toUpperCase() : null; +} + +/** Escape text destined for raw HTML in the generated index. */ +function escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Markdown inside a raw HTML block is passed through untouched, so text + * carrying `code spans` needs them rendered here. + */ +function renderInline(text) { + return escapeHtml(text) + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, href) => { + // Cross-references are bare ADR slugs. Link only to records that are + // actually published — drafts would 404. + const slug = href.replace(/^\.?\/?/, '').replace(/\.md$/, ''); + if (/^\d{3}-/.test(slug)) { + return published.has(slug) ? `${label}` : label; + } + return `${label}`; + }) + .replace(/\*([^*]+)\*/g, '$1'); +} + +/** Slugs of the records this build publishes, for cross-reference linking. */ +const published = new Set(); + +/** + * The `**Summary**:` line from the metadata block, authored at implementation + * time. There is deliberately no fallback — an unwritten summary shows up as a + * blank index row rather than as plausible-looking prose lifted from Context. + */ +function extractSummary(md) { + const m = md.match(/^\*\*Summary\*\*:\s*(.+)$/m); + if (!m) return null; + const text = m[1].trim(); + return /^\*\(.*\)\*$/.test(text) ? null : text; +} + +/** Drop the H1 — Starlight renders the title from frontmatter. */ +function stripTitle(md) { + return md.replace(/^#\s+.+$/m, '').replace(/^\n+/, ''); +} + +/** + * The `**Key**: value` lines opening every ADR are consecutive, so markdown + * joins them into one run-on paragraph. Render them as a label/value grid + * instead, and drop the horizontal rule that followed the block. + * + * `Branch` is dropped — it names a merged branch that no longer exists. + * `Created` folds into the Status row, and `Supersedes` appears only when it + * actually points at a prior record. + */ +function formatMetadata(md) { + return md.replace(/^((?:\*\*[A-Za-z]+\*\*:.*\n)+)(\n---\n)?/, (match, block) => { + const fields = Object.fromEntries( + [...block.matchAll(/^\*\*([A-Za-z]+)\*\*:\s*(.*)$/gm)].map(([, key, value]) => [ + key, + value.trim(), + ]), + ); + + const status = [fields.Status, fields.Created].filter(Boolean).join(' · '); + // Many records use Supersedes for an "(none — extends ADR-0xx)" aside; + // only a value that actually names a superseded record earns a row. + const raw = fields.Supersedes ?? ''; + const supersedes = /^\*?\(?\s*none\b/i.test(raw.trim()) || !raw.trim() ? null : raw; + + const rows = [ + ['Summary', fields.Summary], + ['Status', status], + ['Deciders', fields.Deciders], + ['Supersedes', supersedes], + ] + .filter(([, value]) => value) + .map( + ([label, value]) => + `
${label}
${renderInline(value)}
`, + ); + + return rows.length ? `
\n${rows.join('\n')}\n
\n` : match; + }); +} + +const files = readdirSync(srcDir) + .filter(f => /^\d{3}-.+\.md$/.test(f)) + .sort(); + +const accepted = []; + +for (const file of files) { + const md = readFileSync(resolve(srcDir, file), 'utf-8'); + if (extractStatus(md) !== 'ACCEPTED') continue; + + const title = extractTitle(md); + if (!title) { + console.warn(`⚠ ${file}: no H1 title, skipped`); + continue; + } + const slug = file.replace(/\.md$/, ''); + published.add(slug); + accepted.push({ file, md, slug, title, number: file.slice(0, 3) }); +} + +// Bodies are rendered in a second pass so cross-references can be resolved +// against the full set of published records. +const adrs = accepted.map(({ file, md, slug, title, number }) => { + const summary = extractSummary(md); + if (!summary) console.warn(`⚠ ${file}: no **Summary** in the metadata block`); + return { number, slug, title, summary, body: formatMetadata(stripTitle(md)) }; +}); + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const adr of adrs) { + const frontmatter = [ + '---', + `title: ${yaml(adr.title)}`, + adr.summary ? `description: ${yaml(adr.summary.replace(/`/g, ''))}` : null, + '---', + ] + .filter(Boolean) + .join('\n'); + + writeFileSync(resolve(outDir, `${adr.slug}.md`), `${frontmatter}\n\n${adr.body}`); +} + +const rows = [...adrs] + .reverse() + .map(a => { + const summary = a.summary + ? `\n

${renderInline(a.summary)}

` + : ''; + return ` +${a.number} +${renderInline(a.title)}${summary} +`; + }) + .join('\n'); + +const index = `--- +title: "Decision Records (ADRs)" +description: "Accepted architecture decisions shaping the Specs schema, CLI, and plugin." +tableOfContents: false +--- + +Every field in the Specs schema exists because of a decision about what a design system needs to express and how Figma exposes it. An **Architecture Decision Record** captures one such decision: the problem, the options weighed, the choice made, and the designs rejected along the way. + +Where [Schema](/schema/) and [Settings](/settings/) tell you what a field is, an ADR tells you why it looks that way — read one when a field's shape surprises you, or when you're weighing a change. Only accepted decisions appear here, newest first, each left as written, so later records may supersede earlier ones. + + + +${rows} + +
+`; + +writeFileSync(resolve(outDir, 'index.md'), index); +console.log(`✓ Built ${adrs.length} ADR pages + index in site/src/content/docs/adr/`); diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 2440d44d..8ae538d7 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -20,6 +20,7 @@ const SECTION_LINKS = [ { label: 'Plugin', href: '/plugin/', prefix: '/plugin/' }, { label: 'Commands', href: '/cli/', prefix: '/cli/' }, { label: 'Schema', href: '/schema/', prefix: '/schema/' }, + { label: 'ADRs', href: '/adr/', prefix: '/adr/' }, // Points at the Settings section home for now, per explicit instruction. // TODO: point to the future consolidated Docs section landing page once Settings+Guides are merged. { label: 'Docs', href: '/settings/', prefix: '/settings/' }, @@ -111,14 +112,14 @@ const SLACK_PATHS = [ .section-links { display: flex; align-items: center; - gap: 2.5rem; + gap: 1.25rem; margin-inline-end: 1rem; white-space: nowrap; } .section-link { - font-size: 18px; - font-weight: 700; + font-size: 14px; + font-weight: 600; color: var(--sl-color-gray-2); text-decoration: none; } diff --git a/site/src/components/PageTitle.astro b/site/src/components/PageTitle.astro new file mode 100644 index 00000000..a6d9f307 --- /dev/null +++ b/site/src/components/PageTitle.astro @@ -0,0 +1,68 @@ +--- +// Custom PageTitle override for Starlight. +// ADR record pages sit outside the sidebar tree, so they arrive with no +// surrounding context. Give them a crumb back to the index and an eyebrow +// naming the section, then defer to the default title rendering. +import Default from '@astrojs/starlight/components/PageTitle.astro'; + +const pathname = Astro.url.pathname; +// Matches a record page (/adr/059-border-style/) but not the index (/adr/). +const isAdrRecord = /^\/adr\/[^/]+\/?$/.test(pathname); +--- + +{ + isAdrRecord && ( + + ) +} + + + + diff --git a/site/src/content/docs/cli/analyze/index.md b/site/src/content/docs/cli/analyze/index.md index 2716b176..d0b1995f 100644 --- a/site/src/content/docs/cli/analyze/index.md +++ b/site/src/content/docs/cli/analyze/index.md @@ -21,6 +21,7 @@ Analyzer names are passed as positional arguments. There is no config key — an specs analyze props specs analyze styling specs analyze dependencies +specs analyze keys specs analyze props styling dependencies specs analyze props --analysis ./reports ``` @@ -41,6 +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` | ## Output Directory @@ -53,6 +55,7 @@ specs/ styling.unused.json # from specs analyze styling dependencies.graph.json # from specs analyze dependencies dependencies.byComponent.json # from specs analyze dependencies + keys.yaml # from specs analyze keys ds-button/ api.yaml contract.ts diff --git a/site/src/content/docs/cli/analyze/keys.md b/site/src/content/docs/cli/analyze/keys.md new file mode 100644 index 00000000..5bf5b05b --- /dev/null +++ b/site/src/content/docs/cli/analyze/keys.md @@ -0,0 +1,152 @@ +--- +title: "keys" +description: "List the Figma layer and property names a formatted key cannot reconstruct, organized as a per-component checklist" +--- + + + +Reads every component's `api.yaml` and produces `_analysis/keys.yaml`: every Figma layer and property name that falls outside the [safe key grammar](/guides/key-formatting/), grouped by component so it reads as a checklist, then by cause and by frequency. + +## 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: + +```yaml +config: + format: + figmaKeys: 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. + +## Use When + +- You want a per-component list of Figma names to tidy, in the order a designer would work through them. +- You want to find a badly-formed name that repeats across the library, where one rename fixes dozens of specs. +- You want to see which kinds of naming problem dominate before deciding what to fix first. +- You want to confirm a naming cleanup actually landed, by diffing the aggregate between runs. + +## Invocation + +```bash +specs analyze keys +``` + +## Output + +Writes a single file to `_analysis/` after all components are processed. + +``` +specs/ + _analysis/ + keys.yaml # cross-library aggregate + ds-button/ + api.yaml + ds-alert/ + api.yaml +``` + +## Sections + +### summary + +Counts for the library as a whole, and the distribution of causes. + +```yaml +summary: + totalComponents: 69 + componentsWithDivergence: 52 + totalNames: 948 + divergentNames: 75 + causeDistribution: + mixed-letter-digit: 42 + casing: 28 + symbol: 2 + separator: 1 + digit-initial: 1 + already-a-key: 1 +``` + +### byComponent + +The checklist. Names are grouped under the component they belong to, because that is the unit a designer opens and edits. + +```yaml +byComponent: + dsAlert: + divergent: 1 + props: + - key: fullBleed + figmaName: Full Bleed + cause: casing + dsAvatar: + divergent: 2 + props: + - key: a11yLabel + figmaName: A11y label + cause: mixed-letter-digit + anatomy: + - key: startIcon + figmaName: Start Icon + cause: casing +``` + +Names appear under `props` (component property names) or `anatomy` (layer names). A surface with nothing to fix is omitted. + +### byCause + +The same names grouped by what is wrong with them, most common first. Useful for deciding what to fix in bulk: a library with 28 casing problems and 2 symbol problems has one systemic issue and two one-offs. + +```yaml +byCause: + - cause: casing + occurrences: 28 + names: + - Alternate Half + - Children minItems + - EGDS Bottom Sheet +``` + +### byName + +Each distinct Figma name with the components it appears in, most frequent first. This is the counterweight to the per-component checklist — a name wrong in forty components is one decision, not forty. + +```yaml +byName: + - figmaName: A11y label + occurrences: 42 + components: + - dsAvatar + - dsBadge + - dsButton + cause: mixed-letter-digit +``` + +## Causes + +| Cause | Meaning | Example | +|-------|---------|---------| +| `separator` | Leading, trailing, or repeated spaces | `Label ` | +| `symbol` | A character outside letters, digits, and single spaces | `Cut & paste` | +| `non-ascii` | A non-ASCII character | `Étiquette` | +| `mixed-letter-digit` | Letters and digits share a word, so the boundary is lost | `A11y label` | +| `digit-initial` | The name begins with a digit | `0000 0000 0000 0000` | +| `casing` | Characters are fine, but casing diverges from the declared convention | `Start Icon` under `SENTENCE` | +| `already-a-key` | Not a defect — a name authored in a key convention rather than as a display name | `x-figmacollapse` | + +A name is reported under the most specific cause that applies, since that is the edit to make. `already-a-key` is listed last because it is a deliberate authoring choice, not a problem: such names are [retained as authored](/guides/key-formatting/#names-already-written-as-keys). + +## Fixing What It Finds + +Nothing here is a validation failure. Every name it lists is fully supported — its Figma name is recorded, so the spec round-trips correctly either way. The report exists so naming can be tidied at the source, which makes the extensions disappear and the specs smaller. + +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. + +## See Also + +- [Key Formatting guide](/guides/key-formatting/) — the safe key grammar and what happens to unsafe names +- [Figma Keys](/settings/figma-keys/) — declaring the source convention this analyzer depends on +- [Analyze overview](/cli/analyze/) — options shared by every analyzer 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 33d62c00..f3518ee3 100644 --- a/site/src/content/docs/cli/commands/apply-custom-tokens.md +++ b/site/src/content/docs/cli/commands/apply-custom-tokens.md @@ -185,6 +185,12 @@ specs applyCustomTokens data/token-mappings.json \ -s data/library.styles.json ``` +## The Render Cache + +Because this command rewrites your fetched variables data in place, the lookup tables [`render`](/cli/commands/render/) uses would otherwise describe your data as it was before the custom tokens were applied. To prevent that, `applyCustomTokens` rebuilds the [cache](/cli/commands/cache/) as its final step, and reports what it rebuilt. + +If the command is interrupted before that step, the cache is left stale — `render` detects this and stops, telling you to run `specs cache`. + ## Behavior Details - **Idempotent**: Running the command multiple times with the same mapping produces identical output. Existing `$custom` values are overwritten. diff --git a/site/src/content/docs/cli/commands/bridge.md b/site/src/content/docs/cli/commands/bridge.md new file mode 100644 index 00000000..acfe7d4d --- /dev/null +++ b/site/src/content/docs/cli/commands/bridge.md @@ -0,0 +1,98 @@ +--- +title: "bridge" +--- + + + +Starts, stops, and checks the local CLI bridge — the background process that relays requests between the CLI and a connected Specs 2 Figma plugin. [`render`](/cli/commands/render/) sends specs into the live file through it, and [`generate --from-bridge`](/cli/commands/generate/#bridge-mode) reads specs back out of the current selection. + +## Usage + +```bash +specs bridge [options] +``` + +## Subcommands + +### `start` + +Starts the bridge server in the background. Prints the process ID, the ports it's listening on, and where its logs go. Safe to run again while already running — reports the existing process instead of starting a second one. + +```bash +specs bridge start +``` + +#### `--workspace ` + +Pin a workspace directory for the whole bridge session, instead of deriving it per-request from each spec's path. Useful when every render in a session targets the same workspace. + +```bash +specs bridge start --workspace ~/design-system +``` + +### `stop` + +Stops the background bridge server. Sends a graceful shutdown signal, then forces termination if it doesn't exit within a few seconds. Safe to run when nothing is running. + +```bash +specs bridge stop +``` + +### `status` + +Reports whether the bridge server is running and, if so, whether a Figma plugin is currently connected to it. + +```bash +specs bridge status +``` + +Each connected plugin is listed by the file key it's connected from, with the file's name when the plugin reported one: + +``` +Bridge running (pid 12345). 2 plugins connected: + abc123XYZ (Design System Library) — connected + def456UVW (Brand Marketing) — connected +``` + +``` +Bridge running (pid 12345). No plugin connected. +``` + +Those file keys are what [`render --file`](/cli/commands/render/#--file) and [`generate --from-bridge --file`](/cli/commands/generate/#--file-filekey) take, so `bridge status` is how you find the value to pin in a scripted run. + +Exits non-zero when the bridge isn't running, so it's safe to use in a conditional (`if specs bridge status; then ...`). It also exits non-zero when the process is up but its control port isn't answering: + +``` +Bridge running (pid 12345), but the control port isn't responding. +``` + +## What It Does + +The bridge server listens on two local ports: a WebSocket for the Specs 2 plugin (`ws://localhost:9001`) and an HTTP control endpoint for the CLI (`http://localhost:9002`). `specs render` posts a spec (or a render manifest) to the HTTP endpoint; the bridge relays it to whichever plugin is connected over the WebSocket, which builds the component live in Figma and reports back. + +Traffic runs both directions. `specs generate --from-bridge` posts to the same endpoint asking the plugin to generate a spec from its current selection; the plugin runs generation against the live document and returns the finished spec, which the CLI writes out. Reading a rendered component's spec is always this explicit second call — never a side effect of `render`. + +Starting the bridge doesn't connect it to anything by itself — you still need the Specs 2 plugin open in Figma with its **CLI Bridge** toggle enabled. See the [Render to Figma guide](/guides/render-to-figma/) for the full setup. + +## Logs and Process Files + +`bridge start` writes a pid file and a log file under `~/.specs/`: + +``` +~/.specs/ + bridge.pid # process ID of the running bridge server + bridge.log # stdout/stderr from the bridge server +``` + +Tail the log while debugging a render: + +```bash +tail -f ~/.specs/bridge.log +``` + +--- + +**See Also:** + +- [Render to Figma](/guides/render-to-figma/) — bridge architecture, setup, and prerequisites +- [`render`](/cli/commands/render/) — sends specs to the bridge diff --git a/site/src/content/docs/cli/commands/cache.md b/site/src/content/docs/cli/commands/cache.md new file mode 100644 index 00000000..015f58cc --- /dev/null +++ b/site/src/content/docs/cli/commands/cache.md @@ -0,0 +1,102 @@ +--- +title: "cache" +--- + + + +Builds the lookup tables `render` uses to resolve a spec's references against your fetched Figma data. + +You rarely run this by hand. [`fetch`](/cli/commands/fetch/) and [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) build the cache themselves as their last step, which covers the normal workflow. Reach for `specs cache` when the cache is missing, when something outside those commands changed the files in your data directory, or when `render` tells you to. + +## Usage + +```bash +specs cache [options] +``` + +## Why it exists + +A spec references the library by name: an instance element names a component, a style key names a token, a glyph element names an icon. None of those names mean anything on their own — they have to be resolved against the data `fetch` downloaded. + +That data arrives as whole Figma API responses. A file payload for a large library can be hundreds of megabytes, and `render` needs only a few hundred small entries out of it. Deriving those entries per render meant re-reading the entire payload every time, which cost seconds before any Figma work started. The cache is those entries, extracted once. + +## What it writes + +Four files under `{dataDirectory}/cache/`, each covering every source you've fetched: + +| File | Maps | Built from | +|------|------|------------| +| `components.yaml` | component node id → published key and name | `{alias}.file.json` | +| `styles.yaml` | style name → key and type | `{alias}.file.json` | +| `variables.yaml` | token name → key, id, published flag | `{alias}.variables.json` | +| `icons.yaml` | glyph name → node id and key | `{alias}.file.json` | + +They're generated files. Deleting them is safe — the next `specs cache` rebuilds them — and they should not be edited by hand. + +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. + +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. + +## Staleness + +Before each render, the bridge checks the recorded source details against the files on disk. A cache that no longer matches is a hard failure: + +``` +Error: Render failed: Render cache is not usable: + - variables.yaml: "library" is stale + +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. + +## Options + +### `--config ` + +Use a specific config file instead of the default `specs.config.yaml`. + +### `--force` + +Rebuild every source, even those whose recorded details still match the files on disk. + +By default a source whose payload hasn't changed is left alone and its entries are carried forward, so rebuilding after fetching one library re-reads that library only. `--force` is the escape hatch for a cache you suspect is wrong for a reason the staleness check can't see. + +```bash +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: + +``` + Cache rebuilt: library + Not fetched, skipped: brand + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + +`render` is stricter: it needs every declared source present and current, and fails naming the ones that aren't. + +## Examples + +```bash +# Rebuild whatever is out of date +specs cache + +# Rebuild everything from scratch +specs cache --force + +# Rebuild as part of a render, without a separate command +specs render specs/deButton/ --refresh-cache +``` + +## Related + +- [`fetch`](/cli/commands/fetch/) — downloads the payloads the cache is built from, and builds it +- [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) — rewrites variables data, then rebuilds the cache +- [`render`](/cli/commands/render/) — reads the cache; fails when it's missing or stale diff --git a/site/src/content/docs/cli/commands/fetch.md b/site/src/content/docs/cli/commands/fetch.md index 7b62226d..1d8830a6 100644 --- a/site/src/content/docs/cli/commands/fetch.md +++ b/site/src/content/docs/cli/commands/fetch.md @@ -48,6 +48,18 @@ specs fetch --no-geometry --verbose ### `--verbose` 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. + +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: + +``` + Cache rebuilt: library + Not fetched, skipped: brand + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + ## Examples ```bash diff --git a/site/src/content/docs/cli/commands/generate.md b/site/src/content/docs/cli/commands/generate.md index eb7afc0f..45469ea9 100644 --- a/site/src/content/docs/cli/commands/generate.md +++ b/site/src/content/docs/cli/commands/generate.md @@ -1,165 +1,190 @@ --- title: "generate" --- -Generate component specifications from Figma data. Accepts either a markdown manifest (for multiple components) or a JSON file with a component flag (for a single component). The source argument is optional — without it, `generate` uses the default manifest location derived from your config. - -## Usage +Generate component specifications from Figma. There are three ways to run it, distinguished by where the Figma data comes from: a **manifest** listing many components, a **JSON file** plus one named component, or the **live Figma file** itself over the CLI bridge. ```bash -# Zero-config: uses default manifest from config (recommended) -specs generate +specs generate # manifest mode — many components +specs generate -c # single component mode +specs generate --from-bridge # bridge mode — live selection +``` -# From an explicit manifest (multiple components) -specs generate [options] +| Mode | Source | Produces | Use it when | +|------|--------|----------|-------------| +| [Manifest](#manifest-mode) | `.md` manifest + downloaded JSON | Every selected component | Generating or regenerating the library | +| [Single component](#single-component-mode) | Downloaded JSON + `-c` | One component | Setting up or iterating on one spec | +| [Bridge](#bridge-mode) | The connected Figma file, live | The current selection | Checking work in progress, or a `render` round-trip | -# From a JSON file (single component) -specs generate -c [options] -``` +Manifest and single component mode both read a Figma REST API snapshot that `specs fetch` downloaded, and run the transformer inside the CLI. Bridge mode reads nothing from disk — the plugin generates against the live document and the CLI only writes the result out. -## Source Modes +## Manifest Mode -### Manifest Mode (recommended) +Pass a markdown manifest created by [`scan`](/cli/commands/scan/) to generate specs for all selected components in one pass. The manifest points at the source JSON file and tracks which components are included (`[x]`). This is faster than running `generate` per component, because the file is loaded and indexed once. -Pass a markdown manifest created by `scan` to generate specs for all selected components in one pass. This is faster than running `generate` multiple times, because the file is loaded and indexed once. +The full workflow: ```bash -# Uses the default manifest from config: {dataDirectory}/{alias}.manifest.md +# 1. Download the Figma data +specs fetch + +# 2. Build a manifest of everything in the file +specs scan + +# 3. Curate — edit data/library.manifest.md and check [x] the components you want + +# 4. Generate specs generate +``` -# Or pass an explicit manifest path +With no arguments, step 4 uses the default manifest (`{dataDirectory}/{alias}.manifest.md`) and writes to `config.outputDirectory`. Pass either explicitly to override: + +```bash specs generate components.md -o specs/library.yaml ``` -The manifest references the source file and tracks which components to include. See [Scan Command](/cli/commands/scan/) for creating manifests. +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): -### File Mode +```bash +specs generate -o specs/ --split-components +``` -Pass a Figma JSON file directly and specify a single component by name or node ID. Useful when setting up a new component or quickly iterating on one spec. +``` +✓ Loaded manifest: 150 components (42 selected) +⏳ Processing 42 components... -```bash -specs generate data/library.file.json -c "DS Button" -o specs/button.yaml +[1/42] DS Accordion... ✓ +[2/42] DS Alert... ✓ +... +[42/42] DS Toggle... ✓ + +✓ Generated specs + - 42 components successful ``` -## Arguments +Components that fail are reported individually and the rest still generate; the run exits non-zero if any failed. -### `[source]` (optional) -Path to a markdown manifest or Figma REST API JSON file. +## Single Component Mode -- **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`. -- **Manifest path (`.md`)**: manifest mode (multiple components) -- **Figma JSON (`.json`)**: file mode (requires `-c`) +Pass a Figma JSON file directly and name one component with `-c`, by name or node ID. Useful when setting up a new component, or iterating quickly on one spec without touching the rest of the library. ```bash -# Default: data/library.manifest.md (from config) -specs generate +specs fetch +specs generate data/library.file.json -c "DS Button" -o specs/button.yaml +``` -# Explicit manifest -specs generate components.md -o specs/all.yaml +The component is resolved against the JSON file's components and component sets. Node IDs work equally well, and are the reliable choice when a name contains special characters or is duplicated: -# JSON file -specs generate data/library.file.json -c "Button" -o specs/button.yaml +```bash +specs generate data/library.file.json -c "1234:5678" -o specs/button.yaml ``` -## Options - -### `-c, --component ` -Component name or Figma node ID. Required in file mode, ignored in manifest mode. +Without `-o` (and with no `config.outputDirectory`), the spec goes to stdout — handy for piping: ```bash -# By name -specs generate data/library.file.json -c "DS Button" - -# By node ID (useful for names with special characters) -specs generate data/library.file.json -c "1234:5678" +specs generate data/library.file.json -c "DS Button" -f yaml | yq '.dsButton.anatomy' ``` -### `-l, --license ` -License key for premium features. +Variables and styles are resolved from your configured sources; without config, `generate` falls back to `foundations/variables.json` and `foundations/styles.json` next to the JSON file. Override either with [`-v`](#-v---variables-path) / [`-s`](#-s---styles-path). -When a valid Pro license is provided, generated specs include additional detail such as design token references, variable bindings, and visibility bindings. Without a license (or with an invalid key), specs are generated at the free tier — full structure and variants, but with raw values instead of token references. +## Bridge Mode -**Resolution priority**: `--license` flag > `SPECS_LICENSE_KEY` env > `ANOVA_LICENSE_KEY` env +Generate from whatever is selected right now in a connected Figma file — no `specs fetch`, no downloaded JSON. Requires the [bridge](/cli/commands/bridge/) running and the Specs 2 plugin open in Figma with its **CLI Bridge** toggle enabled. ```bash -# Via flag -specs generate components.md -o specs/all.yaml -l "your-license-key" +specs bridge start +specs generate --from-bridge -o specs/button.yaml +``` -# Via environment variable (recommended) -export SPECS_LICENSE_KEY="your-license-key" -specs generate components.md -o specs/all.yaml +``` +✓ Generated from selection: DS Button +✓ Saved to specs/button.yaml ``` -See [Getting Started — License](/cli/getting-started.md/#step-3-set-your-license-key-optional) for setup details. +Because the plugin does the generating, bridge mode behaves differently from the other two in ways worth knowing: -### `-o, --output ` -Output file or directory path. Accepts both file paths and directory paths. +- **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 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. + +The selection must be a component, component set, or frame. To target something other than the selection — for a repeatable script, where relying on a manual selection is fragile — pass [`--node`](#--node-id). When several Figma files are connected to one bridge, pick one with [`--file`](#--file-filekey). -- **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 (manifest mode)**: Falls back to `config.outputDirectory` (default `./specs`) -- **Not provided (file mode)**: Outputs to stdout +Since bridge mode reads the live document, it closes the loop on [`render`](/cli/commands/render/) — render a spec into Figma, then read the result back and compare: ```bash -# Single file -specs generate components.md -o specs/library.yaml +specs bridge start +specs render specs/dsButton.yaml +specs generate --from-bridge -o specs/roundtrip/dsButton.yaml +diff specs/dsButton.yaml specs/roundtrip/dsButton.yaml +``` -# Directory for split output -specs generate components.md -o specs/ --split-components +Common failures: -# Manifest mode without -o: uses config.outputDirectory -specs generate components.md +- `Error: bridge is not running.` — start it with `specs bridge start`. +- `Nothing selected in Figma...` — select a component, component set, or frame in the connected file. +- `Selection must be a component, component set, or frame — got INSTANCE.` — select the source component rather than an instance of it. -# Output to stdout (file mode) -specs generate data/library.file.json -c "Button" | yq . -``` +## Arguments + +### `[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`. +- **Markdown manifest**: manifest mode. +- **Figma JSON**: single component mode — requires `-c`. +- **Bridge mode**: takes no source argument; passing one is an error. + +## Options + +### `-c, --component ` +Component name or Figma node ID. Required in single component mode; ignored in manifest and bridge mode. + +### `-o, --output ` +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. ### `-f, --format ` -Output format: `yaml` or `json`. +Output format: `yaml` or `json`. Defaults to `config.format.output` (or JSON with no config); the flag takes precedence. -- **Default**: Uses `config.format.output` from config (or `JSON` if no config) -- **Override**: CLI flag takes precedence over config +### `-l, --license ` +License key for premium features. -```bash -specs generate components.md --format yaml -o specs/library.yaml -``` +With a valid Pro license, generated specs include additional detail such as design token references, variable bindings, and visibility bindings. Without one (or with an invalid key), specs are generated at the free tier — full structure and variants, but raw values instead of token references. -### `--data-dir ` -Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `dataDirectory` from config, or `./data` if not configured. +**Resolution priority**: `--license` flag > `SPECS_LICENSE_KEY` env > `ANOVA_LICENSE_KEY` env ```bash -specs generate components.md --data-dir ./custom-data -o specs/library.yaml +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. + +### `--data-dir ` +Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `dataDirectory` from config, or `./data`. + ### `-v, --variables ` External variables JSON file. - **Default** (no flag): loads all `${alias}.variables.json` for aliases in config whose `data` includes `variables`. -- **Fallback** (no sources configured): tries `foundations/variables.json` next to the ``. -- **Override**: CLI flag replaces that list for this run. - -```bash -specs generate data/library.file.json -c "Button" --variables data/library.variables.json -``` +- **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`. -- **Fallback** (no sources configured): tries `foundations/styles.json` next to the ``. -- **Override**: CLI flag replaces that list for this run. - -```bash -specs generate data/library.file.json -c "Button" --styles data/library.styles.json -``` +- **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 (manifest mode only). - -- **Default**: `false` (single file with all components) -- **Output**: Individual files named `componentName.yaml` +Create a separate file per component, instead of one file containing all of them. ```bash -specs generate components.md -o specs/ --split-components +specs generate -o specs/ --split-components ``` ``` @@ -170,15 +195,10 @@ specs/ ``` ### `--split-concerns` -Separate API specification, variant configuration, and examples. - -- **Default**: `false` (complete component data in each file) -- **Output**: 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; components without examples are omitted from it. -- Example output (`slotContentExamples`, `instanceExamples`) is a [Pro feature](/settings/default-slot-content/) — on the free tier it is omitted, so `examples.yaml` is not produced. +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`). ```bash -specs generate components.md -o specs/ --split-concerns +specs generate -o specs/ --split-concerns ``` ``` @@ -188,10 +208,12 @@ specs/ └── examples.yaml ``` -When combined with `--split-components`, each component gets its own directory with concern files (`examples.yaml` appears only for components that have examples): +`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. + +Combined with `--split-components`, each component gets its own directory of concern files: ```bash -specs generate components.md -o specs/ --split-components --split-concerns +specs generate -o specs/ --split-components --split-concerns ``` ``` @@ -212,7 +234,7 @@ specs/ Organize component files in subdirectories (requires `--split-components`). Wraps each component file in its own folder. ```bash -specs generate components.md -o specs/ --split-components --use-subfolders +specs generate -o specs/ --split-components --use-subfolders ``` ``` @@ -229,7 +251,7 @@ specs/ 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). ```bash -specs generate components.md -o specs/ --split-components --get-images +specs generate -o specs/ --split-components --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: @@ -260,74 +282,36 @@ specs/ `$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. -### `--config ` -Path to configuration file. - -```bash -specs generate components.md --config configs/mobile.yaml -o specs/mobile.yaml -``` - -### `--verbose` -Enable detailed logging with progress indicator. - -```bash -specs generate components.md --verbose -o specs/library.yaml -``` - -**Manifest mode output:** -``` -✓ Loaded manifest: 150 components (42 selected) -⏳ Processing 42 components... - -[1/42] DS Accordion... ✓ -[2/42] DS Alert... ✓ -... -[42/42] DS Toggle... ✓ - -✓ Generated specs - - 42 components successful -✓ Saved to specs/library.yaml -``` - -## Examples - -### Manifest workflow - -```bash -# 1. Fetch data -specs fetch - -# 2. Create manifest (auto-resolves configured source; writes to data/library.manifest.md by default) -specs scan - -# 3. Curate (edit data/library.manifest.md to select [x] components) +### `--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). -# 4. Generate specs (uses default manifest + outputDirectory from config) -specs generate -``` +### `--file ` +Target a specific connected Figma file (bridge mode only). More than one file can be connected to a single bridge at once. -### Single component +- **One file connected**: not needed. +- **Several connected, interactive terminal**: omitting it prints a numbered picker and prompts you to choose. +- **Several connected, non-interactive (scripts, CI)**: required — the run fails rather than hanging on a prompt. -```bash -specs generate data/library.file.json -c "DS Button" -o specs/button.yaml -``` +### `--node ` +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. -### Per-component files +### `--config ` +Path to a configuration file, when it isn't the `specs.config.yaml` in the working directory. ```bash -specs generate components.md -o specs/ --split-components +specs generate --config configs/mobile.yaml -o specs/mobile.yaml ``` -### With license key - -```bash -export SPECS_LICENSE_KEY="your-license-key" -specs generate -``` +### `--verbose` +Enable detailed logging — resolved config path, source and mode detection, foundations loaded, and per-component progress. --- **See Also:** - [Scan Command](/cli/commands/scan/) - Create component manifest +- [Fetch Command](/cli/commands/fetch/) - Download Figma data for manifest and single component mode +- [Render Command](/cli/commands/render/) - Send a generated spec back into Figma +- [Bridge Command](/cli/commands/bridge/) - Start the local bridge used by `--from-bridge` +- [Render to Figma](/guides/render-to-figma/) - Bridge architecture, setup, and prerequisites - [Configuration Reference](/settings/) - Format and config options - [Getting Started](/cli/getting-started/) - Installation and license setup diff --git a/site/src/content/docs/cli/commands/render.md b/site/src/content/docs/cli/commands/render.md new file mode 100644 index 00000000..12dbda5a --- /dev/null +++ b/site/src/content/docs/cli/commands/render.md @@ -0,0 +1,260 @@ +--- +title: "render" +--- + + + +Sends a spec (or a batch of specs) to a running CLI bridge, which renders the matching component live in a Figma file — creating or updating it. + +This is the reverse of the rest of the CLI: instead of reading Figma and producing a spec, `render` reads a spec and renders it in Figma. It requires the bridge to be running (`specs bridge start`) with a connected Figma plugin — see the [Render to Figma guide](/guides/render-to-figma/) for full setup before running this command for the first time. + +It also requires a current [cache](/cli/commands/cache/) — the lookup tables that give a spec's component, token, style, and icon names meaning. `fetch` builds it, so a normal workflow already has one. When it's missing or no longer matches your fetched data, `render` stops and names what to rebuild rather than rendering against data that has moved on. + +## How a Render Works + +Each render is a single round trip through four steps: + +1. **Load the spec.** A spec file is read directly; a component folder is assembled from its `api.*`, `variants.*`, and optional `examples.*` files. +2. **Resolve the target file.** From `--file`, or from the bridge's connections — prompting when more than one plugin is connected and the terminal is interactive. In a directory batch this happens once, up front, so a sweep never prompts per component. +3. **Post to the bridge.** The bridge validates the [cache](/cli/commands/cache/) against your fetched data, builds the lookup manifests the spec's names resolve through, and relays the payload to the connected plugin. +4. **The plugin writes.** The component is built inside the live Figma document — frames, styles, variants, instances — and the resulting node id comes back. + +`render` reports that outcome and stops there. It performs no round-trip check of its own: reading back what actually landed is an explicit [`generate --from-bridge`](/cli/commands/generate/#bridge-mode) call. The bridge holds one render slot, so concurrent calls queue rather than overlap. + +## Usage + +```bash +specs render [specPath] [options] +``` + +## Arguments + +### `[specPath]` + +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 directory of component folders** — renders every component beneath it, sequentially, in path order. + +```bash +specs render specs/deButton.yaml # one spec file +specs render specs/deButton/ # one component folder +specs render specs/ # every component in the directory +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. + +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. + +## Options + +### `--config ` + +Use a specific config file instead of the default `specs.config.yaml`. + +### `--file ` + +Target a specific connected Figma file. Only matters when the bridge has more than one plugin connected: + +- **Passed explicitly** — that file is used, no prompt. +- **Omitted, interactive terminal, 2+ connections** — `render` lists the connected files and asks which to use. +- **Omitted, non-interactive (scripts, CI)** — no prompt; the bridge reports the ambiguity as an error rather than hanging on stdin. + +Scripted runs against a multi-file bridge should always pass `--file`. + +```bash +specs render specs/deButton.yaml --file abc123XYZ +``` + +In a directory batch the file is resolved once, up front, so a sweep never prompts per component. + +### `--page ` + +Render onto a specific page instead of whatever page the plugin currently has open. Recommended for scripted runs — without it, the target is the user's current page, which can drift mid-run if someone navigates in Figma. + +```bash +specs render specs/deButton.yaml --page 12:345 +``` + +### `--overwrite` + +Delete any existing page component with the same title before rendering. Without it, a title collision is an error. + +This is destructive — the existing component is removed, not merged into. When a directory batch would overwrite more than one component and the terminal is interactive, `render` asks for confirmation first; non-interactive runs proceed without asking. + +```bash +specs render specs/deButton.yaml --overwrite +``` + +### `--watch` + +Watch the spec path and re-render on every change. Implies `--overwrite`, since each re-render replaces the component produced by the last one. + +Requires an explicit spec path — it can't be combined with zero-arg config resolution. It accepts any of the three `[specPath]` shapes; for a single spec file the watch is set on its containing directory, so sibling edits also trigger it. Changes are debounced (300ms), and a render in flight defers the next one rather than overlapping. Runs until interrupted with Ctrl+C. + +In watch mode a failed render is logged and the watch continues, rather than exiting — the next save may fix it. + +```bash +specs render specs/deButton/ --watch +``` + +### `--refresh-cache` + +Rebuild the [cache](/cli/commands/cache/) from your fetched data before rendering. Off by default, since `fetch` already builds it and rebuilding on every render would reintroduce the cost the cache exists to remove. + +Use it when a render has just failed on a stale cache and you'd rather not run a separate command. + +```bash +specs render specs/deButton/ --refresh-cache +``` + +### `--timing` + +Print a phase-by-phase timing report after the render: the bridge's lookup work and payload size, then each phase of the write inside Figma, with each phase's share of total time and how many times it ran. + +Phases that run concurrently — one row per variant, for instance — can sum to more than the total; the count column is what makes that readable. + +```bash +specs render specs/deButton/ --timing +``` + +### `--strict` + +Fail the render when an instance element can't be resolved, instead of rendering a component with missing content. + +By default an unresolvable instance is a warning: the component is still created in Figma, minus that content, and `render` reports how many elements were dropped. `--strict` turns that into a failure — useful in CI, where a silently incomplete component is worse than a red build. + +```bash +specs render specs/deButton.yaml --strict +``` + +In a directory batch, `--strict` fails the individual component; the sweep continues and the exit code reflects the total. + +## Output + +While the plugin works, a spinner holds a single line (`⠹ Rendering: deButton (3s)`); the outcome then prints over it, so a batch reads as one line per component. In a non-interactive terminal the status is printed as a plain line instead. + +### A successful render + +``` +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +``` + +The node id is the component that was created or updated — open it directly at `figma.com/file/.../?node-id=1234-5678`. + +### An incomplete render + +``` + ⚠ INCOMPLETE: 2 instance element(s) could not be resolved and were not rendered. The component exists in Figma but is missing content. +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) +``` + +An instance element that can't be resolved is content the component was supposed to contain, so this is called out as a count rather than left to a per-element warning that scrolls past in a batch. It's still a success — the component exists — unless you pass [`--strict`](#--strict). + +The usual cause is a cache that doesn't know the referenced component: run [`specs fetch`](/cli/commands/fetch/) to refresh it. + +### A batch + +``` +Found 6 components in specs: + - deButton + - deCard + ... +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) + ✗ deModal: Render failed: no page component named "DS Modal" +... + +Done: 5 rendered in Figma, 1 failed. +⚠ 1 rendered with missing content: deCard (2) +``` + +A failure doesn't abort the sweep — the remaining components still render, and the exit code reflects the total. Incomplete renders are reported separately from failures, because they succeeded. + +## Examples + +```bash +# Render one component +specs render specs/deButton.yaml + +# Render every component in the output directory +specs render specs/ + +# Same, resolved from config (outputDirectory) +specs render + +# Re-render on every save while iterating on a spec +specs render specs/deButton/ --watch + +# Replace an existing component instead of erroring on the title collision +specs render specs/deButton.yaml --overwrite + +# Rebuild the lookup cache first, then render +specs render specs/deButton/ --refresh-cache + +# See where a slow render spends its time +specs render specs/deButton/ --timing + +# Scripted run: pin the file and page, fail on incomplete renders +specs render specs/ --file abc123XYZ --page 12:345 --strict +``` + +## Fidelity + +A component rendered from its spec and read back should be byte-identical to the spec that +produced it, except where a limitation below explains the difference or an open defect +accounts for it — anything else is a bug nobody has written down yet. + +### Limitations + +Permanent, and imposed by Figma rather than by this tool. None of them lose a property, a +value or a binding; each is about how something is expressed. + +- **Property order in the panel.** Figma orders properties by creation, with no way to + reorder afterwards, so slots lead and code-only props trail. Variant order *is* preserved. +- **A locked aspect ratio holds one bound dimension, not two.** Declaring a ratio plus both + dimensions is over-specified, and Figma derives the second from the first. +- **A filling root has no width to reproduce.** Derived from the ratio where one exists, + otherwise a fixed fallback ([#341](https://github.com/DirectedEdges/specs/issues/341)). +- **Boolean variant values render lowercase.** `True` becomes `true`; identical behaviour, + different casing. +- **Instances placed elsewhere in the file are not recreated.** A render reproduces the + component asset, so the examples drawn from its instances have no source to come from. +- **A mask is neither read nor rendered.** Shapes masked by another layer — corner shaping, + for instance — are read as the shapes they are, and a render reproduces them unmasked. +- **Bindings from slot content to the component that holds it are ignored.** Figma withdrew + that capability; older files keep theirs, and each affected component reports it once. + +### Open defects + +Real differences between a component and a render of its own spec, whatever a given release +schedules. Expect these in a comparison until they are fixed. + +- A slot constraint naming a component absent from the fetched data is dropped — + [#325](https://github.com/DirectedEdges/specs/issues/325) +- A code-only prop whose name collides with a native property is dropped — + [#344](https://github.com/DirectedEdges/specs/issues/344) +- A code-only prop backed by a variant picker loses its provenance and its Figma type — + [#350](https://github.com/DirectedEdges/specs/issues/350) + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `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 | + +--- + +**See Also:** + +- [`bridge`](/cli/commands/bridge/) — start/stop/check the bridge `render` talks to +- [Render to Figma](/guides/render-to-figma/) — bridge architecture, setup, and prerequisites +- [`cache`](/cli/commands/cache/) — the lookup tables `render` resolves component, style, token, and glyph names against +- [`fetch`](/cli/commands/fetch/) — downloads the library data and builds that cache +- [`generate`](/cli/commands/generate/) — produces the specs that `render` consumes diff --git a/site/src/content/docs/cli/commands/scan.md b/site/src/content/docs/cli/commands/scan.md index 8a8eb509..1253fe49 100644 --- a/site/src/content/docs/cli/commands/scan.md +++ b/site/src/content/docs/cli/commands/scan.md @@ -240,5 +240,6 @@ 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 - [Configuration Reference](/settings/) - Format and config options diff --git a/site/src/content/docs/cli/index.md b/site/src/content/docs/cli/index.md index c156daac..dd50d095 100644 --- a/site/src/content/docs/cli/index.md +++ b/site/src/content/docs/cli/index.md @@ -11,8 +11,12 @@ The Specs command-line interface (CLI) generates design system specifications fr | [`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` | | [`scan`](/cli/commands/scan/) | List all components in file | Markdown manifest | -| [`generate`](/cli/commands/generate/) | Generate specs from a manifest or single component | YAML/JSON spec file(s) | | [`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` | +| [`render`](/cli/commands/render/) *(experimental)* | Send a spec to the CLI bridge to render it live in Figma | Live Figma component | ### Global Options @@ -94,6 +98,7 @@ data/ - `file` — any Figma plan with REST API access - `variables` / `styles` — Figma restricts these REST endpoints to organizations on an **Enterprise** plan, regardless of your Specs license - **License key** (optional) via `SPECS_LICENSE_KEY` for Pro features +- **A running CLI bridge and open Figma session** (for `render` only) — see the [Render to Figma guide](/guides/render-to-figma/) See [Getting Started](/cli/getting-started/) for installation instructions. diff --git a/site/src/content/docs/cli/workflows.md b/site/src/content/docs/cli/workflows.md index 659f4f27..3e87aa38 100644 --- a/site/src/content/docs/cli/workflows.md +++ b/site/src/content/docs/cli/workflows.md @@ -9,6 +9,8 @@ Three ways to generate specs, depending on how many components you need: - [**Many Components**](#many-components) — curate a manifest and generate in batch - [**CI/CD Integration**](#cicd-integration) — automate generation in a pipeline +There's also a reverse direction — turning a spec back into a live Figma component with [`render`](#render-to-figma). Unlike the workflows above, it's inherently interactive (it needs an open Figma session) and isn't a fit for CI/CD. + ## Single Component Generate a spec for one component: @@ -172,6 +174,59 @@ echo "Sync complete!" --- +## Render to Figma + +Turn a spec back into a live Figma component. This needs a running CLI bridge and an open Figma session — see the [Render to Figma guide](/guides/render-to-figma/) for full setup. + +```bash +# One-time per session: start the bridge, then in Figma enable +# the CLI Bridge in the Specs 2 plugin and navigate to the target page. +specs bridge start +``` + +```bash +# Render one component +specs render specs/deButton.yaml + +# Or render every component in a directory +specs render specs/ +``` + +--- + +## Generate from the Current Selection + +When the component you want is already open in Figma, skip the fetch entirely and generate from the live selection over the same bridge. See [Bridge Mode](/cli/commands/generate/#bridge-mode) for the full behavior. + +```bash +# Bridge running, Specs 2 plugin open with CLI Bridge enabled, +# and the component selected in Figma. +specs generate --from-bridge -o specs/dsButton.yaml +``` + +The spec reflects the document as it stands right now, including unsaved edits — useful while iterating on a component rather than after a fetch. + +```bash +# Pin the node instead of relying on a manual selection +specs generate --from-bridge --node 5507:123 -o specs/dsButton.yaml + +# Pick a file when several are connected to one bridge +specs bridge status +specs generate --from-bridge --file abc123XYZ -o specs/dsButton.yaml +``` + +With 2+ files connected and no `--file`, an interactive terminal prompts you to choose; scripts and CI fail with the ambiguity error instead of hanging. + +Pair it with `render` to round-trip a spec and diff the result: + +```bash +specs render specs/dsButton.yaml +specs generate --from-bridge -o specs/roundtrip/dsButton.yaml +diff specs/dsButton.yaml specs/roundtrip/dsButton.yaml +``` + +--- + ## Tips ### Pipe to Other Tools @@ -247,3 +302,4 @@ git commit -m "manifest: keep DS Tooltip NEW unchecked — pending API redesign" - [CLI Overview](/cli/) - Commands, Free vs Pro, output format - [Settings](/settings/) - Config file reference +- [Render to Figma](/guides/render-to-figma/) - CLI bridge setup and usage diff --git a/site/src/content/docs/guides/key-formatting.md b/site/src/content/docs/guides/key-formatting.md index 25fa55cd..07ae5f3c 100644 --- a/site/src/content/docs/guides/key-formatting.md +++ b/site/src/content/docs/guides/key-formatting.md @@ -89,12 +89,113 @@ model: **Choose one format and use it consistently.** Mixing formats across different spec runs creates the same inconsistency problem you're trying to solve. Set the format once in your config file and leave it. -**`SAFE` is lossless; everything else is lossy.** The `SAFE` format preserves the original name exactly. All other formats discard information (casing, separators, spaces). If you need the original Figma name alongside a transformed key, `SAFE` is the only format that preserves it. +**`SAFE` is lossless; everything else is lossy.** The `SAFE` format preserves the original name exactly. All other formats discard information (casing, separators, spaces). Names that would lose information are preserved separately — see [Round-Trip Safety](#round-trip-safety) below. **Match your consuming platform.** If your specs feed a React component library, use `CAMEL`. If they feed a Python SDK, use `SNAKE`. The format should eliminate transformation work for the most common consumer, not add it. **Special characters are handled gracefully.** Keys containing special characters (slashes, dots, brackets) are cleaned during transformation. The `SAFE` format preserves them as-is; other formats normalize them into the target convention. +## Round-Trip Safety + +Formatting a key is one-way: `Icon leading`, `Icon-leading`, and `Icon_leading` all become `icon-leading` under `KEBAB`, and the formatted key alone cannot say which one it came from. That matters when a spec is rendered back into Figma, where layer and property names are the identity used to match existing nodes. + +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: + +```yaml +model: + format: + figmaKeys: SENTENCE # what your Figma file uses + keys: KEBAB # what the spec emits +``` + +| Value | Shape | Example | +|-------|-------|---------| +| `NONE` (default) | No convention declared | — | +| `SENTENCE` | First word capitalized, rest lowercase | `Icon leading` | +| `TITLE` | Every word capitalized | `Icon Leading` | + +Everything in the rest of this guide is **opt-in**. Under the `NONE` default, names are formatted per `keys` and nothing else happens: no grammar check, no preserved names, no defined reversal. Declaring `SENTENCE` or `TITLE` turns all of it on, and your specs grow an extension block for each name that needs one. Declare a convention when you intend to render specs back into Figma, or when you want divergent names surfaced. + +### The safe key grammar + +*Applies when `figmaKeys` is `SENTENCE` or `TITLE`.* + +A Figma name survives every `keys` format when it satisfies all of the following: + +- ASCII letters and digits only — no `&`, `+`, `/`, `.`, parentheses, punctuation, or accented characters +- 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` + +```yaml +# figmaKeys: SENTENCE + +# Safe — reconstructs under every keys format +Icon leading +Label +Badge count 2 # the digit is its own word, so the boundary survives +Icon 2 leading + +# Unsafe — the Figma name is preserved separately +Icon-leading # separator is not a space +URL field # inner capitals are lost +Badge count2 # letters and digits share a word +2 icons # begins with a digit +Cut & paste # the symbol and its word boundary are deleted +``` + +A digit run always counts as its own word, in both directions. That is what lets `Badge count 2` become `badgeCount2` and come back intact — and why `Badge count2` cannot, since it formats to the same key but is not the same name. + +### Names already written as keys + +Figma files are rarely uniform. If yours is mostly sentence case but a few properties were named `isDisabled` or `is-disabled` for the engineers consuming them, those names fail the grammar — yet they are already exactly what you want on both sides. + +A name that is already a well-formed key **in the convention you emit** is kept exactly as authored, and recorded. With `keys: CAMEL`, a layer named `isDisabled` stays `isDisabled`, and rendering the spec back into Figma restores it rather than rewriting it to `Is disabled`: + +```yaml +props: + isDisabled: + type: boolean + $extensions: + com.figma: + name: isDisabled +``` + +Only the convention you emit is retained. A name in some *other* key convention is formatted like any divergent name — with `keys: KEBAB`, a layer named `isDisabled` or `IsDisabled` becomes `isdisabled` and carries its Figma name in the extension. Formatting splits on spaces, hyphens, and underscores rather than case, so it flattens such names rather than converting them; the extension is what keeps them recoverable. + +### What happens to unsafe names + +Unsafe names are fully supported — nothing is rejected. When a key cannot reconstruct its Figma name, that name is recorded on the definition: + +```yaml +anatomy: + icon-leading: # safe — nothing extra emitted + type: glyph + url-field: + type: text + $extensions: + com.figma: + name: URL field +``` + +References elsewhere in the spec (`elements`, `propConfigurations`) keep pointing at the formatted key; the Figma name is resolved through the definition. + +Because well-formed names emit nothing, the presence of `com.figma.name` doubles as a signal that a Figma layer or property name is worth tidying. + +### What is not covered + +The Figma names of anatomy elements and props are preserved, including those nested inside compositions and slot content. Two things are not: + +- **Variant option values.** The values inside a variant prop's `options` are formatted like keys, but no Figma name is recorded for them. +- **Titles.** Component and subcomponent titles are never formatted — they carry the Figma name verbatim already, so there is nothing to preserve. + ## See Also +- [Keys](/settings/keys/) — the output convention setting +- [Figma Keys](/settings/figma-keys/) — the source convention setting - [CLI Configuration](/settings/) — full config reference diff --git a/site/src/content/docs/guides/render-to-figma.md b/site/src/content/docs/guides/render-to-figma.md new file mode 100644 index 00000000..933af97b --- /dev/null +++ b/site/src/content/docs/guides/render-to-figma.md @@ -0,0 +1,278 @@ +--- +title: "Render to Figma" +description: "The full command sequence behind a successful render — config, fetch, cache, bridge, plugin, render, and verification" +--- + +Every other command in Specs reads Figma and produces a spec. [`render`](/cli/commands/render/) runs the pipeline in reverse: it takes a spec and creates or updates the matching component in a real, open Figma file. + +`render` itself is a thin client. Almost everything that determines whether a render succeeds happens *before* you type it — the data you fetched, the lookup cache built from it, the bridge process, and the plugin connection. This guide walks the whole chain in order, with what each stage contributes, how to verify it before moving on, and what it looks like when it's wrong. + +## The Chain + +``` +specs init config: sources, dataDirectory, outputDirectory + ↓ +specs fetch Figma payloads → data/{alias}.file.json, .variables.json + ↓ (and builds the cache as its last step) +specs cache lookup tables → data/cache/*.yaml + ↓ +specs bridge start background server on localhost + ↓ +Figma plugin Specs 2 open, CLI Bridge toggled on → connects to the bridge + ↓ +specs render spec → bridge → plugin → component in Figma + ↓ +specs generate --from-bridge: read back what actually landed + --from-bridge +``` + +The first three stages are about *meaning* — a spec names components, styles, tokens, and glyphs, and those names are resolved against your fetched library. The next two are about *reach* — a Node process cannot touch a Figma document, so the bridge relays to a plugin that can. The last is how you check the result, and it is always an explicit second call, never a side effect of `render`. + +## Stage 1 — Config + +A render workspace is an ordinary Specs workspace. If you don't have one: + +```bash +specs init +``` + +Then fill in `sources` with your Figma file keys. Three config values 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. | + +See [sources config](/settings/data-sources/) for the full shape. + +## Stage 2 — Fetch the Library + +```bash +export FIGMA_TOKEN=figd_... +specs fetch +``` + +This downloads the raw Figma payloads *and* builds the render cache as its final step, which is why a normal workflow never runs `specs cache` by hand. + +Re-run `fetch` whenever the library moves underneath you — components added or renamed, styles or variables changed. A spec binds by name, so rendering against data that predates a rename silently binds to the wrong thing or drops content. + +Verify the last lines of the output name your sources and non-zero entry counts: + +``` + Cache rebuilt: library + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + +See [`fetch`](/cli/commands/fetch/) for flags, and note that `variables` and `styles` data requires a Figma Enterprise plan — `file` and `icons` work on any plan. + +## 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/`: + +| File | Resolves | What breaks without it | +|---|---|---| +| `components.yaml` | instance elements → published component keys | Instances can't be placed; content is missing | +| `styles.yaml` | style names → style keys | Styles aren't applied | +| `variables.yaml` | token names → variable keys | Token bindings fall back to raw values | +| `icons.yaml` | glyph names → icon node ids | Glyph elements can't be placed | + +You run [`specs cache`](/cli/commands/cache/) by hand only when the cache is missing, when something outside `fetch` changed your data directory, or when `render` tells you to: + +```bash +specs cache # rebuild whatever is out of date +specs cache --force # rebuild every source from scratch +``` + +The bridge checks the cache against your data files before **every** render, and a mismatch is a hard failure rather than an automatic rebuild: + +``` +Error: Render failed: Render cache is not usable: + - variables.yaml: "library" is stale + +Run `specs cache` to rebuild it. +``` + +Rebuilding on every render would reintroduce exactly the cost the cache exists to remove, and rendering against data that has moved on produces a failure that surfaces far from its cause. When you'd rather not run a separate command, [`render --refresh-cache`](/cli/commands/render/#--refresh-cache) rebuilds first and then renders. + +Note that `render` reads nothing produced by [`scan`](/cli/commands/scan/). A scan manifest is generated and then hand-authored, which makes it a poor thing to resolve against; `scan` serves `generate`, not `render`. + +## Stage 4 — The Bridge + +Making a spec real in Figma means placing frames, applying styles, wiring variants, and building instances inside a running Figma session — work that can only happen inside the plugin runtime, not in a Node process. The bridge is what connects the two. + +```bash +specs bridge start +``` + +``` +Bridge started (pid 12345). + WebSocket : ws://localhost:9001 (plugin) + HTTP : http://localhost:9002/render (control) + Logs : /Users/you/.specs/bridge.log +Enable the CLI Bridge in the Specs 2 plugin to connect. +``` + +It runs in the background until you stop it, so this is once per session, not once per render. `specs render` never starts it for you — if it isn't running, the command fails immediately rather than hanging: + +``` +Error: bridge is not running. + Start it with: specs bridge start +``` + +Pin a workspace for the whole session with `specs bridge start --workspace ~/design-system` when every render targets the same one; otherwise it's derived per request from each spec's path. + +## Stage 5 — Connect the Plugin + +Starting the bridge connects it to nothing by itself. In Figma: + +1. Open the **target file** — the one you want components rendered into. +2. Run the **Specs 2** plugin. +3. Toggle **CLI Bridge** on. If the plugin was already open before you started the server, toggle it off and back on. +4. Navigate to the **page** you want rendered onto, unless you plan to pass `--page`. + +Verify from the terminal: + +```bash +specs bridge status +``` + +``` +Bridge running (pid 12345). 1 plugin connected: + abc123XYZ (Design System Library) — connected +``` + +`bridge status` exits non-zero when the bridge isn't running, so it's safe in a conditional (`if specs bridge status; then ...`). + +**More than one file can be connected at once.** When two or more plugins are connected, `render` asks which file to use in an interactive terminal, and reports the ambiguity as an error in a non-interactive one rather than hanging on stdin. Scripted runs should always pass `--file ` — the key is the first column of `bridge status`. + +## Stage 6 — Render + +### One component + +```bash +specs render specs/deButton.yaml # a spec file +specs render specs/deButton/ # a component folder (api.* + variants.*) +``` + +A spinner holds the line while Figma works, then the outcome replaces it: + +``` +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +``` + +The node id is the component that was created or updated — open it directly at `figma.com/file/.../?node-id=1234-5678`. + +### A batch + +Point `render` at a directory of component folders, or omit the path entirely to use `outputDirectory`: + +```bash +specs render specs/ # every component beneath specs/ +specs render specs/forms/ # one group +specs render # resolved from config +``` + +``` +Found 6 components in specs: + - deButton + - deCard + ... +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) +... + +Done: 6 rendered in Figma, 0 failed. +``` + +Batch scanning looks at most two levels deep and never descends into a component folder. Components render **one at a time, in path order** — order isn't configurable, so when one component's spec references another, render them individually in the order you need. A single failure doesn't abort the sweep; the exit code reflects the total. + +### Reading the outcome + +| Output | Means | +|---|---| +| `✓ Rendered: name (…)` | The component was created or updated | +| `⚠ INCOMPLETE: n instance element(s) …` | The component exists but is **missing content** — an instance couldn't be resolved | +| `✗ name: Render failed: …` | Nothing was produced for that component | +| `⚠ n rendered with missing content: …` | Batch summary of the incomplete ones | + +An incomplete render is a success, not a failure — the component is in Figma, minus what couldn't be placed. Missing instances almost always mean the cache doesn't know the referenced component: re-run `specs fetch`. Pass [`--strict`](/cli/commands/render/#--strict) to turn incompleteness into a failure, which is what you want in CI where a silently partial component is worse than a red build. + +## Stage 7 — Verify What Landed + +`render` reports success and a node id — nothing about fidelity. To read back what actually exists in Figma, select the rendered component and run generation through the same bridge: + +```bash +specs generate --from-bridge -o specs/roundtrip/deButton.yaml +diff specs/deButton.yaml specs/roundtrip/deButton.yaml +``` + +That round trip is the real check. Because render is under active development, treat every render as something to look at in Figma too, rather than a guaranteed match to the source spec. + +## Stage 8 — Iterate + +While working on one spec, let the watcher re-render on every save: + +```bash +specs render specs/deButton/ --watch +``` + +`--watch` implies `--overwrite`, since each pass replaces the component the last one produced. It requires an explicit path, debounces changes (300ms), defers rather than overlaps when a render is in flight, and keeps going after a failed render — the next save may fix it. Ctrl+C to stop. + +Without `--watch`, a title collision is an error; [`--overwrite`](/cli/commands/render/#--overwrite) deletes the existing same-titled component first. That's destructive, so an interactive batch of more than one component asks for confirmation. + +When a render is slow, [`--timing`](/cli/commands/render/#--timing) attributes the wall clock to bridge lookup work and each plugin write phase. + +## Stage 9 — Scripted Runs + +Everything ambiguous in an interactive session must be pinned in a script: + +```bash +specs render specs/ --file abc123XYZ --page 12:345 --strict +``` + +- `--file` — never rely on the prompt; a non-interactive run errors instead of asking. +- `--page` — without it the target is whatever page the user has open, which can drift mid-run if someone navigates in Figma. +- `--strict` — fail on incomplete renders instead of shipping partial components. + +Note that a render session still needs a human-driven Figma app open with the plugin connected, so `render` is not a fit for headless CI. + +## Stage 10 — Tear Down + +```bash +specs bridge stop +``` + +Graceful first, forced if it doesn't exit within a few seconds. Safe to run when nothing is running. + +## Troubleshooting + +| Symptom | Stage | Fix | +|---|---|---| +| `Error: bridge is not running.` | 4 | `specs bridge start`, then retry | +| `Bridge running … No plugin connected.` | 5 | Enable CLI Bridge in the Specs 2 plugin; toggle off/on if it was already open | +| Prompted to choose a file, or an ambiguity error | 5 | Two plugins are connected — pass `--file ` from `bridge status` | +| Nothing happens / times out | 5 | `specs bridge status` to confirm the connection; check you're on the intended page; `tail -f ~/.specs/bridge.log` | +| `Render cache is not usable` | 3 | `specs cache`, or render with `--refresh-cache` | +| `Not fetched, skipped: ` then a render failure | 2 | `render` needs every declared source fetched — run `specs fetch` | +| `⚠ 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 | +| `no component folders found` | 6 | A component folder holds `api.(yaml\|json)` and `variants.(yaml\|json)`, at most two levels deep | + +## Limits + +- **One render at a time.** The bridge holds a single render slot; concurrent calls queue. +- **No automatic round trip.** Reading a rendered component back is always an explicit `generate --from-bridge`. +- **Render order isn't configurable.** Batches go in path order. +- **This is a young feature.** Expect rough edges on more elaborate components. + +## Further Reading + +- [`render` command reference](/cli/commands/render/) — every flag, argument shape, and exit code +- [`bridge` command reference](/cli/commands/bridge/) — start/stop/status, logs, pid file +- [`cache`](/cli/commands/cache/) — what the lookup tables hold, and when they go stale +- [`fetch`](/cli/commands/fetch/) — downloads the library data and builds the cache +- [`generate`](/cli/commands/generate/) — produces the specs that `render` consumes, and reads them back with `--from-bridge` +- [sources config](/settings/data-sources/) — how workspace aliases and data files are resolved diff --git a/site/src/content/docs/schema/anatomy.md b/site/src/content/docs/schema/anatomy.md index 37bfc18e..d76b84de 100644 --- a/site/src/content/docs/schema/anatomy.md +++ b/site/src/content/docs/schema/anatomy.md @@ -16,6 +16,15 @@ type Anatomy = Record; | `type` | `ElementType \| ElementTypeRef` | Yes | What kind of element this is | | `detectedIn` | `string` | No | Frame or node name where this element was found | | `instanceOf` | `string \| SubcomponentRef` | No | Component name this element is an instance of, or a `$ref` to a subcomponent | +| `$extensions` | `AnatomyElementExtensions` | No | Vendor extensions; `com.figma` carries extraction provenance | + +### AnatomyElementExtensions + +Only the `com.figma` extension is defined. + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | The element's Figma layer name, present only when the anatomy key cannot reconstruct it — either after [wrapper collapse](/settings/collapse-primitive-wrapper/) or when the name falls outside the safe key grammar (see [Key Formatting](/guides/key-formatting/)) | ### ElementType diff --git a/site/src/content/docs/schema/config.md b/site/src/content/docs/schema/config.md index e304d5e6..44b2df08 100644 --- a/site/src/content/docs/schema/config.md +++ b/site/src/content/docs/schema/config.md @@ -11,6 +11,7 @@ Controls how specs are generated. See the [settings reference](/settings/) for d |----------|------|---------|-------------| | [`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` | @@ -99,6 +100,7 @@ const DEFAULT_CONFIG: ResolvedConfig = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'NONE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', diff --git a/site/src/content/docs/schema/props.md b/site/src/content/docs/schema/props.md index 1bc09110..109a91c9 100644 --- a/site/src/content/docs/schema/props.md +++ b/site/src/content/docs/schema/props.md @@ -115,6 +115,7 @@ The `$extensions` object holds vendor-specific metadata. Currently only the `com |----------|------|-------------| | `type` | `string` | Figma property type (e.g. `BOOLEAN`, `TEXT`, `INSTANCE_SWAP`, `VARIANT`) | | `source` | `FigmaCodeOnlySource` | Present when the prop originates from a code-only prop layer | +| `name` | `string` | The Figma property name, present only when the prop key cannot reconstruct it — see [Key Formatting](/guides/key-formatting/) | ### FigmaCodeOnlySource @@ -131,3 +132,4 @@ The `$extensions` object holds vendor-specific metadata. Currently only the `com - [ADR 056 — Rename SlotProp.minItems/maxItems → minChildren/maxChildren](https://github.com/DirectedEdges/specs/blob/main/adr/056-slot-children-constraints.md) — aligns field names with Figma native `slotSettings`; adds native `preferredValues` resolution - [ADR 029 — NumberProp](https://github.com/DirectedEdges/specs/blob/main/adr/029-number-prop.md) — adds the `NumberProp` type with opt-in inference - [ADR 063 — Image Content](https://github.com/DirectedEdges/specs/blob/main/adr/063-image-content.md) — adds the `ImageProp` type and image fills/registry +- [ADR 066 — Lossless Key Formatting](https://github.com/DirectedEdges/specs/blob/main/adr/066-lossless-key-formatting.md) — adds `name` to `FigmaPropExtension` so lossy key formats stay reversible diff --git a/site/src/content/docs/schema/styles.md b/site/src/content/docs/schema/styles.md index 4d345c7f..6c55e57d 100644 --- a/site/src/content/docs/schema/styles.md +++ b/site/src/content/docs/schema/styles.md @@ -15,7 +15,7 @@ The `Styles` object holds visual properties for an element. Every property is op | [`bottom`](/schema/styles/bottom/) | `PositionOffset` | `y` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | | [`centerHorizontalOffset`](/schema/styles/center-horizontal-offset/) | `PositionOffset` | `x` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | | [`centerVerticalOffset`](/schema/styles/center-vertical-offset/) | `PositionOffset` | `y` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | -| [`clipContent`](/schema/styles/clip-content/) | `Style` | — | +| [`clipsContent`](/schema/styles/clips-content/) | `Style` | — | | [`cornerRadius`](/schema/styles/corner-radius/) | `Style`
`Corners` | — | | [`cornerSmoothing`](/schema/styles/corner-smoothing/) | `Style` | — | | [`crossAxisAlignment`](/schema/styles/cross-axis-alignment/) | `CrossAxisAlignment` | `counterAxisAlignItems` [ADR 040](https://github.com/DirectedEdges/specs/blob/main/adr/040-layout-alignment.md) | diff --git a/site/src/content/docs/schema/styles/clip-content.md b/site/src/content/docs/schema/styles/clips-content.md similarity index 80% rename from site/src/content/docs/schema/styles/clip-content.md rename to site/src/content/docs/schema/styles/clips-content.md index 0f4dd1c6..607d660e 100644 --- a/site/src/content/docs/schema/styles/clip-content.md +++ b/site/src/content/docs/schema/styles/clips-content.md @@ -1,9 +1,9 @@ --- -title: "clipContent" -description: "Whether a container clips overflowing children" +title: "clipsContent" +description: "Whether a container clips overflowing content" --- -Whether the container clips content that overflows its bounds. +Whether the container clips content that overflows its box. #### Type(s) diff --git a/site/src/content/docs/settings/collapse-primitive-wrapper.md b/site/src/content/docs/settings/collapse-primitive-wrapper.md index f38373fc..18063694 100644 --- a/site/src/content/docs/settings/collapse-primitive-wrapper.md +++ b/site/src/content/docs/settings/collapse-primitive-wrapper.md @@ -33,10 +33,10 @@ anatomy: root: type: text $extensions: - com.figma.originalName: Label + com.figma.name: Label ``` -The `$extensions.com.figma.originalName` field on the anatomy root carries the original Figma layer name so the source layer remains traceable. +The `$extensions.com.figma.name` field on the anatomy root carries the Figma layer name so the source layer remains traceable. ## Eligibility @@ -47,7 +47,7 @@ A component qualifies for collapse when **all** of the following are true: - That child's type is `text` or `glyph`. - The child has no children of its own. - The root carries no slot binding on its children. -- The root carries none of the following styles after default/zero values are stripped: `clipContent`, `cornerRadius`, `strokes`, `strokeAlign`, `strokeWeight`, `itemSpacing`, `padding`, `effects`, `backgroundColor`, `cornerSmoothing`. +- The root carries none of the following styles after default/zero values are stripped: `clipsContent`, `cornerRadius`, `strokes`, `strokeAlign`, `strokeWeight`, `itemSpacing`, `padding`, `effects`, `backgroundColor`, `cornerSmoothing`. Collapse is **all-or-nothing**: if any variant in the component set fails the eligibility check, no collapse occurs for any variant. diff --git a/site/src/content/docs/settings/figma-keys.md b/site/src/content/docs/settings/figma-keys.md new file mode 100644 index 00000000..754090ec --- /dev/null +++ b/site/src/content/docs/settings/figma-keys.md @@ -0,0 +1,56 @@ +--- +title: "Figma Keys" +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. + +## Options + +- **Default**: `NONE` +- **Values**: + - `NONE` - No convention declared. Names are not checked, and nothing is preserved + - `SENTENCE` - First word capitalized, rest lowercase (`Icon leading`) + - `TITLE` - Every word capitalized (`Icon Leading`) + +Only the two conventions observed in real Figma files are accepted as declarations. This is deliberately narrower than `keys`. + +## Path + +`config.format.figmaKeys` + +### Example + +```yaml +config: + format: + figmaKeys: TITLE # Figma layer names are Title Case + keys: CAMEL # spec emits camelCase +``` + +## Opting in + +`NONE` is the default, and it means your specs behave exactly as they always have: names are formatted per [`keys`](/settings/keys/), nothing is checked, and no Figma names are preserved. + +Declaring `SENTENCE` or `TITLE` turns on three things at once: + +- Every layer and property name is checked against the safe key grammar. +- Any name that cannot survive formatting has its Figma name recorded in `$extensions.com.figma.name`. +- Rendering a spec back into Figma has a defined name to reverse into. + +This is a trade, not a free upgrade. You gain round-trip fidelity and pay for it in spec size — every name outside the grammar grows an extension block. On a file with inconsistent layer naming, that can be a lot of new output. Declare a convention when you intend to render specs back into Figma, or when you want the divergences surfaced. + +## Why the declaration matters + +Declaring the convention your file actually uses keeps specs quiet. A file authored in Title Case but read as sentence case treats every name as divergent, so each anatomy element and prop carries a preserved copy of its Figma name — noise that buries the names genuinely worth fixing. + +Names that fall outside the declared convention, or outside the safe character set, are still fully supported. Their Figma name is recorded in `$extensions.com.figma.name` on the definition. + +Names already written as keys are handled too. If your file is mostly sentence case but a few properties are named `isDisabled` for the engineers reading them, those are kept exactly as authored — provided they match the convention you emit — and never rewritten to `Is disabled` when the spec is rendered back. + +## See Also + +- [Keys](/settings/keys/) - the output naming convention +- [Key Formatting guide](/guides/key-formatting/) - the safe key grammar and round-trip behavior diff --git a/site/src/content/docs/settings/index.mdx b/site/src/content/docs/settings/index.mdx index d3b45acc..8ed845ac 100644 --- a/site/src/content/docs/settings/index.mdx +++ b/site/src/content/docs/settings/index.mdx @@ -25,6 +25,7 @@ The **Format** section controls how spec data is serialized once it's extracted: - [Output Format](/settings/output-format/) — Serializes specs as `YAML` or `JSON` - [Keys](/settings/keys/) — Renames keys, such as `camelCase` or `snake_case` +- [Figma Keys](/settings/figma-keys/) — Declares the convention your Figma file uses, keeping renamed keys reversible - [Tokens](/settings/tokens/) — Serializes as forms such as a name string or `{ $token, $type }` object - [Color](/settings/color/) — Formats color, such as `#FF6600` or `{ colorSpace, components }` @@ -153,6 +154,7 @@ config: format: output: YAML keys: SAFE + figmaKeys: SENTENCE # opts in to key preservation; defaults to NONE layout: LAYOUT tokens: TOKEN diff --git a/site/src/content/docs/settings/keys.md b/site/src/content/docs/settings/keys.md index a4fdac25..569a4659 100644 --- a/site/src/content/docs/settings/keys.md +++ b/site/src/content/docs/settings/keys.md @@ -41,6 +41,9 @@ config: 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. + ## See Also +- [Figma Keys](/settings/figma-keys/) - The convention your Figma file uses, and the target keys reverse into - [Key Formatting guide](/guides/key-formatting/) - Detailed formatting behavior and edge cases diff --git a/site/src/custom.css b/site/src/custom.css index fa9cfbcc..e8357388 100644 --- a/site/src/custom.css +++ b/site/src/custom.css @@ -377,3 +377,84 @@ html[data-has-hero] .hero .tagline { max-width: 700px; line-height: 1.2; } + +/* ── ADR index ───────────────────────────────────────────────── */ + +.adr-index { + width: 100%; + border-collapse: collapse; + margin-top: 1.5rem; +} + +.adr-index td { + padding: 0.75rem 0; + border-bottom: 1px solid var(--sl-color-gray-5); + vertical-align: baseline; +} + +.adr-index .adr-number { + width: 3.5rem; + padding-right: 1rem; + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +.adr-index .adr-entry a { + font-weight: 600; + text-decoration: none; +} + +.adr-index .adr-entry a:hover { + text-decoration: underline; +} + +.adr-index .adr-summary { + margin: 0.25rem 0 0; + font-size: var(--sl-text-sm); + line-height: 1.5; + color: var(--sl-color-gray-3); +} + +/* ── ADR metadata block ──────────────────────────────────────── */ + +.adr-meta { + margin: 0 0 2rem; + padding: 0.6rem 1.25rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + background-color: var(--sl-color-gray-7, var(--sl-color-gray-6)); + font-size: var(--sl-text-sm); +} + +.adr-meta-row { + display: grid; + grid-template-columns: 7rem 1fr; + gap: 0 1rem; + padding: 0.4rem 0; +} + +.adr-meta-row + .adr-meta-row { + border-top: 1px solid var(--sl-color-gray-5); +} + +.adr-meta dt { + font-weight: 600; + color: var(--sl-color-gray-2); +} + +.adr-meta dd { + margin: 0; + color: var(--sl-color-gray-1); +} + +.adr-meta dd > :first-child { margin-top: 0; } +.adr-meta dd > :last-child { margin-bottom: 0; } + +@media (max-width: 30rem) { + .adr-meta-row { + grid-template-columns: 1fr; + gap: 0.1rem; + } +}