@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { toCanvasId, parseCanvasId, canvasIdBasename, isLegacyCanvasId, CANVAS_IDENTITY_CONSUMERS } from './identity.js'
3
+
4
+ describe('canvas/identity', () => {
5
+ describe('toCanvasId', () => {
6
+ it('strips src/canvas/ prefix and .canvas.jsonl suffix', () => {
7
+ expect(toCanvasId('src/canvas/overview.canvas.jsonl')).toBe('overview')
8
+ })
9
+
10
+ it('normalizes .folder segments', () => {
11
+ expect(toCanvasId('src/canvas/design.folder/overview.canvas.jsonl')).toBe('design/overview')
12
+ })
13
+
14
+ it('handles nested folders', () => {
15
+ expect(toCanvasId('src/canvas/design.folder/sub.folder/a.canvas.jsonl')).toBe('design/sub/a')
16
+ })
17
+
18
+ it('prefixes proto: for src/prototypes/', () => {
19
+ expect(toCanvasId('src/prototypes/Main/board.canvas.jsonl')).toBe('proto:Main/board')
20
+ })
21
+
22
+ it('handles prototype with .folder', () => {
23
+ expect(toCanvasId('src/prototypes/main.folder/Example/board.canvas.jsonl')).toBe('proto:main/Example/board')
24
+ })
25
+
26
+ it('normalizes backslashes', () => {
27
+ expect(toCanvasId('src\\canvas\\design.folder\\overview.canvas.jsonl')).toBe('design/overview')
28
+ })
29
+
30
+ it('returns "unknown" for edge case empty result', () => {
31
+ expect(toCanvasId('src/canvas/.canvas.jsonl')).toBe('unknown')
32
+ })
33
+ })
34
+
35
+ describe('parseCanvasId', () => {
36
+ it('parses a simple name', () => {
37
+ expect(parseCanvasId('overview')).toEqual({
38
+ namespace: 'canvas',
39
+ segments: ['overview'],
40
+ name: 'overview',
41
+ })
42
+ })
43
+
44
+ it('parses a folder/name ID', () => {
45
+ expect(parseCanvasId('design/overview')).toEqual({
46
+ namespace: 'canvas',
47
+ segments: ['design', 'overview'],
48
+ name: 'overview',
49
+ })
50
+ })
51
+
52
+ it('parses a proto: prefixed ID', () => {
53
+ expect(parseCanvasId('proto:Main/board')).toEqual({
54
+ namespace: 'prototype',
55
+ segments: ['Main', 'board'],
56
+ name: 'board',
57
+ })
58
+ })
59
+
60
+ it('parses deeply nested ID', () => {
61
+ expect(parseCanvasId('design/sub/a')).toEqual({
62
+ namespace: 'canvas',
63
+ segments: ['design', 'sub', 'a'],
64
+ name: 'a',
65
+ })
66
+ })
67
+ })
68
+
69
+ describe('canvasIdBasename', () => {
70
+ it('returns the last segment', () => {
71
+ expect(canvasIdBasename('design/overview')).toBe('overview')
72
+ expect(canvasIdBasename('overview')).toBe('overview')
73
+ expect(canvasIdBasename('proto:Main/board')).toBe('board')
74
+ })
75
+ })
76
+
77
+ describe('isLegacyCanvasId', () => {
78
+ it('returns true for bare names', () => {
79
+ expect(isLegacyCanvasId('overview')).toBe(true)
80
+ })
81
+
82
+ it('returns false for path-based IDs', () => {
83
+ expect(isLegacyCanvasId('design/overview')).toBe(false)
84
+ })
85
+
86
+ it('returns false for proto: prefixed IDs', () => {
87
+ expect(isLegacyCanvasId('proto:Main')).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('CANVAS_IDENTITY_CONSUMERS', () => {
92
+ it('is a non-empty array of strings', () => {
93
+ expect(Array.isArray(CANVAS_IDENTITY_CONSUMERS)).toBe(true)
94
+ expect(CANVAS_IDENTITY_CONSUMERS.length).toBeGreaterThan(0)
95
+ for (const entry of CANVAS_IDENTITY_CONSUMERS) {
96
+ expect(typeof entry).toBe('string')
97
+ }
98
+ })
99
+ })
100
+ })
@@ -15,6 +15,8 @@
15
15
  * POST /widget — append a widget_added event
16
16
  * DELETE /widget — append a widget_removed event
17
17
  * POST /create — create a new .canvas.jsonl file
18
+ * GET /stories — list all .story.{jsx,tsx} files with exports
19
+ * POST /create-story — scaffold a new .story.{jsx,tsx} file
18
20
  * POST /image — upload a pasted image to src/canvas/images/
19
21
  * GET /images/* — serve an image file from src/canvas/images/
20
22
  * POST /image/toggle-private — toggle _prefix on image filename
@@ -24,6 +26,31 @@ import fs from 'node:fs'
24
26
  import path from 'node:path'
25
27
  import { Buffer } from 'node:buffer'
26
28
  import { materializeFromText, serializeEvent } from './materializer.js'
29
+ import { toCanvasId, parseCanvasId } from './identity.js'
30
+
31
+ /**
32
+ * Scan src/canvas/ for directories containing .meta.json files.
33
+ * Returns an object keyed by directory name (without .folder suffix).
34
+ */
35
+ function findCanvasMeta(root) {
36
+ const canvasDir = path.join(root, 'src', 'canvas')
37
+ const groups = {}
38
+ if (!fs.existsSync(canvasDir)) return groups
39
+
40
+ const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
41
+ for (const entry of entries) {
42
+ if (!entry.isDirectory()) continue
43
+ const dirName = entry.name.replace(/\.folder$/, '')
44
+ const metaPath = path.join(canvasDir, entry.name, `${dirName}.meta.json`)
45
+ if (fs.existsSync(metaPath)) {
46
+ try {
47
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
48
+ groups[dirName] = meta
49
+ } catch { /* skip invalid meta */ }
50
+ }
51
+ }
52
+ return groups
53
+ }
27
54
 
28
55
  /**
29
56
  * Recursively find all .canvas.jsonl files in the project.
@@ -52,17 +79,82 @@ function findCanvasFiles(root) {
52
79
  }
53
80
 
54
81
  /**
55
- * Find a canvas JSONL file by name.
82
+ * Recursively find all .story.{jsx,tsx} files in routable directories
83
+ * (src/canvas/ and src/components/) and extract their named exports.
84
+ */
85
+ function findStoryFiles(root) {
86
+ const results = []
87
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
88
+ const ROUTABLE_DIRS = ['src/canvas', 'src/components']
89
+
90
+ function walk(dir, rel) {
91
+ let entries
92
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
93
+ for (const entry of entries) {
94
+ if (ignore.has(entry.name)) continue
95
+ if (entry.name.startsWith('_')) continue
96
+ const fullPath = path.join(dir, entry.name)
97
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
98
+ if (entry.isDirectory()) {
99
+ walk(fullPath, relPath)
100
+ } else if (/\.story\.(jsx|tsx)$/.test(entry.name)) {
101
+ const name = entry.name.replace(/\.story\.(jsx|tsx)$/, '')
102
+ const exports = parseExportNames(fullPath)
103
+ results.push({ name, path: relPath, exports })
104
+ }
105
+ }
106
+ }
107
+
108
+ for (const dir of ROUTABLE_DIRS) {
109
+ const absDir = path.join(root, dir)
110
+ if (fs.existsSync(absDir)) {
111
+ walk(absDir, dir)
112
+ }
113
+ }
114
+ return results
115
+ }
116
+
117
+ /**
118
+ * Parse named function/const exports from a JSX/TSX file.
56
119
  */
57
- function findCanvasPath(root, name) {
120
+ function parseExportNames(filePath) {
121
+ try {
122
+ const src = fs.readFileSync(filePath, 'utf-8')
123
+ const names = []
124
+ const re = /export\s+(?:function|const|class)\s+([A-Z]\w*)/g
125
+ let m
126
+ while ((m = re.exec(src)) !== null) names.push(m[1])
127
+ return names
128
+ } catch { return [] }
129
+ }
130
+
131
+ /**
132
+ * Find a canvas JSONL file by canonical ID or legacy basename.
133
+ * Path-based ID is tried first. Basename fallback only works when it
134
+ * resolves to exactly one file — ambiguous names return null.
135
+ */
136
+ function findCanvasPath(root, nameOrId) {
58
137
  const files = findCanvasFiles(root)
138
+
139
+ // Try matching by canonical ID first
140
+ for (const file of files) {
141
+ const id = toCanvasId(file)
142
+ if (id === nameOrId) {
143
+ return path.resolve(root, file)
144
+ }
145
+ }
146
+
147
+ // Fallback: match by basename (legacy). Only if unique.
148
+ const basenameMatches = []
59
149
  for (const file of files) {
60
150
  const base = path.basename(file)
61
151
  const match = base.match(/^(.+)\.canvas\.jsonl$/)
62
- if (match && match[1] === name) {
63
- return path.resolve(root, file)
152
+ if (match && match[1] === nameOrId) {
153
+ basenameMatches.push(path.resolve(root, file))
64
154
  }
65
155
  }
156
+
157
+ if (basenameMatches.length === 1) return basenameMatches[0]
66
158
  return null
67
159
  }
68
160
 
@@ -117,9 +209,20 @@ export function createCanvasHandler(ctx) {
117
209
  let folders = []
118
210
  try {
119
211
  if (fs.existsSync(canvasDir)) {
120
- folders = fs.readdirSync(canvasDir, { withFileTypes: true })
212
+ const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
213
+ // .folder directories (existing behavior)
214
+ const folderDirs = entries
121
215
  .filter((d) => d.isDirectory() && d.name.endsWith('.folder'))
122
216
  .map((d) => d.name.replace('.folder', ''))
217
+ // Plain directories containing .canvas.jsonl files
218
+ const plainDirs = entries
219
+ .filter((d) => {
220
+ if (!d.isDirectory() || d.name.endsWith('.folder') || d.name.startsWith('_')) return false
221
+ const files = fs.readdirSync(path.join(canvasDir, d.name))
222
+ return files.some((f) => f.endsWith('.canvas.jsonl'))
223
+ })
224
+ .map((d) => d.name)
225
+ folders = [...folderDirs, ...plainDirs]
123
226
  }
124
227
  } catch { /* empty */ }
125
228
  sendJson(res, 200, { folders })
@@ -141,7 +244,17 @@ export function createCanvasHandler(ctx) {
141
244
  }
142
245
  try {
143
246
  const data = readCanvas(filePath)
144
- sendJson(res, 200, data)
247
+ const widgetFilter = url.searchParams.get('widget')
248
+ if (widgetFilter) {
249
+ const widget = (data.widgets || []).find((w) => w.id === widgetFilter)
250
+ if (!widget) {
251
+ sendJson(res, 404, { error: `Widget "${widgetFilter}" not found in canvas "${name}"` })
252
+ return
253
+ }
254
+ sendJson(res, 200, { ...data, widgets: [widget] })
255
+ } else {
256
+ sendJson(res, 200, data)
257
+ }
145
258
  } catch (err) {
146
259
  sendJson(res, 500, { error: `Failed to read canvas: ${err.message}` })
147
260
  }
@@ -152,22 +265,25 @@ export function createCanvasHandler(ctx) {
152
265
  if (routePath === '/list' && method === 'GET') {
153
266
  const files = findCanvasFiles(root)
154
267
  const canvases = files.map((file) => {
155
- const base = path.basename(file)
156
- const match = base.match(/^(.+)\.canvas\.jsonl$/)
157
- if (!match) return null
268
+ const id = toCanvasId(file)
269
+ if (!id) return null
270
+ const { segments } = parseCanvasId(id)
271
+ const group = segments.length > 1 ? segments.slice(0, -1).join('/') : null
158
272
  try {
159
273
  const data = readCanvas(path.resolve(root, file))
160
274
  return {
161
- name: match[1],
162
- title: data.title || match[1],
275
+ name: id,
276
+ title: data.title || segments[segments.length - 1],
163
277
  path: file,
164
278
  widgetCount: (data.widgets || []).length + (data.sources || []).length,
279
+ group,
165
280
  }
166
281
  } catch {
167
- return { name: match[1], title: match[1], path: file, widgetCount: 0 }
282
+ return { name: id, title: segments[segments.length - 1], path: file, widgetCount: 0, group }
168
283
  }
169
284
  }).filter(Boolean)
170
- sendJson(res, 200, { canvases })
285
+ const groups = findCanvasMeta(root)
286
+ sendJson(res, 200, { canvases, groups })
171
287
  return
172
288
  }
173
289
 
@@ -296,6 +412,8 @@ export function createCanvasHandler(ctx) {
296
412
  title,
297
413
  folder,
298
414
  author,
415
+ description,
416
+ meta,
299
417
  grid = true,
300
418
  gridSize = 24,
301
419
  colorMode = 'auto',
@@ -325,12 +443,30 @@ export function createCanvasHandler(ctx) {
325
443
  let targetDir = canvasDir
326
444
 
327
445
  if (folder) {
328
- const folderDir = path.join(canvasDir, `${folder}.folder`)
329
- if (!fs.existsSync(folderDir)) {
330
- sendJson(res, 400, { error: `Folder "${folder}" does not exist` })
331
- return
446
+ const dotFolderDir = path.join(canvasDir, `${folder}.folder`)
447
+ const plainDir = path.join(canvasDir, folder)
448
+
449
+ if (fs.existsSync(dotFolderDir)) {
450
+ // Existing .folder/ directory
451
+ targetDir = dotFolderDir
452
+ } else if (fs.existsSync(plainDir) && fs.statSync(plainDir).isDirectory()) {
453
+ // Existing plain directory
454
+ targetDir = plainDir
455
+ } else {
456
+ // Create new plain directory
457
+ try {
458
+ fs.mkdirSync(plainDir, { recursive: true })
459
+ // Write .meta.json if meta was provided
460
+ if (meta && typeof meta === 'object') {
461
+ const metaPath = path.join(plainDir, `${folder}.meta.json`)
462
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
463
+ }
464
+ } catch (err) {
465
+ sendJson(res, 500, { error: `Failed to create directory: ${err.message}` })
466
+ return
467
+ }
468
+ targetDir = plainDir
332
469
  }
333
- targetDir = folderDir
334
470
  }
335
471
 
336
472
  const canvasPath = path.join(targetDir, `${kebab}.canvas.jsonl`)
@@ -353,6 +489,10 @@ export function createCanvasHandler(ctx) {
353
489
  creationEvent.author = author
354
490
  }
355
491
 
492
+ if (description) {
493
+ creationEvent.description = description
494
+ }
495
+
356
496
  if (includeJsx) {
357
497
  creationEvent.jsx = `${kebab}.canvas.jsx`
358
498
  }
@@ -361,11 +501,14 @@ export function createCanvasHandler(ctx) {
361
501
  fs.mkdirSync(targetDir, { recursive: true })
362
502
  writeNewCanvas(canvasPath, creationEvent)
363
503
 
504
+ const relPath = path.relative(root, canvasPath).replace(/\\/g, '/')
505
+ const canonicalName = toCanvasId(relPath) || kebab
506
+
364
507
  const result = {
365
508
  success: true,
366
- name: kebab,
367
- path: path.relative(root, canvasPath),
368
- route: `/canvas/${kebab}`,
509
+ name: canonicalName,
510
+ path: relPath,
511
+ route: `/canvas/${canonicalName}`,
369
512
  }
370
513
 
371
514
  // Optionally create starter JSX file
@@ -397,14 +540,124 @@ export function ${componentName}Example() {
397
540
  return
398
541
  }
399
542
 
543
+ // ── Story routes ──────────────────────────────────────────────────
544
+
545
+ // GET /stories — list all .story.{jsx,tsx} files with their exports
546
+ if (routePath === '/stories' && method === 'GET') {
547
+ try {
548
+ const storyFiles = findStoryFiles(root)
549
+ sendJson(res, 200, { stories: storyFiles })
550
+ } catch (err) {
551
+ sendJson(res, 500, { error: `Failed to list stories: ${err.message}` })
552
+ }
553
+ return
554
+ }
555
+
556
+ // POST /create-story — scaffold a new .story.jsx/.tsx file
557
+ if (routePath === '/create-story' && method === 'POST') {
558
+ const { name, location, format = 'jsx', canvasName: storyCanvasName } = body
559
+
560
+ if (!name || typeof name !== 'string') {
561
+ sendJson(res, 400, { error: 'Component name is required' })
562
+ return
563
+ }
564
+
565
+ const kebab = name
566
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
567
+ .trim()
568
+ .replace(/[\s_]+/g, '-')
569
+ .toLowerCase()
570
+ .replace(/-+/g, '-')
571
+ .replace(/^-|-$/g, '')
572
+
573
+ if (!kebab) {
574
+ sendJson(res, 400, { error: 'Name must contain at least one alphanumeric character' })
575
+ return
576
+ }
577
+
578
+ const ext = format === 'tsx' ? 'tsx' : 'jsx'
579
+
580
+ // Resolve target directory from location + canvas name
581
+ let targetDir
582
+ if (location === 'components') {
583
+ targetDir = path.join(root, 'src', 'components')
584
+ } else if (storyCanvasName) {
585
+ const canvasPath = findCanvasPath(root, storyCanvasName)
586
+ targetDir = canvasPath ? path.dirname(canvasPath) : path.join(root, 'src', 'canvas')
587
+ } else {
588
+ targetDir = path.join(root, 'src', 'canvas')
589
+ }
590
+
591
+ const storyPath = path.join(targetDir, `${kebab}.story.${ext}`)
592
+ if (fs.existsSync(storyPath)) {
593
+ sendJson(res, 409, { error: `Story "${kebab}.story.${ext}" already exists at ${path.relative(root, targetDir)}` })
594
+ return
595
+ }
596
+
597
+ // Check for duplicate story name anywhere in the project (Vite data plugin
598
+ // enforces global uniqueness and would fail the build on duplicates)
599
+ const existing = findStoryFiles(root)
600
+ if (existing.some(s => s.name === kebab)) {
601
+ sendJson(res, 409, { error: `A story named "${kebab}" already exists in the project` })
602
+ return
603
+ }
604
+
605
+ const componentName = kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
606
+ const content = `/**
607
+ * ${componentName} component stories.
608
+ * Each named export becomes a draggable widget on the canvas.
609
+ */
610
+
611
+ export function Default() {
612
+ return (
613
+ <div style={{ padding: '1.5rem', minWidth: 200 }}>
614
+ <h3>${componentName}</h3>
615
+ <p>Edit this file to build your component.</p>
616
+ </div>
617
+ )
618
+ }
619
+ `
620
+
621
+ try {
622
+ fs.mkdirSync(targetDir, { recursive: true })
623
+ fs.writeFileSync(storyPath, content, 'utf-8')
624
+
625
+ const relPath = path.relative(root, storyPath)
626
+ sendJson(res, 201, {
627
+ success: true,
628
+ name: kebab,
629
+ path: relPath,
630
+ storyId: kebab,
631
+ })
632
+ } catch (err) {
633
+ sendJson(res, 500, { error: `Failed to create story: ${err.message}` })
634
+ }
635
+ return
636
+ }
637
+
400
638
  // ── Image routes ──────────────────────────────────────────────────
401
639
 
402
- const imagesDir = path.join(root, 'src', 'canvas', 'images')
640
+ const imagesDir = path.join(root, 'assets', 'canvas', 'images')
641
+ const snapshotsDir = path.join(root, 'assets', 'canvas', 'snapshots')
403
642
 
404
643
  const MIME_TO_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
405
644
  const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
406
645
  const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
407
646
 
647
+ // Resolve which directory to write to based on canvasName prefix
648
+ function resolveWriteDir(canvasName) {
649
+ return canvasName && canvasName.startsWith('snapshot-') ? snapshotsDir : imagesDir
650
+ }
651
+
652
+ // Resolve a filename to its on-disk path (check snapshots first, then images)
653
+ function resolveImagePath(filename) {
654
+ const snapshotPath = path.join(snapshotsDir, filename)
655
+ if (fs.existsSync(snapshotPath)) return snapshotPath
656
+ const imagePath = path.join(imagesDir, filename)
657
+ if (fs.existsSync(imagePath)) return imagePath
658
+ return null
659
+ }
660
+
408
661
  // POST /image — upload a pasted image (base64 data URL)
409
662
  if (routePath === '/image' && method === 'POST') {
410
663
  const { dataUrl, canvasName } = body
@@ -438,12 +691,13 @@ export function ${componentName}Example() {
438
691
  const now = new Date()
439
692
  const pad = (n) => String(n).padStart(2, '0')
440
693
  const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
441
- const prefix = canvasName ? `${canvasName}--` : ''
694
+ const prefix = canvasName ? `${canvasName.replace(/[\/:]/g, '--')}--` : ''
442
695
  const filename = `${prefix}${dateStr}.${ext}`
696
+ const targetDir = resolveWriteDir(canvasName)
443
697
 
444
698
  try {
445
- fs.mkdirSync(imagesDir, { recursive: true })
446
- fs.writeFileSync(path.join(imagesDir, filename), buffer)
699
+ fs.mkdirSync(targetDir, { recursive: true })
700
+ fs.writeFileSync(path.join(targetDir, filename), buffer)
447
701
  sendJson(res, 201, { success: true, filename })
448
702
  } catch (err) {
449
703
  sendJson(res, 500, { error: `Failed to save image: ${err.message}` })
@@ -461,8 +715,8 @@ export function ${componentName}Example() {
461
715
  return
462
716
  }
463
717
 
464
- const filePath = path.join(imagesDir, filename)
465
- if (!fs.existsSync(filePath)) {
718
+ const filePath = resolveImagePath(filename)
719
+ if (!filePath) {
466
720
  sendJson(res, 404, { error: 'Image not found' })
467
721
  return
468
722
  }
@@ -500,13 +754,13 @@ export function ${componentName}Example() {
500
754
 
501
755
  const isPrivate = filename.startsWith('_')
502
756
  const newFilename = isPrivate ? filename.slice(1) : `_${filename}`
503
- const oldPath = path.join(imagesDir, filename)
504
- const newPath = path.join(imagesDir, newFilename)
505
-
506
- if (!fs.existsSync(oldPath)) {
757
+ const oldPath = resolveImagePath(filename)
758
+ if (!oldPath) {
507
759
  sendJson(res, 404, { error: 'Image not found' })
508
760
  return
509
761
  }
762
+ const parentDir = path.dirname(oldPath)
763
+ const newPath = path.join(parentDir, newFilename)
510
764
 
511
765
  try {
512
766
  fs.renameSync(oldPath, newPath)
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Canvas Config — project-level overrides for canvas behavior.
3
+ *
4
+ * Client repos use the "canvas" key in storyboard.config.json to customize
5
+ * canvas paste rules and other canvas-level settings.
6
+ *
7
+ * {
8
+ * "canvas": {
9
+ * "pasteRules": [
10
+ * { "pattern": "youtube\\.com/watch", "type": "link-preview", "props": { "url": "$url" } }
11
+ * ]
12
+ * }
13
+ * }
14
+ *
15
+ * Framework-agnostic (zero npm dependencies).
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Internal state
20
+ // ---------------------------------------------------------------------------
21
+
22
+ let _pasteRules = []
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Configuration
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Initialize canvas config from storyboard.config.json's "canvas" key.
30
+ * Called by mountStoryboardCore.
31
+ *
32
+ * @param {{ pasteRules?: object[] }} [config]
33
+ */
34
+ export function initCanvasConfig(config = {}) {
35
+ _pasteRules = Array.isArray(config.pasteRules) ? config.pasteRules : []
36
+ }
37
+
38
+ /**
39
+ * Get the configured paste rules (raw config objects).
40
+ *
41
+ * @returns {object[]}
42
+ */
43
+ export function getPasteRules() {
44
+ return _pasteRules
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Test helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Reset all internal state. Only for use in tests.
53
+ */
54
+ export function _resetCanvasConfig() {
55
+ _pasteRules = []
56
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { initCanvasConfig, getPasteRules, _resetCanvasConfig } from './canvasConfig.js'
3
+
4
+ describe('canvasConfig', () => {
5
+ beforeEach(() => {
6
+ _resetCanvasConfig()
7
+ })
8
+
9
+ it('returns empty array by default', () => {
10
+ expect(getPasteRules()).toEqual([])
11
+ })
12
+
13
+ it('stores paste rules from config', () => {
14
+ const rules = [
15
+ { pattern: 'youtube\\.com', type: 'link-preview', props: { url: '$url' } },
16
+ ]
17
+ initCanvasConfig({ pasteRules: rules })
18
+ expect(getPasteRules()).toEqual(rules)
19
+ })
20
+
21
+ it('handles missing pasteRules gracefully', () => {
22
+ initCanvasConfig({})
23
+ expect(getPasteRules()).toEqual([])
24
+ })
25
+
26
+ it('handles undefined config', () => {
27
+ initCanvasConfig()
28
+ expect(getPasteRules()).toEqual([])
29
+ })
30
+
31
+ it('handles non-array pasteRules', () => {
32
+ initCanvasConfig({ pasteRules: 'not-an-array' })
33
+ expect(getPasteRules()).toEqual([])
34
+ })
35
+
36
+ it('resets on _resetCanvasConfig', () => {
37
+ initCanvasConfig({ pasteRules: [{ pattern: '.', type: 'test' }] })
38
+ expect(getPasteRules()).toHaveLength(1)
39
+ _resetCanvasConfig()
40
+ expect(getPasteRules()).toEqual([])
41
+ })
42
+ })