@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.
- package/package.json +7 -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 -139
- package/src/cli.js +16 -1
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/emit/sorbMantine.js +116 -0
- package/src/emit/sorbMantine.test.js +68 -0
- package/src/emit/sorbMatSys.js +159 -0
- package/src/emit/sorbMatSys.test.js +75 -0
- package/src/emit/sorbMui.js +165 -0
- package/src/emit/sorbMui.test.js +102 -0
- package/src/emit/sorbPrimevue.js +240 -0
- package/src/emit/sorbPrimevue.test.js +130 -0
- package/src/emit/sorbShadcn.js +186 -0
- package/src/emit/sorbShadcn.test.js +102 -0
- package/src/index.js +48 -0
- package/src/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- 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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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[
|
|
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(
|
|
201
|
-
captured.set(
|
|
100
|
+
if (!captured.has(unit.importPath)) {
|
|
101
|
+
captured.set(unit.importPath, {
|
|
202
102
|
schemaVersion: 1,
|
|
203
|
-
component: componentNameFromImportPath(
|
|
204
|
-
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(
|
|
210
|
-
id:
|
|
211
|
-
name:
|
|
212
|
-
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(' ✗',
|
|
218
|
-
|
|
219
|
-
await page.close()
|
|
117
|
+
console.error(' ✗', unit.id, '—', e.message)
|
|
118
|
+
continue
|
|
220
119
|
}
|
|
221
120
|
}
|
|
222
|
-
await
|
|
121
|
+
await closeSession()
|
|
223
122
|
|
|
224
|
-
//
|
|
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:
|
|
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(
|
|
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
|
}
|