@dfosco/storyboard-core 4.0.0-beta.21 → 4.0.0-beta.22
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 +1 -1
- package/src/cli/snapshots.js +173 -17
package/package.json
CHANGED
package/src/cli/snapshots.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import fs from 'node:fs'
|
|
20
20
|
import path from 'node:path'
|
|
21
|
+
import { execSync, execFileSync } from 'node:child_process'
|
|
21
22
|
import { materializeFromText, serializeEvent } from '../canvas/materializer.js'
|
|
22
23
|
import * as p from '@clack/prompts'
|
|
23
24
|
import { bold, dim, green, yellow, cyan } from './intro.js'
|
|
@@ -58,12 +59,11 @@ function readCanvasState(absPath) {
|
|
|
58
59
|
return materializeFromText(raw)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function writeImage(imagesDir, widgetId, theme, buffer) {
|
|
62
|
+
function writeImage(imagesDir, canvasId, widgetId, theme, buffer) {
|
|
62
63
|
fs.mkdirSync(imagesDir, { recursive: true })
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const filename = `snapshot-${widgetId}--${theme}--${dateStr}.png`
|
|
64
|
+
// Include canvas ID (slashes → dashes) to prevent cross-canvas collisions
|
|
65
|
+
const safeCanvasId = canvasId.replace(/\//g, '-')
|
|
66
|
+
const filename = `snapshot-${safeCanvasId}-${widgetId}--${theme}--latest.png`
|
|
67
67
|
fs.writeFileSync(path.join(imagesDir, filename), buffer)
|
|
68
68
|
return filename
|
|
69
69
|
}
|
|
@@ -77,11 +77,102 @@ function appendWidgetsReplaced(jsonlPath, widgets) {
|
|
|
77
77
|
fs.appendFileSync(jsonlPath, serializeEvent(event) + '\n', 'utf-8')
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Dirty detection helpers ──
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute a content hash for a file on disk using git hash-object.
|
|
84
|
+
* Returns null if the file doesn't exist or git isn't available.
|
|
85
|
+
*/
|
|
86
|
+
function gitHashFile(filePath, root) {
|
|
87
|
+
try {
|
|
88
|
+
return execFileSync('git', ['hash-object', '--', filePath], {
|
|
89
|
+
cwd: root, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
}).trim()
|
|
91
|
+
} catch { return null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the source file path for a widget (story or prototype) so we
|
|
96
|
+
* can track content hashes. Returns an absolute path or null.
|
|
97
|
+
*/
|
|
98
|
+
function resolveSourceFile(widget, root) {
|
|
99
|
+
if (widget.type === 'story') {
|
|
100
|
+
const storyId = widget.props?.storyId
|
|
101
|
+
if (!storyId) return null
|
|
102
|
+
// Story files live under src/ with a .story.jsx/.story.tsx extension
|
|
103
|
+
for (const ext of ['.story.jsx', '.story.tsx', '.story.js', '.story.ts']) {
|
|
104
|
+
const candidates = [
|
|
105
|
+
path.join(root, 'src', 'canvas', storyId + ext),
|
|
106
|
+
path.join(root, 'src', 'canvas', 'examples', storyId + ext),
|
|
107
|
+
path.join(root, 'src', 'components', storyId + ext),
|
|
108
|
+
]
|
|
109
|
+
for (const p of candidates) {
|
|
110
|
+
if (fs.existsSync(p)) return p
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Fallback: glob for it
|
|
114
|
+
return findFileRecursive(root, storyId + '.story.')
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Recursively find a file whose name starts with the given prefix.
|
|
121
|
+
* Returns the absolute path or null.
|
|
122
|
+
*/
|
|
123
|
+
function findFileRecursive(dir, prefix) {
|
|
124
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
125
|
+
try {
|
|
126
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (ignore.has(entry.name)) continue
|
|
129
|
+
const full = path.join(dir, entry.name)
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
const result = findFileRecursive(full, prefix)
|
|
132
|
+
if (result) return result
|
|
133
|
+
} else if (entry.name.startsWith(prefix)) {
|
|
134
|
+
return full
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch { /* permission errors, etc. */ }
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Determine whether a widget needs snapshot regeneration.
|
|
143
|
+
* A widget is "dirty" if:
|
|
144
|
+
* - It has no snapshots at all (missing snapshotLight/Dark)
|
|
145
|
+
* - Its snapshot-relevant props changed (compared to what was captured)
|
|
146
|
+
* - The source file's git hash changed (for story/prototype widgets)
|
|
147
|
+
*/
|
|
148
|
+
function isWidgetDirty(widget, root) {
|
|
149
|
+
const isFigma = widget.type === 'figma-embed'
|
|
150
|
+
const hasLight = !!widget.props?.snapshotLight
|
|
151
|
+
const hasDark = !!widget.props?.snapshotDark
|
|
152
|
+
const allExist = isFigma ? hasLight : (hasLight && hasDark)
|
|
153
|
+
|
|
154
|
+
// No snapshots at all — always dirty
|
|
155
|
+
if (!allExist) return true
|
|
156
|
+
|
|
157
|
+
// Check source file hash for story widgets
|
|
158
|
+
if (widget.type === 'story') {
|
|
159
|
+
const sourceFile = resolveSourceFile(widget, root)
|
|
160
|
+
if (sourceFile) {
|
|
161
|
+
const currentHash = gitHashFile(sourceFile, root)
|
|
162
|
+
const savedHash = widget.props?._snapshotHash
|
|
163
|
+
if (currentHash && currentHash !== savedHash) return true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
|
|
80
170
|
// ── Main ──
|
|
81
171
|
|
|
82
172
|
async function run() {
|
|
83
173
|
const args = process.argv.slice(3)
|
|
84
174
|
const force = args.includes('--force')
|
|
175
|
+
const changedOnly = args.includes('--changed-only')
|
|
85
176
|
const canvasFilter = args.find(a => !a.startsWith('--')) || null
|
|
86
177
|
|
|
87
178
|
p.intro(bold('storyboard snapshots'))
|
|
@@ -90,7 +181,60 @@ async function run() {
|
|
|
90
181
|
const imagesDir = path.join(root, 'assets', 'canvas', 'snapshots')
|
|
91
182
|
|
|
92
183
|
// Discover canvases from disk
|
|
93
|
-
|
|
184
|
+
let allFiles = findCanvasFiles(root)
|
|
185
|
+
|
|
186
|
+
// --changed-only: restrict to canvases whose JSONL changed OR whose
|
|
187
|
+
// referenced story source files changed since the previous commit.
|
|
188
|
+
if (changedOnly && !force) {
|
|
189
|
+
try {
|
|
190
|
+
const allChanged = execSync('git diff HEAD~1 --name-only', {
|
|
191
|
+
cwd: root, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
192
|
+
}).trim().split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
|
|
193
|
+
|
|
194
|
+
const changedJsonl = new Set(allChanged.filter(f => f.endsWith('.canvas.jsonl')))
|
|
195
|
+
const changedSources = new Set(allChanged.filter(f =>
|
|
196
|
+
f.endsWith('.story.jsx') || f.endsWith('.story.tsx') ||
|
|
197
|
+
f.endsWith('.story.js') || f.endsWith('.story.ts')
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
if (changedJsonl.size === 0 && changedSources.size === 0) {
|
|
201
|
+
p.log.info('No canvas or story changes detected')
|
|
202
|
+
p.outro(dim('Nothing to do'))
|
|
203
|
+
process.exit(0)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Include canvases with direct JSONL changes
|
|
207
|
+
const targetSet = new Set()
|
|
208
|
+
for (const f of allFiles) {
|
|
209
|
+
if (changedJsonl.has(f.relPath.replace(/\\/g, '/'))) targetSet.add(f)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Also include canvases that reference changed story source files.
|
|
213
|
+
// We need to read each canvas to check widget storyIds, but only
|
|
214
|
+
// if there are changed sources to match against.
|
|
215
|
+
if (changedSources.size > 0) {
|
|
216
|
+
for (const f of allFiles) {
|
|
217
|
+
if (targetSet.has(f)) continue
|
|
218
|
+
try {
|
|
219
|
+
const state = readCanvasState(f.absPath)
|
|
220
|
+
const widgets = (state.widgets || []).filter(w => w.type === 'story')
|
|
221
|
+
for (const w of widgets) {
|
|
222
|
+
const sourceFile = resolveSourceFile(w, root)
|
|
223
|
+
if (sourceFile) {
|
|
224
|
+
const rel = path.relative(root, sourceFile).replace(/\\/g, '/')
|
|
225
|
+
if (changedSources.has(rel)) { targetSet.add(f); break }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch { /* skip unreadable canvases */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
allFiles = [...targetSet]
|
|
233
|
+
} catch {
|
|
234
|
+
// git diff failed (maybe no previous commit) — fall through to full scan
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
94
238
|
const targets = canvasFilter
|
|
95
239
|
? allFiles.filter(f => {
|
|
96
240
|
const id = toCanvasId(f.relPath)
|
|
@@ -187,20 +331,25 @@ async function run() {
|
|
|
187
331
|
// Figma embeds only need a single snapshot (no theme variants)
|
|
188
332
|
const themesNeeded = isFigma ? ['light'] : THEMES
|
|
189
333
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
const hasDark = !!widget.props?.snapshotDark
|
|
193
|
-
const allExist = isFigma ? hasLight : (hasLight && hasDark)
|
|
194
|
-
if (allExist && !force) {
|
|
334
|
+
// Skip widgets that don't need regeneration
|
|
335
|
+
if (!force && !isWidgetDirty(widget, root)) {
|
|
195
336
|
totalSkipped++
|
|
196
|
-
p.log.step(dim(` ${widgetLabel} —
|
|
337
|
+
p.log.step(dim(` ${widgetLabel} — up to date, skipping`))
|
|
197
338
|
continue
|
|
198
339
|
}
|
|
199
340
|
|
|
200
|
-
|
|
201
|
-
|
|
341
|
+
// When force is set, recapture all themes; otherwise only missing ones
|
|
342
|
+
const hasLight = !!widget.props?.snapshotLight
|
|
343
|
+
const hasDark = !!widget.props?.snapshotDark
|
|
344
|
+
let themesToCapture = force
|
|
345
|
+
? [...themesNeeded]
|
|
202
346
|
: themesNeeded.filter(t => t === 'light' ? !hasLight : !hasDark)
|
|
203
347
|
|
|
348
|
+
// If all themes exist but source hash changed, recapture all
|
|
349
|
+
if (themesToCapture.length === 0) {
|
|
350
|
+
themesToCapture = [...themesNeeded]
|
|
351
|
+
}
|
|
352
|
+
|
|
204
353
|
const embedUrl = resolveEmbedUrl(serverUrl, widget)
|
|
205
354
|
if (!embedUrl) {
|
|
206
355
|
p.log.warn(` ${widgetLabel} — could not resolve embed URL, skipping`)
|
|
@@ -228,7 +377,7 @@ async function run() {
|
|
|
228
377
|
const buffer = await page.screenshot({ type: 'png' })
|
|
229
378
|
await context.close()
|
|
230
379
|
|
|
231
|
-
const filename = writeImage(imagesDir, widget.id, theme, buffer)
|
|
380
|
+
const filename = writeImage(imagesDir, canvasId, widget.id, theme, buffer)
|
|
232
381
|
const imageUrl = `/_storyboard/canvas/images/${filename}`
|
|
233
382
|
const themeKey = theme === 'dark' ? 'snapshotDark' : 'snapshotLight'
|
|
234
383
|
updates[themeKey] = imageUrl
|
|
@@ -240,6 +389,14 @@ async function run() {
|
|
|
240
389
|
}
|
|
241
390
|
|
|
242
391
|
if (Object.keys(updates).length > 0) {
|
|
392
|
+
// Record source file hash so future runs can detect staleness
|
|
393
|
+
if (widget.type === 'story') {
|
|
394
|
+
const sourceFile = resolveSourceFile(widget, root)
|
|
395
|
+
if (sourceFile) {
|
|
396
|
+
const hash = gitHashFile(sourceFile, root)
|
|
397
|
+
if (hash) updates._snapshotHash = hash
|
|
398
|
+
}
|
|
399
|
+
}
|
|
243
400
|
widget.props = { ...widget.props, ...updates }
|
|
244
401
|
canvasDirty = true
|
|
245
402
|
}
|
|
@@ -260,10 +417,9 @@ async function run() {
|
|
|
260
417
|
// Auto-commit snapshot files so they don't clutter git status
|
|
261
418
|
if (totalSnapshots > 0) {
|
|
262
419
|
try {
|
|
263
|
-
const { execSync } = await import(/* @vite-ignore */ 'node:child_process')
|
|
264
420
|
execSync('git add assets/canvas/snapshots/ src/canvas/', { cwd: root, stdio: 'pipe' })
|
|
265
421
|
execSync(
|
|
266
|
-
`git commit -m "chore: update canvas snapshots" --no-verify --allow-empty`,
|
|
422
|
+
`git commit -m "chore: update canvas snapshots [skip ci]" --no-verify --allow-empty`,
|
|
267
423
|
{ cwd: root, stdio: 'pipe' }
|
|
268
424
|
)
|
|
269
425
|
p.log.step(dim('Snapshots committed'))
|