@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15
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 +3 -3
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.jsx +77 -109
- package/src/canvas/CanvasPage.module.css +3 -47
- 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/componentIsolate.jsx +3 -3
- package/src/canvas/widgets/FigmaEmbed.jsx +6 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +84 -4
- package/src/canvas/widgets/MarkdownBlock.module.css +30 -4
- package/src/canvas/widgets/PrototypeEmbed.jsx +177 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +34 -0
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StoryWidget.jsx +438 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +30 -3
- 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 +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +4 -1
- package/src/context.jsx +138 -13
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +234 -27
- package/src/vite/data-plugin.test.js +179 -4
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 '../../../core/src/canvas/identity.js'
|
|
8
|
+
import { getConfig } from '../../../core/src/configSchema.js'
|
|
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?)$/)
|
|
@@ -250,6 +316,8 @@ function buildIndex(root) {
|
|
|
250
316
|
const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
|
|
251
317
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
252
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 })
|
|
253
321
|
|
|
254
322
|
// Detect nested .folder/ directories (not supported)
|
|
255
323
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -266,35 +334,58 @@ function buildIndex(root) {
|
|
|
266
334
|
}
|
|
267
335
|
}
|
|
268
336
|
|
|
269
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
|
|
270
|
-
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)
|
|
271
339
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
272
340
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
273
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
|
|
274
346
|
|
|
275
|
-
for (const relPath of [...files, ...canvasFiles]) {
|
|
347
|
+
for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
|
|
276
348
|
const parsed = parseDataFile(relPath)
|
|
277
349
|
if (!parsed) continue
|
|
278
350
|
|
|
279
|
-
|
|
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}`
|
|
280
355
|
const absPath = path.resolve(root, relPath)
|
|
281
356
|
|
|
282
|
-
if (seen[
|
|
357
|
+
if (seen[dedupKey]) {
|
|
283
358
|
const hint = parsed.suffix === 'folder'
|
|
284
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.'
|
|
285
362
|
: ' Flows, records, and objects are scoped to their prototype directory.\n' +
|
|
286
363
|
' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
|
|
287
364
|
|
|
288
365
|
throw new Error(
|
|
289
|
-
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
|
|
290
|
-
` Found at: ${seen[
|
|
366
|
+
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
|
|
367
|
+
` Found at: ${seen[dedupKey]}\n` +
|
|
291
368
|
` And at: ${absPath}\n` +
|
|
292
369
|
hint
|
|
293
370
|
)
|
|
294
371
|
}
|
|
295
372
|
|
|
296
|
-
seen[
|
|
297
|
-
|
|
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
|
+
}
|
|
298
389
|
|
|
299
390
|
// Track which folder a prototype belongs to
|
|
300
391
|
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
@@ -306,13 +397,24 @@ function buildIndex(root) {
|
|
|
306
397
|
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
307
398
|
}
|
|
308
399
|
|
|
309
|
-
// Track inferred routes for canvases
|
|
400
|
+
// Track inferred routes for canvases (keyed by canonical ID)
|
|
310
401
|
if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
|
|
311
|
-
|
|
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
|
|
312
414
|
}
|
|
313
415
|
}
|
|
314
416
|
|
|
315
|
-
return { index, protoFolders, flowRoutes, canvasRoutes }
|
|
417
|
+
return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
|
|
316
418
|
}
|
|
317
419
|
|
|
318
420
|
/**
|
|
@@ -366,7 +468,7 @@ function computeTemplateVars(absPath, root) {
|
|
|
366
468
|
*/
|
|
367
469
|
/**
|
|
368
470
|
* Read storyboard.config.json from the project root (if it exists).
|
|
369
|
-
* Returns the parsed config object, or null if not found.
|
|
471
|
+
* Returns the parsed and defaulted config object, or null if not found.
|
|
370
472
|
*/
|
|
371
473
|
function readConfig(root) {
|
|
372
474
|
const configPath = path.resolve(root, 'storyboard.config.json')
|
|
@@ -376,7 +478,7 @@ function readConfig(root) {
|
|
|
376
478
|
const config = parseJsonc(raw, errors)
|
|
377
479
|
// Treat malformed JSON (e.g. mid-edit partial saves) as missing config
|
|
378
480
|
if (errors.length > 0) return { config: null, configPath }
|
|
379
|
-
return { config, configPath }
|
|
481
|
+
return { config: getConfig(config), configPath }
|
|
380
482
|
} catch {
|
|
381
483
|
return { config: null, configPath }
|
|
382
484
|
}
|
|
@@ -420,10 +522,11 @@ function readModesConfig(root) {
|
|
|
420
522
|
return fallback
|
|
421
523
|
}
|
|
422
524
|
|
|
423
|
-
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
|
|
525
|
+
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
|
|
424
526
|
const declarations = []
|
|
425
527
|
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
|
|
426
528
|
const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
|
|
529
|
+
const storyEntries = [] // handled separately (code modules, not JSON data)
|
|
427
530
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
428
531
|
let i = 0
|
|
429
532
|
|
|
@@ -434,6 +537,21 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
434
537
|
]
|
|
435
538
|
const gitMeta = batchGitMetadata(root, gitPaths)
|
|
436
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
|
+
|
|
437
555
|
for (const suffix of INDEX_KEYS) {
|
|
438
556
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
439
557
|
const varName = `_d${i++}`
|
|
@@ -499,11 +617,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
499
617
|
}
|
|
500
618
|
}
|
|
501
619
|
|
|
502
|
-
// Inject inferred route and resolve JSX companion for canvases
|
|
620
|
+
// Inject inferred route, group, and resolve JSX companion for canvases
|
|
503
621
|
if (suffix === 'canvas') {
|
|
504
622
|
if (canvasRoutes[name]) {
|
|
505
623
|
parsed = { ...parsed, _route: canvasRoutes[name] }
|
|
506
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
|
+
}
|
|
507
632
|
// Inject folder association
|
|
508
633
|
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
509
634
|
if (folderDirMatch) {
|
|
@@ -555,8 +680,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
555
680
|
}
|
|
556
681
|
}
|
|
557
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
|
+
|
|
558
697
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
559
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
698
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
|
|
560
699
|
|
|
561
700
|
// Feature flags from storyboard.config.json
|
|
562
701
|
const { config } = readConfig(root)
|
|
@@ -624,14 +763,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
624
763
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
625
764
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
626
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 || {})}`,
|
|
627
770
|
'',
|
|
628
771
|
'// Backward-compatible alias',
|
|
629
772
|
'const scenes = flows',
|
|
630
773
|
'',
|
|
631
774
|
initCalls.join('\n'),
|
|
632
775
|
'',
|
|
633
|
-
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
634
|
-
`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 }`,
|
|
635
778
|
`export default index`,
|
|
636
779
|
'',
|
|
637
780
|
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
@@ -646,7 +789,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
646
789
|
' ? Object.assign({}, canvases[data.name], data.metadata)',
|
|
647
790
|
' : data.metadata',
|
|
648
791
|
' }',
|
|
649
|
-
' init({ flows, objects, records, prototypes, folders, canvases })',
|
|
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"))',
|
|
650
804
|
' })',
|
|
651
805
|
'}',
|
|
652
806
|
].join('\n')
|
|
@@ -655,7 +809,7 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
655
809
|
/**
|
|
656
810
|
* Vite plugin for storyboard data discovery.
|
|
657
811
|
*
|
|
658
|
-
* - 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}
|
|
659
813
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
660
814
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
661
815
|
* - Watches for file additions/removals in dev mode
|
|
@@ -731,6 +885,29 @@ export default function storyboardDataPlugin() {
|
|
|
731
885
|
}
|
|
732
886
|
})
|
|
733
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
|
+
|
|
734
911
|
// Watch for data file changes in dev mode
|
|
735
912
|
const watcher = server.watcher
|
|
736
913
|
if (!buildResult) buildResult = buildIndex(root)
|
|
@@ -879,6 +1056,36 @@ export default function storyboardDataPlugin() {
|
|
|
879
1056
|
}
|
|
880
1057
|
}
|
|
881
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
|
+
|
|
882
1089
|
// Non-canvas additions/removals and folder changes update the route/data graph.
|
|
883
1090
|
triggerFullReload()
|
|
884
1091
|
}
|
|
@@ -924,4 +1131,4 @@ export default function storyboardDataPlugin() {
|
|
|
924
1131
|
}
|
|
925
1132
|
|
|
926
1133
|
// Exported for testing
|
|
927
|
-
export { resolveTemplateVars, computeTemplateVars }
|
|
1134
|
+
export { resolveTemplateVars, computeTemplateVars, parseDataFile }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
|
|
4
|
+
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
|
|
5
5
|
|
|
6
6
|
const RESOLVED_ID = '\0virtual:storyboard-data-index'
|
|
7
7
|
|
|
@@ -77,13 +77,13 @@ describe('storyboardDataPlugin', () => {
|
|
|
77
77
|
const code = plugin.load(RESOLVED_ID)
|
|
78
78
|
|
|
79
79
|
expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
|
|
80
|
-
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
|
|
80
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
|
|
81
81
|
expect(code).toContain('"Test"')
|
|
82
82
|
expect(code).toContain('"Jane"')
|
|
83
83
|
expect(code).toContain('"First"')
|
|
84
84
|
// Backward-compat alias
|
|
85
85
|
expect(code).toContain('const scenes = flows')
|
|
86
|
-
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases }')
|
|
86
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
|
|
87
87
|
})
|
|
88
88
|
|
|
89
89
|
it('load returns null for other IDs', () => {
|
|
@@ -169,7 +169,7 @@ describe('storyboardDataPlugin', () => {
|
|
|
169
169
|
|
|
170
170
|
// .scene.json files should be normalized to the flows category
|
|
171
171
|
expect(code).toContain('"Legacy Scene"')
|
|
172
|
-
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
|
|
172
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
|
|
173
173
|
})
|
|
174
174
|
|
|
175
175
|
it('buildStart resets the index cache', () => {
|
|
@@ -1045,4 +1045,179 @@ describe('canvas watcher behavior', () => {
|
|
|
1045
1045
|
expect(code2).toContain('"refresh-canvas"')
|
|
1046
1046
|
expect(code2).toContain('After Refresh')
|
|
1047
1047
|
})
|
|
1048
|
+
|
|
1049
|
+
// ── Story file discovery ──────────────────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
it('discovers .story.jsx files and generates _storyImport', () => {
|
|
1052
|
+
writeDataFiles(tmpDir)
|
|
1053
|
+
writeFileSync(
|
|
1054
|
+
path.join(tmpDir, 'button-patterns.story.jsx'),
|
|
1055
|
+
'export function Primary() { return null }',
|
|
1056
|
+
)
|
|
1057
|
+
const plugin = createPlugin()
|
|
1058
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1059
|
+
|
|
1060
|
+
expect(code).toContain('"button-patterns"')
|
|
1061
|
+
expect(code).toContain('_storyModule')
|
|
1062
|
+
expect(code).toContain('_storyImport')
|
|
1063
|
+
expect(code).toContain('.story.jsx')
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
it('discovers .story.tsx files', () => {
|
|
1067
|
+
writeDataFiles(tmpDir)
|
|
1068
|
+
writeFileSync(
|
|
1069
|
+
path.join(tmpDir, 'card.story.tsx'),
|
|
1070
|
+
'export function Default() { return null }',
|
|
1071
|
+
)
|
|
1072
|
+
const plugin = createPlugin()
|
|
1073
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1074
|
+
|
|
1075
|
+
expect(code).toContain('"card"')
|
|
1076
|
+
expect(code).toContain('card.story.tsx')
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
it('skips _-prefixed story files', () => {
|
|
1080
|
+
writeDataFiles(tmpDir)
|
|
1081
|
+
writeFileSync(
|
|
1082
|
+
path.join(tmpDir, '_draft.story.jsx'),
|
|
1083
|
+
'export function Draft() { return null }',
|
|
1084
|
+
)
|
|
1085
|
+
const plugin = createPlugin()
|
|
1086
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1087
|
+
|
|
1088
|
+
expect(code).not.toContain('"_draft"')
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
it('throws on duplicate story names', () => {
|
|
1092
|
+
writeDataFiles(tmpDir)
|
|
1093
|
+
mkdirSync(path.join(tmpDir, 'a'), { recursive: true })
|
|
1094
|
+
mkdirSync(path.join(tmpDir, 'b'), { recursive: true })
|
|
1095
|
+
writeFileSync(
|
|
1096
|
+
path.join(tmpDir, 'a', 'dupe.story.jsx'),
|
|
1097
|
+
'export function A() { return null }',
|
|
1098
|
+
)
|
|
1099
|
+
writeFileSync(
|
|
1100
|
+
path.join(tmpDir, 'b', 'dupe.story.jsx'),
|
|
1101
|
+
'export function B() { return null }',
|
|
1102
|
+
)
|
|
1103
|
+
const plugin = createPlugin()
|
|
1104
|
+
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate story "dupe"/)
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('includes stories in the init() call and exports', () => {
|
|
1108
|
+
writeDataFiles(tmpDir)
|
|
1109
|
+
writeFileSync(
|
|
1110
|
+
path.join(tmpDir, 'test.story.jsx'),
|
|
1111
|
+
'export function Test() { return null }',
|
|
1112
|
+
)
|
|
1113
|
+
const plugin = createPlugin()
|
|
1114
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1115
|
+
|
|
1116
|
+
expect(code).toContain('const stories = {')
|
|
1117
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
|
|
1118
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, stories }')
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('infers /components/ route for stories in src/canvas/', () => {
|
|
1122
|
+
writeDataFiles(tmpDir)
|
|
1123
|
+
mkdirSync(path.join(tmpDir, 'src', 'canvas'), { recursive: true })
|
|
1124
|
+
writeFileSync(
|
|
1125
|
+
path.join(tmpDir, 'src', 'canvas', 'button-patterns.story.jsx'),
|
|
1126
|
+
'export function Primary() { return null }',
|
|
1127
|
+
)
|
|
1128
|
+
const plugin = createPlugin()
|
|
1129
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1130
|
+
|
|
1131
|
+
expect(code).toContain('"button-patterns"')
|
|
1132
|
+
expect(code).toContain('"/components/button-patterns"')
|
|
1133
|
+
expect(code).toContain('_route')
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
it('infers /components/ route for stories in src/components/', () => {
|
|
1137
|
+
writeDataFiles(tmpDir)
|
|
1138
|
+
mkdirSync(path.join(tmpDir, 'src', 'components'), { recursive: true })
|
|
1139
|
+
writeFileSync(
|
|
1140
|
+
path.join(tmpDir, 'src', 'components', 'text-input.story.jsx'),
|
|
1141
|
+
'export function Default() { return null }',
|
|
1142
|
+
)
|
|
1143
|
+
const plugin = createPlugin()
|
|
1144
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1145
|
+
|
|
1146
|
+
expect(code).toContain('"text-input"')
|
|
1147
|
+
expect(code).toContain('"/components/text-input"')
|
|
1148
|
+
})
|
|
1149
|
+
|
|
1150
|
+
it('stories outside src/canvas/ or src/components/ have no inferred route', () => {
|
|
1151
|
+
writeDataFiles(tmpDir)
|
|
1152
|
+
writeFileSync(
|
|
1153
|
+
path.join(tmpDir, 'orphan.story.jsx'),
|
|
1154
|
+
'export function Default() { return null }',
|
|
1155
|
+
)
|
|
1156
|
+
const plugin = createPlugin()
|
|
1157
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1158
|
+
|
|
1159
|
+
expect(code).toContain('"orphan"')
|
|
1160
|
+
// Should not have _route since it's not in a recognized directory
|
|
1161
|
+
expect(code).not.toContain('"/orphan"')
|
|
1162
|
+
})
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
describe('parseDataFile — canvas path-based IDs', () => {
|
|
1166
|
+
it('flat canvas in src/canvas/ gets basename-only ID', () => {
|
|
1167
|
+
const result = parseDataFile('src/canvas/overview.canvas.jsonl')
|
|
1168
|
+
expect(result.name).toBe('overview')
|
|
1169
|
+
expect(result.inferredRoute).toBe('/canvas/overview')
|
|
1170
|
+
expect(result.group).toBeNull()
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it('canvas inside .folder/ gets path-based ID', () => {
|
|
1174
|
+
const result = parseDataFile('src/canvas/research.folder/interviews.canvas.jsonl')
|
|
1175
|
+
expect(result.name).toBe('research/interviews')
|
|
1176
|
+
expect(result.inferredRoute).toBe('/canvas/research/interviews')
|
|
1177
|
+
expect(result.group).toBe('research')
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('duplicate basenames in different folders get distinct IDs', () => {
|
|
1181
|
+
const a = parseDataFile('src/canvas/alpha.folder/overview.canvas.jsonl')
|
|
1182
|
+
const b = parseDataFile('src/canvas/beta.folder/overview.canvas.jsonl')
|
|
1183
|
+
expect(a.name).toBe('alpha/overview')
|
|
1184
|
+
expect(b.name).toBe('beta/overview')
|
|
1185
|
+
expect(a.name).not.toBe(b.name)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it('prototype-scoped canvas gets path-based ID', () => {
|
|
1189
|
+
const result = parseDataFile('src/prototypes/Dashboard/plan.canvas.jsonl')
|
|
1190
|
+
expect(result.name).toBe('Dashboard/plan')
|
|
1191
|
+
expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('prototype inside .folder/ strips folder from ID', () => {
|
|
1195
|
+
const result = parseDataFile('src/prototypes/main.folder/Dashboard/plan.canvas.jsonl')
|
|
1196
|
+
expect(result.name).toBe('Dashboard/plan')
|
|
1197
|
+
expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
it('skips _-prefixed canvas files', () => {
|
|
1201
|
+
expect(parseDataFile('src/canvas/_draft.canvas.jsonl')).toBeNull()
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
it('skips canvas files in _-prefixed directories', () => {
|
|
1205
|
+
expect(parseDataFile('src/canvas/_hidden/public.canvas.jsonl')).toBeNull()
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it('canvas outside known directories gets basename-only ID', () => {
|
|
1209
|
+
const result = parseDataFile('random/path/notes.canvas.jsonl')
|
|
1210
|
+
expect(result.name).toBe('notes')
|
|
1211
|
+
expect(result.inferredRoute).toBeNull()
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it('sets group for grouped canvases', () => {
|
|
1215
|
+
const result = parseDataFile('src/canvas/ux.folder/onboarding.canvas.jsonl')
|
|
1216
|
+
expect(result.group).toBe('ux')
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
it('sets group to null for ungrouped canvases', () => {
|
|
1220
|
+
const result = parseDataFile('src/canvas/standalone.canvas.jsonl')
|
|
1221
|
+
expect(result.group).toBeNull()
|
|
1222
|
+
})
|
|
1048
1223
|
})
|