@sorb/seed 0.2.0 → 0.4.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.
Files changed (39) hide show
  1. package/package.json +7 -3
  2. package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
  3. package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
  4. package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
  5. package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
  6. package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
  7. package/src/adapt/adaptCli.js +110 -0
  8. package/src/adapt/adaptCli.test.js +109 -0
  9. package/src/adapt/benchmark.js +113 -0
  10. package/src/adapt/benchmark.test.js +79 -0
  11. package/src/adapt/codemod.js +160 -0
  12. package/src/adapt/codemod.test.js +120 -0
  13. package/src/adapt/detectHardcoded.js +165 -0
  14. package/src/adapt/detectHardcoded.test.js +62 -0
  15. package/src/adapt/glob.js +87 -0
  16. package/src/adapt/mapToToken.js +94 -0
  17. package/src/adapt/mapToToken.test.js +103 -0
  18. package/src/adapt/report.js +53 -0
  19. package/src/adapt/report.test.js +66 -0
  20. package/src/adapt/runBenchmark.js +16 -0
  21. package/src/adapt/types.js +48 -0
  22. package/src/captureCli.js +38 -139
  23. package/src/cli.js +16 -1
  24. package/src/emit/sorbFormat.js +313 -0
  25. package/src/emit/sorbFormat.test.js +133 -0
  26. package/src/emit/sorbMantine.js +116 -0
  27. package/src/emit/sorbMantine.test.js +68 -0
  28. package/src/emit/sorbMatSys.js +159 -0
  29. package/src/emit/sorbMatSys.test.js +75 -0
  30. package/src/emit/sorbMui.js +165 -0
  31. package/src/emit/sorbMui.test.js +102 -0
  32. package/src/emit/sorbPrimevue.js +240 -0
  33. package/src/emit/sorbPrimevue.test.js +130 -0
  34. package/src/emit/sorbShadcn.js +186 -0
  35. package/src/emit/sorbShadcn.test.js +102 -0
  36. package/src/index.js +48 -0
  37. package/src/sources/figmaPlugin.js +83 -0
  38. package/src/sources/figmaPlugin.test.js +65 -0
  39. package/src/sources/storybookDom.js +188 -0
