@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli/snapshots.js +173 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.0.0-beta.21",
3
+ "version": "4.0.0-beta.22",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
- const now = new Date()
64
- const pad = (n) => String(n).padStart(2, '0')
65
- const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
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
- const allFiles = findCanvasFiles(root)
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
- // Check existing snapshots
191
- const hasLight = !!widget.props?.snapshotLight
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} — snapshots exist, skipping`))
337
+ p.log.step(dim(` ${widgetLabel} — up to date, skipping`))
197
338
  continue
198
339
  }
199
340
 
200
- const themesToCapture = force
201
- ? themesNeeded
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'))