@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +512 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +56 -0
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +458 -71
- package/src/vite/data-plugin.test.js +405 -5
package/src/vite/data-plugin.js
CHANGED
|
@@ -4,12 +4,16 @@ import { execSync } from 'node:child_process'
|
|
|
4
4
|
import { globSync } from 'glob'
|
|
5
5
|
import { parse as parseJsonc } from 'jsonc-parser'
|
|
6
6
|
import { materializeFromText } from '@dfosco/storyboard-core/canvas/materializer'
|
|
7
|
+
import { toCanvasId } from '@dfosco/storyboard-core/canvas/identity'
|
|
8
|
+
import { getConfig } from '@dfosco/storyboard-core/config'
|
|
7
9
|
|
|
8
10
|
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
9
11
|
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
10
12
|
|
|
11
13
|
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
|
|
12
14
|
const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
|
|
15
|
+
const CANVAS_META_GLOB_PATTERN = '**/*.meta.json'
|
|
16
|
+
const STORY_GLOB_PATTERN = '**/*.story.{jsx,tsx}'
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* Extract the data name and type suffix from a file path.
|
|
@@ -32,7 +36,8 @@ function parseDataFile(filePath) {
|
|
|
32
36
|
const normalized = filePath.replace(/\\/g, '/')
|
|
33
37
|
if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
|
|
34
38
|
|
|
35
|
-
const
|
|
39
|
+
const baseName = canvasJsonlMatch[1]
|
|
40
|
+
let name = baseName
|
|
36
41
|
let inferredRoute = null
|
|
37
42
|
const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvas\/([^/]+)\.folder\//)
|
|
38
43
|
const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
|
|
@@ -46,20 +51,83 @@ function parseDataFile(filePath) {
|
|
|
46
51
|
.replace(/^.*?src\/canvas\//, '')
|
|
47
52
|
.replace(/[^/]*\.folder\/?/g, '')
|
|
48
53
|
.replace(/\/$/, '')
|
|
49
|
-
|
|
54
|
+
// Path-based ID: include folder context for uniqueness.
|
|
55
|
+
// .folder dirs contribute their name (sans .folder suffix) to the ID.
|
|
56
|
+
const idBase = (dirPath + '/')
|
|
57
|
+
.replace(/^.*?src\/canvas\//, '')
|
|
58
|
+
.replace(/\.folder\/?/g, '/')
|
|
59
|
+
.replace(/\/+/g, '/')
|
|
60
|
+
.replace(/\/$/, '')
|
|
61
|
+
name = idBase ? `${idBase}/${baseName}` : baseName
|
|
62
|
+
inferredRoute = '/canvas/' + name
|
|
50
63
|
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
51
64
|
}
|
|
52
65
|
const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
|
|
53
66
|
if (!canvasCheck && protoCheck) {
|
|
54
67
|
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
55
|
-
|
|
68
|
+
// For prototypes, .folder is purely organizational — strip entirely
|
|
69
|
+
const idBase = (dirPath + '/')
|
|
56
70
|
.replace(/^.*?src\/prototypes\//, '')
|
|
57
71
|
.replace(/[^/]*\.folder\/?/g, '')
|
|
72
|
+
.replace(/\/+/g, '/')
|
|
58
73
|
.replace(/\/$/, '')
|
|
59
|
-
|
|
74
|
+
name = idBase ? `${idBase}/${baseName}` : baseName
|
|
75
|
+
inferredRoute = '/canvas/' + name
|
|
60
76
|
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
61
77
|
}
|
|
62
|
-
|
|
78
|
+
// Derive group: canvases sharing a directory form a group
|
|
79
|
+
const slashIdx = name.lastIndexOf('/')
|
|
80
|
+
const group = canvasFolderName || (slashIdx > 0 ? name.substring(0, slashIdx) : null)
|
|
81
|
+
// Extract a relative path for toCanvasId (it expects src/canvas/... or src/prototypes/...)
|
|
82
|
+
const canvasIdInput = normalized.replace(/^.*?(src\/(?:canvas|prototypes)\/)/, '$1')
|
|
83
|
+
return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute, id: toCanvasId(canvasIdInput), group }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle canvas .meta.json files
|
|
87
|
+
const metaMatch = base.match(/^(.+)\.meta\.json$/)
|
|
88
|
+
if (metaMatch) {
|
|
89
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
90
|
+
// Only handle meta files inside src/canvas/ directories
|
|
91
|
+
const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
|
|
92
|
+
if (!canvasCheck) return null
|
|
93
|
+
// Skip _-prefixed
|
|
94
|
+
if (metaMatch[1].startsWith('_')) return null
|
|
95
|
+
if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
|
|
96
|
+
return { name: metaMatch[1], suffix: 'canvas-meta', ext: 'json', inferredRoute: null }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Handle .story.jsx / .story.tsx files
|
|
100
|
+
const storyMatch = base.match(/^(.+)\.story\.(jsx|tsx)$/)
|
|
101
|
+
if (storyMatch) {
|
|
102
|
+
if (storyMatch[1].startsWith('_')) return null
|
|
103
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
104
|
+
if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
|
|
105
|
+
|
|
106
|
+
const name = storyMatch[1]
|
|
107
|
+
let inferredRoute = null
|
|
108
|
+
|
|
109
|
+
// All stories route under /components/ regardless of directory location
|
|
110
|
+
const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
|
|
111
|
+
const componentsCheck = normalized.match(/(?:^|\/)src\/components\//)
|
|
112
|
+
if (canvasCheck) {
|
|
113
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
114
|
+
const routeBase = (dirPath + '/')
|
|
115
|
+
.replace(/^.*?src\/canvas\//, '')
|
|
116
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
117
|
+
.replace(/\/$/, '')
|
|
118
|
+
inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
|
|
119
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
|
|
120
|
+
} else if (componentsCheck) {
|
|
121
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
122
|
+
const routeBase = (dirPath + '/')
|
|
123
|
+
.replace(/^.*?src\/components\//, '')
|
|
124
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
125
|
+
.replace(/\/$/, '')
|
|
126
|
+
inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
|
|
127
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { name, suffix: 'story', ext: storyMatch[2], inferredRoute }
|
|
63
131
|
}
|
|
64
132
|
|
|
65
133
|
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
@@ -155,14 +223,103 @@ function getLastModified(root, dirPath) {
|
|
|
155
223
|
}
|
|
156
224
|
}
|
|
157
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Batch-fetch git metadata (author + lastModified) for multiple files in a
|
|
228
|
+
* single subprocess, avoiding per-file git overhead during startup.
|
|
229
|
+
*
|
|
230
|
+
* Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
|
|
231
|
+
*/
|
|
232
|
+
function batchGitMetadata(root, filePaths) {
|
|
233
|
+
const result = new Map()
|
|
234
|
+
if (filePaths.length === 0) return result
|
|
235
|
+
|
|
236
|
+
// Initialize all entries
|
|
237
|
+
for (const fp of filePaths) {
|
|
238
|
+
result.set(fp, { gitAuthor: null, lastModified: null })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Batch lastModified: one git log call with all paths
|
|
243
|
+
// git log -1 gives the most recent commit touching any of these paths,
|
|
244
|
+
// but we need per-path data. Use --name-only to correlate.
|
|
245
|
+
// For efficiency, use a single git log with --format and --name-only
|
|
246
|
+
// that outputs one record per commit touching these files.
|
|
247
|
+
const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
|
|
248
|
+
const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
|
|
249
|
+
|
|
250
|
+
// Get lastModified per directory in one call using git log --format
|
|
251
|
+
// We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
|
|
252
|
+
const logResult = execSync(
|
|
253
|
+
`git log --format="%aI" --name-only -- ${dirsArg}`,
|
|
254
|
+
{ cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
|
|
255
|
+
).trim()
|
|
256
|
+
|
|
257
|
+
if (logResult) {
|
|
258
|
+
// Parse: alternating date lines and filename lines separated by blank lines
|
|
259
|
+
const blocks = logResult.split('\n\n')
|
|
260
|
+
const dirDates = new Map() // dir → most recent date
|
|
261
|
+
for (const block of blocks) {
|
|
262
|
+
const lines = block.split('\n').filter(Boolean)
|
|
263
|
+
if (lines.length < 2) continue
|
|
264
|
+
const date = lines[0]
|
|
265
|
+
for (let li = 1; li < lines.length; li++) {
|
|
266
|
+
const fileLine = lines[li].trim()
|
|
267
|
+
if (!fileLine) continue
|
|
268
|
+
const dir = path.dirname(path.resolve(root, fileLine))
|
|
269
|
+
if (!dirDates.has(dir)) {
|
|
270
|
+
dirDates.set(dir, date)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const fp of filePaths) {
|
|
275
|
+
const dir = path.dirname(fp)
|
|
276
|
+
const entry = result.get(fp)
|
|
277
|
+
if (dirDates.has(dir) && entry) {
|
|
278
|
+
entry.lastModified = dirDates.get(dir)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch { /* git not available or failed — leave nulls */ }
|
|
283
|
+
|
|
284
|
+
// Batch gitAuthor: use git log for each file's creation author.
|
|
285
|
+
// Unfortunately --follow --diff-filter=A doesn't combine well with multiple
|
|
286
|
+
// paths, so batch them in a single shell invocation using a for loop.
|
|
287
|
+
try {
|
|
288
|
+
const relPaths = filePaths.map(fp => path.relative(root, fp))
|
|
289
|
+
// Build a shell script that outputs "PATH<tab>AUTHOR" per file
|
|
290
|
+
const cmds = relPaths.map(rp =>
|
|
291
|
+
`echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
|
|
292
|
+
).join('; ')
|
|
293
|
+
const authorResult = execSync(cmds, {
|
|
294
|
+
cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
|
|
295
|
+
}).trim()
|
|
296
|
+
|
|
297
|
+
if (authorResult) {
|
|
298
|
+
for (const line of authorResult.split('\n')) {
|
|
299
|
+
const tabIdx = line.indexOf('\t')
|
|
300
|
+
if (tabIdx < 0) continue
|
|
301
|
+
const relPath = line.slice(0, tabIdx)
|
|
302
|
+
const author = line.slice(tabIdx + 1).trim()
|
|
303
|
+
if (!author) continue
|
|
304
|
+
const absPath2 = path.resolve(root, relPath)
|
|
305
|
+
const entry = result.get(absPath2)
|
|
306
|
+
if (entry) entry.gitAuthor = author
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch { /* git not available */ }
|
|
310
|
+
|
|
311
|
+
return result
|
|
312
|
+
}
|
|
313
|
+
|
|
158
314
|
/**
|
|
159
315
|
* Scan the repo for all data files, validate uniqueness, return the index.
|
|
160
316
|
*/
|
|
161
317
|
function buildIndex(root) {
|
|
162
|
-
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const
|
|
318
|
+
const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
|
|
319
|
+
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
320
|
+
const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
321
|
+
const canvasMetaFiles = globSync(CANVAS_META_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
322
|
+
const storyFiles = globSync(STORY_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
166
323
|
|
|
167
324
|
// Detect nested .folder/ directories (not supported)
|
|
168
325
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -179,35 +336,58 @@ function buildIndex(root) {
|
|
|
179
336
|
}
|
|
180
337
|
}
|
|
181
338
|
|
|
182
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
|
|
183
|
-
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
339
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
|
|
340
|
+
const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
|
|
184
341
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
185
342
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
186
343
|
const canvasRoutes = {} // canvas name → inferred route
|
|
344
|
+
const canvasAliases = {} // basename → canonical ID (only when unique)
|
|
345
|
+
const canvasNameCount = {} // canvas basename → count (for ambiguity detection)
|
|
346
|
+
const canvasGroups = {} // canvas name → group name (shared folder prefix)
|
|
347
|
+
const storyRoutes = {} // story name → inferred route
|
|
187
348
|
|
|
188
|
-
for (const relPath of [...files, ...canvasFiles]) {
|
|
349
|
+
for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
|
|
189
350
|
const parsed = parseDataFile(relPath)
|
|
190
351
|
if (!parsed) continue
|
|
191
352
|
|
|
192
|
-
|
|
353
|
+
// Canvas files use path-based IDs for dedup; others use basename
|
|
354
|
+
const dedupKey = parsed.suffix === 'canvas' && parsed.id
|
|
355
|
+
? `${parsed.id}.${parsed.suffix}`
|
|
356
|
+
: `${parsed.name}.${parsed.suffix}`
|
|
193
357
|
const absPath = path.resolve(root, relPath)
|
|
194
358
|
|
|
195
|
-
if (seen[
|
|
359
|
+
if (seen[dedupKey]) {
|
|
196
360
|
const hint = parsed.suffix === 'folder'
|
|
197
361
|
? ' Folder names must be unique across the project.'
|
|
362
|
+
: parsed.suffix === 'canvas'
|
|
363
|
+
? ' Canvas IDs must be unique. Move or rename one file to resolve the collision.'
|
|
198
364
|
: ' Flows, records, and objects are scoped to their prototype directory.\n' +
|
|
199
365
|
' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
|
|
200
366
|
|
|
201
367
|
throw new Error(
|
|
202
|
-
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
|
|
203
|
-
` Found at: ${seen[
|
|
368
|
+
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
|
|
369
|
+
` Found at: ${seen[dedupKey]}\n` +
|
|
204
370
|
` And at: ${absPath}\n` +
|
|
205
371
|
hint
|
|
206
372
|
)
|
|
207
373
|
}
|
|
208
374
|
|
|
209
|
-
seen[
|
|
210
|
-
|
|
375
|
+
seen[dedupKey] = absPath
|
|
376
|
+
|
|
377
|
+
// Canvas: index only by canonical ID. Basename aliases go in a separate map
|
|
378
|
+
// so listCanvases() and viewfinder don't show duplicates.
|
|
379
|
+
if (parsed.suffix === 'canvas' && parsed.id) {
|
|
380
|
+
index.canvas[parsed.id] = absPath
|
|
381
|
+
// Track basename for alias resolution (only when unique)
|
|
382
|
+
canvasNameCount[parsed.name] = (canvasNameCount[parsed.name] || 0) + 1
|
|
383
|
+
if (canvasNameCount[parsed.name] === 1) {
|
|
384
|
+
canvasAliases[parsed.name] = parsed.id
|
|
385
|
+
} else {
|
|
386
|
+
delete canvasAliases[parsed.name]
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
index[parsed.suffix][parsed.name] = absPath
|
|
390
|
+
}
|
|
211
391
|
|
|
212
392
|
// Track which folder a prototype belongs to
|
|
213
393
|
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
@@ -219,13 +399,26 @@ function buildIndex(root) {
|
|
|
219
399
|
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
220
400
|
}
|
|
221
401
|
|
|
222
|
-
// Track inferred routes for canvases
|
|
402
|
+
// Track inferred routes for canvases (keyed by canonical ID)
|
|
223
403
|
if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
|
|
224
|
-
|
|
404
|
+
const canvasKey = parsed.id || parsed.name
|
|
405
|
+
canvasRoutes[canvasKey] = parsed.inferredRoute
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Track canvas groups (canvases sharing a folder prefix)
|
|
409
|
+
// Use canonical ID as key to match the canvas index
|
|
410
|
+
if (parsed.suffix === 'canvas' && parsed.group) {
|
|
411
|
+
const groupKey = parsed.id || parsed.name
|
|
412
|
+
canvasGroups[groupKey] = parsed.group
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Track inferred routes for stories
|
|
416
|
+
if (parsed.suffix === 'story' && parsed.inferredRoute) {
|
|
417
|
+
storyRoutes[parsed.name] = parsed.inferredRoute
|
|
225
418
|
}
|
|
226
419
|
}
|
|
227
420
|
|
|
228
|
-
return { index, protoFolders, flowRoutes, canvasRoutes }
|
|
421
|
+
return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
|
|
229
422
|
}
|
|
230
423
|
|
|
231
424
|
/**
|
|
@@ -279,7 +472,7 @@ function computeTemplateVars(absPath, root) {
|
|
|
279
472
|
*/
|
|
280
473
|
/**
|
|
281
474
|
* Read storyboard.config.json from the project root (if it exists).
|
|
282
|
-
* Returns the parsed config object, or null if not found.
|
|
475
|
+
* Returns the parsed and defaulted config object, or null if not found.
|
|
283
476
|
*/
|
|
284
477
|
function readConfig(root) {
|
|
285
478
|
const configPath = path.resolve(root, 'storyboard.config.json')
|
|
@@ -289,7 +482,7 @@ function readConfig(root) {
|
|
|
289
482
|
const config = parseJsonc(raw, errors)
|
|
290
483
|
// Treat malformed JSON (e.g. mid-edit partial saves) as missing config
|
|
291
484
|
if (errors.length > 0) return { config: null, configPath }
|
|
292
|
-
return { config, configPath }
|
|
485
|
+
return { config: getConfig(config), configPath }
|
|
293
486
|
} catch {
|
|
294
487
|
return { config: null, configPath }
|
|
295
488
|
}
|
|
@@ -333,13 +526,36 @@ function readModesConfig(root) {
|
|
|
333
526
|
return fallback
|
|
334
527
|
}
|
|
335
528
|
|
|
336
|
-
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
|
|
529
|
+
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
|
|
337
530
|
const declarations = []
|
|
338
531
|
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
|
|
339
532
|
const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
|
|
533
|
+
const storyEntries = [] // handled separately (code modules, not JSON data)
|
|
340
534
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
341
535
|
let i = 0
|
|
342
536
|
|
|
537
|
+
// Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
|
|
538
|
+
const gitPaths = [
|
|
539
|
+
...Object.values(index.prototype || {}),
|
|
540
|
+
...Object.values(index.canvas || {}),
|
|
541
|
+
]
|
|
542
|
+
const gitMeta = batchGitMetadata(root, gitPaths)
|
|
543
|
+
|
|
544
|
+
// Read canvas-meta files and build a directory-based lookup
|
|
545
|
+
const canvasMetaByDir = {}
|
|
546
|
+
for (const [, absPath] of Object.entries(index['canvas-meta'] || {})) {
|
|
547
|
+
try {
|
|
548
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
549
|
+
const parsed = parseJsonc(raw)
|
|
550
|
+
if (parsed) {
|
|
551
|
+
// Key by the parent directory path relative to src/canvas/
|
|
552
|
+
const dirPath = path.dirname(absPath).replace(/\\/g, '/')
|
|
553
|
+
const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
|
|
554
|
+
canvasMetaByDir[canvasRelDir] = parsed
|
|
555
|
+
}
|
|
556
|
+
} catch { /* skip invalid meta files */ }
|
|
557
|
+
}
|
|
558
|
+
|
|
343
559
|
for (const suffix of INDEX_KEYS) {
|
|
344
560
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
345
561
|
const varName = `_d${i++}`
|
|
@@ -350,18 +566,17 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
350
566
|
|
|
351
567
|
// Auto-fill gitAuthor for prototype metadata from git history
|
|
352
568
|
if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
|
|
353
|
-
const
|
|
354
|
-
if (gitAuthor) {
|
|
355
|
-
parsed = { ...parsed, gitAuthor }
|
|
569
|
+
const meta = gitMeta.get(absPath)
|
|
570
|
+
if (meta?.gitAuthor) {
|
|
571
|
+
parsed = { ...parsed, gitAuthor: meta.gitAuthor }
|
|
356
572
|
}
|
|
357
573
|
}
|
|
358
574
|
|
|
359
575
|
// Auto-fill lastModified from git history for prototypes
|
|
360
576
|
if (suffix === 'prototype' && parsed) {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
parsed = { ...parsed, lastModified }
|
|
577
|
+
const meta = gitMeta.get(absPath)
|
|
578
|
+
if (meta?.lastModified) {
|
|
579
|
+
parsed = { ...parsed, lastModified: meta.lastModified }
|
|
365
580
|
}
|
|
366
581
|
}
|
|
367
582
|
|
|
@@ -400,17 +615,24 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
400
615
|
|
|
401
616
|
// Auto-fill gitAuthor for canvas metadata from git history
|
|
402
617
|
if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
|
|
403
|
-
const
|
|
404
|
-
if (gitAuthor) {
|
|
405
|
-
parsed = { ...parsed, gitAuthor }
|
|
618
|
+
const meta = gitMeta.get(absPath)
|
|
619
|
+
if (meta?.gitAuthor) {
|
|
620
|
+
parsed = { ...parsed, gitAuthor: meta.gitAuthor }
|
|
406
621
|
}
|
|
407
622
|
}
|
|
408
623
|
|
|
409
|
-
// Inject inferred route and resolve JSX companion for canvases
|
|
624
|
+
// Inject inferred route, group, and resolve JSX companion for canvases
|
|
410
625
|
if (suffix === 'canvas') {
|
|
411
626
|
if (canvasRoutes[name]) {
|
|
412
627
|
parsed = { ...parsed, _route: canvasRoutes[name] }
|
|
413
628
|
}
|
|
629
|
+
if (canvasGroups[name]) {
|
|
630
|
+
parsed = { ...parsed, _group: canvasGroups[name] }
|
|
631
|
+
}
|
|
632
|
+
// Inject canvas folder metadata from .meta.json
|
|
633
|
+
if (canvasGroups[name] && canvasMetaByDir[canvasGroups[name]]) {
|
|
634
|
+
parsed = { ...parsed, _canvasMeta: canvasMetaByDir[canvasGroups[name]] }
|
|
635
|
+
}
|
|
414
636
|
// Inject folder association
|
|
415
637
|
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
416
638
|
if (folderDirMatch) {
|
|
@@ -462,8 +684,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
462
684
|
}
|
|
463
685
|
}
|
|
464
686
|
|
|
687
|
+
// Generate story entries (code modules with dynamic imports, not JSON data)
|
|
688
|
+
for (const [name, absPath] of Object.entries(index.story || {})) {
|
|
689
|
+
const varName = `_d${i++}`
|
|
690
|
+
const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
|
|
691
|
+
const storyMeta = { _storyModule: relModule }
|
|
692
|
+
if (storyRoutes[name]) {
|
|
693
|
+
storyMeta._route = storyRoutes[name]
|
|
694
|
+
}
|
|
695
|
+
declarations.push(
|
|
696
|
+
`const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
|
|
697
|
+
)
|
|
698
|
+
storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
|
|
699
|
+
}
|
|
700
|
+
|
|
465
701
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
466
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
702
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
|
|
467
703
|
|
|
468
704
|
// Feature flags from storyboard.config.json
|
|
469
705
|
const { config } = readConfig(root)
|
|
@@ -531,22 +767,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
531
767
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
532
768
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
533
769
|
`const canvases = {\n${entries.canvas.join(',\n')}\n}`,
|
|
770
|
+
`const stories = {\n${storyEntries.join(',\n')}\n}`,
|
|
771
|
+
'',
|
|
772
|
+
`// Legacy basename → canonical ID aliases (only unique basenames)`,
|
|
773
|
+
`const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
|
|
534
774
|
'',
|
|
535
775
|
'// Backward-compatible alias',
|
|
536
776
|
'const scenes = flows',
|
|
537
777
|
'',
|
|
538
778
|
initCalls.join('\n'),
|
|
539
779
|
'',
|
|
540
|
-
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
541
|
-
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
780
|
+
`export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
781
|
+
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
542
782
|
`export default index`,
|
|
783
|
+
'',
|
|
784
|
+
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
785
|
+
'if (import.meta.hot) {',
|
|
786
|
+
' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
|
|
787
|
+
' if (!data) return',
|
|
788
|
+
' const id = data.canvasId || data.name',
|
|
789
|
+
' if (data.removed) {',
|
|
790
|
+
' delete canvases[id]',
|
|
791
|
+
' } else if (data.metadata) {',
|
|
792
|
+
' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
|
|
793
|
+
' canvases[id] = canvases[id]',
|
|
794
|
+
' ? Object.assign({}, canvases[id], data.metadata)',
|
|
795
|
+
' : data.metadata',
|
|
796
|
+
' }',
|
|
797
|
+
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
798
|
+
' })',
|
|
799
|
+
' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
|
|
800
|
+
' if (!data) return',
|
|
801
|
+
' if (data.removed) {',
|
|
802
|
+
' delete stories[data.name]',
|
|
803
|
+
' } else {',
|
|
804
|
+
' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
|
|
805
|
+
' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
|
|
806
|
+
' }',
|
|
807
|
+
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
808
|
+
' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
|
|
809
|
+
' })',
|
|
810
|
+
'}',
|
|
543
811
|
].join('\n')
|
|
544
812
|
}
|
|
545
813
|
|
|
546
814
|
/**
|
|
547
815
|
* Vite plugin for storyboard data discovery.
|
|
548
816
|
*
|
|
549
|
-
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
|
|
817
|
+
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
|
|
550
818
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
551
819
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
552
820
|
* - Watches for file additions/removals in dev mode
|
|
@@ -562,6 +830,11 @@ export default function storyboardDataPlugin() {
|
|
|
562
830
|
config() {
|
|
563
831
|
return {
|
|
564
832
|
optimizeDeps: {
|
|
833
|
+
// @dfosco/storyboard-react is excluded (virtual module), so Vite
|
|
834
|
+
// can't trace into its deps. Include the remark entry points so
|
|
835
|
+
// Vite pre-bundles the full chain — covers all transitive CJS
|
|
836
|
+
// packages (debug, extend, etc.) without whack-a-mole.
|
|
837
|
+
include: ['remark', 'remark-gfm', 'remark-html'],
|
|
565
838
|
exclude: ['@dfosco/storyboard-react'],
|
|
566
839
|
},
|
|
567
840
|
}
|
|
@@ -582,10 +855,68 @@ export default function storyboardDataPlugin() {
|
|
|
582
855
|
},
|
|
583
856
|
|
|
584
857
|
configureServer(server) {
|
|
858
|
+
// ── Component isolate middleware ───────────────────────────────
|
|
859
|
+
// Serves a minimal HTML shell for iframe-isolated component widgets.
|
|
860
|
+
// The iframe loads componentIsolate.jsx which reads query params
|
|
861
|
+
// (module, export, theme) and renders a single canvas.jsx export.
|
|
862
|
+
const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
|
|
863
|
+
server.middlewares.use(async (req, res, next) => {
|
|
864
|
+
if (!req.url) return next()
|
|
865
|
+
let url = req.url
|
|
866
|
+
const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
|
|
867
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
868
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
869
|
+
}
|
|
870
|
+
if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
|
|
871
|
+
|
|
872
|
+
const rawHtml = [
|
|
873
|
+
'<!DOCTYPE html>',
|
|
874
|
+
'<html><head>',
|
|
875
|
+
'<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
|
|
876
|
+
'</head><body>',
|
|
877
|
+
'<div id="root"></div>',
|
|
878
|
+
`<script type="module" src="/@fs${isolateEntryPath}"></script>`,
|
|
879
|
+
'</body></html>',
|
|
880
|
+
].join('\n')
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const html = await server.transformIndexHtml(req.url, rawHtml)
|
|
884
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
885
|
+
res.end(html)
|
|
886
|
+
} catch (err) {
|
|
887
|
+
console.error('[storyboard] Component isolate HTML transform failed:', err)
|
|
888
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
889
|
+
res.end('Component isolate failed')
|
|
890
|
+
}
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
// ── Stories list API ──────────────────────────────────────────
|
|
894
|
+
// Serves the list of discovered stories for the CLI and UI story picker.
|
|
895
|
+
server.middlewares.use(async (req, res, next) => {
|
|
896
|
+
if (!req.url) return next()
|
|
897
|
+
let url = req.url
|
|
898
|
+
const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
|
|
899
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
900
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
901
|
+
}
|
|
902
|
+
if (!url.startsWith('/_storyboard/stories/list')) return next()
|
|
903
|
+
|
|
904
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
905
|
+
const storyEntries = Object.entries(buildResult.index.story || {})
|
|
906
|
+
const storyRoutes = buildResult.storyRoutes || {}
|
|
907
|
+
const stories = storyEntries.map(([name]) => ({
|
|
908
|
+
name,
|
|
909
|
+
route: storyRoutes[name] || null,
|
|
910
|
+
}))
|
|
911
|
+
|
|
912
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
913
|
+
res.end(JSON.stringify({ stories }))
|
|
914
|
+
})
|
|
915
|
+
|
|
585
916
|
// Watch for data file changes in dev mode
|
|
586
917
|
const watcher = server.watcher
|
|
587
918
|
if (!buildResult) buildResult = buildIndex(root)
|
|
588
|
-
const
|
|
919
|
+
const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
|
|
589
920
|
const pendingCanvasUnlinks = new Map()
|
|
590
921
|
|
|
591
922
|
const triggerFullReload = () => {
|
|
@@ -597,22 +928,50 @@ export default function storyboardDataPlugin() {
|
|
|
597
928
|
}
|
|
598
929
|
}
|
|
599
930
|
|
|
931
|
+
// Mark the virtual module as stale so the next page load rebuilds it,
|
|
932
|
+
// but do NOT trigger a full-reload (avoids losing canvas editing state).
|
|
933
|
+
const softInvalidate = () => {
|
|
934
|
+
buildResult = null
|
|
935
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
936
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Read a canvas file and build HMR metadata for the client-side listener.
|
|
940
|
+
const readCanvasMetadata = (filePath, parsed) => {
|
|
941
|
+
try {
|
|
942
|
+
const absPath = path.resolve(root, filePath)
|
|
943
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
944
|
+
const materialized = materializeFromText(raw)
|
|
945
|
+
const result = { ...materialized }
|
|
946
|
+
// Inject _route and _folder the same way generateModule does
|
|
947
|
+
if (parsed.inferredRoute) result._route = parsed.inferredRoute
|
|
948
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
949
|
+
if (folderDirMatch) result._folder = folderDirMatch[1]
|
|
950
|
+
return result
|
|
951
|
+
} catch {
|
|
952
|
+
return null
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
600
956
|
const invalidate = (filePath) => {
|
|
601
957
|
const normalized = filePath.replace(/\\/g, '/')
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
958
|
+
// Canvas .jsonl content changes are mutated at runtime by the canvas
|
|
959
|
+
// server API. A full-reload would create a feedback loop (save →
|
|
960
|
+
// file change → reload → lose editing state). Instead, soft-invalidate
|
|
961
|
+
// the virtual module (so page refresh picks up changes) and send a
|
|
962
|
+
// custom HMR event with updated metadata so the canvas page and
|
|
963
|
+
// viewfinder can react in place.
|
|
607
964
|
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
608
965
|
const parsed = parseDataFile(filePath)
|
|
609
|
-
if (parsed?.suffix === 'canvas' && parsed?.
|
|
966
|
+
if (parsed?.suffix === 'canvas' && parsed?.id) {
|
|
967
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
610
968
|
server.ws.send({
|
|
611
969
|
type: 'custom',
|
|
612
970
|
event: 'storyboard:canvas-file-changed',
|
|
613
|
-
data: {
|
|
971
|
+
data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
|
|
614
972
|
})
|
|
615
973
|
}
|
|
974
|
+
softInvalidate()
|
|
616
975
|
return
|
|
617
976
|
}
|
|
618
977
|
|
|
@@ -649,53 +1008,89 @@ export default function storyboardDataPlugin() {
|
|
|
649
1008
|
// Treat canvas add/unlink as runtime data updates and never full-reload
|
|
650
1009
|
// from watcher events. Canvas pages sync from disk via custom WS events.
|
|
651
1010
|
if (parsed?.suffix === 'canvas') {
|
|
652
|
-
const
|
|
1011
|
+
const canvasId = parsed.id || parsed.name
|
|
653
1012
|
if (eventType === 'unlink') {
|
|
654
1013
|
const timer = setTimeout(() => {
|
|
655
|
-
pendingCanvasUnlinks.delete(
|
|
656
|
-
|
|
1014
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
1015
|
+
knownCanvasIds.delete(canvasId)
|
|
657
1016
|
server.ws.send({
|
|
658
1017
|
type: 'custom',
|
|
659
1018
|
event: 'storyboard:canvas-file-changed',
|
|
660
|
-
data: { name },
|
|
1019
|
+
data: { canvasId, name: canvasId, removed: true },
|
|
661
1020
|
})
|
|
1021
|
+
softInvalidate()
|
|
662
1022
|
}, 1500)
|
|
663
|
-
pendingCanvasUnlinks.set(
|
|
1023
|
+
pendingCanvasUnlinks.set(canvasId, timer)
|
|
664
1024
|
return
|
|
665
1025
|
}
|
|
666
1026
|
|
|
667
1027
|
if (eventType === 'add') {
|
|
668
|
-
const
|
|
1028
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
1029
|
+
const pending = pendingCanvasUnlinks.get(canvasId)
|
|
669
1030
|
if (pending) {
|
|
1031
|
+
// unlink+add pair = in-place save (atomic write), not a real remove
|
|
670
1032
|
clearTimeout(pending)
|
|
671
|
-
pendingCanvasUnlinks.delete(
|
|
1033
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
672
1034
|
server.ws.send({
|
|
673
1035
|
type: 'custom',
|
|
674
1036
|
event: 'storyboard:canvas-file-changed',
|
|
675
|
-
data: { name },
|
|
1037
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
676
1038
|
})
|
|
1039
|
+
softInvalidate()
|
|
677
1040
|
return
|
|
678
1041
|
}
|
|
679
1042
|
|
|
680
|
-
if (
|
|
1043
|
+
if (knownCanvasIds.has(canvasId)) {
|
|
681
1044
|
server.ws.send({
|
|
682
1045
|
type: 'custom',
|
|
683
1046
|
event: 'storyboard:canvas-file-changed',
|
|
684
|
-
data: { name },
|
|
1047
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
685
1048
|
})
|
|
1049
|
+
softInvalidate()
|
|
686
1050
|
return
|
|
687
1051
|
}
|
|
688
1052
|
|
|
689
|
-
|
|
1053
|
+
knownCanvasIds.add(canvasId)
|
|
690
1054
|
server.ws.send({
|
|
691
1055
|
type: 'custom',
|
|
692
1056
|
event: 'storyboard:canvas-file-changed',
|
|
693
|
-
data: { name },
|
|
1057
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
694
1058
|
})
|
|
1059
|
+
softInvalidate()
|
|
695
1060
|
return
|
|
696
1061
|
}
|
|
697
1062
|
}
|
|
698
1063
|
|
|
1064
|
+
// Story add/remove: soft-invalidate + custom HMR event (full-reload
|
|
1065
|
+
// is blocked by the canvas reload guard). The virtual module HMR
|
|
1066
|
+
// handler live-patches `stories` and re-runs init().
|
|
1067
|
+
if (parsed?.suffix === 'story') {
|
|
1068
|
+
softInvalidate()
|
|
1069
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
1070
|
+
const storyRoutes = buildResult.storyRoutes || {}
|
|
1071
|
+
const storyIndex = buildResult.index.story || {}
|
|
1072
|
+
const name = parsed.name
|
|
1073
|
+
if (eventType === 'unlink') {
|
|
1074
|
+
server.ws.send({
|
|
1075
|
+
type: 'custom',
|
|
1076
|
+
event: 'storyboard:story-file-changed',
|
|
1077
|
+
data: { name, removed: true },
|
|
1078
|
+
})
|
|
1079
|
+
} else if (eventType === 'add' && storyIndex[name]) {
|
|
1080
|
+
const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
|
|
1081
|
+
server.ws.send({
|
|
1082
|
+
type: 'custom',
|
|
1083
|
+
event: 'storyboard:story-file-changed',
|
|
1084
|
+
data: {
|
|
1085
|
+
name,
|
|
1086
|
+
_storyModule: relModule,
|
|
1087
|
+
_route: storyRoutes[name] || null,
|
|
1088
|
+
},
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
|
|
699
1094
|
// Non-canvas additions/removals and folder changes update the route/data graph.
|
|
700
1095
|
triggerFullReload()
|
|
701
1096
|
}
|
|
@@ -726,18 +1121,10 @@ export default function storyboardDataPlugin() {
|
|
|
726
1121
|
const normalized = ctx.file.replace(/\\/g, '/')
|
|
727
1122
|
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
728
1123
|
|
|
729
|
-
const parsed = parseDataFile(ctx.file)
|
|
730
|
-
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
731
|
-
ctx.server.ws.send({
|
|
732
|
-
type: 'custom',
|
|
733
|
-
event: 'storyboard:canvas-file-changed',
|
|
734
|
-
data: { name: parsed.name },
|
|
735
|
-
})
|
|
736
|
-
}
|
|
737
|
-
|
|
738
1124
|
// Prevent Vite's default fallback behavior (full page reload) for
|
|
739
|
-
// non-module .canvas.jsonl edits.
|
|
740
|
-
//
|
|
1125
|
+
// non-module .canvas.jsonl edits. The watcher 'change' handler
|
|
1126
|
+
// (invalidate) already sends the custom HMR event and soft-invalidates
|
|
1127
|
+
// the virtual module — no duplicate event needed here.
|
|
741
1128
|
return []
|
|
742
1129
|
},
|
|
743
1130
|
|
|
@@ -749,4 +1136,4 @@ export default function storyboardDataPlugin() {
|
|
|
749
1136
|
}
|
|
750
1137
|
|
|
751
1138
|
// Exported for testing
|
|
752
|
-
export { resolveTemplateVars, computeTemplateVars }
|
|
1139
|
+
export { resolveTemplateVars, computeTemplateVars, parseDataFile }
|