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