@sorb/seed 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/package.json +10 -3
- package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
- package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
- package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
- package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
- package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
- package/src/adapt/adaptCli.js +110 -0
- package/src/adapt/adaptCli.test.js +109 -0
- package/src/adapt/benchmark.js +113 -0
- package/src/adapt/benchmark.test.js +79 -0
- package/src/adapt/codemod.js +160 -0
- package/src/adapt/codemod.test.js +120 -0
- package/src/adapt/detectHardcoded.js +165 -0
- package/src/adapt/detectHardcoded.test.js +62 -0
- package/src/adapt/glob.js +87 -0
- package/src/adapt/mapToToken.js +94 -0
- package/src/adapt/mapToToken.test.js +103 -0
- package/src/adapt/report.js +53 -0
- package/src/adapt/report.test.js +66 -0
- package/src/adapt/runBenchmark.js +16 -0
- package/src/adapt/types.js +48 -0
- package/src/captureCli.js +38 -118
- package/src/cli.js +200 -3
- package/src/cli.test.js +51 -0
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/index.js +27 -0
- package/src/render/cli.js +82 -0
- package/src/render/cli.test.js +47 -0
- package/src/render/diffCache.js +152 -0
- package/src/render/diffCache.test.js +110 -0
- package/src/render/pagePool.js +82 -0
- package/src/render/pagePool.test.js +23 -0
- package/src/render/tokenInject.js +53 -0
- package/src/render/tokenInject.test.js +59 -0
- package/src/render/walkerBundle.js +24 -0
- package/src/render/walkerBundle.test.js +12 -0
- package/src/render/worker.js +193 -0
- package/src/render/worker.test.js +233 -0
- package/src/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- package/src/sources/storybookDom.js +188 -0
- package/src/variants.js +279 -0
- package/src/variants.test.js +129 -0
|
@@ -0,0 +1,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,27 +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
|
-
}
|
|
12
|
+
import { closeSession, storybookUrlOf } from './sources/storybookDom.js'
|
|
29
13
|
|
|
30
14
|
const cwd = process.cwd()
|
|
31
15
|
|
|
@@ -50,47 +34,12 @@ const loadResolved = () => {
|
|
|
50
34
|
|
|
51
35
|
const sha256 = (s) => createHash('sha256').update(s).digest('hex')
|
|
52
36
|
|
|
53
|
-
// Bundle the walker into a single IIFE string we can addInitScript() into
|
|
54
|
-
// every page. Playwright can't pass functions across the boundary directly,
|
|
55
|
-
// and our walker has cross-file imports → bundling is the clean answer.
|
|
56
|
-
const buildWalkerBundle = async () => {
|
|
57
|
-
const here = dirname(new URL(import.meta.url).pathname)
|
|
58
|
-
const out = await build({
|
|
59
|
-
entryPoints: [resolve(here, 'capture.js')],
|
|
60
|
-
bundle: true,
|
|
61
|
-
format: 'iife',
|
|
62
|
-
platform: 'browser',
|
|
63
|
-
write: false,
|
|
64
|
-
logLevel: 'silent',
|
|
65
|
-
target: 'es2020',
|
|
66
|
-
})
|
|
67
|
-
return out.outputFiles[0].text
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const isStoryEntry = (e) =>
|
|
71
|
-
e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
|
|
72
|
-
|
|
73
|
-
// Group story entries by their component (importPath) and pick the directory
|
|
74
|
-
// of the story file as the artifact output dir (co-located).
|
|
75
|
-
const groupByComponent = (entries) => {
|
|
76
|
-
const groups = new Map()
|
|
77
|
-
for (const e of entries) {
|
|
78
|
-
if (!groups.has(e.importPath)) groups.set(e.importPath, [])
|
|
79
|
-
groups.get(e.importPath).push(e)
|
|
80
|
-
}
|
|
81
|
-
return groups
|
|
82
|
-
}
|
|
83
|
-
|
|
84
37
|
// componentName: "Button" from "./src/.../Button.stories.jsx"
|
|
85
38
|
const componentNameFromImportPath = (importPath) => {
|
|
86
39
|
const file = basename(importPath, extname(importPath)) // "Button.stories"
|
|
87
40
|
return file.replace(/\.stories$/i, '')
|
|
88
41
|
}
|
|
89
42
|
|
|
90
|
-
// sorb.config.json may set seed.storybookUrl. Fall back to localhost.
|
|
91
|
-
const storybookUrlOf = (config) =>
|
|
92
|
-
(config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
|
|
93
|
-
|
|
94
43
|
const filterEntries = (entries, only) => {
|
|
95
44
|
if (!only) return entries
|
|
96
45
|
const re = new RegExp(only.replace(/[*]/g, '.*'), 'i')
|
|
@@ -101,65 +50,38 @@ export const runCapture = async (opts) => {
|
|
|
101
50
|
const config = loadConfig()
|
|
102
51
|
const resolved = loadResolved()
|
|
103
52
|
const index = buildTokenIndex(resolved)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const entries = filterEntries(
|
|
118
|
-
Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry),
|
|
119
|
-
opts.only,
|
|
120
|
-
)
|
|
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)
|
|
121
66
|
if (!entries.length) {
|
|
122
67
|
console.error('✗ No stories matched filter:', opts.only || '(all)')
|
|
123
68
|
process.exit(1)
|
|
124
69
|
}
|
|
125
70
|
console.log(`→ ${entries.length} stories selected`)
|
|
126
71
|
|
|
127
|
-
// 2.
|
|
128
|
-
const chromium = await loadChromium()
|
|
129
|
-
const walker = await buildWalkerBundle()
|
|
130
|
-
const browser = await chromium.launch()
|
|
131
|
-
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
|
|
132
|
-
await ctx.addInitScript({ content: walker })
|
|
133
|
-
|
|
134
|
-
// 3. Capture each story, group by component
|
|
72
|
+
// 2. Capture each unit (source connector), group by component
|
|
135
73
|
const captured = new Map() // importPath -> { component, importPath, stories[] }
|
|
136
74
|
const oldIndex = readOldIndex(config)
|
|
137
75
|
|
|
138
|
-
for (const
|
|
139
|
-
|
|
140
|
-
|
|
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).
|
|
141
81
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Wait for Storybook to actually render the story.
|
|
145
|
-
await page
|
|
146
|
-
.waitForFunction(
|
|
147
|
-
() => !!document.querySelector('#storybook-root *'),
|
|
148
|
-
{ timeout: 15000 },
|
|
149
|
-
)
|
|
150
|
-
.catch(() => {})
|
|
151
|
-
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
152
|
-
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
|
|
153
84
|
|
|
154
|
-
const rawTree = await page.evaluate(() => {
|
|
155
|
-
const root = document.querySelector('#storybook-root')
|
|
156
|
-
return root ? window.__sorbCapture(root) : null
|
|
157
|
-
})
|
|
158
|
-
if (!rawTree) {
|
|
159
|
-
console.warn(' ⚠ no #storybook-root content; skipped')
|
|
160
|
-
await page.close()
|
|
161
|
-
continue
|
|
162
|
-
}
|
|
163
85
|
// Trim the story container down to the meaningful component BEFORE
|
|
164
86
|
// annotation/storage so insert + preview get a tight, token-bound node
|
|
165
87
|
// (sorb-capture-trim-spec.md). Annotation runs on the kept subtree, so
|
|
@@ -169,42 +91,40 @@ export const runCapture = async (opts) => {
|
|
|
169
91
|
const hash = 'sha256:' + sha256(JSON.stringify(tree))
|
|
170
92
|
|
|
171
93
|
// --changed: reuse the previous artifact if hash matches
|
|
172
|
-
const prevHash = oldIndex.stories[
|
|
94
|
+
const prevHash = oldIndex.stories[unit.id]?.hash
|
|
173
95
|
if (opts.changed && prevHash === hash) {
|
|
174
96
|
console.log(' = unchanged')
|
|
175
|
-
await page.close()
|
|
176
97
|
continue
|
|
177
98
|
}
|
|
178
99
|
|
|
179
|
-
if (!captured.has(
|
|
180
|
-
captured.set(
|
|
100
|
+
if (!captured.has(unit.importPath)) {
|
|
101
|
+
captured.set(unit.importPath, {
|
|
181
102
|
schemaVersion: 1,
|
|
182
|
-
component: componentNameFromImportPath(
|
|
183
|
-
importPath:
|
|
103
|
+
component: componentNameFromImportPath(unit.importPath),
|
|
104
|
+
importPath: unit.importPath,
|
|
184
105
|
capturedAt: new Date().toISOString(),
|
|
185
106
|
stories: [],
|
|
186
107
|
})
|
|
187
108
|
}
|
|
188
|
-
captured.get(
|
|
189
|
-
id:
|
|
190
|
-
name:
|
|
191
|
-
title:
|
|
109
|
+
captured.get(unit.importPath).stories.push({
|
|
110
|
+
id: unit.id,
|
|
111
|
+
name: unit.name,
|
|
112
|
+
title: unit.title,
|
|
192
113
|
hash,
|
|
193
114
|
root: tree,
|
|
194
115
|
})
|
|
195
116
|
} catch (e) {
|
|
196
|
-
console.error(' ✗',
|
|
197
|
-
|
|
198
|
-
await page.close()
|
|
117
|
+
console.error(' ✗', unit.id, '—', e.message)
|
|
118
|
+
continue
|
|
199
119
|
}
|
|
200
120
|
}
|
|
201
|
-
await
|
|
121
|
+
await closeSession()
|
|
202
122
|
|
|
203
|
-
//
|
|
123
|
+
// 3. Write artifacts next to each story file, then the index
|
|
204
124
|
const indexOut = {
|
|
205
125
|
schemaVersion: 1,
|
|
206
126
|
generatedAt: new Date().toISOString(),
|
|
207
|
-
storybookUrl:
|
|
127
|
+
storybookUrl: storybookUrlOf(effectiveConfig).replace(/\/$/, ''),
|
|
208
128
|
components: [],
|
|
209
129
|
stories: { ...oldIndex.stories }, // preserves entries we didn't recapture
|
|
210
130
|
}
|