@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.0
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 +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- 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 +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- 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 +277 -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 +2 -6
- 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 +138 -39
- 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 +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- 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) {
|
|
@@ -520,13 +649,6 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
520
649
|
`[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
|
|
521
650
|
)
|
|
522
651
|
}
|
|
523
|
-
} else {
|
|
524
|
-
// Auto-detect a same-name .canvas.jsx companion
|
|
525
|
-
const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
|
|
526
|
-
if (fs.existsSync(autoJsx)) {
|
|
527
|
-
const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
|
|
528
|
-
parsed = { ...parsed, _jsxModule: relJsx }
|
|
529
|
-
}
|
|
530
652
|
}
|
|
531
653
|
}
|
|
532
654
|
|
|
@@ -555,8 +677,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
555
677
|
}
|
|
556
678
|
}
|
|
557
679
|
|
|
680
|
+
// Generate story entries (code modules with dynamic imports, not JSON data)
|
|
681
|
+
for (const [name, absPath] of Object.entries(index.story || {})) {
|
|
682
|
+
const varName = `_d${i++}`
|
|
683
|
+
const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
|
|
684
|
+
const storyMeta = { _storyModule: relModule }
|
|
685
|
+
if (storyRoutes[name]) {
|
|
686
|
+
storyMeta._route = storyRoutes[name]
|
|
687
|
+
}
|
|
688
|
+
declarations.push(
|
|
689
|
+
`const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
|
|
690
|
+
)
|
|
691
|
+
storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
|
|
692
|
+
}
|
|
693
|
+
|
|
558
694
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
559
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
695
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
|
|
560
696
|
|
|
561
697
|
// Feature flags from storyboard.config.json
|
|
562
698
|
const { config } = readConfig(root)
|
|
@@ -595,6 +731,12 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
595
731
|
initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
|
|
596
732
|
}
|
|
597
733
|
|
|
734
|
+
// Customer mode config from storyboard.config.json
|
|
735
|
+
if (config?.customerMode) {
|
|
736
|
+
imports.push(`import { initCustomerModeConfig } from '@dfosco/storyboard-core'`)
|
|
737
|
+
initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
|
|
738
|
+
}
|
|
739
|
+
|
|
598
740
|
// Log info when multiple flows target the same route
|
|
599
741
|
const routeGroups = {}
|
|
600
742
|
for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
|
|
@@ -624,22 +766,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
624
766
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
625
767
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
626
768
|
`const canvases = {\n${entries.canvas.join(',\n')}\n}`,
|
|
769
|
+
`const stories = {\n${storyEntries.join(',\n')}\n}`,
|
|
770
|
+
'',
|
|
771
|
+
`// Legacy basename → canonical ID aliases (only unique basenames)`,
|
|
772
|
+
`const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
|
|
627
773
|
'',
|
|
628
774
|
'// Backward-compatible alias',
|
|
629
775
|
'const scenes = flows',
|
|
630
776
|
'',
|
|
631
777
|
initCalls.join('\n'),
|
|
632
778
|
'',
|
|
633
|
-
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
634
|
-
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
779
|
+
`export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
780
|
+
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
|
|
635
781
|
`export default index`,
|
|
782
|
+
'',
|
|
783
|
+
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
784
|
+
'if (import.meta.hot) {',
|
|
785
|
+
' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
|
|
786
|
+
' if (!data) return',
|
|
787
|
+
' const id = data.canvasId || data.name',
|
|
788
|
+
' if (data.removed) {',
|
|
789
|
+
' delete canvases[id]',
|
|
790
|
+
' } else if (data.metadata) {',
|
|
791
|
+
' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
|
|
792
|
+
' canvases[id] = canvases[id]',
|
|
793
|
+
' ? Object.assign({}, canvases[id], data.metadata)',
|
|
794
|
+
' : data.metadata',
|
|
795
|
+
' }',
|
|
796
|
+
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
797
|
+
' })',
|
|
798
|
+
' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
|
|
799
|
+
' if (!data) return',
|
|
800
|
+
' if (data.removed) {',
|
|
801
|
+
' delete stories[data.name]',
|
|
802
|
+
' } else {',
|
|
803
|
+
' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
|
|
804
|
+
' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
|
|
805
|
+
' }',
|
|
806
|
+
' init({ flows, objects, records, prototypes, folders, canvases, stories })',
|
|
807
|
+
' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
|
|
808
|
+
' })',
|
|
809
|
+
'}',
|
|
636
810
|
].join('\n')
|
|
637
811
|
}
|
|
638
812
|
|
|
639
813
|
/**
|
|
640
814
|
* Vite plugin for storyboard data discovery.
|
|
641
815
|
*
|
|
642
|
-
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
|
|
816
|
+
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
|
|
643
817
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
644
818
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
645
819
|
* - Watches for file additions/removals in dev mode
|
|
@@ -655,6 +829,11 @@ export default function storyboardDataPlugin() {
|
|
|
655
829
|
config() {
|
|
656
830
|
return {
|
|
657
831
|
optimizeDeps: {
|
|
832
|
+
// @dfosco/storyboard-react is excluded (virtual module), so Vite
|
|
833
|
+
// can't trace into its deps. Include the remark entry points so
|
|
834
|
+
// Vite pre-bundles the full chain — covers all transitive CJS
|
|
835
|
+
// packages (debug, extend, etc.) without whack-a-mole.
|
|
836
|
+
include: ['react-cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
|
|
658
837
|
exclude: ['@dfosco/storyboard-react'],
|
|
659
838
|
},
|
|
660
839
|
}
|
|
@@ -678,7 +857,7 @@ export default function storyboardDataPlugin() {
|
|
|
678
857
|
// ── Component isolate middleware ───────────────────────────────
|
|
679
858
|
// Serves a minimal HTML shell for iframe-isolated component widgets.
|
|
680
859
|
// The iframe loads componentIsolate.jsx which reads query params
|
|
681
|
-
// (module, export, theme) and renders a single
|
|
860
|
+
// (module, export, theme) and renders a single story export.
|
|
682
861
|
const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
|
|
683
862
|
server.middlewares.use(async (req, res, next) => {
|
|
684
863
|
if (!req.url) return next()
|
|
@@ -692,7 +871,7 @@ export default function storyboardDataPlugin() {
|
|
|
692
871
|
const rawHtml = [
|
|
693
872
|
'<!DOCTYPE html>',
|
|
694
873
|
'<html><head>',
|
|
695
|
-
'<style>html,body{margin:0;padding:0;width:100%;height:100
|
|
874
|
+
'<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
|
|
696
875
|
'</head><body>',
|
|
697
876
|
'<div id="root"></div>',
|
|
698
877
|
`<script type="module" src="/@fs${isolateEntryPath}"></script>`,
|
|
@@ -710,10 +889,33 @@ export default function storyboardDataPlugin() {
|
|
|
710
889
|
}
|
|
711
890
|
})
|
|
712
891
|
|
|
892
|
+
// ── Stories list API ──────────────────────────────────────────
|
|
893
|
+
// Serves the list of discovered stories for the CLI and UI story picker.
|
|
894
|
+
server.middlewares.use(async (req, res, next) => {
|
|
895
|
+
if (!req.url) return next()
|
|
896
|
+
let url = req.url
|
|
897
|
+
const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
|
|
898
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
899
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
900
|
+
}
|
|
901
|
+
if (!url.startsWith('/_storyboard/stories/list')) return next()
|
|
902
|
+
|
|
903
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
904
|
+
const storyEntries = Object.entries(buildResult.index.story || {})
|
|
905
|
+
const storyRoutes = buildResult.storyRoutes || {}
|
|
906
|
+
const stories = storyEntries.map(([name]) => ({
|
|
907
|
+
name,
|
|
908
|
+
route: storyRoutes[name] || null,
|
|
909
|
+
}))
|
|
910
|
+
|
|
911
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
912
|
+
res.end(JSON.stringify({ stories }))
|
|
913
|
+
})
|
|
914
|
+
|
|
713
915
|
// Watch for data file changes in dev mode
|
|
714
916
|
const watcher = server.watcher
|
|
715
917
|
if (!buildResult) buildResult = buildIndex(root)
|
|
716
|
-
const
|
|
918
|
+
const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
|
|
717
919
|
const pendingCanvasUnlinks = new Map()
|
|
718
920
|
|
|
719
921
|
const triggerFullReload = () => {
|
|
@@ -725,22 +927,50 @@ export default function storyboardDataPlugin() {
|
|
|
725
927
|
}
|
|
726
928
|
}
|
|
727
929
|
|
|
930
|
+
// Mark the virtual module as stale so the next page load rebuilds it,
|
|
931
|
+
// but do NOT trigger a full-reload (avoids losing canvas editing state).
|
|
932
|
+
const softInvalidate = () => {
|
|
933
|
+
buildResult = null
|
|
934
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
935
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Read a canvas file and build HMR metadata for the client-side listener.
|
|
939
|
+
const readCanvasMetadata = (filePath, parsed) => {
|
|
940
|
+
try {
|
|
941
|
+
const absPath = path.resolve(root, filePath)
|
|
942
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
943
|
+
const materialized = materializeFromText(raw)
|
|
944
|
+
const result = { ...materialized }
|
|
945
|
+
// Inject _route and _folder the same way generateModule does
|
|
946
|
+
if (parsed.inferredRoute) result._route = parsed.inferredRoute
|
|
947
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
948
|
+
if (folderDirMatch) result._folder = folderDirMatch[1]
|
|
949
|
+
return result
|
|
950
|
+
} catch {
|
|
951
|
+
return null
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
728
955
|
const invalidate = (filePath) => {
|
|
729
956
|
const normalized = filePath.replace(/\\/g, '/')
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
957
|
+
// Canvas .jsonl content changes are mutated at runtime by the canvas
|
|
958
|
+
// server API. A full-reload would create a feedback loop (save →
|
|
959
|
+
// file change → reload → lose editing state). Instead, soft-invalidate
|
|
960
|
+
// the virtual module (so page refresh picks up changes) and send a
|
|
961
|
+
// custom HMR event with updated metadata so the canvas page and
|
|
962
|
+
// viewfinder can react in place.
|
|
735
963
|
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
736
964
|
const parsed = parseDataFile(filePath)
|
|
737
|
-
if (parsed?.suffix === 'canvas' && parsed?.
|
|
965
|
+
if (parsed?.suffix === 'canvas' && parsed?.id) {
|
|
966
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
738
967
|
server.ws.send({
|
|
739
968
|
type: 'custom',
|
|
740
969
|
event: 'storyboard:canvas-file-changed',
|
|
741
|
-
data: {
|
|
970
|
+
data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
|
|
742
971
|
})
|
|
743
972
|
}
|
|
973
|
+
softInvalidate()
|
|
744
974
|
return
|
|
745
975
|
}
|
|
746
976
|
|
|
@@ -777,53 +1007,89 @@ export default function storyboardDataPlugin() {
|
|
|
777
1007
|
// Treat canvas add/unlink as runtime data updates and never full-reload
|
|
778
1008
|
// from watcher events. Canvas pages sync from disk via custom WS events.
|
|
779
1009
|
if (parsed?.suffix === 'canvas') {
|
|
780
|
-
const
|
|
1010
|
+
const canvasId = parsed.id || parsed.name
|
|
781
1011
|
if (eventType === 'unlink') {
|
|
782
1012
|
const timer = setTimeout(() => {
|
|
783
|
-
pendingCanvasUnlinks.delete(
|
|
784
|
-
|
|
1013
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
1014
|
+
knownCanvasIds.delete(canvasId)
|
|
785
1015
|
server.ws.send({
|
|
786
1016
|
type: 'custom',
|
|
787
1017
|
event: 'storyboard:canvas-file-changed',
|
|
788
|
-
data: { name },
|
|
1018
|
+
data: { canvasId, name: canvasId, removed: true },
|
|
789
1019
|
})
|
|
1020
|
+
softInvalidate()
|
|
790
1021
|
}, 1500)
|
|
791
|
-
pendingCanvasUnlinks.set(
|
|
1022
|
+
pendingCanvasUnlinks.set(canvasId, timer)
|
|
792
1023
|
return
|
|
793
1024
|
}
|
|
794
1025
|
|
|
795
1026
|
if (eventType === 'add') {
|
|
796
|
-
const
|
|
1027
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
1028
|
+
const pending = pendingCanvasUnlinks.get(canvasId)
|
|
797
1029
|
if (pending) {
|
|
1030
|
+
// unlink+add pair = in-place save (atomic write), not a real remove
|
|
798
1031
|
clearTimeout(pending)
|
|
799
|
-
pendingCanvasUnlinks.delete(
|
|
1032
|
+
pendingCanvasUnlinks.delete(canvasId)
|
|
800
1033
|
server.ws.send({
|
|
801
1034
|
type: 'custom',
|
|
802
1035
|
event: 'storyboard:canvas-file-changed',
|
|
803
|
-
data: { name },
|
|
1036
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
804
1037
|
})
|
|
1038
|
+
softInvalidate()
|
|
805
1039
|
return
|
|
806
1040
|
}
|
|
807
1041
|
|
|
808
|
-
if (
|
|
1042
|
+
if (knownCanvasIds.has(canvasId)) {
|
|
809
1043
|
server.ws.send({
|
|
810
1044
|
type: 'custom',
|
|
811
1045
|
event: 'storyboard:canvas-file-changed',
|
|
812
|
-
data: { name },
|
|
1046
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
813
1047
|
})
|
|
1048
|
+
softInvalidate()
|
|
814
1049
|
return
|
|
815
1050
|
}
|
|
816
1051
|
|
|
817
|
-
|
|
1052
|
+
knownCanvasIds.add(canvasId)
|
|
818
1053
|
server.ws.send({
|
|
819
1054
|
type: 'custom',
|
|
820
1055
|
event: 'storyboard:canvas-file-changed',
|
|
821
|
-
data: { name },
|
|
1056
|
+
data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
|
|
822
1057
|
})
|
|
1058
|
+
softInvalidate()
|
|
823
1059
|
return
|
|
824
1060
|
}
|
|
825
1061
|
}
|
|
826
1062
|
|
|
1063
|
+
// Story add/remove: soft-invalidate + custom HMR event (full-reload
|
|
1064
|
+
// is blocked by the canvas reload guard). The virtual module HMR
|
|
1065
|
+
// handler live-patches `stories` and re-runs init().
|
|
1066
|
+
if (parsed?.suffix === 'story') {
|
|
1067
|
+
softInvalidate()
|
|
1068
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
1069
|
+
const storyRoutes = buildResult.storyRoutes || {}
|
|
1070
|
+
const storyIndex = buildResult.index.story || {}
|
|
1071
|
+
const name = parsed.name
|
|
1072
|
+
if (eventType === 'unlink') {
|
|
1073
|
+
server.ws.send({
|
|
1074
|
+
type: 'custom',
|
|
1075
|
+
event: 'storyboard:story-file-changed',
|
|
1076
|
+
data: { name, removed: true },
|
|
1077
|
+
})
|
|
1078
|
+
} else if (eventType === 'add' && storyIndex[name]) {
|
|
1079
|
+
const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
|
|
1080
|
+
server.ws.send({
|
|
1081
|
+
type: 'custom',
|
|
1082
|
+
event: 'storyboard:story-file-changed',
|
|
1083
|
+
data: {
|
|
1084
|
+
name,
|
|
1085
|
+
_storyModule: relModule,
|
|
1086
|
+
_route: storyRoutes[name] || null,
|
|
1087
|
+
},
|
|
1088
|
+
})
|
|
1089
|
+
}
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
|
|
827
1093
|
// Non-canvas additions/removals and folder changes update the route/data graph.
|
|
828
1094
|
triggerFullReload()
|
|
829
1095
|
}
|
|
@@ -854,21 +1120,37 @@ export default function storyboardDataPlugin() {
|
|
|
854
1120
|
const normalized = ctx.file.replace(/\\/g, '/')
|
|
855
1121
|
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
856
1122
|
|
|
857
|
-
const parsed = parseDataFile(ctx.file)
|
|
858
|
-
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
859
|
-
ctx.server.ws.send({
|
|
860
|
-
type: 'custom',
|
|
861
|
-
event: 'storyboard:canvas-file-changed',
|
|
862
|
-
data: { name: parsed.name },
|
|
863
|
-
})
|
|
864
|
-
}
|
|
865
|
-
|
|
866
1123
|
// Prevent Vite's default fallback behavior (full page reload) for
|
|
867
|
-
// non-module .canvas.jsonl edits.
|
|
868
|
-
//
|
|
1124
|
+
// non-module .canvas.jsonl edits. The watcher 'change' handler
|
|
1125
|
+
// (invalidate) already sends the custom HMR event and soft-invalidates
|
|
1126
|
+
// the virtual module — no duplicate event needed here.
|
|
869
1127
|
return []
|
|
870
1128
|
},
|
|
871
1129
|
|
|
1130
|
+
// Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
|
|
1131
|
+
// Reads .worktrees/ports.json to enumerate active worktree dev servers.
|
|
1132
|
+
transformIndexHtml(html, ctx) {
|
|
1133
|
+
// Only inject in dev mode
|
|
1134
|
+
if (!ctx.server) return html
|
|
1135
|
+
|
|
1136
|
+
try {
|
|
1137
|
+
const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
|
|
1138
|
+
if (!fs.existsSync(portsJsonPath)) return html
|
|
1139
|
+
|
|
1140
|
+
const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
|
|
1141
|
+
const branches = Object.entries(ports)
|
|
1142
|
+
.filter(([name]) => name !== 'main')
|
|
1143
|
+
.map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
|
|
1144
|
+
|
|
1145
|
+
if (branches.length === 0) return html
|
|
1146
|
+
|
|
1147
|
+
const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
|
|
1148
|
+
return html.replace('</head>', `${script}\n</head>`)
|
|
1149
|
+
} catch {
|
|
1150
|
+
return html
|
|
1151
|
+
}
|
|
1152
|
+
},
|
|
1153
|
+
|
|
872
1154
|
// Rebuild index on each build start
|
|
873
1155
|
buildStart() {
|
|
874
1156
|
buildResult = null
|
|
@@ -877,4 +1159,4 @@ export default function storyboardDataPlugin() {
|
|
|
877
1159
|
}
|
|
878
1160
|
|
|
879
1161
|
// Exported for testing
|
|
880
|
-
export { resolveTemplateVars, computeTemplateVars }
|
|
1162
|
+
export { resolveTemplateVars, computeTemplateVars, parseDataFile }
|