@@ -0,0 +1,94 @@
1
+ // P1 — mapToToken: map a detected site to the nearest resolved token, reusing
2
+ // the EXACT capture matcher (matchColor/matchDimension from annotateTokens.js),
3
+ // and attach a confidence score.
4
+ //
5
+ // Confidence model (spec §6-P1, open-question "Confidence score model"):
6
+ // - exact value match + exactly ONE *on-role* candidate + on-role (offRole=false)
7
+ // ⇒ high confidence (1.0) ⇒ status 'auto'
8
+ // - a token bound, but EITHER offRole=true OR more than one on-role candidate
9
+ // ⇒ medium confidence (0.6) ⇒ status 'review' (ambiguous → human gate)
10
+ // - no token matched
11
+ // ⇒ confidence 0 ⇒ status 'unmapped'
12
+ //
13
+ // "One candidate" = one candidate AFTER property-affinity role filtering (the
14
+ // disambiguated pool the matcher actually picks from), NOT the raw value-
15
+ // collision set. A single color (#0f65ef) collides with bg/border/text tokens,
16
+ // but role 'bg' narrows that to exactly one bg token — an unambiguous bind, so
17
+ // it earns 'auto'. The full collision set is still reported as `candidates`.
18
+ //
19
+ // AUTO_THRESHOLD is the single explicit cut between 'auto' and 'review'. A
20
+ // mapping with confidence >= AUTO_THRESHOLD is auto-applied; below it routes to
21
+ // review. 0.9 sits above the medium score (0.6) and at-or-below high (1.0), so
22
+ // only unambiguous exact one-candidate on-role picks clear it.
23
+ export const AUTO_THRESHOLD = 0.9
24
+
25
+ const HIGH = 1.0 // exact + single candidate + on-role
26
+ const MEDIUM = 0.6 // bound but ambiguous (off-role OR multiple candidates)
27
+
28
+ import { matchColor, matchDimension, normalizeColor } from '../annotateTokens.js'
29
+
30
+ /**
31
+ * Choose the matcher by the site's value type: a value that normalizes to a
32
+ * color goes through matchColor; otherwise (a dimension) through matchDimension.
33
+ * Role is passed straight through (annotateTokens applies property affinity).
34
+ * @param {import('./types.js').AdaptSite} site
35
+ * @param {{colors:Map, dims:Map}} index
36
+ */
37
+ const matchSite = (site, index) => {
38
+ // Prefer color when the raw value is a color; the detector only ever flags a
39
+ // raw that is one or the other, but a value like '4' is unambiguously a dim.
40
+ if (normalizeColor(site.raw) != null) return matchColor(index, site.raw, site.role)
41
+ return matchDimension(index, site.raw, site.role)
42
+ }
43
+
44
+ /**
45
+ * Map one detected site → its nearest resolved token + confidence.
46
+ * @param {import('./types.js').AdaptSite} site
47
+ * @param {{colors:Map, dims:Map}} index from buildTokenIndex(resolved)
48
+ * @param {import('@sorb/core').ResolvedToken[]} [resolved] optional, to resolve cssVar
49
+ * @returns {import('./types.js').AdaptMapping}
50
+ */
51
+ export function mapToToken(site, index, resolved) {
52
+ const res = matchSite(site, index)
53
+ if (!res.token) {
54
+ return { tokenId: null, cssVar: null, confidence: 0, candidates: res.candidates || [], offRole: false }
55
+ }
56
+ const offRole = !!res.offRole
57
+ // Count candidates AFTER role filtering — the pool the matcher disambiguated
58
+ // to. When offRole (role missed entirely) we use the full set, which is
59
+ // already ambiguous by definition.
60
+ const onRole =
61
+ site.role && !offRole
62
+ ? res.candidates.filter((id) => id.includes('.' + site.role))
63
+ : res.candidates
64
+ const ambiguous = offRole || onRole.length > 1
65
+ const confidence = ambiguous ? MEDIUM : HIGH
66
+ const cssVar = resolveCssVar(res.token, resolved)
67
+ return { tokenId: res.token, cssVar, confidence, candidates: res.candidates, offRole }
68
+ }
69
+
70
+ /**
71
+ * Map a confidence score to a report status using the single AUTO_THRESHOLD cut.
72
+ * @param {import('./types.js').AdaptMapping} mapping
73
+ * @returns {'auto'|'review'|'unmapped'}
74
+ */
75
+ export function statusFor(mapping) {
76
+ if (!mapping.tokenId) return 'unmapped'
77
+ return mapping.confidence >= AUTO_THRESHOLD ? 'auto' : 'review'
78
+ }
79
+
80
+ /**
81
+ * Look up a token's cssVar from the resolved map (so the report carries the
82
+ * `--var` for the codemod/shim). Falls back to deriving it from the id when the
83
+ * resolved map isn't supplied.
84
+ * @param {string} tokenId
85
+ * @param {import('@sorb/core').ResolvedToken[]} [resolved]
86
+ */
87
+ export function resolveCssVar(tokenId, resolved) {
88
+ if (resolved) {
89
+ const t = resolved.find((r) => r.id === tokenId)
90
+ if (t && t.cssVar) return t.cssVar
91
+ }
92
+ // Derive `--a-b-c` from `a.b.c` as a last resort (matches the SD convention).
93
+ return '--' + String(tokenId).replace(/\./g, '-')
94
+ }
@@ -0,0 +1,103 @@
1
+ // P1 acceptance: against sorb-demo's real resolved.json, mapToToken binds the
2
+ // legacy Button fixture's hardcoded values to the SAME tokens annotateTokens.js
3
+ // would, with the documented auto/review split. Run: node --test src/
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'fs'
7
+ import { fileURLToPath } from 'url'
8
+ import { dirname, join } from 'path'
9
+ import { buildTokenIndex, matchColor, matchDimension } from '../annotateTokens.js'
10
+ import { detectHardcoded } from './detectHardcoded.js'
11
+ import { mapToToken, statusFor, AUTO_THRESHOLD, resolveCssVar } from './mapToToken.js'
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const RESOLVED = JSON.parse(
15
+ readFileSync(
16
+ join(here, '..', '..', '..', 'sorb-demo', '.sorb', 'resolved.json'),
17
+ 'utf-8',
18
+ ),
19
+ )
20
+ const index = buildTokenIndex(RESOLVED)
21
+
22
+ const site = (raw, role, prop = 'x') => ({ file: 'f', loc: { line: 1, column: 0 }, prop, raw, role })
23
+
24
+ test('P1: #0f65ef as bg → button.primary.bg.default, auto (exact, on-role, single bg)', () => {
25
+ const m = mapToToken(site('#0f65ef', 'bg'), index, RESOLVED)
26
+ assert.equal(m.tokenId, 'button.primary.bg.default')
27
+ assert.equal(m.cssVar, '--button-primary-bg-default')
28
+ assert.equal(m.confidence, 1.0)
29
+ assert.equal(m.offRole, false)
30
+ assert.equal(statusFor(m), 'auto')
31
+ })
32
+
33
+ test('P1: 4 and 4px as radius → button.radius, auto', () => {
34
+ for (const raw of ['4', '4px']) {
35
+ const m = mapToToken(site(raw, 'radius'), index, RESOLVED)
36
+ assert.equal(m.tokenId, 'button.radius', `raw=${raw}`)
37
+ assert.equal(m.cssVar, '--button-radius')
38
+ assert.equal(statusFor(m), 'auto')
39
+ }
40
+ })
41
+
42
+ test('P1: white as text → a *.text.*default* token, in review (ambiguous: many text candidates)', () => {
43
+ const m = mapToToken(site('#ffffff', 'text'), index, RESOLVED)
44
+ assert.ok(/\.text\..*default$/.test(m.tokenId), `got ${m.tokenId}`)
45
+ assert.equal(m.tokenId, 'button.primary.text.default')
46
+ assert.ok(m.candidates.length > 1)
47
+ assert.equal(m.confidence, 0.6)
48
+ assert.equal(statusFor(m), 'review')
49
+ })
50
+
51
+ test('P1: binding is byte-identical to annotateTokens matchColor/matchDimension', () => {
52
+ // Same index, same matcher → same token. This is the no-drift guarantee.
53
+ assert.equal(
54
+ mapToToken(site('#0f65ef', 'bg'), index, RESOLVED).tokenId,
55
+ matchColor(index, '#0f65ef', 'bg').token,
56
+ )
57
+ assert.equal(
58
+ mapToToken(site('#0f65ef', 'border'), index, RESOLVED).tokenId,
59
+ matchColor(index, '#0f65ef', 'border').token,
60
+ )
61
+ assert.equal(
62
+ mapToToken(site('4px', 'radius'), index, RESOLVED).tokenId,
63
+ matchDimension(index, '4px', 'radius').token,
64
+ )
65
+ })
66
+
67
+ test('P1: a value with no token → unmapped, confidence 0', () => {
68
+ const m = mapToToken(site('#123456', 'bg'), index, RESOLVED)
69
+ assert.equal(m.tokenId, null)
70
+ assert.equal(m.confidence, 0)
71
+ assert.equal(statusFor(m), 'unmapped')
72
+ })
73
+
74
+ test('P1: an off-role bind is medium confidence → review', () => {
75
+ // #0f65ef has no `.text` token in sorb-demo? It does NOT — narrow to a value
76
+ // that only carries one role and force the wrong role. white only carries
77
+ // bg/text tokens; asking for 'border' falls back off-role.
78
+ const m = mapToToken(site('#ffffff', 'border'), index, RESOLVED)
79
+ assert.equal(m.offRole, true)
80
+ assert.equal(statusFor(m), 'review')
81
+ })
82
+
83
+ test('P1: AUTO_THRESHOLD sits between medium (0.6) and high (1.0)', () => {
84
+ assert.ok(0.6 < AUTO_THRESHOLD && AUTO_THRESHOLD <= 1.0)
85
+ })
86
+
87
+ test('P1: resolveCssVar prefers the resolved map, falls back to id derivation', () => {
88
+ assert.equal(resolveCssVar('button.radius', RESOLVED), '--button-radius')
89
+ assert.equal(resolveCssVar('a.b.c', undefined), '--a-b-c')
90
+ })
91
+
92
+ test('P1: end-to-end over the legacy fixture maps the expected auto set', () => {
93
+ const src = readFileSync(join(here, '__fixtures__', 'Button.legacy.jsx'), 'utf-8')
94
+ const sites = detectHardcoded(src, 'Button.legacy.jsx')
95
+ const mapped = sites.map((s) => ({ s, m: mapToToken(s, index, RESOLVED) }))
96
+ // bg #0F65EF and radius 4/4px must be auto.
97
+ const bg = mapped.find((x) => x.s.role === 'bg')
98
+ assert.equal(bg.m.tokenId, 'button.primary.bg.default')
99
+ assert.equal(statusFor(bg.m), 'auto')
100
+ const rad = mapped.find((x) => x.s.role === 'radius')
101
+ assert.equal(rad.m.tokenId, 'button.radius')
102
+ assert.equal(statusFor(rad.m), 'auto')
103
+ })
@@ -0,0 +1,53 @@
1
+ // P1 — report emitter: detect → map → score → AdaptRow[], and write it to
2
+ // `.sorb/adapt-report.json` (gitignored generated output).
3
+ import { mkdirSync, writeFileSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { mapToToken, statusFor } from './mapToToken.js'
6
+
7
+ /**
8
+ * Score a list of detected sites into report rows.
9
+ * @param {import('./types.js').AdaptSite[]} sites
10
+ * @param {{colors:Map, dims:Map}} index
11
+ * @param {import('@sorb/core').ResolvedToken[]} [resolved] to fill cssVar
12
+ * @returns {import('./types.js').AdaptRow[]}
13
+ */
14
+ export function buildReport(sites, index, resolved) {
15
+ return sites.map((site) => {
16
+ const m = mapToToken(site, index, resolved)
17
+ return {
18
+ file: site.file,
19
+ loc: site.loc,
20
+ prop: site.prop,
21
+ raw: site.raw,
22
+ tokenId: m.tokenId,
23
+ cssVar: m.cssVar,
24
+ confidence: m.confidence,
25
+ candidates: m.candidates,
26
+ status: statusFor(m),
27
+ }
28
+ })
29
+ }
30
+
31
+ /**
32
+ * Summary counts by status.
33
+ * @param {import('./types.js').AdaptRow[]} rows
34
+ */
35
+ export function summarize(rows) {
36
+ const out = { total: rows.length, auto: 0, review: 0, unmapped: 0 }
37
+ for (const r of rows) out[r.status]++
38
+ return out
39
+ }
40
+
41
+ /**
42
+ * Write the report to `<cwd>/.sorb/adapt-report.json`.
43
+ * @param {import('./types.js').AdaptRow[]} rows
44
+ * @param {string} cwd
45
+ * @returns {string} the absolute path written
46
+ */
47
+ export function writeReport(rows, cwd) {
48
+ const dir = join(cwd, '.sorb')
49
+ mkdirSync(dir, { recursive: true })
50
+ const path = join(dir, 'adapt-report.json')
51
+ writeFileSync(path, JSON.stringify(rows, null, 2) + '\n', 'utf-8')
52
+ return path
53
+ }
@@ -0,0 +1,66 @@
1
+ // P1 acceptance: buildReport produces scored AdaptRow[] with the auto/review/
2
+ // unmapped split, and writeReport writes JSON to .sorb/. Run: node --test src/
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync, existsSync, rmSync, mkdtempSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { fileURLToPath } from 'url'
8
+ import { dirname, join } from 'path'
9
+ import { buildTokenIndex } from '../annotateTokens.js'
10
+ import { detectHardcoded } from './detectHardcoded.js'
11
+ import { buildReport, summarize, writeReport } from './report.js'
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const RESOLVED = JSON.parse(
15
+ readFileSync(join(here, '..', '..', '..', 'sorb-demo', '.sorb', 'resolved.json'), 'utf-8'),
16
+ )
17
+ const index = buildTokenIndex(RESOLVED)
18
+ const fixtureSrc = readFileSync(join(here, '__fixtures__', 'Button.legacy.jsx'), 'utf-8')
19
+
20
+ test('buildReport emits one row per detected site with the full schema', () => {
21
+ const sites = detectHardcoded(fixtureSrc, 'Button.legacy.jsx')
22
+ const rows = buildReport(sites, index, RESOLVED)
23
+ assert.equal(rows.length, sites.length)
24
+ for (const r of rows) {
25
+ for (const k of ['file', 'loc', 'prop', 'raw', 'tokenId', 'cssVar', 'confidence', 'candidates', 'status']) {
26
+ assert.ok(Object.prototype.hasOwnProperty.call(r, k), `missing ${k}`)
27
+ }
28
+ assert.ok(['auto', 'review', 'unmapped'].includes(r.status))
29
+ }
30
+ })
31
+
32
+ test('buildReport splits the legacy fixture into auto/review correctly', () => {
33
+ const sites = detectHardcoded(fixtureSrc, 'Button.legacy.jsx')
34
+ const rows = buildReport(sites, index, RESOLVED)
35
+ const bg = rows.find((r) => r.prop === 'backgroundColor')
36
+ assert.equal(bg.tokenId, 'button.primary.bg.default')
37
+ assert.equal(bg.status, 'auto')
38
+ const rad = rows.find((r) => r.prop === 'borderRadius')
39
+ assert.equal(rad.tokenId, 'button.radius')
40
+ assert.equal(rad.status, 'auto')
41
+ // white text is ambiguous → review
42
+ const text = rows.find((r) => r.prop === 'color')
43
+ assert.equal(text.status, 'review')
44
+ })
45
+
46
+ test('summarize counts statuses and totals', () => {
47
+ const rows = buildReport(detectHardcoded(fixtureSrc, 'f'), index, RESOLVED)
48
+ const s = summarize(rows)
49
+ assert.equal(s.total, rows.length)
50
+ assert.equal(s.auto + s.review + s.unmapped, rows.length)
51
+ assert.ok(s.auto >= 1)
52
+ })
53
+
54
+ test('writeReport writes .sorb/adapt-report.json with valid JSON', () => {
55
+ const cwd = mkdtempSync(join(tmpdir(), 'sorb-adapt-'))
56
+ try {
57
+ const rows = buildReport(detectHardcoded(fixtureSrc, 'f'), index, RESOLVED)
58
+ const path = writeReport(rows, cwd)
59
+ assert.ok(existsSync(path))
60
+ assert.equal(path, join(cwd, '.sorb', 'adapt-report.json'))
61
+ const back = JSON.parse(readFileSync(path, 'utf-8'))
62
+ assert.deepEqual(back, rows)
63
+ } finally {
64
+ rmSync(cwd, { recursive: true, force: true })
65
+ }
66
+ })
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Run the legacy-adapter benchmark over the labeled fixture corpus against
3
+ // sorb-demo's resolved map. Prints the MEASURED precision/recall/coverage —
4
+ // the number behind the "~99%" claim. `pnpm bench`.
5
+ import { fileURLToPath } from 'url'
6
+ import { dirname, join, resolve } from 'path'
7
+ import { runBenchmark } from './benchmark.js'
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url))
10
+ const corpus = join(here, '__fixtures__', 'corpus')
11
+ // Default to sorb-demo's resolved map (sibling repo); override with argv[2].
12
+ const resolvedPath =
13
+ process.argv[2] ||
14
+ resolve(here, '..', '..', '..', 'sorb-demo', '.sorb', 'resolved.json')
15
+
16
+ runBenchmark(corpus, resolvedPath)
@@ -0,0 +1,48 @@
1
+ // JSDoc typedefs for the Legacy-React adapter (roadmap §6).
2
+ //
3
+ // JavaScript only — these are documentation/IDE shapes, not emitted types.
4
+ // Shared token shapes (`ResolvedToken`, `Tier`) come from @sorb/core; the
5
+ // adapter never re-declares them.
6
+
7
+ /**
8
+ * A CSS property "role" the matcher understands. Drives property affinity in
9
+ * `matchColor`/`matchDimension` (annotateTokens.js). `null` ⇒ tier-only match.
10
+ * @typedef {'bg'|'text'|'border'|'radius'|null} AdaptRole
11
+ */
12
+
13
+ /**
14
+ * A single detected hardcoded style site in consumer source.
15
+ * @typedef {Object} AdaptSite
16
+ * @property {string} file Source file path (as passed to detect).
17
+ * @property {{line:number, column:number}} loc 1-based line, 0-based column (Babel loc.start).
18
+ * @property {string} prop The CSS/JSX property name as written (e.g. 'backgroundColor', 'border-radius').
19
+ * @property {string} raw The raw literal value as written (e.g. '#0F65EF', '4px', '4').
20
+ * @property {AdaptRole} role Property→role mapping (bg/text/border/radius) or null.
21
+ */
22
+
23
+ /**
24
+ * The result of mapping one site to the nearest resolved token.
25
+ * @typedef {Object} AdaptMapping
26
+ * @property {string|null} tokenId Resolved token id, or null when unmapped.
27
+ * @property {string|null} cssVar The token's `--css-var`, or null.
28
+ * @property {number} confidence 0..1 confidence score (see mapToToken).
29
+ * @property {string[]} candidates All token ids that matched the value.
30
+ * @property {boolean} offRole True when the pick fell back off-role (low confidence).
31
+ */
32
+
33
+ /**
34
+ * A fully scored report row (detect → map → score). Emitted to
35
+ * `.sorb/adapt-report.json`.
36
+ * @typedef {Object} AdaptRow
37
+ * @property {string} file
38
+ * @property {{line:number, column:number}} loc
39
+ * @property {string} prop
40
+ * @property {string} raw
41
+ * @property {string|null} tokenId
42
+ * @property {string|null} cssVar
43
+ * @property {number} confidence
44
+ * @property {string[]} candidates
45
+ * @property {'auto'|'review'|'unmapped'} status
46
+ */
47
+
48
+ export {}
package/src/captureCli.js CHANGED
@@ -5,48 +5,11 @@
5
5
 
