@sorb/seed 0.1.1 → 0.3.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 +15 -5
- package/package.json +10 -3
- package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
- package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
- package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
- package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
- package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
- package/src/adapt/adaptCli.js +110 -0
- package/src/adapt/adaptCli.test.js +109 -0
- package/src/adapt/benchmark.js +113 -0
- package/src/adapt/benchmark.test.js +79 -0
- package/src/adapt/codemod.js +160 -0
- package/src/adapt/codemod.test.js +120 -0
- package/src/adapt/detectHardcoded.js +165 -0
- package/src/adapt/detectHardcoded.test.js +62 -0
- package/src/adapt/glob.js +87 -0
- package/src/adapt/mapToToken.js +94 -0
- package/src/adapt/mapToToken.test.js +103 -0
- package/src/adapt/report.js +53 -0
- package/src/adapt/report.test.js +66 -0
- package/src/adapt/runBenchmark.js +16 -0
- package/src/adapt/types.js +48 -0
- package/src/captureCli.js +38 -118
- package/src/cli.js +200 -3
- package/src/cli.test.js +51 -0
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/index.js +27 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- package/src/sources/storybookDom.js +188 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Tests for the `figma-plugin` SourceConnector (figma-source-connector.md
|
|
2
|
+
// §1a). Mirrors the contract-registration style of sorb-core's
|
|
3
|
+
// connectors.test.js dummy-source test, plus exercises readTokens() as a
|
|
4
|
+
// bridge CLIENT against a mocked `fetch` (no real juice server needed —
|
|
5
|
+
// the contract is "GET /tokens/figma returns { tokens: ResolvedToken[] }").
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { connectors, getSource } from '@sorb/core'
|
|
9
|
+
import { figmaPluginConnector, bridgeOriginOf } from './figmaPlugin.js'
|
|
10
|
+
|
|
11
|
+
test('registers under id "figma-plugin" and round-trips via getSource (contract dispatch)', () => {
|
|
12
|
+
assert.equal(getSource('figma-plugin'), figmaPluginConnector)
|
|
13
|
+
assert.equal(connectors.source.get('figma-plugin'), figmaPluginConnector)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('bridgeOriginOf: defaults to the juice dev port, honors seed.bridgeOrigin, strips trailing slash', () => {
|
|
17
|
+
assert.equal(bridgeOriginOf({}), 'http://localhost:7777')
|
|
18
|
+
assert.equal(bridgeOriginOf(), 'http://localhost:7777')
|
|
19
|
+
assert.equal(bridgeOriginOf({ seed: { bridgeOrigin: 'http://localhost:9999/' } }), 'http://localhost:9999')
|
|
20
|
+
assert.equal(bridgeOriginOf({ bridgeOrigin: 'http://example.com/' }), 'http://example.com')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('readTokens: reads the resolved-map array back from GET /tokens/figma', async (t) => {
|
|
24
|
+
const tokens = [{ id: 'color.bg.primary', cssVar: '--color-bg-primary', value: '#ffffff', tier: 'primitive', type: 'color' }]
|
|
25
|
+
const calls = []
|
|
26
|
+
const realFetch = global.fetch
|
|
27
|
+
global.fetch = async (url) => {
|
|
28
|
+
calls.push(url)
|
|
29
|
+
return { ok: true, status: 200, json: async () => ({ fileKey: 'abc', exportedAt: '2026-08-29T00:00:00Z', tokens }) }
|
|
30
|
+
}
|
|
31
|
+
t.after(() => { global.fetch = realFetch })
|
|
32
|
+
|
|
33
|
+
const got = await figmaPluginConnector.readTokens({ seed: { bridgeOrigin: 'http://localhost:7777' } })
|
|
34
|
+
assert.deepEqual(got, tokens)
|
|
35
|
+
assert.equal(calls[0], 'http://localhost:7777/tokens/figma')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('readTokens: 404 (no export yet) throws an actionable "Export variables" error', async (t) => {
|
|
39
|
+
const realFetch = global.fetch
|
|
40
|
+
global.fetch = async () => ({ ok: false, status: 404, json: async () => ({ error: 'No Figma export yet.' }) })
|
|
41
|
+
t.after(() => { global.fetch = realFetch })
|
|
42
|
+
|
|
43
|
+
await assert.rejects(() => figmaPluginConnector.readTokens({}), /Export variables/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('readTokens: non-2xx, non-404 → surfaces the HTTP status', async (t) => {
|
|
47
|
+
const realFetch = global.fetch
|
|
48
|
+
global.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) })
|
|
49
|
+
t.after(() => { global.fetch = realFetch })
|
|
50
|
+
|
|
51
|
+
await assert.rejects(() => figmaPluginConnector.readTokens({}), /HTTP 500/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('readTokens: network failure (bridge not running) → actionable error naming the origin', async (t) => {
|
|
55
|
+
const realFetch = global.fetch
|
|
56
|
+
global.fetch = async () => { throw new Error('ECONNREFUSED') }
|
|
57
|
+
t.after(() => { global.fetch = realFetch })
|
|
58
|
+
|
|
59
|
+
await assert.rejects(() => figmaPluginConnector.readTokens({}), /could not reach the bridge at http:\/\/localhost:7777/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('listUnits/captureGeometry: v1 tokens-first scope — throw a clear not-yet-implemented error, not a silent no-op', async () => {
|
|
63
|
+
await assert.rejects(() => figmaPluginConnector.listUnits({}), /not implemented in v1/)
|
|
64
|
+
await assert.rejects(() => figmaPluginConnector.captureGeometry({ id: 'u1' }, {}), /not implemented in v1/)
|
|
65
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// `storybook-dom` SourceConnector — the default SOURCE connector (registered
|
|
2
|
+
// under `@sorb/core`'s DEFAULT_SOURCE_ID = 'storybook-dom'). Extracted from
|
|
3
|
+
// captureCli.js verbatim (spec/sorb/connectors-architecture.md §3.1, C1) — a
|
|
4
|
+
// pure refactor, no behavior change. Owns everything source-specific:
|
|
5
|
+
// discovering Storybook story entries (`listUnits`), running Playwright +
|
|
6
|
+
// the `capture.js` walker to capture one entry's raw geometry
|
|
7
|
+
// (`captureGeometry`), and reading the DTCG/Style-Dictionary resolved token
|
|
8
|
+
// map (`readTokens`). The generic pipeline (tightenRoot -> annotateTree ->
|
|
9
|
+
// hash -> write) stays in captureCli.js and is fed by this connector.
|
|
10
|
+
|
|
11
|
+
import { basename, extname, dirname, resolve } from 'path'
|
|
12
|
+
import { readFileSync, existsSync } from 'fs'
|
|
13
|
+
import { build } from 'esbuild'
|
|
14
|
+
import { registerSource } from '@sorb/core'
|
|
15
|
+
|
|
16
|
+
// Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
|
|
17
|
+
// ~150 MB browser. Lazy-load it so plain installs and `resolve` stay lean.
|
|
18
|
+
const loadChromium = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const { chromium } = await import('playwright')
|
|
21
|
+
return chromium
|
|
22
|
+
} catch {
|
|
23
|
+
console.error(
|
|
24
|
+
'✗ `sorb-seed capture` needs Playwright (it is an optional peer dep).\n' +
|
|
25
|
+
' Install it where you run capture:\n' +
|
|
26
|
+
' npm install playwright # its postinstall fetches Chromium\n' +
|
|
27
|
+
' (or: npm install playwright && npx playwright install chromium)',
|
|
28
|
+
)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The `playwright` PACKAGE can be installed while its Chromium BROWSER binary is
|
|
34
|
+
// not (that's a separate `npx playwright install chromium` step). Launching then
|
|
35
|
+
// throws a raw "Executable doesn't exist" error — turn it into the same
|
|
36
|
+
// actionable guidance the missing-package path already gives.
|
|
37
|
+
export const launchChromium = async (chromium) => {
|
|
38
|
+
try {
|
|
39
|
+
return await chromium.launch()
|
|
40
|
+
} catch (e) {
|
|
41
|
+
const msg = e && e.message ? e.message : String(e)
|
|
42
|
+
if (/Executable doesn't exist|playwright install|browserType\.launch/i.test(msg)) {
|
|
43
|
+
console.error(
|
|
44
|
+
'✗ `sorb-seed capture` found Playwright but its Chromium browser is not installed.\n' +
|
|
45
|
+
' Install the browser where you run capture:\n' +
|
|
46
|
+
' npx playwright install chromium',
|
|
47
|
+
)
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
throw e
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bundle the walker into a single IIFE string we can addInitScript() into
|
|
55
|
+
// every page. Playwright can't pass functions across the boundary directly,
|
|
56
|
+
// and our walker has cross-file imports → bundling is the clean answer.
|
|
57
|
+
const buildWalkerBundle = async () => {
|
|
58
|
+
const here = dirname(new URL(import.meta.url).pathname)
|
|
59
|
+
const out = await build({
|
|
60
|
+
entryPoints: [resolve(here, '..', 'capture.js')],
|
|
61
|
+
bundle: true,
|
|
62
|
+
format: 'iife',
|
|
63
|
+
platform: 'browser',
|
|
64
|
+
write: false,
|
|
65
|
+
logLevel: 'silent',
|
|
66
|
+
target: 'es2020',
|
|
67
|
+
})
|
|
68
|
+
return out.outputFiles[0].text
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isStoryEntry = (e) =>
|
|
72
|
+
e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
|
|
73
|
+
|
|
74
|
+
// componentName: "Button" from "./src/.../Button.stories.jsx"
|
|
75
|
+
const componentNameFromImportPath = (importPath) => {
|
|
76
|
+
const file = basename(importPath, extname(importPath)) // "Button.stories"
|
|
77
|
+
return file.replace(/\.stories$/i, '')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// sorb.config.json may set seed.storybookUrl. Fall back to localhost.
|
|
81
|
+
export const storybookUrlOf = (config) =>
|
|
82
|
+
(config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
|
|
83
|
+
|
|
84
|
+
// ─── Playwright session (browser + context + walker init script) ──────────
|
|
85
|
+
// Lazily launched on the first captureGeometry() call and reused across every
|
|
86
|
+
// unit in a run — exactly today's single-launch/many-pages lifecycle. Not
|
|
87
|
+
// part of the shared SourceConnector contract (that's just listUnits /
|
|
88
|
+
// captureGeometry / readTokens); captureCli.js calls closeSession() once
|
|
89
|
+
// after it has processed every unit, mirroring today's single browser.close().
|
|
90
|
+
let session = null // { browser, ctx, sbUrl }
|
|
91
|
+
|
|
92
|
+
const ensureSession = async (sbUrl) => {
|
|
93
|
+
if (session && session.sbUrl === sbUrl) return session
|
|
94
|
+
if (session) await closeSession()
|
|
95
|
+
const chromium = await loadChromium()
|
|
96
|
+
const walker = await buildWalkerBundle()
|
|
97
|
+
const browser = await launchChromium(chromium)
|
|
98
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
|
|
99
|
+
await ctx.addInitScript({ content: walker })
|
|
100
|
+
session = { browser, ctx, sbUrl }
|
|
101
|
+
return session
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const closeSession = async () => {
|
|
105
|
+
if (session) {
|
|
106
|
+
await session.browser.close()
|
|
107
|
+
session = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── SourceConnector implementation ────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const listUnits = async (config) => {
|
|
114
|
+
const sbUrl = storybookUrlOf(config).replace(/\/$/, '')
|
|
115
|
+
console.log(`→ Storybook: ${sbUrl}`)
|
|
116
|
+
let sbIndex
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`${sbUrl}/index.json`)
|
|
119
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
120
|
+
sbIndex = await res.json()
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('✗ Could not fetch Storybook index:', e.message)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
const entries = Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry)
|
|
126
|
+
return entries.map((e) => ({
|
|
127
|
+
id: e.id,
|
|
128
|
+
name: e.name,
|
|
129
|
+
title: e.title,
|
|
130
|
+
importPath: e.importPath,
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const captureGeometry = async (unit, config) => {
|
|
135
|
+
const sbUrl = storybookUrlOf(config).replace(/\/$/, '')
|
|
136
|
+
const { ctx } = await ensureSession(sbUrl)
|
|
137
|
+
const url = `${sbUrl}/iframe.html?id=${unit.id}&viewMode=story`
|
|
138
|
+
const page = await ctx.newPage()
|
|
139
|
+
try {
|
|
140
|
+
console.log(` · ${unit.id}`)
|
|
141
|
+
await page.goto(url, { waitUntil: 'load' })
|
|
142
|
+
// Wait for Storybook to actually render the story.
|
|
143
|
+
await page
|
|
144
|
+
.waitForFunction(
|
|
145
|
+
() => !!document.querySelector('#storybook-root *'),
|
|
146
|
+
{ timeout: 15000 },
|
|
147
|
+
)
|
|
148
|
+
.catch(() => {})
|
|
149
|
+
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
150
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
|
|
151
|
+
|
|
152
|
+
const rawTree = await page.evaluate(() => {
|
|
153
|
+
const root = document.querySelector('#storybook-root')
|
|
154
|
+
return root ? window.__sorbCapture(root) : null
|
|
155
|
+
})
|
|
156
|
+
if (!rawTree) {
|
|
157
|
+
console.warn(' ⚠ no #storybook-root content; skipped')
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
return rawTree
|
|
161
|
+
} finally {
|
|
162
|
+
await page.close()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Thin wrapper over the resolved bindable token map produced by `sorb-seed
|
|
167
|
+
// resolve` (Style Dictionary build against the DTCG sources). Capture's own
|
|
168
|
+
// generic pipeline still reads `.sorb/resolved.json` directly (unchanged) —
|
|
169
|
+
// this exists so the connector satisfies the SOURCE contract for other
|
|
170
|
+
// consumers. Does not move the SD internals (those stay owned by `resolve`).
|
|
171
|
+
const readTokens = async (config) => {
|
|
172
|
+
const cwd = process.cwd()
|
|
173
|
+
const p = resolve(cwd, '.sorb/resolved.json')
|
|
174
|
+
if (!existsSync(p)) {
|
|
175
|
+
throw new Error('No .sorb/resolved.json — run `sorb-seed resolve` first.')
|
|
176
|
+
}
|
|
177
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'))
|
|
178
|
+
return Array.isArray(data) ? data : data.tokens
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const storybookDomConnector = {
|
|
182
|
+
id: 'storybook-dom',
|
|
183
|
+
listUnits,
|
|
184
|
+
captureGeometry,
|
|
185
|
+
readTokens,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
registerSource(storybookDomConnector)
|
package/src/variants.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// sorb-seed — variant lifecycle engine.
|
|
2
|
+
//
|
|
3
|
+
// Provides add and deprecate operations against DTCG component token sources.
|
|
4
|
+
// Each operation mutates the source JSON, bumps the $version, re-runs the SD
|
|
5
|
+
// build, and returns a VariantChangeset describing what changed.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
8
|
+
import { spawn } from 'child_process'
|
|
9
|
+
|
|
10
|
+
// ─── types ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} AddVariantOptions
|
|
14
|
+
* @property {string} componentJsonPath - Absolute path to the component DTCG JSON file.
|
|
15
|
+
* @property {string} variantId - Full dot-path of the new variant, e.g. "button.tertiary".
|
|
16
|
+
* @property {string} fromVariant - Full dot-path of the source variant, e.g. "button.primary".
|
|
17
|
+
* @property {string} sdConfigDir - Directory containing sd.config.js (runs `npm run tokens` here).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} DeprecateVariantOptions
|
|
22
|
+
* @property {string} componentJsonPath - Absolute path to the component DTCG JSON file.
|
|
23
|
+
* @property {string} variantId - Full dot-path of the variant to deprecate, e.g. "button.danger".
|
|
24
|
+
* @property {string} replacedBy - Replacement variant dot-path, e.g. "button.error".
|
|
25
|
+
* @property {string} sdConfigDir - Directory containing sd.config.js.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} VariantChangeset
|
|
30
|
+
* @property {'add'|'deprecate'} action - Which operation was performed.
|
|
31
|
+
* @property {string} variantId - The target variant dot-path.
|
|
32
|
+
* @property {string[]} tokenIds - All token ids affected (leaf nodes).
|
|
33
|
+
* @property {string} newVersion - The bumped $version written to the source file.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bump the semver patch segment of a version string.
|
|
40
|
+
* Non-parseable input returns "1.0.1" as a safe fallback.
|
|
41
|
+
* @param {string} version
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export const bumpVersion = (version) => {
|
|
45
|
+
if (typeof version !== 'string') return '1.0.1'
|
|
46
|
+
const parts = version.split('.')
|
|
47
|
+
if (parts.length !== 3) return '1.0.1'
|
|
48
|
+
const [major, minor, patch] = parts.map(Number)
|
|
49
|
+
if (parts.some((p, i) => isNaN([major, minor, patch][i]))) return '1.0.1'
|
|
50
|
+
return `${major}.${minor}.${patch + 1}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk a variant slice (prop × state tree) and collect fully-qualified token
|
|
55
|
+
* ids for every leaf node (a node that has a `$value` property).
|
|
56
|
+
* @param {string} componentId - e.g. "button"
|
|
57
|
+
* @param {string} variantKey - e.g. "tertiary"
|
|
58
|
+
* @param {Object} sliceObj - The variant's token subtree.
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export const flattenVariantIds = (componentId, variantKey, sliceObj) => {
|
|
62
|
+
const ids = []
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Object} node
|
|
66
|
+
* @param {string[]} path - segments accumulated so far (below variantKey)
|
|
67
|
+
*/
|
|
68
|
+
const walk = (node, path) => {
|
|
69
|
+
if (node === null || typeof node !== 'object') return
|
|
70
|
+
if ('$value' in node) {
|
|
71
|
+
// Leaf token — build the full dot-path id.
|
|
72
|
+
ids.push([componentId, variantKey, ...path].join('.'))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
for (const key of Object.keys(node)) {
|
|
76
|
+
if (key.startsWith('$')) continue // skip metadata keys ($type, $description, etc.)
|
|
77
|
+
walk(node[key], [...path, key])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
walk(sliceObj, [])
|
|
82
|
+
return ids
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Deep-clone a variant slice (the value stored under a variant key).
|
|
87
|
+
* Equivalent to JSON round-trip — suitable for pure token objects.
|
|
88
|
+
* @param {Object} fromSlice
|
|
89
|
+
* @returns {Object}
|
|
90
|
+
*/
|
|
91
|
+
export const cloneVariantSlice = (fromSlice) => JSON.parse(JSON.stringify(fromSlice))
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk a variant slice and mark every leaf node as deprecated, setting
|
|
95
|
+
* `$deprecated = true` and `$extensions.sorb.replacedBy` to `replacedBy`.
|
|
96
|
+
* Returns a **deep clone** with the mutations applied; the original is never
|
|
97
|
+
* touched.
|
|
98
|
+
* @param {Object} sliceObj - The variant's token subtree.
|
|
99
|
+
* @param {string} replacedBy - Replacement variant dot-path.
|
|
100
|
+
* @returns {Object} mutated deep clone
|
|
101
|
+
*/
|
|
102
|
+
export const applyDeprecation = (sliceObj, replacedBy) => {
|
|
103
|
+
const clone = cloneVariantSlice(sliceObj)
|
|
104
|
+
|
|
105
|
+
const walk = (node) => {
|
|
106
|
+
if (node === null || typeof node !== 'object') return
|
|
107
|
+
if ('$value' in node) {
|
|
108
|
+
node.$deprecated = true
|
|
109
|
+
node.$extensions = { sorb: { replacedBy } }
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
for (const key of Object.keys(node)) {
|
|
113
|
+
if (key.startsWith('$')) continue
|
|
114
|
+
walk(node[key])
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
walk(clone)
|
|
119
|
+
return clone
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run `npm run tokens` in the given directory and return a promise that
|
|
124
|
+
* resolves on exit 0 or rejects with the collected output on non-zero exit.
|
|
125
|
+
* @param {string} sdConfigDir
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
const runSdBuild = (sdConfigDir) =>
|
|
129
|
+
new Promise((resolve, reject) => {
|
|
130
|
+
const chunks = []
|
|
131
|
+
const child = spawn('npm', ['run', 'tokens'], {
|
|
132
|
+
cwd: sdConfigDir,
|
|
133
|
+
stdio: 'pipe',
|
|
134
|
+
})
|
|
135
|
+
child.stdout.on('data', (d) => chunks.push(d))
|
|
136
|
+
child.stderr.on('data', (d) => chunks.push(d))
|
|
137
|
+
child.on('close', (code) => {
|
|
138
|
+
if (code === 0) {
|
|
139
|
+
resolve()
|
|
140
|
+
} else {
|
|
141
|
+
const output = Buffer.concat(chunks).toString('utf-8')
|
|
142
|
+
reject(
|
|
143
|
+
new Error(
|
|
144
|
+
`SD build failed (exit ${code}) in ${sdConfigDir}:\n${output}`,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
child.on('error', (err) => reject(err))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ─── exported API ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Add a new component variant by cloning an existing one.
|
|
156
|
+
*
|
|
157
|
+
* Reads `componentJsonPath`, deep-clones the `fromVariant` slice into a new
|
|
158
|
+
* `variantId` key, bumps the set's `$version`, writes the file back, runs the
|
|
159
|
+
* SD build, and returns a {@link VariantChangeset}.
|
|
160
|
+
*
|
|
161
|
+
* @param {AddVariantOptions} options
|
|
162
|
+
* @returns {Promise<VariantChangeset>}
|
|
163
|
+
*/
|
|
164
|
+
export const addVariant = async (options) => {
|
|
165
|
+
const { componentJsonPath, variantId, fromVariant, sdConfigDir } = options
|
|
166
|
+
|
|
167
|
+
// 1. Read + parse.
|
|
168
|
+
const raw = readFileSync(componentJsonPath, 'utf-8')
|
|
169
|
+
const parsed = JSON.parse(raw)
|
|
170
|
+
|
|
171
|
+
// 2. Resolve keys.
|
|
172
|
+
const componentId = variantId.split('.')[0]
|
|
173
|
+
const newKey = variantId.split('.').slice(1).join('.')
|
|
174
|
+
const fromKey = fromVariant.split('.').slice(1).join('.')
|
|
175
|
+
|
|
176
|
+
// 3. Find component object.
|
|
177
|
+
const componentObj = parsed[componentId]
|
|
178
|
+
if (!componentObj || typeof componentObj !== 'object') {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`addVariant: component "${componentId}" not found in ${componentJsonPath}`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 4. Find source slice.
|
|
185
|
+
const fromSlice = componentObj[fromKey]
|
|
186
|
+
if (!fromSlice || typeof fromSlice !== 'object') {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`addVariant: source variant "${fromKey}" not found in component "${componentId}"`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 5. Deep-clone into new key.
|
|
193
|
+
componentObj[newKey] = JSON.parse(JSON.stringify(fromSlice))
|
|
194
|
+
|
|
195
|
+
// 6. Bump $version.
|
|
196
|
+
const prevVersion = parsed.$version ?? '1.0.0'
|
|
197
|
+
const newVersion = bumpVersion(String(prevVersion))
|
|
198
|
+
parsed.$version = newVersion
|
|
199
|
+
|
|
200
|
+
// 7. Write back.
|
|
201
|
+
writeFileSync(componentJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8')
|
|
202
|
+
|
|
203
|
+
// 8. Run SD build.
|
|
204
|
+
await runSdBuild(sdConfigDir)
|
|
205
|
+
|
|
206
|
+
// 9. Collect token ids from the cloned slice.
|
|
207
|
+
const tokenIds = flattenVariantIds(componentId, newKey, componentObj[newKey])
|
|
208
|
+
|
|
209
|
+
return { action: 'add', variantId, tokenIds, newVersion }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mark every leaf token in a component variant as deprecated.
|
|
214
|
+
*
|
|
215
|
+
* Walks the `variantId` slice, sets `$deprecated = true` and
|
|
216
|
+
* `$extensions.sorb.replacedBy` on every leaf, bumps `$version`, writes the
|
|
217
|
+
* file back, runs the SD build, and returns a {@link VariantChangeset}.
|
|
218
|
+
*
|
|
219
|
+
* @param {DeprecateVariantOptions} options
|
|
220
|
+
* @returns {Promise<VariantChangeset>}
|
|
221
|
+
*/
|
|
222
|
+
export const deprecateVariant = async (options) => {
|
|
223
|
+
const { componentJsonPath, variantId, replacedBy, sdConfigDir } = options
|
|
224
|
+
|
|
225
|
+
// 1. Read + parse.
|
|
226
|
+
const raw = readFileSync(componentJsonPath, 'utf-8')
|
|
227
|
+
const parsed = JSON.parse(raw)
|
|
228
|
+
|
|
229
|
+
// 2. Resolve keys.
|
|
230
|
+
const componentId = variantId.split('.')[0]
|
|
231
|
+
const variantKey = variantId.split('.').slice(1).join('.')
|
|
232
|
+
|
|
233
|
+
// 3. Find variant slice (error if absent).
|
|
234
|
+
const componentObj = parsed[componentId]
|
|
235
|
+
if (!componentObj || typeof componentObj !== 'object') {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`deprecateVariant: component "${componentId}" not found in ${componentJsonPath}`,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
const variantSlice = componentObj[variantKey]
|
|
241
|
+
if (!variantSlice || typeof variantSlice !== 'object') {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`deprecateVariant: variant "${variantKey}" not found in component "${componentId}"`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 4. Walk the slice; mark every leaf node.
|
|
248
|
+
const tokenIds = []
|
|
249
|
+
|
|
250
|
+
const walk = (node, path) => {
|
|
251
|
+
if (node === null || typeof node !== 'object') return
|
|
252
|
+
if ('$value' in node) {
|
|
253
|
+
node.$deprecated = true
|
|
254
|
+
node.$extensions = { sorb: { replacedBy } }
|
|
255
|
+
tokenIds.push([componentId, variantKey, ...path].join('.'))
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
for (const key of Object.keys(node)) {
|
|
259
|
+
if (key.startsWith('$')) continue
|
|
260
|
+
walk(node[key], [...path, key])
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
walk(variantSlice, [])
|
|
265
|
+
|
|
266
|
+
// 5. Bump $version.
|
|
267
|
+
const prevVersion = parsed.$version ?? '1.0.0'
|
|
268
|
+
const newVersion = bumpVersion(String(prevVersion))
|
|
269
|
+
parsed.$version = newVersion
|
|
270
|
+
|
|
271
|
+
// 6. Write back.
|
|
272
|
+
writeFileSync(componentJsonPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8')
|
|
273
|
+
|
|
274
|
+
// 7. Run SD build.
|
|
275
|
+
await runSdBuild(sdConfigDir)
|
|
276
|
+
|
|
277
|
+
// 8. Return changeset.
|
|
278
|
+
return { action: 'deprecate', variantId, tokenIds, newVersion }
|
|
279
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Tests for the variants.js pure helpers.
|
|
2
|
+
// Run: node --test src/variants.test.js
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import {
|
|
6
|
+
bumpVersion,
|
|
7
|
+
flattenVariantIds,
|
|
8
|
+
cloneVariantSlice,
|
|
9
|
+
applyDeprecation,
|
|
10
|
+
} from './variants.js'
|
|
11
|
+
|
|
12
|
+
// ─── In-memory button component token matrix ─────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const BUTTON_PRIMARY_SLICE = {
|
|
15
|
+
bg: {
|
|
16
|
+
default: { $value: '#0f65ef', $type: 'color' },
|
|
17
|
+
hover: { $value: '#0a4fc4', $type: 'color' },
|
|
18
|
+
},
|
|
19
|
+
text: {
|
|
20
|
+
default: { $value: '#ffffff', $type: 'color' },
|
|
21
|
+
},
|
|
22
|
+
border: {
|
|
23
|
+
default: { $value: '#0f65ef', $type: 'color' },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BUTTON_SINGLE_LEVEL_SLICE = {
|
|
28
|
+
radius: { $value: '4px', $type: 'dimension' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── flattenVariantIds ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('flattenVariantIds — button.primary slice produces expected ids', () => {
|
|
34
|
+
const ids = flattenVariantIds('button', 'primary', BUTTON_PRIMARY_SLICE)
|
|
35
|
+
assert.deepEqual(ids.sort(), [
|
|
36
|
+
'button.primary.bg.default',
|
|
37
|
+
'button.primary.bg.hover',
|
|
38
|
+
'button.primary.border.default',
|
|
39
|
+
'button.primary.text.default',
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('flattenVariantIds — single-level slice produces correct ids', () => {
|
|
44
|
+
const ids = flattenVariantIds('button', 'base', BUTTON_SINGLE_LEVEL_SLICE)
|
|
45
|
+
assert.deepEqual(ids, ['button.base.radius'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('flattenVariantIds — skips $-prefixed metadata keys', () => {
|
|
49
|
+
const sliceWithMeta = {
|
|
50
|
+
$description: 'ignored',
|
|
51
|
+
bg: { $value: '#000000', $type: 'color' },
|
|
52
|
+
}
|
|
53
|
+
const ids = flattenVariantIds('button', 'ghost', sliceWithMeta)
|
|
54
|
+
assert.deepEqual(ids, ['button.ghost.bg'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ─── bumpVersion ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('bumpVersion — "1.0.0" bumps patch to "1.0.1"', () => {
|
|
60
|
+
assert.equal(bumpVersion('1.0.0'), '1.0.1')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('bumpVersion — "2.3.7" bumps patch to "2.3.8"', () => {
|
|
64
|
+
assert.equal(bumpVersion('2.3.7'), '2.3.8')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('bumpVersion — malformed string falls back to "1.0.1"', () => {
|
|
68
|
+
assert.equal(bumpVersion('not-a-semver'), '1.0.1')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('bumpVersion — too few parts falls back to "1.0.1"', () => {
|
|
72
|
+
assert.equal(bumpVersion('1.0'), '1.0.1')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('bumpVersion — non-string input falls back to "1.0.1"', () => {
|
|
76
|
+
assert.equal(bumpVersion(null), '1.0.1')
|
|
77
|
+
assert.equal(bumpVersion(undefined), '1.0.1')
|
|
78
|
+
assert.equal(bumpVersion(42), '1.0.1')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ─── cloneVariantSlice ────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
test('cloneVariantSlice — returns a deep copy, not the same reference', () => {
|
|
84
|
+
const clone = cloneVariantSlice(BUTTON_PRIMARY_SLICE)
|
|
85
|
+
assert.deepEqual(clone, BUTTON_PRIMARY_SLICE)
|
|
86
|
+
assert.notEqual(clone, BUTTON_PRIMARY_SLICE)
|
|
87
|
+
assert.notEqual(clone.bg, BUTTON_PRIMARY_SLICE.bg)
|
|
88
|
+
assert.notEqual(clone.bg.default, BUTTON_PRIMARY_SLICE.bg.default)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('cloneVariantSlice — mutation of clone does not affect original', () => {
|
|
92
|
+
const clone = cloneVariantSlice(BUTTON_PRIMARY_SLICE)
|
|
93
|
+
clone.bg.default.$value = 'mutated'
|
|
94
|
+
assert.equal(BUTTON_PRIMARY_SLICE.bg.default.$value, '#0f65ef')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ─── applyDeprecation ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test('applyDeprecation — all leaf nodes get $deprecated:true', () => {
|
|
100
|
+
const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
|
|
101
|
+
assert.equal(result.bg.default.$deprecated, true)
|
|
102
|
+
assert.equal(result.bg.hover.$deprecated, true)
|
|
103
|
+
assert.equal(result.text.default.$deprecated, true)
|
|
104
|
+
assert.equal(result.border.default.$deprecated, true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('applyDeprecation — all leaf nodes get $extensions.sorb.replacedBy set', () => {
|
|
108
|
+
const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
|
|
109
|
+
assert.equal(result.bg.default.$extensions.sorb.replacedBy, 'button.primary-v2')
|
|
110
|
+
assert.equal(result.bg.hover.$extensions.sorb.replacedBy, 'button.primary-v2')
|
|
111
|
+
assert.equal(result.text.default.$extensions.sorb.replacedBy, 'button.primary-v2')
|
|
112
|
+
assert.equal(result.border.default.$extensions.sorb.replacedBy, 'button.primary-v2')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('applyDeprecation — does not mutate the original slice', () => {
|
|
116
|
+
const original = {
|
|
117
|
+
bg: { default: { $value: '#0f65ef', $type: 'color' } },
|
|
118
|
+
}
|
|
119
|
+
applyDeprecation(original, 'button.new')
|
|
120
|
+
assert.equal(original.bg.default.$deprecated, undefined)
|
|
121
|
+
assert.equal(original.bg.default.$extensions, undefined)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('applyDeprecation — skips intermediate (non-leaf) nodes', () => {
|
|
125
|
+
const result = applyDeprecation(BUTTON_PRIMARY_SLICE, 'button.primary-v2')
|
|
126
|
+
// intermediate nodes should not have $deprecated or $extensions set
|
|
127
|
+
assert.equal(result.bg.$deprecated, undefined)
|
|
128
|
+
assert.equal(result.$deprecated, undefined)
|
|
129
|
+
})
|