@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41
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 +9 -4
- package/src/Icon.jsx +179 -0
- package/src/Viewfinder.jsx +1030 -57
- package/src/Viewfinder.module.css +1524 -155
- 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 +843 -301
- package/src/canvas/CanvasPage.module.css +73 -50
- 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 +198 -0
- package/src/canvas/PageSelector.module.css +158 -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 +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -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 +95 -144
- 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 +276 -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/snapshotDisplay.test.jsx +259 -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 +375 -57
- 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?)$/)
|
|
@@ -250,6 +318,8 @@ function buildIndex(root) {
|
|
|
250
318
|
const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
|
|
251
319
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
252
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 })
|
|
253
323
|
|
|
254
324
|
// Detect nested .folder/ directories (not supported)
|
|
255
325
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -266,35 +336,58 @@ function buildIndex(root) {
|
|
|
266
336
|
}
|
|
267
337
|
}
|
|
268
338
|
|
|
269
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
|
|
270
|
-
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)
|
|
271
341
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
272
342
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
273
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
|
|
274
348
|
|
|
275
|
-
for (const relPath of [...files, ...canvasFiles]) {
|
|
349
|
+
for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
|
|
276
350
|
const parsed = parseDataFile(relPath)
|
|
277
351
|
if (!parsed) continue
|
|
278
352
|
|
|
279
|
-
|
|
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}`
|
|
280
357
|
const absPath = path.resolve(root, relPath)
|
|
281
358
|
|
|
282
|
-
if (seen[
|
|
359
|
+
if (seen[dedupKey]) {
|
|
283
360
|
const hint = parsed.suffix === 'folder'
|
|
284
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.'
|
|
285
364
|
: ' Flows, records, and objects are scoped to their prototype directory.\n' +
|
|
286
365
|
' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
|
|
287
366
|
|
|
288
367
|
throw new Error(
|
|
289
|
-
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
|
|
290
|
-
` Found at: ${seen[
|
|
368
|
+
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
|
|
369
|
+
` Found at: ${seen[dedupKey]}\n` +
|
|
291
370
|
` And at: ${absPath}\n` +
|
|
292
371
|
hint
|
|
293
372
|
)
|
|
294
373
|
}
|
|
295
374
|
|
|
296
|
-
seen[
|
|
297
|
-
|
|
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
|
+
}
|
|
298
391
|
|
|
299
392
|
// Track which folder a prototype belongs to
|
|
300
393
|
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
@@ -306,13 +399,26 @@ function buildIndex(root) {
|
|
|
306
399
|
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
307
400
|
}
|
|
308
401
|
|
|
309
|
-
// Track inferred routes for canvases
|
|
402
|
+
// Track inferred routes for canvases (keyed by canonical ID)
|
|
310
403
|
if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
|
|
311
|
-
|
|
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
|
|
312
418
|
}
|
|
313
419
|
}
|
|
314
420
|
|
|
315
|
-
return { index, protoFolders, flowRoutes, canvasRoutes }
|
|
421
|
+
return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
|
|
316
422
|
}
|
|
317
423
|
|
|
318
424
|
/**
|
|
@@ -366,7 +472,7 @@ function computeTemplateVars(absPath, root) {
|
|
|
366
472
|
*/
|
|
367
473
|
/**
|
|
368
474
|
* Read storyboard.config.json from the project root (if it exists).
|
|
369
|
-
* Returns the parsed config object, or null if not found.
|
|
475
|
+
* Returns the parsed and defaulted config object, or null if not found.
|
|
370
476
|
*/
|
|
371
477
|
function readConfig(root) {
|
|
372
478
|
const configPath = path.resolve(root, 'storyboard.config.json')
|
|
@@ -376,7 +482,7 @@ function readConfig(root) {
|
|
|
376
482
|
const config = parseJsonc(raw, errors)
|
|
377
483
|
// Treat malformed JSON (e.g. mid-edit partial saves) as missing config
|
|
378
484
|
if (errors.length > 0) return { config: null, configPath }
|
|
379
|
-
return { config, configPath }
|
|
485
|
+
return { config: getConfig(config), configPath }
|
|
380
486
|
} catch {
|
|
381
487
|
return { config: null, configPath }
|
|
382
488
|
}
|
|
@@ -420,10 +526,11 @@ function readModesConfig(root) {
|
|
|
420
526
|
return fallback
|
|
421
527
|
}
|
|
422
528
|
|
|
423
|
-
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
|
|
529
|
+
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
|
|
424
530
|
const declarations = []
|
|
425
531
|
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
|
|
426
532
|
const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
|
|
533
|
+
const storyEntries = [] // handled separately (code modules, not JSON data)
|
|
427
534
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
428
535
|
let i = 0
|
|
429
536
|
|
|
@@ -434,6 +541,21 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
434
541
|
]
|
|
435
542
|
const gitMeta = batchGitMetadata(root, gitPaths)
|
|
436
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
|
+
|
|
437
559
|
for (const suffix of INDEX_KEYS) {
|
|
438
560
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
439
561
|
const varName = `_d${i++}`
|
|
@@ -499,11 +621,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
499
621
|
}
|
|
500
622
|
}
|
|
501
623
|
|
|
502
|
-
// Inject inferred route and resolve JSX companion for canvases
|
|
624
|
+
// Inject inferred route, group, and resolve JSX companion for canvases
|
|
503
625
|
if (suffix === 'canvas') {
|
|
504
626
|
if (canvasRoutes[name]) {
|
|
505
627
|
parsed = { ...parsed, _route: canvasRoutes[name] }
|
|
506
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
|
+
}
|
|
507
636
|
// Inject folder association
|
|
508
637
|
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
509
638
|
if (folderDirMatch) {
|
|
@@ -555,8 +684,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
555
684
|
}
|
|
556
685
|
}
|
|
557
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
|
+
|
|
558
701
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
559
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
702
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
|
|
560
703
|
|
|
561
704
|
// Feature flags from storyboard.config.json
|
|
562
705
|
const { config } = readConfig(root)
|
|
@@ -624,22 +767,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
624
767
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
625
768
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
626
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 || {})}`,
|
|
627
774
|
'',
|
|
628
775
|
'// Backward-compatible alias',
|
|
629
776
|
'const scenes = flows',
|
|
630
777
|
'',
|
|
631
778
|
initCalls.join('\n'),
|
|
632
779
|
'',
|
|
633
|
-
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
634
|
-
`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 }`,
|
|
635
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
|
+
'}',
|
|
636
811
|
].join('\n')
|
|
637
812
|
}
|
|
638
813
|
|
|
639
814
|
/**
|
|
640
815
|
* Vite plugin for storyboard data discovery.
|
|
641
816
|
*
|
|
642
|
-
* - 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}
|
|
643
818
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
644
819
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
645
820
|
* - Watches for file additions/removals in dev mode
|
|
@@ -655,6 +830,11 @@ export default function storyboardDataPlugin() {
|
|
|
655
830
|
config() {
|
|
656
831
|
return {
|
|
657
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', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
|
|
658
838
|
exclude: ['@dfosco/storyboard-react'],
|
|
659
839
|
},
|
|
660
840
|
}
|
|
@@ -675,10 +855,68 @@ export default function storyboardDataPlugin() {
|
|
|
675
855
|
},
|
|
676
856
|
|
|
677
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
|
+
|
|
678
916
|
// Watch for data file changes in dev mode
|
|
679
917
|
const watcher = server.watcher
|
|
680
918
|
if (!buildResult) buildResult = buildIndex(root)
|
|
681
|
-
const
|
|
919
|
+
const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
|
|
682
920
|
const pendingCanvasUnlinks = new Map()
|
|
683
921
|
|
|
684
922
|
const triggerFullReload = () => {
|
|
@@ -690,22 +928,50 @@ export default function storyboardDataPlugin() {
|
|
|
690
928
|
}
|
|
691
929
|
}
|
|
692
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
|
+
|
|
693
956
|
const invalidate = (filePath) => {
|
|
694
957
|
const normalized = filePath.replace(/\\/g, '/')
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
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.
|
|
700
964
|
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
701
965
|
const parsed = parseDataFile(filePath)
|
|
702
|
-
if (parsed?.suffix === 'canvas' && parsed?.
|
|
966
|
+
if (parsed?.suffix === 'canvas' && parsed?.id) {
|
|
967
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
703
968
|
server.ws.send({
|
|
704
969
|
type: 'custom',
|
|
705
970
|
event: 'storyboard:canvas-file-changed',
|
|
706
|
-
data: {
|
|
971
|
+
data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
|
|
707
972
|
})
|
|
708
973
|
}
|
|
974
|
+
softInvalidate()
|
|
709
975
|
return
|
|
710
976
|
}
|
|
711
977
|
|
|
@@ -742,53 +1008,89 @@ export default function storyboardDataPlugin() {
|
|
|
742
1008
|
// Treat canvas add/unlink as runtime data updates and never full-reload
|
|
743
1009
|
// from watcher events. Canvas pages sync from disk via custom WS events.
|
|
744
1010
|
if (parsed?.suffix === 'canvas') {
|
|
745
|
-
const
|
|
1011
|
+
const canvasId = parsed.id || parsed.name
|
|
746
1012
|
if (eventType === 'unlink') {
|
|
747
1013
|
const timer = setTimeout(() => {
|
|
748
|
-
pendingCanvasUnlinks.delete(
|
|
749
|
-
|
|
1014
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
1015
|
+
knownCanvasIds.delete(canvasId)
|
|
750
1016
|
server.ws.send({
|
|
751
1017
|
type: 'custom',
|
|
752
1018
|
event: 'storyboard:canvas-file-changed',
|
|
753
|
-
data: { name },
|
|
1019
|
+
data: { canvasId, name: canvasId, removed: true },
|
|
754
1020
|
})
|
|
1021
|
+
softInvalidate()
|
|
755
1022
|
}, 1500)
|
|
756
|
-
pendingCanvasUnlinks.set(
|
|
1023
|
+
pendingCanvasUnlinks.set(canvasId, timer)
|
|
757
1024
|
return
|
|
758
1025
|
}
|
|
759
1026
|
|
|
760
1027
|
if (eventType === 'add') {
|
|
761
|
-
const
|
|
1028
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
1029
|
+
const pending = pendingCanvasUnlinks.get(canvasId)
|
|
762
1030
|
if (pending) {
|
|
1031
|
+
// unlink+add pair = in-place save (atomic write), not a real remove
|
|
763
1032
|
clearTimeout(pending)
|
|
764
|
-
pendingCanvasUnlinks.delete(
|
|
1033
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
765
1034
|
server.ws.send({
|
|
766
1035
|
type: 'custom',
|
|
767
1036
|
event: 'storyboard:canvas-file-changed',
|
|
768
|
-
data: { name },
|
|
1037
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
769
1038
|
})
|
|
1039
|
+
softInvalidate()
|
|
770
1040
|
return
|
|
771
1041
|
}
|
|
772
1042
|
|
|
773
|
-
if (
|
|
1043
|
+
if (knownCanvasIds.has(canvasId)) {
|
|
774
1044
|
server.ws.send({
|
|
775
1045
|
type: 'custom',
|
|
776
1046
|
event: 'storyboard:canvas-file-changed',
|
|
777
|
-
data: { name },
|
|
1047
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
778
1048
|
})
|
|
1049
|
+
softInvalidate()
|
|
779
1050
|
return
|
|
780
1051
|
}
|
|
781
1052
|
|
|
782
|
-
|
|
1053
|
+
knownCanvasIds.add(canvasId)
|
|
783
1054
|
server.ws.send({
|
|
784
1055
|
type: 'custom',
|
|
785
1056
|
event: 'storyboard:canvas-file-changed',
|
|
786
|
-
data: { name },
|
|
1057
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
787
1058
|
})
|
|
1059
|
+
softInvalidate()
|
|
788
1060
|
return
|
|
789
1061
|
}
|
|
790
1062
|
}
|
|
791
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
|
+
|
|
792
1094
|
// Non-canvas additions/removals and folder changes update the route/data graph.
|
|
793
1095
|
triggerFullReload()
|
|
794
1096
|
}
|
|
@@ -819,21 +1121,37 @@ export default function storyboardDataPlugin() {
|
|
|
819
1121
|
const normalized = ctx.file.replace(/\\/g, '/')
|
|
820
1122
|
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
821
1123
|
|
|
822
|
-
const parsed = parseDataFile(ctx.file)
|
|
823
|
-
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
824
|
-
ctx.server.ws.send({
|
|
825
|
-
type: 'custom',
|
|
826
|
-
event: 'storyboard:canvas-file-changed',
|
|
827
|
-
data: { name: parsed.name },
|
|
828
|
-
})
|
|
829
|
-
}
|
|
830
|
-
|
|
831
1124
|
// Prevent Vite's default fallback behavior (full page reload) for
|
|
832
|
-
// non-module .canvas.jsonl edits.
|
|
833
|
-
//
|
|
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.
|
|
834
1128
|
return []
|
|
835
1129
|
},
|
|
836
1130
|
|
|
1131
|
+
// Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
|
|
1132
|
+
// Reads .worktrees/ports.json to enumerate active worktree dev servers.
|
|
1133
|
+
transformIndexHtml(html, ctx) {
|
|
1134
|
+
// Only inject in dev mode
|
|
1135
|
+
if (!ctx.server) return html
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
|
|
1139
|
+
if (!fs.existsSync(portsJsonPath)) return html
|
|
1140
|
+
|
|
1141
|
+
const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
|
|
1142
|
+
const branches = Object.entries(ports)
|
|
1143
|
+
.filter(([name]) => name !== 'main')
|
|
1144
|
+
.map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
|
|
1145
|
+
|
|
1146
|
+
if (branches.length === 0) return html
|
|
1147
|
+
|
|
1148
|
+
const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
|
|
1149
|
+
return html.replace('</head>', `${script}\n</head>`)
|
|
1150
|
+
} catch {
|
|
1151
|
+
return html
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
|
|
837
1155
|
// Rebuild index on each build start
|
|
838
1156
|
buildStart() {
|
|
839
1157
|
buildResult = null
|
|
@@ -842,4 +1160,4 @@ export default function storyboardDataPlugin() {
|
|
|
842
1160
|
}
|
|
843
1161
|
|
|
844
1162
|
// Exported for testing
|
|
845
|
-
export { resolveTemplateVars, computeTemplateVars }
|
|
1163
|
+
export { resolveTemplateVars, computeTemplateVars, parseDataFile }
|