@sorb/seed 0.1.0-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/package.json +42 -0
- package/src/annotateTokens.js +118 -0
- package/src/capture.js +209 -0
- package/src/captureCli.js +256 -0
- package/src/cli.js +59 -0
- package/src/index.js +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# @sorb/seed
|
|
2
|
+
|
|
3
|
+
Storybook → Figma capture for Sorb. This package holds the **heavy**
|
|
4
|
+
pieces (esbuild now; Playwright later) so the bridge (`@sorb/juice`)
|
|
5
|
+
and `@sorb/leaf` stay lean.
|
|
6
|
+
|
|
7
|
+
The full design lives in the team's internal spec (kept out of the repo).
|
|
8
|
+
|
|
9
|
+
## Install & link the CLI
|
|
10
|
+
|
|
11
|
+
This package is **private / not published to npm yet**, so there's no
|
|
12
|
+
`npm i @sorb/seed`. To get the `sorb-seed` command working:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 1. install this package's deps (from this directory)
|
|
16
|
+
cd packages/seed
|
|
17
|
+
npm install # pulls esbuild (Playwright is optional — see capture)
|
|
18
|
+
|
|
19
|
+
# 2. expose the `sorb-seed` bin on your PATH
|
|
20
|
+
npm link # creates a global symlink to bin → src/cli.js
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`sorb-seed` is now runnable from anywhere. To remove the global symlink
|
|
24
|
+
later: `npm unlink -g @sorb/seed` (or `npm rm -g @sorb/seed`).
|
|
25
|
+
|
|
26
|
+
**Prefer not to touch your global PATH?** Skip `npm link` and invoke the source
|
|
27
|
+
directly from the consuming app:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node /abs/path/to/sorb/packages/seed/src/cli.js resolve
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Where you run it matters.** `sorb-seed` reads `sorb.config.json`,
|
|
34
|
+
> `sd.config.js`, and `tokens/` from the **current working directory** — i.e.
|
|
35
|
+
> your *app* (e.g. `example/`), **not** this package directory. Run the commands
|
|
36
|
+
> below from the app you're capturing, after `npm link`ing here once.
|
|
37
|
+
|
|
38
|
+
## Status
|
|
39
|
+
|
|
40
|
+
Early — not yet published (`private`). Implemented so far:
|
|
41
|
+
|
|
42
|
+
- **`sorb-seed resolve`** — a thin wrapper around **Style Dictionary**. The
|
|
43
|
+
DTCG token sets (`tokens/{primitive,semantic,component}.json`) are the source
|
|
44
|
+
of truth; SD's `sorb/resolved-map` format emits `.sorb/resolved.json` —
|
|
45
|
+
one entry per token: `[{ id, cssVar, value, tier, type }]` where
|
|
46
|
+
`tier ∈ {primitive, semantic, component}`. Reads `sorb.config.json`
|
|
47
|
+
(`styleDictionaryConfig`, default `sd.config.js`). The bridge (`sorb dev`)
|
|
48
|
+
serves this at `GET /tokens/resolved`; the plugin's **Sync Variables** button
|
|
49
|
+
and `capture`'s annotator both consume it. (This retired the old
|
|
50
|
+
esbuild-bundle-and-eval theme resolver.)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
sorb-seed resolve # → runs style-dictionary build → .sorb/resolved.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **`sorb-seed capture`** (`src/captureCli.js`) — Playwright runner that
|
|
57
|
+
visits every story in your running Storybook, injects the walker (below),
|
|
58
|
+
captures the rendered root, annotates tokens against `.sorb/resolved.json`,
|
|
59
|
+
and writes:
|
|
60
|
+
- one **`<Component>.sorb.json`** *next to each story file* (containing
|
|
61
|
+
all of that component's stories), and
|
|
62
|
+
- **`.sorb/index.json`** — a story-id → artifact map (with content hashes
|
|
63
|
+
for `--changed`).
|
|
64
|
+
|
|
65
|
+
Playwright is an **optional peer dependency** — it (and its ~150 MB browser)
|
|
66
|
+
is only needed for `capture`, never for `resolve` or a plain install.
|
|
67
|
+
|
|
68
|
+
The URLs below align with the **sorb-demo** services (`npm run demo`):
|
|
69
|
+
|
|
70
|
+
| Service | URL |
|
|
71
|
+
|---|---|
|
|
72
|
+
| App (Vite) | `http://localhost:5173` |
|
|
73
|
+
| Bridge (`sorb dev`) | `http://localhost:7777` |
|
|
74
|
+
| Storybook | `http://localhost:6006` |
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# one-time, only if you'll run capture:
|
|
78
|
+
npm install playwright # its postinstall fetches Chromium automatically
|
|
79
|
+
# (if browsers were skipped: npx playwright install chromium)
|
|
80
|
+
|
|
81
|
+
# capture against the demo's Storybook (set once in sorb.config.json)
|
|
82
|
+
sorb-seed capture # uses seed.storybookUrl
|
|
83
|
+
sorb-seed capture --only=Button.stories # filter by importPath/title/id
|
|
84
|
+
sorb-seed capture --changed # skip unchanged stories
|
|
85
|
+
sorb-seed capture --storybook-url=http://localhost:6006 # override on the fly
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Set `seed.storybookUrl` in `sorb.config.json` so you don't need the flag:
|
|
89
|
+
|
|
90
|
+
```jsonc
|
|
91
|
+
{
|
|
92
|
+
"seed": { "storybookUrl": "http://localhost:6006" }
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The captured artifacts are then served by the bridge at
|
|
97
|
+
`GET http://localhost:7777/artifacts` (the index) and
|
|
98
|
+
`GET http://localhost:7777/artifact?id=<storyId>` (one artifact, looked up
|
|
99
|
+
by id — never a raw filesystem path). The Figma plugin's **Storybook** tab
|
|
100
|
+
fetches from these endpoints to list and insert captured components.
|
|
101
|
+
|
|
102
|
+
- **`captureRoot(el)`** (`src/capture.js`) — in-page DOM walker (our own
|
|
103
|
+
capture engine, no `htmlToFigma` dependency). Maps element →
|
|
104
|
+
FRAME/RECTANGLE/TEXT `LayerNode` with fills, strokes, corner radius, single
|
|
105
|
+
box-shadow, text (family/weight/size/line-height/letter-spacing/align/color),
|
|
106
|
+
flex → auto-layout + padding, and geometry relative to each parent. Designed
|
|
107
|
+
to run via Playwright's `page.evaluate`; pure helpers (color/dim/shadow
|
|
108
|
+
parsing) unit-tested. Scope (v1) supports the design-system primitive case;
|
|
109
|
+
gradients/grid/transform/pseudo-elements are deferred.
|
|
110
|
+
- **`annotateTree(node, index)`** (`src/annotateTokens.js`) — walks a captured
|
|
111
|
+
tree, attaches a `sorb.tokens` / `sorb.candidates` side-channel to each
|
|
112
|
+
node whose bindable values (fill, stroke, corner radius, effect color) match
|
|
113
|
+
the resolved bindable map. Idempotent; preserves raw values for the plugin
|
|
114
|
+
materializer.
|
|
115
|
+
|
|
116
|
+
Validated end-to-end against the sorb-demo resolved map: a Button DOM →
|
|
117
|
+
`captureRoot` → `annotateTree` binds `fill` → `button.primary.bg.default`,
|
|
118
|
+
`stroke` → `button.primary.border.default`, `cornerRadius` → `button.radius`,
|
|
119
|
+
text fill → `button.primary.text.default`, using tier + property-affinity
|
|
120
|
+
ranking (component > semantic > primitive).
|
|
121
|
+
|
|
122
|
+
Planned: the **plugin materializer** (turns each `LayerNode` into a Figma
|
|
123
|
+
component bound to Variables via `setBoundVariable`); pseudo-elements and
|
|
124
|
+
forced interaction states; component-set assembly from per-story captures.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sorb/seed",
|
|
3
|
+
"version": "0.1.0-canary.0",
|
|
4
|
+
"description": "Storybook → Figma capture for Sorb: headless capture + the resolved bindable token map. Heavy deps (esbuild, later Playwright) live here so the bridge stays lean.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"sorb",
|
|
8
|
+
"design-tokens",
|
|
9
|
+
"figma",
|
|
10
|
+
"storybook",
|
|
11
|
+
"tokens"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/metatoy/sorb-seed#readme",
|
|
14
|
+
"bugs": "https://github.com/metatoy/sorb-seed/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/metatoy/sorb-seed.git"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "src/index.js",
|
|
24
|
+
"bin": {
|
|
25
|
+
"sorb-seed": "src/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@sorb/core": "^0.1.0",
|
|
32
|
+
"esbuild": "^0.21.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"playwright": "^1.60.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"playwright": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { TIER_RANK } from '@sorb/core'
|
|
2
|
+
|
|
3
|
+
// Token annotation for captured layer values.
|
|
4
|
+
//
|
|
5
|
+
// Exact, normalized matching against the resolved bindable token map
|
|
6
|
+
// (Style Dictionary's `sorb/resolved-map`: { id, cssVar, value, tier, type }):
|
|
7
|
+
// colors → canonical #rrggbbaa (lowercase); dimensions → px number.
|
|
8
|
+
// On collision, best-guess prefers the most specific tier
|
|
9
|
+
// (component > semantic > primitive), then resolved order; ALL matches are kept
|
|
10
|
+
// as `candidates` so the plugin can offer a switch.
|
|
11
|
+
// (Phase 4 adds property affinity — fill→bg, stroke→border, etc.)
|
|
12
|
+
|
|
13
|
+
const expandHex = (h) => {
|
|
14
|
+
h = h.toLowerCase()
|
|
15
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
|
|
16
|
+
if (h.length === 6) h += 'ff'
|
|
17
|
+
return '#' + h
|
|
18
|
+
}
|
|
19
|
+
const toHex2 = (n) =>
|
|
20
|
+
Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0')
|
|
21
|
+
const alphaHex = (a) => toHex2(Math.max(0, Math.min(1, a)) * 255)
|
|
22
|
+
|
|
23
|
+
/** Normalize any CSS color to canonical `#rrggbbaa`, or null if not a color. */
|
|
24
|
+
export const normalizeColor = (value) => {
|
|
25
|
+
if (value == null) return null
|
|
26
|
+
const s = String(value).trim().toLowerCase()
|
|
27
|
+
if (s === 'transparent') return '#00000000'
|
|
28
|
+
let m = s.match(/^#([0-9a-f]{3,8})$/)
|
|
29
|
+
if (m) return expandHex(m[1])
|
|
30
|
+
m = s.match(/^rgba?\(\s*([^)]+)\)$/)
|
|
31
|
+
if (m) {
|
|
32
|
+
const p = m[1].split(',').map((x) => x.trim())
|
|
33
|
+
if (p.length < 3) return null
|
|
34
|
+
const a = p[3] !== undefined ? parseFloat(p[3]) : 1
|
|
35
|
+
return '#' + toHex2(+p[0]) + toHex2(+p[1]) + toHex2(+p[2]) + alphaHex(a)
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize a CSS length to a px number, or null. */
|
|
41
|
+
export const normalizeDimension = (value) => {
|
|
42
|
+
if (value == null) return null
|
|
43
|
+
const m = String(value).trim().match(/^(-?\d+(?:\.\d+)?)(?:px)?$/)
|
|
44
|
+
return m ? parseFloat(m[1]) : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build value→[token] indexes (colors, dims) from the resolved bindable map. */
|
|
48
|
+
export const buildTokenIndex = (resolved) => {
|
|
49
|
+
const colors = new Map()
|
|
50
|
+
const dims = new Map()
|
|
51
|
+
const add = (map, key, t) => {
|
|
52
|
+
if (key == null) return
|
|
53
|
+
if (!map.has(key)) map.set(key, [])
|
|
54
|
+
map.get(key).push(t)
|
|
55
|
+
}
|
|
56
|
+
for (const t of resolved) {
|
|
57
|
+
const c = normalizeColor(t.value)
|
|
58
|
+
if (c) add(colors, c, t)
|
|
59
|
+
else add(dims, normalizeDimension(t.value), t)
|
|
60
|
+
}
|
|
61
|
+
return { colors, dims }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Binding = property affinity first, then tier precedence.
|
|
65
|
+
//
|
|
66
|
+
// Tier rank alone is ambiguous: one color value (e.g. #0F65EF) can match a
|
|
67
|
+
// `bg`, a `border`, AND a `text` token. The captured node's *property* tells us
|
|
68
|
+
// the role it plays — a frame fill wants a `bg` token, a stroke a `border`, a
|
|
69
|
+
// text fill a `text`, a corner radius a `radius`. We filter candidates to that
|
|
70
|
+
// role, then break remaining ties by tier (component > semantic > primitive).
|
|
71
|
+
// If no candidate carries the role, fall back to the full set (tier-only).
|
|
72
|
+
// TIER_RANK is the canonical ordering from @sorb/core (shared contract).
|
|
73
|
+
|
|
74
|
+
// role → the path segment a matching token id should contain (`.bg`, `.text`,
|
|
75
|
+
// `.border`, `.radius`). Matched on `.<role>` so `color.bg.surface`,
|
|
76
|
+
// `button.primary.bg.default`, and `button.radius` all qualify.
|
|
77
|
+
const byTier = (a, b) => (TIER_RANK[a.tier] ?? 9) - (TIER_RANK[b.tier] ?? 9)
|
|
78
|
+
|
|
79
|
+
const pick = (cands, role) => {
|
|
80
|
+
if (!cands || !cands.length) return { token: null, candidates: [] }
|
|
81
|
+
const roled = role ? cands.filter((c) => c.id.includes('.' + role)) : []
|
|
82
|
+
const pool = roled.length ? roled : cands
|
|
83
|
+
const best = [...pool].sort(byTier)[0]
|
|
84
|
+
return { token: best.id, candidates: cands.map((c) => c.id) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const matchColor = (index, value, role) => {
|
|
88
|
+
const c = normalizeColor(value)
|
|
89
|
+
return c ? pick(index.colors.get(c), role) : { token: null, candidates: [] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const matchDimension = (index, value, role) => {
|
|
93
|
+
const d = normalizeDimension(value)
|
|
94
|
+
return d == null ? { token: null, candidates: [] } : pick(index.dims.get(d), role)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Walk a captured LayerNode tree and attach `sorb.tokens` / `.candidates`
|
|
98
|
+
// to each node whose bindable values match a token. Mutates and returns the
|
|
99
|
+
// tree. (The plugin materializer reads these to bind Figma Variables.)
|
|
100
|
+
export const annotateTree = (node, index) => {
|
|
101
|
+
const tokens = {}
|
|
102
|
+
const candidates = {}
|
|
103
|
+
const set = (key, res) => {
|
|
104
|
+
if (res.token) { tokens[key] = res.token; candidates[key] = res.candidates }
|
|
105
|
+
}
|
|
106
|
+
// A fill on a TEXT node is foreground (`text`); on a frame it's `bg`.
|
|
107
|
+
const fillRole = node.type === 'TEXT' ? 'text' : 'bg'
|
|
108
|
+
if (node.fills && node.fills[0]) set('fill', matchColor(index, node.fills[0].raw, fillRole))
|
|
109
|
+
if (node.strokes && node.strokes[0]) set('stroke', matchColor(index, node.strokes[0].raw, 'border'))
|
|
110
|
+
if (typeof node.cornerRadius === 'number') set('cornerRadius', matchDimension(index, node.cornerRadius, 'radius'))
|
|
111
|
+
if (Array.isArray(node.effects)) {
|
|
112
|
+
// No shadow tokens yet → no role, falls back to tier-only.
|
|
113
|
+
node.effects.forEach((e, i) => { if (e.color) set(`effect${i}`, matchColor(index, e.color.raw)) })
|
|
114
|
+
}
|
|
115
|
+
if (Object.keys(tokens).length) node.sorb = { tokens, candidates }
|
|
116
|
+
if (Array.isArray(node.children)) node.children.forEach((c) => annotateTree(c, index))
|
|
117
|
+
return node
|
|
118
|
+
}
|
package/src/capture.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Sorb capture walker — runs IN THE PAGE (headless browser).
|
|
2
|
+
//
|
|
3
|
+
// Turns a DOM element (a Storybook story root) into our own `LayerNode` tree
|
|
4
|
+
// via getComputedStyle + getBoundingClientRect. Values are kept as `raw` CSS
|
|
5
|
+
// strings so the token annotator can match them later; colors are also given
|
|
6
|
+
// in Figma's {r,g,b}+opacity form for the materializer.
|
|
7
|
+
//
|
|
8
|
+
// Scope (v1): FRAME/RECTANGLE/TEXT; solid background fills; uniform border +
|
|
9
|
+
// corner radius; a single box-shadow; text (family/weight/size/line-height/
|
|
10
|
+
// letter-spacing/align/color); flex → auto-layout (+ padding); geometry
|
|
11
|
+
// relative to each parent. Deferred: gradients, bg-images, transforms, grid,
|
|
12
|
+
// pseudo-elements, SVG/img, per-side borders, multiple shadows.
|
|
13
|
+
//
|
|
14
|
+
// The DOM functions only run in a browser; the pure helpers are unit-tested.
|
|
15
|
+
|
|
16
|
+
// ─── pure helpers (no DOM) ───────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** "rgb(15, 101, 239)" / "rgba(…,a)" → { color:{r,g,b}, opacity, raw } or null. */
|
|
19
|
+
export const rgbToFigma = (str) => {
|
|
20
|
+
if (str == null) return null
|
|
21
|
+
const s = String(str).trim()
|
|
22
|
+
const m = s.match(/^rgba?\(\s*([^)]+)\)$/i)
|
|
23
|
+
if (!m) return null
|
|
24
|
+
const p = m[1].split(',').map((x) => parseFloat(x.trim()))
|
|
25
|
+
if (p.length < 3 || p.some((n, i) => i < 3 && Number.isNaN(n))) return null
|
|
26
|
+
const a = p[3] === undefined ? 1 : p[3]
|
|
27
|
+
return { color: { r: p[0] / 255, g: p[1] / 255, b: p[2] / 255 }, opacity: a, raw: s }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "4px" → 4; "0px" → 0; "none"/"normal"/"" → null. */
|
|
31
|
+
export const pxNum = (str) => {
|
|
32
|
+
if (str == null) return null
|
|
33
|
+
const m = String(str).trim().match(/^(-?\d+(?:\.\d+)?)px$/)
|
|
34
|
+
return m ? parseFloat(m[1]) : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const FULLY_TRANSPARENT = (c) => !c || c.opacity === 0
|
|
38
|
+
|
|
39
|
+
/** Parse a single CSS box-shadow into a Figma effect, or null for "none". */
|
|
40
|
+
export const parseShadow = (str) => {
|
|
41
|
+
if (str == null) return null
|
|
42
|
+
const s = String(str).trim()
|
|
43
|
+
if (s === '' || s === 'none') return null
|
|
44
|
+
const inset = /\binset\b/.test(s)
|
|
45
|
+
const colorMatch = s.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}/i)
|
|
46
|
+
const color = colorMatch ? rgbToFigma(colorMatch[0]) : { color: { r: 0, g: 0, b: 0 }, opacity: 1, raw: 'rgb(0,0,0)' }
|
|
47
|
+
const rest = s.replace(colorMatch ? colorMatch[0] : '', '').replace(/\binset\b/, '')
|
|
48
|
+
const nums = (rest.match(/-?\d+(?:\.\d+)?px/g) || []).map((n) => pxNum(n))
|
|
49
|
+
const [x = 0, y = 0, blur = 0, spread = 0] = nums
|
|
50
|
+
return {
|
|
51
|
+
type: inset ? 'INNER_SHADOW' : 'DROP_SHADOW',
|
|
52
|
+
offset: { x, y },
|
|
53
|
+
radius: blur,
|
|
54
|
+
spread,
|
|
55
|
+
color,
|
|
56
|
+
raw: s,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** CSS font-weight → number (normal=400, bold=700). */
|
|
61
|
+
export const normalizeWeight = (w) => {
|
|
62
|
+
if (w === 'normal' || w == null) return 400
|
|
63
|
+
if (w === 'bold') return 700
|
|
64
|
+
const n = parseInt(w, 10)
|
|
65
|
+
return Number.isNaN(n) ? 400 : n
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const mapJustify = (v) =>
|
|
69
|
+
({ 'flex-start': 'MIN', start: 'MIN', 'flex-end': 'MAX', end: 'MAX',
|
|
70
|
+
center: 'CENTER', 'space-between': 'SPACE_BETWEEN' })[v] || 'MIN'
|
|
71
|
+
|
|
72
|
+
export const mapAlign = (v) =>
|
|
73
|
+
({ 'flex-start': 'MIN', start: 'MIN', 'flex-end': 'MAX', end: 'MAX',
|
|
74
|
+
center: 'CENTER', baseline: 'MIN', stretch: 'MIN' })[v] || 'MIN'
|
|
75
|
+
|
|
76
|
+
export const mapTextAlign = (v) =>
|
|
77
|
+
({ left: 'LEFT', right: 'RIGHT', center: 'CENTER', justify: 'JUSTIFIED',
|
|
78
|
+
start: 'LEFT', end: 'RIGHT' })[v] || 'LEFT'
|
|
79
|
+
|
|
80
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT', 'BR'])
|
|
81
|
+
|
|
82
|
+
// ─── DOM walker (browser only) ───────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const firstFamily = (fontFamily) =>
|
|
85
|
+
String(fontFamily || '').split(',')[0].trim().replace(/^["']|["']$/g, '')
|
|
86
|
+
|
|
87
|
+
const lineHeightOf = (cs) => {
|
|
88
|
+
if (cs.lineHeight === 'normal') return { unit: 'AUTO' }
|
|
89
|
+
const px = pxNum(cs.lineHeight)
|
|
90
|
+
return px == null ? { unit: 'AUTO' } : { unit: 'PIXELS', value: px }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cornerRadiusOf = (cs) => {
|
|
94
|
+
const tl = pxNum(cs.borderTopLeftRadius) || 0
|
|
95
|
+
const tr = pxNum(cs.borderTopRightRadius) || 0
|
|
96
|
+
const br = pxNum(cs.borderBottomRightRadius) || 0
|
|
97
|
+
const bl = pxNum(cs.borderBottomLeftRadius) || 0
|
|
98
|
+
return tl === tr && tr === br && br === bl ? tl : [tl, tr, br, bl]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isHidden = (cs) =>
|
|
102
|
+
cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse'
|
|
103
|
+
|
|
104
|
+
const textLayer = (textNode, cs, parentRect) => {
|
|
105
|
+
const value = textNode.nodeValue.replace(/\s+/g, ' ').trim()
|
|
106
|
+
if (!value) return null
|
|
107
|
+
const range = document.createRange()
|
|
108
|
+
range.selectNodeContents(textNode)
|
|
109
|
+
const r = range.getBoundingClientRect()
|
|
110
|
+
const fill = rgbToFigma(cs.color)
|
|
111
|
+
return {
|
|
112
|
+
type: 'TEXT',
|
|
113
|
+
name: value.slice(0, 40),
|
|
114
|
+
x: r.left - parentRect.left,
|
|
115
|
+
y: r.top - parentRect.top,
|
|
116
|
+
width: r.width,
|
|
117
|
+
height: r.height,
|
|
118
|
+
characters: value,
|
|
119
|
+
fontFamily: firstFamily(cs.fontFamily),
|
|
120
|
+
fontWeight: normalizeWeight(cs.fontWeight),
|
|
121
|
+
italic: cs.fontStyle === 'italic',
|
|
122
|
+
fontSize: pxNum(cs.fontSize) || 16,
|
|
123
|
+
lineHeight: lineHeightOf(cs),
|
|
124
|
+
letterSpacing: pxNum(cs.letterSpacing) || 0,
|
|
125
|
+
textAlign: mapTextAlign(cs.textAlign),
|
|
126
|
+
fills: FULLY_TRANSPARENT(fill) ? [] : [{ type: 'SOLID', ...fill }],
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Capture one element (and its subtree) into a LayerNode.
|
|
132
|
+
* @param {Element} el
|
|
133
|
+
* @param {DOMRect} parentRect rect to make this node's geometry relative to
|
|
134
|
+
* @returns {object|null} LayerNode, or null if skipped
|
|
135
|
+
*/
|
|
136
|
+
export const captureNode = (el, parentRect) => {
|
|
137
|
+
if (SKIP_TAGS.has(el.tagName)) return null
|
|
138
|
+
const cs = getComputedStyle(el)
|
|
139
|
+
if (isHidden(cs)) return null
|
|
140
|
+
const rect = el.getBoundingClientRect()
|
|
141
|
+
|
|
142
|
+
const bg = rgbToFigma(cs.backgroundColor)
|
|
143
|
+
const fills = FULLY_TRANSPARENT(bg) ? [] : [{ type: 'SOLID', ...bg }]
|
|
144
|
+
|
|
145
|
+
const borderW = pxNum(cs.borderTopWidth) || 0
|
|
146
|
+
const borderC = rgbToFigma(cs.borderTopColor)
|
|
147
|
+
const hasStroke = borderW > 0 && cs.borderTopStyle !== 'none' && !FULLY_TRANSPARENT(borderC)
|
|
148
|
+
const strokes = hasStroke ? [{ type: 'SOLID', ...borderC }] : []
|
|
149
|
+
|
|
150
|
+
const shadow = parseShadow(cs.boxShadow)
|
|
151
|
+
|
|
152
|
+
// drop entirely invisible, zero-area, contentless nodes
|
|
153
|
+
const area = rect.width * rect.height
|
|
154
|
+
if (area === 0 && !fills.length && !strokes.length) return null
|
|
155
|
+
|
|
156
|
+
const display = cs.display
|
|
157
|
+
const isFlex = display === 'flex' || display === 'inline-flex'
|
|
158
|
+
const layout = isFlex
|
|
159
|
+
? {
|
|
160
|
+
mode: cs.flexDirection.startsWith('column') ? 'VERTICAL' : 'HORIZONTAL',
|
|
161
|
+
primaryAxisAlign: mapJustify(cs.justifyContent),
|
|
162
|
+
counterAxisAlign: mapAlign(cs.alignItems),
|
|
163
|
+
itemSpacing: pxNum(cs.columnGap) || pxNum(cs.gap) || 0,
|
|
164
|
+
paddingTop: pxNum(cs.paddingTop) || 0,
|
|
165
|
+
paddingRight: pxNum(cs.paddingRight) || 0,
|
|
166
|
+
paddingBottom: pxNum(cs.paddingBottom) || 0,
|
|
167
|
+
paddingLeft: pxNum(cs.paddingLeft) || 0,
|
|
168
|
+
}
|
|
169
|
+
: { mode: 'NONE' }
|
|
170
|
+
|
|
171
|
+
const node = {
|
|
172
|
+
type: 'FRAME',
|
|
173
|
+
name: el.tagName.toLowerCase(),
|
|
174
|
+
x: rect.left - parentRect.left,
|
|
175
|
+
y: rect.top - parentRect.top,
|
|
176
|
+
width: rect.width,
|
|
177
|
+
height: rect.height,
|
|
178
|
+
opacity: cs.opacity === '' ? 1 : parseFloat(cs.opacity),
|
|
179
|
+
clipsContent: cs.overflow === 'hidden' || cs.overflowX === 'hidden',
|
|
180
|
+
fills,
|
|
181
|
+
strokes,
|
|
182
|
+
strokeWeight: hasStroke ? borderW : 0,
|
|
183
|
+
cornerRadius: cornerRadiusOf(cs),
|
|
184
|
+
effects: shadow ? [shadow] : [],
|
|
185
|
+
layout,
|
|
186
|
+
children: [],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const child of el.childNodes) {
|
|
190
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
191
|
+
const t = textLayer(child, cs, rect)
|
|
192
|
+
if (t) node.children.push(t)
|
|
193
|
+
} else if (child.nodeType === 1 /* ELEMENT_NODE */) {
|
|
194
|
+
const c = captureNode(child, rect)
|
|
195
|
+
if (c) node.children.push(c)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return node
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Entry point: capture a root element with itself at the origin (0,0). */
|
|
202
|
+
export const captureRoot = (el) => captureNode(el, el.getBoundingClientRect())
|
|
203
|
+
|
|
204
|
+
// Install the walker on `window` so it can be called from a Playwright
|
|
205
|
+
// page.evaluate(() => window.__sorbCapture(...)) after this module is
|
|
206
|
+
// bundled and injected via addInitScript. No-op in Node tests.
|
|
207
|
+
if (typeof window !== 'undefined') {
|
|
208
|
+
window.__sorbCapture = (el) => captureRoot(el || document.querySelector('#storybook-root'))
|
|
209
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// `sorb-seed capture` — Playwright runner that visits each Storybook story,
|
|
2
|
+
// injects our in-page walker, captures the rendered root, annotates tokens
|
|
3
|
+
// against the resolved bindable map, and writes per-component artifacts +
|
|
4
|
+
// a top-level index. See spec → Capture engine.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
|
|
7
|
+
import { createHash } from 'crypto'
|
|
8
|
+
import { dirname, resolve, basename, extname } from 'path'
|
|
9
|
+
import { build } from 'esbuild'
|
|
10
|
+
import { buildTokenIndex, annotateTree } from './annotateTokens.js'
|
|
11
|
+
|
|
12
|
+
// Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
|
|
13
|
+
// ~150 MB browser. Lazy-load it so plain installs and `resolve` stay lean.
|
|
14
|
+
const loadChromium = async () => {
|
|
15
|
+
try {
|
|
16
|
+
const { chromium } = await import('playwright')
|
|
17
|
+
return chromium
|
|
18
|
+
} catch {
|
|
19
|
+
console.error(
|
|
20
|
+
'✗ `sorb-seed capture` needs Playwright (it is an optional peer dep).\n' +
|
|
21
|
+
' Install it where you run capture:\n' +
|
|
22
|
+
' npm install playwright # its postinstall fetches Chromium\n' +
|
|
23
|
+
' (or: npm install playwright && npx playwright install chromium)',
|
|
24
|
+
)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cwd = process.cwd()
|
|
30
|
+
|
|
31
|
+
const loadConfig = () => {
|
|
32
|
+
const p = resolve(cwd, 'sorb.config.json')
|
|
33
|
+
if (!existsSync(p)) {
|
|
34
|
+
console.error('✗ No sorb.config.json in', cwd)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(readFileSync(p, 'utf-8'))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const loadResolved = () => {
|
|
41
|
+
const p = resolve(cwd, '.sorb/resolved.json')
|
|
42
|
+
if (!existsSync(p)) {
|
|
43
|
+
console.error('✗ No .sorb/resolved.json — run `sorb-seed resolve` first.')
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'))
|
|
47
|
+
return Array.isArray(data) ? data : data.tokens
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex')
|
|
51
|
+
|
|
52
|
+
// Bundle the walker into a single IIFE string we can addInitScript() into
|
|
53
|
+
// every page. Playwright can't pass functions across the boundary directly,
|
|
54
|
+
// and our walker has cross-file imports → bundling is the clean answer.
|
|
55
|
+
const buildWalkerBundle = async () => {
|
|
56
|
+
const here = dirname(new URL(import.meta.url).pathname)
|
|
57
|
+
const out = await build({
|
|
58
|
+
entryPoints: [resolve(here, 'capture.js')],
|
|
59
|
+
bundle: true,
|
|
60
|
+
format: 'iife',
|
|
61
|
+
platform: 'browser',
|
|
62
|
+
write: false,
|
|
63
|
+
logLevel: 'silent',
|
|
64
|
+
target: 'es2020',
|
|
65
|
+
})
|
|
66
|
+
return out.outputFiles[0].text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isStoryEntry = (e) =>
|
|
70
|
+
e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
|
|
71
|
+
|
|
72
|
+
// Group story entries by their component (importPath) and pick the directory
|
|
73
|
+
// of the story file as the artifact output dir (co-located).
|
|
74
|
+
const groupByComponent = (entries) => {
|
|
75
|
+
const groups = new Map()
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (!groups.has(e.importPath)) groups.set(e.importPath, [])
|
|
78
|
+
groups.get(e.importPath).push(e)
|
|
79
|
+
}
|
|
80
|
+
return groups
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// componentName: "Button" from "./src/.../Button.stories.jsx"
|
|
84
|
+
const componentNameFromImportPath = (importPath) => {
|
|
85
|
+
const file = basename(importPath, extname(importPath)) // "Button.stories"
|
|
86
|
+
return file.replace(/\.stories$/i, '')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// sorb.config.json may set seed.storybookUrl. Fall back to localhost.
|
|
90
|
+
const storybookUrlOf = (config) =>
|
|
91
|
+
(config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
|
|
92
|
+
|
|
93
|
+
const filterEntries = (entries, only) => {
|
|
94
|
+
if (!only) return entries
|
|
95
|
+
const re = new RegExp(only.replace(/[*]/g, '.*'), 'i')
|
|
96
|
+
return entries.filter((e) => re.test(e.importPath) || re.test(e.title) || re.test(e.id))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const runCapture = async (opts) => {
|
|
100
|
+
const config = loadConfig()
|
|
101
|
+
const resolved = loadResolved()
|
|
102
|
+
const index = buildTokenIndex(resolved)
|
|
103
|
+
const sbUrl = (opts.storybookUrl || storybookUrlOf(config)).replace(/\/$/, '')
|
|
104
|
+
|
|
105
|
+
// 1. Discover stories
|
|
106
|
+
console.log(`→ Storybook: ${sbUrl}`)
|
|
107
|
+
let sbIndex
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${sbUrl}/index.json`)
|
|
110
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
111
|
+
sbIndex = await res.json()
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error('✗ Could not fetch Storybook index:', e.message)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
const entries = filterEntries(
|
|
117
|
+
Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry),
|
|
118
|
+
opts.only,
|
|
119
|
+
)
|
|
120
|
+
if (!entries.length) {
|
|
121
|
+
console.error('✗ No stories matched filter:', opts.only || '(all)')
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
console.log(`→ ${entries.length} stories selected`)
|
|
125
|
+
|
|
126
|
+
// 2. Browser setup + walker injection
|
|
127
|
+
const chromium = await loadChromium()
|
|
128
|
+
const walker = await buildWalkerBundle()
|
|
129
|
+
const browser = await chromium.launch()
|
|
130
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
|
|
131
|
+
await ctx.addInitScript({ content: walker })
|
|
132
|
+
|
|
133
|
+
// 3. Capture each story, group by component
|
|
134
|
+
const captured = new Map() // importPath -> { component, importPath, stories[] }
|
|
135
|
+
const oldIndex = readOldIndex(config)
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const url = `${sbUrl}/iframe.html?id=${entry.id}&viewMode=story`
|
|
139
|
+
const page = await ctx.newPage()
|
|
140
|
+
try {
|
|
141
|
+
console.log(` · ${entry.id}`)
|
|
142
|
+
await page.goto(url, { waitUntil: 'load' })
|
|
143
|
+
// Wait for Storybook to actually render the story.
|
|
144
|
+
await page
|
|
145
|
+
.waitForFunction(
|
|
146
|
+
() => !!document.querySelector('#storybook-root *'),
|
|
147
|
+
{ timeout: 15000 },
|
|
148
|
+
)
|
|
149
|
+
.catch(() => {})
|
|
150
|
+
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
151
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
|
|
152
|
+
|
|
153
|
+
const rawTree = await page.evaluate(() => {
|
|
154
|
+
const root = document.querySelector('#storybook-root')
|
|
155
|
+
return root ? window.__sorbCapture(root) : null
|
|
156
|
+
})
|
|
157
|
+
if (!rawTree) {
|
|
158
|
+
console.warn(' ⚠ no #storybook-root content; skipped')
|
|
159
|
+
await page.close()
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const tree = annotateTree(rawTree, index)
|
|
163
|
+
const hash = 'sha256:' + sha256(JSON.stringify(tree))
|
|
164
|
+
|
|
165
|
+
// --changed: reuse the previous artifact if hash matches
|
|
166
|
+
const prevHash = oldIndex.stories[entry.id]?.hash
|
|
167
|
+
if (opts.changed && prevHash === hash) {
|
|
168
|
+
console.log(' = unchanged')
|
|
169
|
+
await page.close()
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!captured.has(entry.importPath)) {
|
|
174
|
+
captured.set(entry.importPath, {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
component: componentNameFromImportPath(entry.importPath),
|
|
177
|
+
importPath: entry.importPath,
|
|
178
|
+
capturedAt: new Date().toISOString(),
|
|
179
|
+
stories: [],
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
captured.get(entry.importPath).stories.push({
|
|
183
|
+
id: entry.id,
|
|
184
|
+
name: entry.name,
|
|
185
|
+
title: entry.title,
|
|
186
|
+
hash,
|
|
187
|
+
root: tree,
|
|
188
|
+
})
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error(' ✗', entry.id, '—', e.message)
|
|
191
|
+
} finally {
|
|
192
|
+
await page.close()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await browser.close()
|
|
196
|
+
|
|
197
|
+
// 4. Write artifacts next to each story file, then the index
|
|
198
|
+
const indexOut = {
|
|
199
|
+
schemaVersion: 1,
|
|
200
|
+
generatedAt: new Date().toISOString(),
|
|
201
|
+
storybookUrl: sbUrl,
|
|
202
|
+
components: [],
|
|
203
|
+
stories: { ...oldIndex.stories }, // preserves entries we didn't recapture
|
|
204
|
+
}
|
|
205
|
+
for (const [importPath, art] of captured) {
|
|
206
|
+
const absStoryFile = resolve(cwd, importPath)
|
|
207
|
+
const outDir = dirname(absStoryFile)
|
|
208
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
|
|
209
|
+
const outPath = resolve(outDir, `${art.component}.sorb.json`)
|
|
210
|
+
writeFileSync(outPath, JSON.stringify(art, null, 2) + '\n')
|
|
211
|
+
const rel = relativeFromCwd(outPath)
|
|
212
|
+
console.log(`✓ ${rel} (${art.stories.length} stor${art.stories.length === 1 ? 'y' : 'ies'})`)
|
|
213
|
+
indexOut.components.push({ component: art.component, importPath, artifact: rel })
|
|
214
|
+
for (const s of art.stories) {
|
|
215
|
+
indexOut.stories[s.id] = {
|
|
216
|
+
component: art.component,
|
|
217
|
+
importPath,
|
|
218
|
+
artifact: rel,
|
|
219
|
+
title: s.title,
|
|
220
|
+
name: s.name,
|
|
221
|
+
hash: s.hash,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const indexDir = resolve(cwd, '.sorb')
|
|
227
|
+
mkdirSync(indexDir, { recursive: true })
|
|
228
|
+
writeFileSync(
|
|
229
|
+
resolve(indexDir, 'index.json'),
|
|
230
|
+
JSON.stringify(indexOut, null, 2) + '\n',
|
|
231
|
+
)
|
|
232
|
+
console.log('→ .sorb/index.json')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const relativeFromCwd = (abs) => {
|
|
236
|
+
const c = cwd.endsWith('/') ? cwd : cwd + '/'
|
|
237
|
+
return abs.startsWith(c) ? abs.slice(c.length) : abs
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const readOldIndex = (config) => {
|
|
241
|
+
const p = resolve(cwd, '.sorb/index.json')
|
|
242
|
+
if (!existsSync(p)) return { stories: {} }
|
|
243
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return { stories: {} } }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Allow running as `node captureCli.js [--only=...] [--changed]` for testing;
|
|
247
|
+
// the package bin (`sorb-seed capture`) routes here via cli.js.
|
|
248
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
249
|
+
const opts = {}
|
|
250
|
+
for (const a of process.argv.slice(2)) {
|
|
251
|
+
if (a === '--changed') opts.changed = true
|
|
252
|
+
else if (a.startsWith('--only=')) opts.only = a.slice('--only='.length)
|
|
253
|
+
else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
|
|
254
|
+
}
|
|
255
|
+
runCapture(opts).catch((e) => { console.error(e); process.exit(1) })
|
|
256
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync } from 'fs'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
import { execSync } from 'child_process'
|
|
5
|
+
|
|
6
|
+
// sorb-seed — Storybook → Figma capture tooling.
|
|
7
|
+
//
|
|
8
|
+
// `resolve` is now a thin wrapper around Style Dictionary: the DTCG token sets
|
|
9
|
+
// (primitive/semantic/component) are the source of truth, and SD's
|
|
10
|
+
// `sorb/resolved-map` format produces `.sorb/resolved.json` directly —
|
|
11
|
+
// the schema { id, cssVar, value, tier, type }. This retires the old
|
|
12
|
+
// esbuild-bundle-and-eval resolver (and its theme.js scraping). The app build
|
|
13
|
+
// owns SD; the bridge and `capture` are pure consumers of its output.
|
|
14
|
+
|
|
15
|
+
const cwd = process.cwd()
|
|
16
|
+
|
|
17
|
+
const loadConfig = () => {
|
|
18
|
+
const p = resolve(cwd, 'sorb.config.json')
|
|
19
|
+
if (!existsSync(p)) {
|
|
20
|
+
console.error('✗ No sorb.config.json in', cwd)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(readFileSync(p, 'utf-8'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cmd = process.argv[2] || 'resolve'
|
|
27
|
+
|
|
28
|
+
if (cmd === 'resolve') {
|
|
29
|
+
const config = loadConfig()
|
|
30
|
+
const sdConfig = config.styleDictionaryConfig || 'sd.config.js'
|
|
31
|
+
const abs = resolve(cwd, sdConfig)
|
|
32
|
+
if (!existsSync(abs)) {
|
|
33
|
+
console.error(
|
|
34
|
+
'✗ Style Dictionary config not found:', sdConfig,
|
|
35
|
+
'\n Set "styleDictionaryConfig" in sorb.config.json (default: sd.config.js).',
|
|
36
|
+
)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
console.log('→ Running Style Dictionary:', sdConfig)
|
|
41
|
+
execSync(`npx style-dictionary build --config ${abs}`, { stdio: 'inherit', cwd })
|
|
42
|
+
console.log('✓ Built .sorb/resolved.json (and CSS vars + theme) from DTCG sources')
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error('✗ Style Dictionary build failed')
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
} else if (cmd === 'capture') {
|
|
48
|
+
const { runCapture } = await import('./captureCli.js')
|
|
49
|
+
const opts = {}
|
|
50
|
+
for (const a of process.argv.slice(3)) {
|
|
51
|
+
if (a === '--changed') opts.changed = true
|
|
52
|
+
else if (a.startsWith('--only=')) opts.only = a.slice('--only='.length)
|
|
53
|
+
else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
|
|
54
|
+
}
|
|
55
|
+
await runCapture(opts)
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture> [options]`)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public library surface for @sorb/seed.
|
|
2
|
+
//
|
|
3
|
+
// The resolved bindable token map is now produced by Style Dictionary (see
|
|
4
|
+
// `sorb-seed resolve`, a thin SD wrapper), not by scraping a theme module —
|
|
5
|
+
// so the old `resolveBindableTokens` export is gone. What remains useful as a
|
|
6
|
+
// library is the token-annotation layer used by `capture`.
|
|
7
|
+
export { buildTokenIndex, annotateTree, matchColor, matchDimension } from './annotateTokens.js'
|