@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
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, existsSync } from 'fs'
|
|
3
|
-
import { resolve } from 'path'
|
|
3
|
+
import { resolve, dirname } from 'path'
|
|
4
4
|
import { execSync } from 'child_process'
|
|
5
5
|
|
|
6
6
|
// sorb-seed — Storybook → Figma capture tooling.
|
|
@@ -25,7 +25,60 @@ const loadConfig = () => {
|
|
|
25
25
|
|
|
26
26
|
const cmd = process.argv[2] || 'resolve'
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const pkgVersion = () => {
|
|
29
|
+
try {
|
|
30
|
+
const pkgPath = resolve(dirname(new URL(import.meta.url).pathname), '..', 'package.json')
|
|
31
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return '0.0.0'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HELP = `sorb-seed — Storybook → Figma token capture for Sorb.
|
|
38
|
+
|
|
39
|
+
Usage: sorb-seed <command> [options]
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
resolve Build .sorb/resolved.json from your DTCG token sets via
|
|
43
|
+
Style Dictionary (the default when no command is given).
|
|
44
|
+
capture Visit each Storybook story with Playwright, capture the
|
|
45
|
+
rendered tree, and annotate it against the resolved token
|
|
46
|
+
map → per-component *.sorb.json + .sorb/index.json.
|
|
47
|
+
render-worker Hosted-capture on-demand render worker (Mode B / E3):
|
|
48
|
+
reads RenderJobInput job(s) as NDJSON on stdin (or
|
|
49
|
+
--job=/--job-file=), renders each against a URL + token
|
|
50
|
+
map, emits RenderJobResult NDJSON on stdout. See
|
|
51
|
+
src/render/worker.js.
|
|
52
|
+
variant <add|deprecate>
|
|
53
|
+
Add or deprecate a component variant in the DTCG source,
|
|
54
|
+
bump $version, and rebuild. See \`variant\` usage below.
|
|
55
|
+
|
|
56
|
+
variant options:
|
|
57
|
+
variant add <id> --from <base> [--tokens-dir <dir>] [--sd-dir <dir>]
|
|
58
|
+
variant deprecate <id> --replaced-by <replacement> [--tokens-dir <dir>] [--sd-dir <dir>]
|
|
59
|
+
|
|
60
|
+
capture options:
|
|
61
|
+
--changed Only re-capture stories whose rendered hash changed.
|
|
62
|
+
--only=<pattern> Capture only stories matching the glob/regex (matched
|
|
63
|
+
against importPath, title, and id).
|
|
64
|
+
--storybook-url=<url>
|
|
65
|
+
Storybook base URL (default: sorb.config.json seed.storybookUrl
|
|
66
|
+
or http://localhost:6006).
|
|
67
|
+
|
|
68
|
+
Global:
|
|
69
|
+
-h, --help Show this help and exit.
|
|
70
|
+
-v, --version Print the sorb-seed version and exit.
|
|
71
|
+
|
|
72
|
+
capture needs Playwright + its Chromium browser:
|
|
73
|
+
npm install playwright && npx playwright install chromium`
|
|
74
|
+
|
|
75
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
76
|
+
console.log(HELP)
|
|
77
|
+
process.exit(0)
|
|
78
|
+
} else if (cmd === '--version' || cmd === '-v') {
|
|
79
|
+
console.log(pkgVersion())
|
|
80
|
+
process.exit(0)
|
|
81
|
+
} else if (cmd === 'resolve') {
|
|
29
82
|
const config = loadConfig()
|
|
30
83
|
const sdConfig = config.styleDictionaryConfig || 'sd.config.js'
|
|
31
84
|
const abs = resolve(cwd, sdConfig)
|
|
@@ -53,7 +106,151 @@ if (cmd === 'resolve') {
|
|
|
53
106
|
else if (a.startsWith('--storybook-url=')) opts.storybookUrl = a.slice('--storybook-url='.length)
|
|
54
107
|
}
|
|
55
108
|
await runCapture(opts)
|
|
109
|
+
} else if (cmd === 'render-worker') {
|
|
110
|
+
const { main: renderWorkerMain } = await import('./render/cli.js')
|
|
111
|
+
await renderWorkerMain(process.argv)
|
|
112
|
+
} else if (cmd === 'variant') {
|
|
113
|
+
const sub = process.argv[3]
|
|
114
|
+
|
|
115
|
+
// ── shared arg parser ──────────────────────────────────────────────────────
|
|
116
|
+
const args = process.argv.slice(4)
|
|
117
|
+
const flag = (name) => {
|
|
118
|
+
const prefix = `--${name}=`
|
|
119
|
+
const eq = args.find((a) => a.startsWith(prefix))
|
|
120
|
+
if (eq) return eq.slice(prefix.length)
|
|
121
|
+
const idx = args.indexOf(`--${name}`)
|
|
122
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1]
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── auto-detect sorb-demo paths ────────────────────────────────────────────
|
|
127
|
+
// Walks up from cwd looking for sorb-demo/tokens/component.json and
|
|
128
|
+
// sorb-demo/sd.config.js. Falls back to cwd-relative guesses.
|
|
129
|
+
const autoDetect = () => {
|
|
130
|
+
// Try workspace-sibling layout: cwd may be sorb-seed or any sibling
|
|
131
|
+
const candidates = [
|
|
132
|
+
resolve(cwd, '../sorb-demo'),
|
|
133
|
+
resolve(cwd, 'sorb-demo'),
|
|
134
|
+
cwd,
|
|
135
|
+
]
|
|
136
|
+
for (const base of candidates) {
|
|
137
|
+
const tokens = resolve(base, 'tokens/component.json')
|
|
138
|
+
const sd = resolve(base, 'sd.config.js')
|
|
139
|
+
if (existsSync(tokens) && existsSync(sd)) return { tokensDir: resolve(base, 'tokens'), sdDir: base }
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (sub === 'add') {
|
|
145
|
+
const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
|
|
146
|
+
const fromRaw = flag('from')
|
|
147
|
+
const tokensDirFlag = flag('tokens-dir')
|
|
148
|
+
const sdDirFlag = flag('sd-dir')
|
|
149
|
+
|
|
150
|
+
if (!variantId) {
|
|
151
|
+
console.error('✗ Usage: sorb-seed variant add <variantId> --from <fromVariant> [--tokens-dir <dir>] [--sd-dir <dir>]')
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
if (!fromRaw) {
|
|
155
|
+
console.error('✗ --from is required (e.g. --from primary or --from button.primary)')
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Normalise --from: if it has no dot, prepend the component from variantId
|
|
160
|
+
const componentId = variantId.split('.')[0]
|
|
161
|
+
const fromVariant = fromRaw.includes('.') ? fromRaw : `${componentId}.${fromRaw}`
|
|
162
|
+
|
|
163
|
+
// Resolve paths
|
|
164
|
+
let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
|
|
165
|
+
let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
|
|
166
|
+
if (!tokensDir || !sdDir) {
|
|
167
|
+
const detected = autoDetect()
|
|
168
|
+
if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
|
|
169
|
+
if (!sdDir) sdDir = detected ? detected.sdDir : cwd
|
|
170
|
+
}
|
|
171
|
+
const componentJsonPath = resolve(tokensDir, 'component.json')
|
|
172
|
+
|
|
173
|
+
if (!existsSync(componentJsonPath)) {
|
|
174
|
+
console.error(`✗ component.json not found at ${componentJsonPath}`)
|
|
175
|
+
console.error(' Use --tokens-dir to point at your tokens directory.')
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { addVariant } = await import('./variants.js')
|
|
180
|
+
try {
|
|
181
|
+
const changeset = await addVariant({ componentJsonPath, variantId, fromVariant, sdConfigDir: sdDir })
|
|
182
|
+
console.log(`✓ Added variant ${changeset.variantId} (${changeset.tokenIds.length} tokens) — $version bumped to ${changeset.newVersion}`)
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.error('✗', e.message)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
} else if (sub === 'deprecate') {
|
|
189
|
+
const variantId = args[0] && !args[0].startsWith('--') ? args[0] : null
|
|
190
|
+
const replacedBy = flag('replaced-by')
|
|
191
|
+
const tokensDirFlag = flag('tokens-dir')
|
|
192
|
+
const sdDirFlag = flag('sd-dir')
|
|
193
|
+
|
|
194
|
+
if (!variantId) {
|
|
195
|
+
console.error('✗ Usage: sorb-seed variant deprecate <variantId> --replaced-by <replacedBy> [--tokens-dir <dir>] [--sd-dir <dir>]')
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
if (!replacedBy) {
|
|
199
|
+
console.error('✗ --replaced-by is required (e.g. --replaced-by button.error)')
|
|
200
|
+
process.exit(1)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolve paths
|
|
204
|
+
let tokensDir = tokensDirFlag ? resolve(cwd, tokensDirFlag) : null
|
|
205
|
+
let sdDir = sdDirFlag ? resolve(cwd, sdDirFlag) : null
|
|
206
|
+
if (!tokensDir || !sdDir) {
|
|
207
|
+
const detected = autoDetect()
|
|
208
|
+
if (!tokensDir) tokensDir = detected ? detected.tokensDir : resolve(cwd, 'tokens')
|
|
209
|
+
if (!sdDir) sdDir = detected ? detected.sdDir : cwd
|
|
210
|
+
}
|
|
211
|
+
const componentJsonPath = resolve(tokensDir, 'component.json')
|
|
212
|
+
|
|
213
|
+
if (!existsSync(componentJsonPath)) {
|
|
214
|
+
console.error(`✗ component.json not found at ${componentJsonPath}`)
|
|
215
|
+
console.error(' Use --tokens-dir to point at your tokens directory.')
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Normalise replacedBy: if no dot, prepend the component from variantId
|
|
220
|
+
const componentId = variantId.split('.')[0]
|
|
221
|
+
const replacedByFull = replacedBy.includes('.') ? replacedBy : `${componentId}.${replacedBy}`
|
|
222
|
+
|
|
223
|
+
const { deprecateVariant } = await import('./variants.js')
|
|
224
|
+
try {
|
|
225
|
+
const changeset = await deprecateVariant({ componentJsonPath, variantId, replacedBy: replacedByFull, sdConfigDir: sdDir })
|
|
226
|
+
console.log(`✓ Deprecated variant ${changeset.variantId} → ${replacedByFull} (${changeset.tokenIds.length} tokens marked)`)
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error('✗', e.message)
|
|
229
|
+
process.exit(1)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
} else {
|
|
233
|
+
console.error(`✗ Unknown variant subcommand: ${sub || '(none)'}`)
|
|
234
|
+
console.error(' Usage: sorb-seed variant <add|deprecate> ...')
|
|
235
|
+
process.exit(1)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
} else if (cmd === 'adapt') {
|
|
239
|
+
// Legacy-React adapter (roadmap §6): detect hardcoded styles → map to the
|
|
240
|
+
// nearest resolved token → scored report / runtime-shim payload / codemod.
|
|
241
|
+
const { runAdaptCli } = await import('./adapt/adaptCli.js')
|
|
242
|
+
await runAdaptCli(process.argv.slice(3), cwd)
|
|
56
243
|
} else {
|
|
57
|
-
console.error(
|
|
244
|
+
console.error(
|
|
245
|
+
`Unknown command: ${cmd}\n` +
|
|
246
|
+
`Usage: sorb-seed <resolve|capture|render-worker|variant|adapt> [options]\n` +
|
|
247
|
+
` resolve build .sorb/resolved.json from DTCG sources (Style Dictionary)\n` +
|
|
248
|
+
` capture [--changed] headless Storybook → Figma capture\n` +
|
|
249
|
+
` render-worker internal render worker (variant preview rendering)\n` +
|
|
250
|
+
` variant <add|deprecate> manage component variants\n` +
|
|
251
|
+
` adapt [--src <glob>] [--resolved <path>] [--mode report|shim|codemod] [--write]\n` +
|
|
252
|
+
` detect hardcoded styles in a legacy React app and map them to tokens\n` +
|
|
253
|
+
`Run \`sorb-seed --help\` for details.`,
|
|
254
|
+
)
|
|
58
255
|
process.exit(1)
|
|
59
256
|
}
|
package/src/cli.test.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Smoke tests for the `sorb-seed` CLI entrypoint — --help / --version / unknown
|
|
2
|
+
// command dispatch (GFP RC1 Part 2 · D4). Spawns the real bin so exit codes and
|
|
3
|
+
// stdout/stderr are exercised end-to-end. Run: `node --test src/cli.test.js`.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { dirname, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const CLI = resolve(here, 'cli.js')
|
|
14
|
+
const pkgVersion = JSON.parse(
|
|
15
|
+
readFileSync(resolve(here, '..', 'package.json'), 'utf-8'),
|
|
16
|
+
).version
|
|
17
|
+
|
|
18
|
+
const run = (args) => spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf-8' })
|
|
19
|
+
|
|
20
|
+
test('--help prints usage, lists resolve+capture, exits 0', () => {
|
|
21
|
+
const r = run(['--help'])
|
|
22
|
+
assert.equal(r.status, 0)
|
|
23
|
+
assert.match(r.stdout, /Usage: sorb-seed/)
|
|
24
|
+
assert.match(r.stdout, /\bresolve\b/)
|
|
25
|
+
assert.match(r.stdout, /\bcapture\b/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('-h is an alias for --help', () => {
|
|
29
|
+
const r = run(['-h'])
|
|
30
|
+
assert.equal(r.status, 0)
|
|
31
|
+
assert.match(r.stdout, /Usage: sorb-seed/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('--version prints the package.json version, exits 0', () => {
|
|
35
|
+
const r = run(['--version'])
|
|
36
|
+
assert.equal(r.status, 0)
|
|
37
|
+
assert.equal(r.stdout.trim(), pkgVersion)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('-v is an alias for --version', () => {
|
|
41
|
+
const r = run(['-v'])
|
|
42
|
+
assert.equal(r.status, 0)
|
|
43
|
+
assert.equal(r.stdout.trim(), pkgVersion)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('unknown command exits 1 and points at --help', () => {
|
|
47
|
+
const r = run(['bogus'])
|
|
48
|
+
assert.equal(r.status, 1)
|
|
49
|
+
assert.match(r.stderr, /Unknown command: bogus/)
|
|
50
|
+
assert.match(r.stderr, /--help/)
|
|
51
|
+
})
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Sorb custom Style Dictionary outputs.
|
|
2
|
+
//
|
|
3
|
+
// The key piece of the token pipeline: a "resolved bindable map" that Sorb's
|
|
4
|
+
// bridge serves, capture annotates against, and the plugin syncs to Figma. One
|
|
5
|
+
// build, one map — this is what retires the runtime esbuild-eval resolver and
|
|
6
|
+
// fixes the "two token lists" problem.
|
|
7
|
+
//
|
|
8
|
+
// Promoted here from `sorb-demo/sd/sorb-format.js` (component-compat-roadmap
|
|
9
|
+
// P0, part 2) — these formats used to live copy-locally in the demo app.
|
|
10
|
+
// `@sorb/seed` is the shared home so target adapters (Tailwind/shadcn/
|
|
11
|
+
// Mantine/… per the roadmap) IMPORT these formats instead of copy-pasting
|
|
12
|
+
// them again. Pure functions of Style Dictionary's `{ dictionary, options }`
|
|
13
|
+
// shape — no dependency on `style-dictionary` itself, so this file has zero
|
|
14
|
+
// new deps for `@sorb/seed`. `sorb-demo/sd/sorb-format.js` now re-exports
|
|
15
|
+
// from here for back-compat.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive the token tier from the source file a token came from.
|
|
19
|
+
* @param {string} filePath
|
|
20
|
+
* @returns {'primitive'|'semantic'|'component'|'unknown'}
|
|
21
|
+
*/
|
|
22
|
+
export const tierOfFile = (filePath = '') =>
|
|
23
|
+
/primitive\./.test(filePath) ? 'primitive'
|
|
24
|
+
: /semantic\./.test(filePath) ? 'semantic'
|
|
25
|
+
: /component\./.test(filePath) ? 'component'
|
|
26
|
+
// bs.json — the bootstrap-styled brand overlay. Classified as `semantic` (a
|
|
27
|
+
// core-valid tier) so its `--bs-*` roles group + bind alongside the other
|
|
28
|
+
// semantic roles rather than falling through to an unranked `unknown` tier.
|
|
29
|
+
: /bs\./.test(filePath) ? 'semantic'
|
|
30
|
+
: 'unknown'
|
|
31
|
+
|
|
32
|
+
export const SORB_RESOLVED = 'sorb/resolved-map'
|
|
33
|
+
export const SORB_THEME_NESTED = 'sorb/theme-nested'
|
|
34
|
+
export const SORB_ALIASES = 'sorb/aliases-css'
|
|
35
|
+
export const SORB_VERSIONS = 'sorb/versions'
|
|
36
|
+
export const SORB_SET_META = 'sorb/set-meta'
|
|
37
|
+
export const SORB_TAILWIND = 'sorb/tailwind-theme'
|
|
38
|
+
export const SORB_TAILWIND_V3 = 'sorb/tailwind-v3-preset'
|
|
39
|
+
export const SORB_TOKENSET = 'sorb/tokenset-esm'
|
|
40
|
+
|
|
41
|
+
// ─── set-level metadata parser ───────────────────────────────────────────────
|
|
42
|
+
// Per-set `$version` lives at each token file's root. SD merges all sources
|
|
43
|
+
// into one tree, so three root `$version` keys collide ("token collision")
|
|
44
|
+
// during merge — and per-set versions wouldn't survive anyway. This parser runs
|
|
45
|
+
// PER FILE before the merge: it lifts `$version` out (stashing it by file) and
|
|
46
|
+
// strips it from the tree, so the merge is clean and versions are preserved for
|
|
47
|
+
// the `sorb/versions` output. (A preprocessor runs post-merge — too late.)
|
|
48
|
+
const _setVersions = {}
|
|
49
|
+
export const sorbSetMeta = {
|
|
50
|
+
name: SORB_SET_META,
|
|
51
|
+
pattern: /\.json$/,
|
|
52
|
+
parser: ({ filePath, contents }) => {
|
|
53
|
+
const obj = JSON.parse(contents)
|
|
54
|
+
if (obj.$version != null) {
|
|
55
|
+
_setVersions[tierOfFile(filePath)] = obj.$version
|
|
56
|
+
delete obj.$version
|
|
57
|
+
}
|
|
58
|
+
return obj
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** format: sorb/versions — { primitive, semantic, component } → version. */
|
|
63
|
+
export const sorbVersions = () =>
|
|
64
|
+
JSON.stringify(_setVersions, null, 2) + '\n'
|
|
65
|
+
|
|
66
|
+
const cssNameOf = (id) => '--' + String(id).split('.').join('-')
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* format: sorb/resolved-map
|
|
70
|
+
* Emits the bindable map: one entry per token. Schema:
|
|
71
|
+
* { id, cssVar, value, tier, type } plus { deprecated, replacedBy } when set.
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* Flat **TokenSet** ESM module for `@sorb/leaf`'s `SorbProvider` — one entry per
|
|
75
|
+
* token, keyed by the CSS-var name WITHOUT the leading `--` (leaf's `applyTokens`
|
|
76
|
+
* re-adds it via `setProperty('--' + key, value)`). This is the committed token
|
|
77
|
+
* set bundled into the app at build time:
|
|
78
|
+
* export const tokens = { 'color-action-primary': '#0f65ef', ... }
|
|
79
|
+
* Same names/values as `variables.css` and `resolved.json` (one source, many
|
|
80
|
+
* surfaces). Consumed by `main.jsx` / `src/sorbConfig.js`.
|
|
81
|
+
*/
|
|
82
|
+
export const sorbTokenSet = ({ dictionary }) => {
|
|
83
|
+
const out = {}
|
|
84
|
+
for (const t of dictionary.allTokens) {
|
|
85
|
+
out[t.path.join('-')] = t.$value ?? t.value
|
|
86
|
+
}
|
|
87
|
+
return 'export const tokens = ' + JSON.stringify(out, null, 2) + '\n'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const sorbResolved = ({ dictionary }) => {
|
|
91
|
+
const deprecated = []
|
|
92
|
+
const out = dictionary.allTokens.map((t) => {
|
|
93
|
+
const entry = {
|
|
94
|
+
id: t.path.join('.'), // color.action.primary
|
|
95
|
+
cssVar: '--' + t.path.join('-'), // --color-action-primary
|
|
96
|
+
value: t.$value ?? t.value, // resolved (refs followed); SD v4 DTCG → $value
|
|
97
|
+
tier: tierOfFile(t.filePath), // primitive | semantic | component
|
|
98
|
+
type: t.$type ?? t.type, // color | dimension | fontWeight | …
|
|
99
|
+
}
|
|
100
|
+
if (t.$deprecated) {
|
|
101
|
+
entry.deprecated = true
|
|
102
|
+
const rb = t.$extensions && t.$extensions.sorb && t.$extensions.sorb.replacedBy
|
|
103
|
+
if (rb) entry.replacedBy = rb
|
|
104
|
+
deprecated.push(entry.id + (rb ? ` → ${rb}` : ''))
|
|
105
|
+
}
|
|
106
|
+
return entry
|
|
107
|
+
})
|
|
108
|
+
if (deprecated.length) {
|
|
109
|
+
console.warn(` ⚠ ${deprecated.length} deprecated token(s): ` + deprecated.join(', '))
|
|
110
|
+
}
|
|
111
|
+
return JSON.stringify(out, null, 2) + '\n'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* format: sorb/aliases-css — legacy back-compat layer (migration window).
|
|
116
|
+
* Reads `options.aliases` ({ legacyName: "new.dtcg.id" }) and emits
|
|
117
|
+
* --legacyName: var(--new-dtcg-id);
|
|
118
|
+
* Each target is validated against the built tokens; unknown targets warn and
|
|
119
|
+
* are skipped. Drop this platform once nothing references the legacy names.
|
|
120
|
+
*/
|
|
121
|
+
export const sorbAliases = ({ dictionary, options }) => {
|
|
122
|
+
const aliases = (options && options.aliases) || {}
|
|
123
|
+
const known = new Set(dictionary.allTokens.map((t) => t.path.join('.')))
|
|
124
|
+
const lines = ['/* AUTO-GENERATED legacy alias layer — @deprecated, remove after migration. */', ':root {']
|
|
125
|
+
const missing = []
|
|
126
|
+
for (const legacy of Object.keys(aliases)) {
|
|
127
|
+
if (legacy.startsWith('$')) continue // skip $comment / metadata keys
|
|
128
|
+
const targetId = aliases[legacy]
|
|
129
|
+
if (!known.has(targetId)) { missing.push(`${legacy} → ${targetId}`); continue }
|
|
130
|
+
lines.push(` --${legacy}: var(${cssNameOf(targetId)}); /* @deprecated → ${targetId} */`)
|
|
131
|
+
}
|
|
132
|
+
lines.push('}', '')
|
|
133
|
+
if (missing.length) {
|
|
134
|
+
console.warn(` ⚠ aliases.json: ${missing.length} unknown target(s) skipped: ` + missing.join(', '))
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* format: sorb/theme-nested
|
|
141
|
+
* Emits a nested object of `var(--kebab, <fallback>)` strings so a
|
|
142
|
+
* styled-components theme can read `theme.color.action.primary`. The fallback
|
|
143
|
+
* is the committed value, so with no preview active rendering is unchanged.
|
|
144
|
+
*/
|
|
145
|
+
export const sorbThemeNested = ({ dictionary }) => {
|
|
146
|
+
const root = {}
|
|
147
|
+
for (const t of dictionary.allTokens) {
|
|
148
|
+
const cssVar = '--' + t.path.join('-')
|
|
149
|
+
let node = root
|
|
150
|
+
for (let i = 0; i < t.path.length - 1; i++) {
|
|
151
|
+
const k = t.path[i]
|
|
152
|
+
node[k] = node[k] || {}
|
|
153
|
+
node = node[k]
|
|
154
|
+
}
|
|
155
|
+
node[t.path[t.path.length - 1]] = `var(${cssVar}, ${t.$value ?? t.value})`
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
'// AUTO-GENERATED by Style Dictionary — do not edit.\n' +
|
|
159
|
+
'export default ' +
|
|
160
|
+
JSON.stringify(root, null, 2) +
|
|
161
|
+
'\n'
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Map one Sorb token to a Tailwind v4 `@theme` entry: { key, ref }.
|
|
167
|
+
*
|
|
168
|
+
* `key` is the theme variable name — its prefix picks the Tailwind utility
|
|
169
|
+
* family (`--color-*`→bg/text/border, `--radius-*`→rounded, `--spacing-*`→
|
|
170
|
+
* p/m/gap/w/h, `--text-*`→font-size, `--font-weight-*`→font weight). `ref` is
|
|
171
|
+
* always `var(<the token's own --css-var>)` so the value stays a *reference* to
|
|
172
|
+
* the same runtime-swappable var the bridge overrides — never a baked literal.
|
|
173
|
+
* Because Sorb already names color vars `--color-*` (colliding with Tailwind's
|
|
174
|
+
* own namespace), the format emits `@theme inline`, where Tailwind uses the ref
|
|
175
|
+
* expression directly in utilities instead of redefining the key in `:root`.
|
|
176
|
+
* @param {{path: string[], $type?: string, type?: string}} t
|
|
177
|
+
* @returns {{key: string, ref: string}}
|
|
178
|
+
*/
|
|
179
|
+
export const tailwindThemeEntry = (t) => {
|
|
180
|
+
const slug = t.path.join('-') // color-action-primary | button-radius
|
|
181
|
+
const ref = `var(--${slug})` // points back at the Sorb css var
|
|
182
|
+
const type = t.$type ?? t.type
|
|
183
|
+
const strip = (re) => slug.replace(re, '').replace(/^-+|-+$/g, '')
|
|
184
|
+
|
|
185
|
+
if (type === 'color') {
|
|
186
|
+
// semantic/primitive already start with `color-`; component colors
|
|
187
|
+
// (button-primary-bg-default) keep their full path under the color family.
|
|
188
|
+
const name = slug.startsWith('color-') ? slug.slice('color-'.length) : slug
|
|
189
|
+
return { key: `--color-${name}`, ref }
|
|
190
|
+
}
|
|
191
|
+
if (type === 'fontWeight') {
|
|
192
|
+
return { key: `--font-weight-${strip(/font-weight-?/)}`, ref }
|
|
193
|
+
}
|
|
194
|
+
if (type === 'dimension') {
|
|
195
|
+
if (t.path.includes('radius')) {
|
|
196
|
+
// radius-100 → 100 ; button-radius → button (→ rounded-100 / rounded-button)
|
|
197
|
+
return { key: `--radius-${strip(/-?radius-?/) || 'DEFAULT'}`, ref }
|
|
198
|
+
}
|
|
199
|
+
if (t.path.includes('space') || t.path.includes('spacing')) {
|
|
200
|
+
return { key: `--spacing-${strip(/space-?/)}`, ref }
|
|
201
|
+
}
|
|
202
|
+
if (t.path.includes('size')) {
|
|
203
|
+
return { key: `--text-${strip(/font-size-?|size-?/)}`, ref }
|
|
204
|
+
}
|
|
205
|
+
return { key: `--spacing-${slug}`, ref } // unknown dimension → spacing family
|
|
206
|
+
}
|
|
207
|
+
return { key: `--sorb-${slug}`, ref } // anything else: registered, no utility family
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* format: sorb/tailwind-theme
|
|
212
|
+
* Emits a Tailwind v4 `@theme inline { … }` block — one entry per resolved
|
|
213
|
+
* token, each value a `var(--token)` reference. Pair it with `variables.css`
|
|
214
|
+
* (which defines those vars) so Tailwind utilities resolve through the exact
|
|
215
|
+
* CSS vars the bridge swaps at runtime → live preview works with zero
|
|
216
|
+
* Tailwind-specific bridge code. Duplicate theme keys are skipped (warned).
|
|
217
|
+
*/
|
|
218
|
+
export const sorbTailwind = ({ dictionary }) => {
|
|
219
|
+
const seen = new Map() // key → originating token id (collision guard)
|
|
220
|
+
const dupes = []
|
|
221
|
+
const lines = []
|
|
222
|
+
for (const t of dictionary.allTokens) {
|
|
223
|
+
const { key, ref } = tailwindThemeEntry(t)
|
|
224
|
+
const id = t.path.join('.')
|
|
225
|
+
if (seen.has(key)) { dupes.push(`${key} (${seen.get(key)} vs ${id})`); continue }
|
|
226
|
+
seen.set(key, id)
|
|
227
|
+
lines.push(` ${key}: ${ref};`)
|
|
228
|
+
}
|
|
229
|
+
if (dupes.length) {
|
|
230
|
+
console.warn(` ⚠ tailwind: ${dupes.length} duplicate theme key(s) skipped: ` + dupes.join(', '))
|
|
231
|
+
}
|
|
232
|
+
return (
|
|
233
|
+
'/* AUTO-GENERATED by Style Dictionary (sorb/tailwind-theme) — do not edit.\n' +
|
|
234
|
+
' Tailwind v4 theme mapped onto Sorb\'s runtime CSS vars. Import order in your\n' +
|
|
235
|
+
' entry CSS:\n' +
|
|
236
|
+
' @import "tailwindcss";\n' +
|
|
237
|
+
' @import "./variables.css"; sorb tokens — the bridge swaps these live\n' +
|
|
238
|
+
' @import "./tailwind-theme.css"; this file\n' +
|
|
239
|
+
' `@theme inline` makes utilities reference var(--token) directly, so a\n' +
|
|
240
|
+
' POST /preview recolors Tailwind-classed elements with no extra code. */\n' +
|
|
241
|
+
'@theme inline {\n' +
|
|
242
|
+
lines.join('\n') +
|
|
243
|
+
'\n}\n'
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Classify one Sorb token into a Tailwind v3 `theme.extend` slot.
|
|
249
|
+
* Returns the category (colors|borderRadius|spacing|fontSize|fontWeight), the
|
|
250
|
+
* nested key path within it (Tailwind v3 flattens nested keys with `-`, so
|
|
251
|
+
* `colors.action.primary` → utility `bg-action-primary`), and the var() ref.
|
|
252
|
+
* Returns null for token types with no v3 utility family.
|
|
253
|
+
* @param {{path: string[], $type?: string, type?: string}} t
|
|
254
|
+
* @returns {{category: string, keyPath: string[], ref: string}|null}
|
|
255
|
+
*/
|
|
256
|
+
export const tailwindV3Slot = (t) => {
|
|
257
|
+
const ref = `var(--${t.path.join('-')})`
|
|
258
|
+
const type = t.$type ?? t.type
|
|
259
|
+
const without = (seg) => t.path.filter((p) => p !== seg)
|
|
260
|
+
|
|
261
|
+
if (type === 'color') {
|
|
262
|
+
// strip a leading `color` segment (semantic/primitive); component colors keep full path
|
|
263
|
+
const keyPath = t.path[0] === 'color' ? t.path.slice(1) : t.path.slice()
|
|
264
|
+
return { category: 'colors', keyPath, ref }
|
|
265
|
+
}
|
|
266
|
+
if (type === 'fontWeight') {
|
|
267
|
+
return { category: 'fontWeight', keyPath: t.path.filter((p) => p !== 'font' && p !== 'weight'), ref }
|
|
268
|
+
}
|
|
269
|
+
if (type === 'dimension') {
|
|
270
|
+
if (t.path.includes('radius')) return { category: 'borderRadius', keyPath: without('radius'), ref }
|
|
271
|
+
if (t.path.includes('space') || t.path.includes('spacing'))
|
|
272
|
+
return { category: 'spacing', keyPath: t.path.filter((p) => p !== 'space' && p !== 'spacing'), ref }
|
|
273
|
+
if (t.path.includes('size'))
|
|
274
|
+
return { category: 'fontSize', keyPath: t.path.filter((p) => p !== 'font' && p !== 'size'), ref }
|
|
275
|
+
return { category: 'spacing', keyPath: t.path.slice(), ref } // unknown dimension → spacing
|
|
276
|
+
}
|
|
277
|
+
return null // unknown type → no v3 utility family
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* format: sorb/tailwind-v3-preset
|
|
282
|
+
* Emits a Tailwind v3 **preset** (CommonJS) — `theme.extend.{colors,borderRadius,
|
|
283
|
+
* spacing,fontSize,fontWeight}` whose leaves are `var(--token)` strings grouped by
|
|
284
|
+
* tier/role. Consumer: `presets: [require('./tailwind-sorb-preset.cjs')]`. Same
|
|
285
|
+
* live-preview behavior as v4 — utilities reference the runtime-swappable Sorb vars.
|
|
286
|
+
*/
|
|
287
|
+
export const sorbTailwindV3 = ({ dictionary }) => {
|
|
288
|
+
const theme = { colors: {}, borderRadius: {}, spacing: {}, fontSize: {}, fontWeight: {} }
|
|
289
|
+
const skipped = []
|
|
290
|
+
for (const t of dictionary.allTokens) {
|
|
291
|
+
const slot = tailwindV3Slot(t)
|
|
292
|
+
if (!slot) { skipped.push(t.path.join('.')); continue }
|
|
293
|
+
let node = theme[slot.category]
|
|
294
|
+
for (let i = 0; i < slot.keyPath.length - 1; i++) {
|
|
295
|
+
const k = slot.keyPath[i]
|
|
296
|
+
node[k] = node[k] || {}
|
|
297
|
+
node = node[k]
|
|
298
|
+
}
|
|
299
|
+
node[slot.keyPath[slot.keyPath.length - 1]] = slot.ref
|
|
300
|
+
}
|
|
301
|
+
if (skipped.length) {
|
|
302
|
+
console.warn(` ⚠ tailwind-v3: ${skipped.length} token(s) with no v3 family skipped: ` + skipped.join(', '))
|
|
303
|
+
}
|
|
304
|
+
return (
|
|
305
|
+
'// AUTO-GENERATED by Style Dictionary (sorb/tailwind-v3-preset) — do not edit.\n' +
|
|
306
|
+
'// Tailwind v3 preset of var(--token) refs from Sorb\'s resolved map. Usage:\n' +
|
|
307
|
+
'// presets: [require(\'./tailwind-sorb-preset.cjs\')]\n' +
|
|
308
|
+
'// Import variables.css globally so the vars resolve; the bridge swaps them live.\n' +
|
|
309
|
+
'module.exports = ' +
|
|
310
|
+
JSON.stringify({ theme: { extend: theme } }, null, 2) +
|
|
311
|
+
'\n'
|
|
312
|
+
)
|
|
313
|
+
}
|