@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.
Files changed (45) hide show
  1. package/README.md +15 -5
  2. package/package.json +10 -3
  3. package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
  4. package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
  5. package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
  6. package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
  7. package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
  8. package/src/adapt/adaptCli.js +110 -0
  9. package/src/adapt/adaptCli.test.js +109 -0
  10. package/src/adapt/benchmark.js +113 -0
  11. package/src/adapt/benchmark.test.js +79 -0
  12. package/src/adapt/codemod.js +160 -0
  13. package/src/adapt/codemod.test.js +120 -0
  14. package/src/adapt/detectHardcoded.js +165 -0
  15. package/src/adapt/detectHardcoded.test.js +62 -0
  16. package/src/adapt/glob.js +87 -0
  17. package/src/adapt/mapToToken.js +94 -0
  18. package/src/adapt/mapToToken.test.js +103 -0
  19. package/src/adapt/report.js +53 -0
  20. package/src/adapt/report.test.js +66 -0
  21. package/src/adapt/runBenchmark.js +16 -0
  22. package/src/adapt/types.js +48 -0
  23. package/src/captureCli.js +38 -118
  24. package/src/cli.js +200 -3
  25. package/src/cli.test.js +51 -0
  26. package/src/emit/sorbFormat.js +313 -0
  27. package/src/emit/sorbFormat.test.js +133 -0
  28. package/src/index.js +27 -0
  29. package/src/render/cli.js +82 -0
  30. package/src/render/cli.test.js +47 -0
  31. package/src/render/diffCache.js +152 -0
  32. package/src/render/diffCache.test.js +110 -0
  33. package/src/render/pagePool.js +82 -0
  34. package/src/render/pagePool.test.js +23 -0
  35. package/src/render/tokenInject.js +53 -0
  36. package/src/render/tokenInject.test.js +59 -0
  37. package/src/render/walkerBundle.js +24 -0
  38. package/src/render/walkerBundle.test.js +12 -0
  39. package/src/render/worker.js +193 -0
  40. package/src/render/worker.test.js +233 -0
  41. package/src/sources/figmaPlugin.js +83 -0
  42. package/src/sources/figmaPlugin.test.js +65 -0
  43. package/src/sources/storybookDom.js +188 -0
  44. package/src/variants.js +279 -0
  45. 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 { 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
- }
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
- const sbUrl = (opts.storybookUrl || storybookUrlOf(config)).replace(/\/$/, '')
105
-
106
- // 1. Discover stories
107
- console.log(`→ Storybook: ${sbUrl}`)
108
- let sbIndex
109
- try {
110
- const res = await fetch(`${sbUrl}/index.json`)
111
- if (!res.ok) throw new Error('HTTP ' + res.status)
112
- sbIndex = await res.json()
113
- } catch (e) {
114
- console.error('✗ Could not fetch Storybook index:', e.message)
115
- process.exit(1)
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. Browser setup + walker injection
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 entry of entries) {
139
- const url = `${sbUrl}/iframe.html?id=${entry.id}&viewMode=story`
140
- 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).
141
81
  try {
142
- console.log(` · ${entry.id}`)
143
- await page.goto(url, { waitUntil: 'load' })
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[entry.id]?.hash
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(entry.importPath)) {
180
- captured.set(entry.importPath, {
100
+ if (!captured.has(unit.importPath)) {
101
+ captured.set(unit.importPath, {
181
102
  schemaVersion: 1,
182
- component: componentNameFromImportPath(entry.importPath),
183
- importPath: entry.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(entry.importPath).stories.push({
189
- id: entry.id,
190
- name: entry.name,
191
- title: entry.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(' ✗', entry.id, '—', e.message)
197
- } finally {
198
- await page.close()
117
+ console.error(' ✗', unit.id, '—', e.message)
118
+ continue
199
119
  }
200
120
  }
201
- await browser.close()
121
+ await closeSession()
202
122
 
203
- // 4. Write artifacts next to each story file, then the index
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: sbUrl,
127
+ storybookUrl: storybookUrlOf(effectiveConfig).replace(/\/$/, ''),
208
128
  components: [],
209
129
  stories: { ...oldIndex.stories }, // preserves entries we didn't recapture
210
130
  }