@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- 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
|
+
})
|
package/src/canvas/server.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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] ===
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
162
|
-
title: data.title ||
|
|
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:
|
|
282
|
+
return { name: id, title: segments[segments.length - 1], path: file, widgetCount: 0, group }
|
|
168
283
|
}
|
|
169
284
|
}).filter(Boolean)
|
|
170
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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:
|
|
367
|
-
path:
|
|
368
|
-
route: `/canvas/${
|
|
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, '
|
|
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(
|
|
446
|
-
fs.writeFileSync(path.join(
|
|
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 =
|
|
465
|
-
if (!
|
|
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 =
|
|
504
|
-
|
|
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
|
+
})
|