feat: local Figma Code Connect pipeline for RescriptBlend components - #128
feat: local Figma Code Connect pipeline for RescriptBlend components#128jagguji wants to merge 8 commits into
Conversation
Adds a self-contained Code Connect setup (Button.figma.ts + a generic
engine) resolved live by Figma's Dev Mode MCP server, plus a codegen
script that derives an equivalent ReScript module
(src/Figma/<Component>CodeConnect.res, published as part of the npm
package) so non-Figma consumers -- e.g. a standalone Figma plugin -- can
call the same rendering logic directly instead of re-deriving it.
Each component's Figma<->RescriptBlend property mapping lives in one
data file (figma/componentMaps/<Component>.mjs), the single source both
the template and the codegen script read from. This repo is public, so
no real Figma file URL/key/node-id is recorded anywhere -- only the
property-mapping knowledge (property names, variant values) is committed;
see figma/README.md's "No Figma URLs/file keys/node-ids in this repo" note.
Button is verified end-to-end: real property values were harvested
interactively from its existing React Code Connect mapping, and the
generated ButtonCodeConnect.fromFigmaProps compiles and reproduces the
expected <Button .../> snippet.
The remaining 208 components are scaffolded (scripts/scaffold-figma-component-maps.mjs
derives prop names/kinds/enum constructors from our own bindings) but
NOT Figma-verified -- every scaffold's figmaComponentName/figmaProp is an
explicit null/TODO, and the codegen script skips unverified scaffolds
rather than emitting placeholder ReScript from them.
CI now runs scripts/check-figma-map-drift.mjs on every push/PR: it
re-scaffolds every component into a temp dir and diffs its prop-key set
against the committed maps, catching props Blend gains/removes that a map
doesn't account for -- local-only, no secrets, no network, no human.
Verified end-to-end, including that it catches a real prop gap.
Props a human has reviewed and deliberately excluded from Code Connect
can be recorded as `{ mapped: false, reason: '...' }`, respected by
engine.mjs, the codegen script, and the drift check so a rejected prop
isn't silently re-flagged or re-rendered.
Figma-side rename detection (a designer renames a property and nothing
notices) needs the Figma REST API polled in CI, which requires a token
and breaks this pipeline's current no-secrets property -- documented in
figma/README.md as a deliberate non-goal pending that tradeoff decision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
e5ef475 to
da378fc
Compare
…olds Discovered by syncing main: a routine bindgen bump (^1.2.6-beta.10 -> ^1.3.0, PR #129) changed the .res shape for ~50 of the 208 still-unverified component scaffolds at once. Since none of those were ever relying on real Figma data (figmaComponentName: null), treating their drift as a CI failure would make the check noisy on essentially every dependency bump, training reviewers to ignore it. Split severity instead: drift in a map with figmaComponentName set (real Figma data exists, e.g. Button.mjs) is a genuine regression and fails the check. Drift in a map still at figmaComponentName: null is reported (so it's visible when someone gets to finishing that component) but doesn't fail CI. Button.mjs itself had zero drift from this bump -- confirmed before and after this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This repo no longer hand-maintains Figma property knowledge.
blend-design-system already publishes its own Code Connect *.figma.tsx
files (github.com/juspay/blend-design-system) -- those are now the sole
source of truth, consumed automatically the same way npm run generate
already treats blend-design-system as the source of truth for the
ReScript bindings themselves.
scripts/sync-figma-code-connect.mjs:
1. Lists every *.figma.tsx in blend-design-system (public GitHub repo,
no secrets -- GITHUB_TOKEN in the env just raises the rate limit).
2. Parses each figma.connect(...) call with the real TypeScript
compiler API (scripts/shared/parse-figma-tsx.mjs) -- not regex,
since these are hand-written by different people with real
structural variety. Correctly resolves the actual React prop name
from the example callback's JSX (not the props: object's local
variable names, which can differ -- e.g. Button's real prop is
leadingIcon, not the leftIcon local name used internally).
3. Cross-references against our OWN generated src/*.res
(scripts/shared/rescript-component-parser.mjs, shared with the
scaffolder) to resolve enum values by bound-value match (Figma
"primary" <-> @as("primary") Primary) -- never by parsing blend's
React enum member names.
4. Writes figma/componentMaps/<Component>.mjs -- fully generated now,
never hand-edited.
18 of 28 available blend-design-system Code Connect files sync cleanly
end-to-end (Button, ButtonGroup, AvatarGroup, Charts, Popover, Radio,
StatCard, SplitTag, Tag, TextInput family); the other 10 hit real,
documented parser limitations (complex JSX expressions, and some .res
files spreading their real props from a separate Types module rather
than inlining them) -- never wrong data, just conservatively skipped.
Runs on a weekly schedule (.github/workflows/sync-figma-code-connect.yml)
and opens a PR on drift, same posture as sync-bindings.yml: never
auto-merges. This also folds in what would have been "Drift B" (Figma-side
renames) from the PR #128 review -- since we consume blend's own published
mapping rather than reading Figma directly, a Figma-side change blend's
team has already reflected in their .figma.tsx is picked up on next sync,
with no Figma API token needed at all.
Also fixes real bugs the drift check caught while building this:
- The resolver only cross-checked kind agreement for enum props;
instanceSwap/string/bool were trusted from blend's shape alone. A
generic Tooltip `slot` prop (React.element, not icon-shaped) slipped
through as instanceSwap even though our own classifier marks it
unsupported. Now requires exact kind agreement across the board.
- The literal-boolean fast path (for derived booleans like Button's
disabled<-state) didn't require the prop to exist on our side at all,
just the value shape -- let a Switch prop through with nothing backing
it (Switch.res's real props are invisible to our parser, see above).
- Rejected (`mapped: false`) entries were only recorded for props blend
never mentions, not ones blend mentions but that fail resolution --
dropped such props entirely instead of recording why, causing false
drift-check positives against a fresh scaffold.
- generate-figma-code-connect.mjs never removed stale *CodeConnect.res
output for a component that regressed to unverified -- now cleans up
orphaned generated files every run.
scripts/lib/ was renamed to scripts/shared/ -- .gitignore's `lib/` entry
(for ReScript's own build output) was silently swallowing the new
directory.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| `; | ||
|
|
||
| await mkdir(MAPS_DIR, { recursive: true }); | ||
| await writeFile(join(MAPS_DIR, `${ourComponentName}.mjs`), source_); |
commit: |
|
Notes from wiring this into juspay-portal's 1. Docs: namespace is
|
## Namespace bug (docs + generated code) Every reference to the ReScript namespace was wrong: it's `JuspayRescriptBlend` (namespace: true + the scoped package name @juspay/rescript-blend), not `RescriptBlend`. Confirmed independently against this repo's own compiled output (lib/ocaml/*-JuspayRescriptBlend.cmi) and against juspay-portal's 905 real references, 0 of which are bare `RescriptBlend`. Fixed in figma/README.md, figma/engine.mjs, all three figma:* scripts, and all 203 componentMaps/*.mjs `open` statements. The pre-existing root README.md has the same bug but predates this PR -- left alone, flagged separately. ## toProps: consumers own tag/wrapper/state composition Per review: returning a finished string collapsed composition juspay-portal's plugin already owns (entry.res supplies the tag name separately from the props array; FigmaUtils.convertPropsNodeStateVariable composes the tag, optional wrapper, and constants/hookState). Each generated module now also exports: let toProps: CodeConnectUtils.figmaProps => array<option<(string, string)>> the same (propName, propValueText) shape a hand-written converter like ButtonUtils.convertToButtonProps already returns. fromFigmaProps stays as a thin wrapper over toProps, now with an optional ~tagName (defaults to the fully-qualified JuspayRescriptBlend.<Component>) for callers who just want a finished snippet with a custom tag/prefix without composing the array themselves. Enum values are now fully qualified (JuspayRescriptBlend.<Module>.<Constructor>) instead of bare (<Module>.<Constructor>), so both toProps' values and fromFigmaProps' default tag compile standalone, regardless of what's `open` in the caller's file -- no import assumption needed for this consumption path. (The imports metadata in componentMaps/*.mjs stays -- it's still used by the separate .figma.ts template path in engine.mjs, a genuinely different, human-facing use case.) CodeConnectUtils.res: added strPropEntry/propEntry/boolPropEntry (build the (name, value) pairs) and joinTag (composes a tag + props array into a finished string, used to implement fromFigmaProps). Removed the now-unused attr/strAttr/boolAttr element-wise string builders. ## CodeQL findings (4) - scaffold-figma-component-maps.mjs: replaced the existsSync-then-write check with an atomic `wx`-flag write (fails with EEXIST if the file already exists), closing the TOCTOU window between the check and the write. Re-verified create/skip-without-force/overwrite-with-force all still behave correctly. - sync-figma-code-connect.yml: pinned peter-evans/create-pull-request to its v8 tag's actual commit SHA instead of the mutable tag. The same unpinned pattern also exists in the pre-existing sync-bindings.yml, untouched here since it's out of this PR's scope. - sync-figma-code-connect.mjs: new jsStringLiteral() helper properly escapes backslashes before quotes (the previous code only escaped quotes, which would produce broken output for any blend-fetched text containing a literal backslash) -- applied everywhere untrusted blend-fetched text gets embedded in generated JS (figmaProp names, enum keys, rejection reasons). - sync-figma-code-connect.mjs: resolveOurComponentName now validates reactComponentName (parsed from blend's fetched TSX) is a plain ReScript-identifier-shaped string before it touches any filesystem path, rather than relying only on the indirect existsSync check to rule out anything unexpected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… by prop Follow-up to the review on #128 (#130): toProps removed the need to hand-copy *property* mapping tables into a consumer, but the consumer still hand-maintains a *component* mapping -- a switch from Figma component name to the right module, one arm added per new synced component. Same duplication problem, one level up. scripts/generate-figma-code-connect.mjs now also emits src/Figma/CodeConnectRegistry.res: let resolve: (string, CodeConnectUtils.figmaProps) => option<(array<option<(string, string)>>, string)> One arm per component actually generated (same not-ready gate as everywhere else -- an unverified scaffold never leaks in). A consumer gets Some((props, codeComponent)) for anything synced, None otherwise, and supplies its own tag prefix/wrapper -- new synced components light up on a version bump with zero downstream edits. Verified for real, not just build-passes: - resolve("Button", props) -> Some((toPropsResult, "Button")) - resolve("NotAThing", props) -> None - regressing one component's map (tested live: Radio, then restored) correctly drops just that component's arm, not the whole registry - no figmaComponentName collisions across all 18 currently-synced maps (would be a ReScript switch compile error anyway, but checked up front) Documented the one real naming caveat, both in the generated file's own header and in figma/README.md: figmaComponentName is whichever identifier blend-design-system's own figma.connect() call uses (a React identifier), NOT necessarily the literal name in Figma's layers panel -- Button's real Figma component set is "Buttons" (plural, confirmed via Figma Desktop), while the registry key is "Button" (singular). Reconciling a live node's actual Figma name against this registry's keys is the consumer's problem, same principle as ~tagName on fromFigmaProps -- this package supplies the mapping, not Figma-node-name resolution. (Considered dispatching by Figma node-id instead, to mirror how blend's own Code Connect resolves -- not done: that would mean storing fileKey/nodeId in this public repo, against the no-Figma-identifiers policy from earlier in this PR. That precision belongs in the consumer's own, presumably-private, node-id lookup.) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ler-side normalization
CodeConnectRegistry.resolve("Buttons", ...) returned None even though the
mapping exists under "Button" -- blend-design-system's figma.connect() call
uses the code identifier "Button", but the real published Figma component
set is named "Buttons" (plural, confirmed via Figma Desktop's Inspect
panel). figma/figmaComponentAliases.mjs records verified Figma-display-name
aliases and generate-figma-code-connect.mjs folds them into extra resolve
patterns, so a consumer never has to hand-normalize Figma names itself.
Kept separate from figma/componentMaps/*.mjs so figma:sync never overwrites
it (sync only knows blend's code identifier, not Figma's display name).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously workflow_dispatch-only, so a new preview required remembering to trigger it manually after each push. Now runs automatically on push to any branch except main (main goes through the regular release path instead), while keeping workflow_dispatch for on-demand re-runs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Figma Code Connect for
RescriptBlendcomponents. blend-design-system is the sole source of truth -- this repo does not hand-maintain Figma property knowledge. Every component blend-design-system has already published a*.figma.tsxCode Connect file for gets its mapping synced automatically from there; nothing is guessed.Two consumers, one shared data source per component (
figma/componentMaps/<Component>.mjs):figma/<Component>.figma.ts) -- resolved by an AI coding assistant with the Figma Dev Mode MCP server connected.src/Figma/<Component>CodeConnect.res, shipped in the npm package) --RescriptBlend.<Component>CodeConnect.fromFigmaProps(props), for consumers that already have Figma property data some other way (e.g. a standalone Figma plugin like juspay-portal'sapps/code-connect), without hand-copying mapping tables into another repo.The primary workflow
18 of 28 available blend-design-system Code Connect files sync cleanly end-to-end (
Button,ButtonGroup,AvatarGroup,Charts,Popover,Radio,StatCard,SplitTag,Tag, theTextInputfamily). The other 10 hit real, documented parser limitations (complex JSX expressions; some.resfiles spread their real props from a separateTypesmodule instead of inlining them) -- never wrong data, just conservatively skipped. A component with no upstream.figma.tsxhas no Code Connect here either -- correct, not a gap to fill by hand.Runs on a weekly schedule (
.github/workflows/sync-figma-code-connect.yml) and opens a PR on drift -- never auto-merges, same posture assync-bindings.yml. This also folds in what would've been Figma-side rename detection: since we consume blend's own published mapping rather than reading Figma directly, a Figma-side change blend's team has already reflected in their.figma.tsxgets picked up on next sync, with no Figma API token needed at all.CI
scripts/check-figma-map-drift.mjsre-scaffolds every component into a temp dir and diffs its prop-key set against the committed map. Runs on every push/PR. Only fails for verified maps (figmaComponentNameset) -- drift in an unfinished scaffold is reported but doesn't fail CI, since it drifts on essentially every blend dependency bump and nothing was relying on it yet.Verification
npm run buildcompiles cleanly (454 modules, zero errors)npm run format:checkpassesnpm run figma:check-driftpasses (zero drift in any verified map)ButtonCodeConnect.fromFigmaPropsreproduces the exact real snippet for two independently-checked real Button instances.figma.tsx-- they agree exactlyscripts/scaffold-figma-component-maps.mjs), pending either an upstream.figma.tsxfrom blend-design-system or manual verificationfigma/README.md)🤖 Generated with Claude Code