6
6
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
7
7
  import { createHash } from 'crypto'
8
- import { dirname, resolve, basename, extname } from 'path'
9
- import { build } from 'esbuild'
8
+ import { resolve, dirname, basename, extname } from 'path'
9
+ import { getSource, resolveConnectorIds } from '@sorb/core'
10
10
  import { tightenRoot } from './capture.js'
11
11
  import { buildTokenIndex, annotateTree } from './annotateTokens.js'
12
-
13
- // Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
14
- // ~150 MB browser. Lazy-load it so plain installs and `resolve` stay lean.
15
- const loadChromium = async () => {
16
- try {
17
- const { chromium } = await import('playwright')
18
- return chromium
19
- } catch {
20
- console.error(
21
- '✗ `sorb-seed capture` needs Playwright (it is an optional peer dep).\n' +
22
- ' Install it where you run capture:\n' +
23
- ' npm install playwright # its postinstall fetches Chromium\n' +
24
- ' (or: npm install playwright && npx playwright install chromium)',
25
- )
26
- process.exit(1)
27
- }
28
- }
29
-
30
- // The `playwright` PACKAGE can be installed while its Chromium BROWSER binary is
31
- // not (that's a separate `npx playwright install chromium` step). Launching then
32
- // throws a raw "Executable doesn't exist" error — turn it into the same
33
- // actionable guidance the missing-package path already gives.
34
- export const launchChromium = async (chromium) => {
35
- try {
36
- return await chromium.launch()
37
- } catch (e) {
38
- const msg = e && e.message ? e.message : String(e)
39
- if (/Executable doesn't exist|playwright install|browserType\.launch/i.test(msg)) {
40
- console.error(
41
- '✗ `sorb-seed capture` found Playwright but its Chromium browser is not installed.\n' +
42
- ' Install the browser where you run capture:\n' +
43
- ' npx playwright install chromium',
44
- )
45
- process.exit(1)
46
- }
47
- throw e
48
- }
49
- }
12
+ import { closeSession, storybookUrlOf } from './sources/storybookDom.js'
50
13
 
