@sorb/seed 0.2.0 → 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.
@@ -0,0 +1,188 @@
1
+ // `storybook-dom` SourceConnector — the default SOURCE connector (registered
2
+ // under `@sorb/core`'s DEFAULT_SOURCE_ID = 'storybook-dom'). Extracted from
3
+ // captureCli.js verbatim (spec/sorb/connectors-architecture.md §3.1, C1) — a
4
+ // pure refactor, no behavior change. Owns everything source-specific:
5
+ // discovering Storybook story entries (`listUnits`), running Playwright +
6
+ // the `capture.js` walker to capture one entry's raw geometry
7
+ // (`captureGeometry`), and reading the DTCG/Style-Dictionary resolved token
8
+ // map (`readTokens`). The generic pipeline (tightenRoot -> annotateTree ->
9
+ // hash -> write) stays in captureCli.js and is fed by this connector.
10
+
11
+ import { basename, extname, dirname, resolve } from 'path'
12
+ import { readFileSync, existsSync } from 'fs'
13
+ import { build } from 'esbuild'
14
+ import { registerSource } from '@sorb/core'
15
+
16
+ // Playwright is an OPTIONAL peer dep — only `capture` needs it, and it pulls a
17
+ // ~150 MB browser. Lazy-load it so plain installs and `resolve` stay lean.
18
+ const loadChromium = async () => {
19
+ try {
20
+ const { chromium } = await import('playwright')
21
+ return chromium
22
+ } catch {
23
+ console.error(
24
+ '✗ `sorb-seed capture` needs Playwright (it is an optional peer dep).\n' +
25
+ ' Install it where you run capture:\n' +
26
+ ' npm install playwright # its postinstall fetches Chromium\n' +
27
+ ' (or: npm install playwright && npx playwright install chromium)',
28
+ )
29
+ process.exit(1)
30
+ }
31
+ }
32
+
33
+ // The `playwright` PACKAGE can be installed while its Chromium BROWSER binary is
34
+ // not (that's a separate `npx playwright install chromium` step). Launching then
35
+ // throws a raw "Executable doesn't exist" error — turn it into the same
36
+ // actionable guidance the missing-package path already gives.
37
+ export const launchChromium = async (chromium) => {
38
+ try {
39
+ return await chromium.launch()
40
+ } catch (e) {
41
+ const msg = e && e.message ? e.message : String(e)
42
+ if (/Executable doesn't exist|playwright install|browserType\.launch/i.test(msg)) {
43
+ console.error(
44
+ '✗ `sorb-seed capture` found Playwright but its Chromium browser is not installed.\n' +
45
+ ' Install the browser where you run capture:\n' +
46
+ ' npx playwright install chromium',
47
+ )
48
+ process.exit(1)
49
+ }
50
+ throw e
51
+ }
52
+ }
53
+
54
+ // Bundle the walker into a single IIFE string we can addInitScript() into
55
+ // every page. Playwright can't pass functions across the boundary directly,
56
+ // and our walker has cross-file imports → bundling is the clean answer.
57
+ const buildWalkerBundle = async () => {
58
+ const here = dirname(new URL(import.meta.url).pathname)
59
+ const out = await build({
60
+ entryPoints: [resolve(here, '..', 'capture.js')],
61
+ bundle: true,
62
+ format: 'iife',
63
+ platform: 'browser',
64
+ write: false,
65
+ logLevel: 'silent',
66
+ target: 'es2020',
67
+ })
68
+ return out.outputFiles[0].text
69
+ }
70
+
71
+ const isStoryEntry = (e) =>
72
+ e && (e.type === 'story' || (e.type === undefined && e.importPath)) // SB7/8: type:'story'
73
+
74
+ // componentName: "Button" from "./src/.../Button.stories.jsx"
75
+ const componentNameFromImportPath = (importPath) => {
76
+ const file = basename(importPath, extname(importPath)) // "Button.stories"
77
+ return file.replace(/\.stories$/i, '')
78
+ }
79
+
80
+ // sorb.config.json may set seed.storybookUrl. Fall back to localhost.
81
+ export const storybookUrlOf = (config) =>
82
+ (config.seed && config.seed.storybookUrl) || 'http://localhost:6006'
83
+
84
+ // ─── Playwright session (browser + context + walker init script) ──────────
85
+ // Lazily launched on the first captureGeometry() call and reused across every
86
+ // unit in a run — exactly today's single-launch/many-pages lifecycle. Not
87
+ // part of the shared SourceConnector contract (that's just listUnits /
88
+ // captureGeometry / readTokens); captureCli.js calls closeSession() once
89
+ // after it has processed every unit, mirroring today's single browser.close().
90
+ let session = null // { browser, ctx, sbUrl }
91
+
92
+ const ensureSession = async (sbUrl) => {
93
+ if (session && session.sbUrl === sbUrl) return session
94
+ if (session) await closeSession()
95
+ const chromium = await loadChromium()
96
+ const walker = await buildWalkerBundle()
97
+ const browser = await launchChromium(chromium)
98
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
99
+ await ctx.addInitScript({ content: walker })
100
+ session = { browser, ctx, sbUrl }
101
+ return session
102
+ }
103
+
104
+ export const closeSession = async () => {
105
+ if (session) {
106
+ await session.browser.close()
107
+ session = null
108
+ }
109
+ }
110
+
111
+ // ─── SourceConnector implementation ────────────────────────────────────────
112
+
113
+ const listUnits = async (config) => {
114
+ const sbUrl = storybookUrlOf(config).replace(/\/$/, '')
115
+ console.log(`→ Storybook: ${sbUrl}`)
116
+ let sbIndex
117
+ try {
118
+ const res = await fetch(`${sbUrl}/index.json`)
119
+ if (!res.ok) throw new Error('HTTP ' + res.status)
120
+ sbIndex = await res.json()
121
+ } catch (e) {
122
+ console.error('✗ Could not fetch Storybook index:', e.message)
123
+ process.exit(1)
124
+ }
125
+ const entries = Object.values(sbIndex.entries || sbIndex.stories || {}).filter(isStoryEntry)
126
+ return entries.map((e) => ({
127
+ id: e.id,
128
+ name: e.name,
129
+ title: e.title,
130
+ importPath: e.importPath,
131
+ }))
132
+ }
133
+
134
+ const captureGeometry = async (unit, config) => {
135
+ const sbUrl = storybookUrlOf(config).replace(/\/$/, '')
136
+ const { ctx } = await ensureSession(sbUrl)
137
+ const url = `${sbUrl}/iframe.html?id=${unit.id}&viewMode=story`
138
+ const page = await ctx.newPage()
139
+ try {
140
+ console.log(` · ${unit.id}`)
141
+ await page.goto(url, { waitUntil: 'load' })
142
+ // Wait for Storybook to actually render the story.
143
+ await page
144
+ .waitForFunction(
145
+ () => !!document.querySelector('#storybook-root *'),
146
+ { timeout: 15000 },
147
+ )
148
+ .catch(() => {})
149
+ await page.evaluate(() => document.fonts && document.fonts.ready)
150
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
151
+
152
+ const rawTree = await page.evaluate(() => {
153
+ const root = document.querySelector('#storybook-root')
154
+ return root ? window.__sorbCapture(root) : null
155
+ })
156
+ if (!rawTree) {
157
+ console.warn(' ⚠ no #storybook-root content; skipped')
158
+ return null
159
+ }
160
+ return rawTree
161
+ } finally {
162
+ await page.close()
163
+ }
164
+ }
165
+
166
+ // Thin wrapper over the resolved bindable token map produced by `sorb-seed
167
+ // resolve` (Style Dictionary build against the DTCG sources). Capture's own
168
+ // generic pipeline still reads `.sorb/resolved.json` directly (unchanged) —
169
+ // this exists so the connector satisfies the SOURCE contract for other
170
+ // consumers. Does not move the SD internals (those stay owned by `resolve`).
171
+ const readTokens = async (config) => {
172
+ const cwd = process.cwd()
173
+ const p = resolve(cwd, '.sorb/resolved.json')
174
+ if (!existsSync(p)) {
175
+ throw new Error('No .sorb/resolved.json — run `sorb-seed resolve` first.')
176
+ }
177
+ const data = JSON.parse(readFileSync(p, 'utf-8'))
178
+ return Array.isArray(data) ? data : data.tokens
179
+ }
180
+
181
+ export const storybookDomConnector = {
182
+ id: 'storybook-dom',
183
+ listUnits,
184
+ captureGeometry,
185
+ readTokens,
186
+ }
187
+
188
+ registerSource(storybookDomConnector)