51
14
  const cwd = process.cwd()
52
15
 
@@ -71,47 +34,12 @@ const loadResolved = () => {
71
34
 
72
35
  const sha256 = (s) => createHash('sha256').update(s).digest('hex')
73
36
 
74
- // Bundle the walker into a single IIFE string we can addInitScript() into
75
- // every page. Playwright can't pass functions across the boundary directly,
76
- // and our walker has cross-file imports → bundling is the clean answer.
77
- const buildWalkerBundle = async () => {
78
- const here = dirname(new URL(import.meta.url).pathname)
79
- const out = await build({
80
- entryPoints: [resolve(here, 'capture.js')],
81
- bundle: true,
82
- format: 'iife',
83
- platform: 'browser',
84
- write: false,
85
- logLevel: 'silent',
86
- target: 'es2020',
87
- })
88
- return out.outputFiles[0].text
89
- }
90
-
91
- const isStoryEntry = (e) =>
92
- e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
93
-
94
- // Group story entries by their component (importPath) and pick the directory
95
- // of the story file as the artifact output dir (co-located).
96
- const groupByComponent = (entries) => {
97
- const groups = new Map()
98
- for (const e of entries) {
99
- if (!groups.has(e.importPath)) groups.set(e.importPath, [])
100
- groups.get(e.importPath).push(e)
101
- }
102
- return groups
103
- }
104
-
105
37
  // componentName: "Button" from "./src/.../Button.stories.jsx"
106
38
  const componentNameFromImportPath = (importPath) => {
107
39
  const file = basename(importPath, extname(importPath)) // "Button.stories"
108
40
  return file.replace(/\.stories$/i, '')
109
41
  }
110
42
 
111
- // sorb.config.json may set seed.storybookUrl. Fall back to localhost.
112
- const storybookUrlOf = (config) =>
113
- (config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
114
-
115
43
  const filterEntries = (entries, only) => {
116
44
  if (!only) return entries
117
45
  const re = new RegExp(only.replace(/[*]/g, '.*'), 'i')
@@ -122,65 +50,38 @@ export const runCapture = async (opts) => {
122
50
  const config = loadConfig()
123
51
  const resolved = loadResolved()
124
52
  const index = buildTokenIndex(resolved)
125
- const sbUrl = (opts.storybookUrl || storybookUrlOf(config)).replace(/\/$/, '')
126
-
127
- // 1. Discover stories
128
- console.log(`→ Storybook: ${sbUrl}`)
129
- let sbIndex
130
- try {
131
- const res = await fetch(`${sbUrl}/index.json`)
132
- if (!res.ok) throw new Error('HTTP ' + res.status)
133
- sbIndex = await res.json()
134
- } catch (e) {
135
- console.error('✗ Could not fetch Storybook index:', e.message)
136
- process.exit(1)
137
- }
138
- const entries = filterEntries(
139
- Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry),
140
- opts.only,
141
- )
53
+ // `--storybook-url=` is a capture-command flag, not a sorb.config.json key —
54
+ // fold it into an effective config so the connector's own storybookUrlOf()
55
+ // resolution (config.seed.storybookUrl -> localhost default) still applies
56
+ // the same precedence the CLI used to apply inline.
57
+ const effectiveConfig = opts.storybookUrl
58
+ ? { ...config, seed: { ...(config.seed || {}), storybookUrl: opts.storybookUrl } }
59
+ : config
60
+
61
+ const connector = getSource(resolveConnectorIds(config).source)
62
+
63
+ // 1. Discover units (source connector)
64
+ const rawEntries = await connector.listUnits(effectiveConfig)
65
+ const entries = filterEntries(rawEntries, opts.only)
142
66
  if (!entries.length) {
143
67
  console.error('✗ No stories matched filter:', opts.only || '(all)')
144
68
  process.exit(1)
145
69
  }
146
70
  console.log(`→ ${entries.length} stories selected`)
147
71
 
148
- // 2. Browser setup + walker injection
149
- const chromium = await loadChromium()
150
- const walker = await buildWalkerBundle()
151
- const browser = await launchChromium(chromium)
152
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
153
- await ctx.addInitScript({ content: walker })
154
-
155
- // 3. Capture each story, group by component
72
+ // 2. Capture each unit (source connector), group by component
156
73
  const captured = new Map() // importPath -> { component, importPath, stories[] }
157
74
  const oldIndex = readOldIndex(config)
158
75
 
159
- for (const entry of entries) {
160
- const url = `${sbUrl}/iframe.html?id=${entry.id}&viewMode=story`
161
- const page = await ctx.newPage()
76
+ for (const unit of entries) {
77
+ // The whole per-unit body is wrapped so a throw in ANY step (capture,
78
+ // tighten, annotate, hash, store) skips just that unit and continues the
79
+ // run — matching the pre-connector behavior (a single bad story must not
80
+ // abort the entire capture).
162
81
  try {
163
- console.log(` · ${entry.id}`)
164
- await page.goto(url, { waitUntil: 'load' })
165
- // Wait for Storybook to actually render the story.
166
- await page
167
- .waitForFunction(
168
- () => !!document.querySelector('#storybook-root *'),
169
- { timeout: 15000 },
170
- )
171
- .catch(() => {})
172
- await page.evaluate(() => document.fonts && document.fonts.ready)
173
- await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
82
+ const rawTree = await connector.captureGeometry(unit, effectiveConfig)
83
+ if (!rawTree) continue // connector already logged why it skipped
174
84
 
175
- const rawTree = await page.evaluate(() => {
176
- const root = document.querySelector('#storybook-root')
177
- return root ? window.__sorbCapture(root) : null
178
- })
179
- if (!rawTree) {
180
- console.warn(' ⚠ no #storybook-root content; skipped')
181
- await page.close()
182
- continue
183
- }
184
85
  // Trim the story container down to the meaningful component BEFORE
185
86
  // annotation/storage so insert + preview get a tight, token-bound node
186
87
  // (sorb-capture-trim-spec.md). Annotation runs on the kept subtree, so
@@ -190,42 +91,40 @@ export const runCapture = async (opts) => {
190
91
  const hash = 'sha256:' + sha256(JSON.stringify(tree))
191
92
 
192
93
  // --changed: reuse the previous artifact if hash matches
193
- const prevHash = oldIndex.stories[entry.id]?.hash
94
+ const prevHash = oldIndex.stories[unit.id]?.hash
194
95
  if (opts.changed && prevHash === hash) {
195
96
  console.log(' = unchanged')
196
- await page.close()
197
97
  continue
198
98
  }
199
99
 
200
- if (!captured.has(entry.importPath)) {
201
- captured.set(entry.importPath, {
100
+ if (!captured.has(unit.importPath)) {
101
+ captured.set(unit.importPath, {
202
102
  schemaVersion: 1,
203
- component: componentNameFromImportPath(entry.importPath),
204
- importPath: entry.importPath,
103
+ component: componentNameFromImportPath(unit.importPath),
104
+ importPath: unit.importPath,
205
105
  capturedAt: new Date().toISOString(),
206
106
  stories: [],
207
107
  })
208
108
  }
209
- captured.get(entry.importPath).stories.push({
210
- id: entry.id,
211
- name: entry.name,
212
- title: entry.title,
109
+ captured.get(unit.importPath).stories.push({
110
+ id: unit.id,
111
+ name: unit.name,
112
+ title: unit.title,
213
113
  hash,
214
114
  root: tree,
215
115
  })
216
116
  } catch (e) {
217
- console.error(' ✗', entry.id, '—', e.message)
218
- } finally {
219
- await page.close()
117
+ console.error(' ✗', unit.id, '—', e.message)
118
+ continue
220
119
  }
221
120
  }
222
- await browser.close()
121
+ await closeSession()
223
122
 
224
- // 4. Write artifacts next to each story file, then the index
123
+ // 3. Write artifacts next to each story file, then the index
225
124
  const indexOut = {
226
125
  schemaVersion: 1,
227
126
  generatedAt: new Date().toISOString(),
228
- storybookUrl: sbUrl,
127
+ storybookUrl: storybookUrlOf(effectiveConfig).replace(/\/$/, ''),
229
128
  components: [],
230
129
  stories: { ...oldIndex.stories }, // preserves entries we didn't recapture
231
130
  }
package/src/cli.js CHANGED
@@ -235,7 +235,22 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
235
235
  process.exit(1)
236
236
  }
237
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)
238
243
  } else {
239
- console.error(`Unknown command: ${cmd}\nUsage: sorb-seed <resolve|capture|render-worker|variant> [options]\nRun \`sorb-seed --help\` for details.`)
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
+ )
240
255
  process.exit(1)
241
256
  }