@dfosco/storyboard 1.0.0-beta.2 → 1.0.0-beta.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { materializeFromText, serializeEvent } from './materializer.js'
15
+ import { contentDir } from '../paths/resolvePaths.js'
15
16
 
16
17
  const COMPACT_THRESHOLD_BYTES = 500 * 1024
17
18
 
@@ -21,7 +22,7 @@ const COMPACT_THRESHOLD_BYTES = 500 * 1024
21
22
  * @returns {{ name: string, filePath: string, size: number }[]}
22
23
  */
23
24
  export function findCanvasFiles(root) {
24
- const canvasDir = path.join(root, 'src', 'canvas')
25
+ const canvasDir = contentDir(root, 'canvas')
25
26
  if (!fs.existsSync(canvasDir)) return []
26
27
 
27
28
  const results = []
@@ -19,6 +19,7 @@
19
19
  import fs from 'node:fs'
20
20
  import path from 'node:path'
21
21
  import { toCanvasId } from './identity.js'
22
+ import { findSystemRoot } from '../paths/resolvePaths.js'
22
23
  import { devLog } from '../logger/devLogger.js'
23
24
 
24
25
  const DIR_NAME = '.storyboard'
@@ -29,12 +30,16 @@ const CLEANUP_INTERVAL_MS = 10_000
29
30
  * Find a canvas JSONL file by canonical ID (cached).
30
31
  */
31
32
  function createPathResolver(root) {
33
+ // Canvas files live at the content root, which differs from the Vite/system
34
+ // `root` in the `.backstage` layout. Walk the content root so canvas IDs
35
+ // resolve to real files (and stored paths are content-root relative).
36
+ const scanRoot = findSystemRoot(root).contentRoot
32
37
  const cache = new Map()
33
38
  let lastScanFiles = null
34
39
 
35
40
  function scanFiles() {
36
41
  const results = []
37
- const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs'])
42
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs', '.backstage'])
38
43
  function walk(dir, rel) {
39
44
  let entries
40
45
  try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
@@ -46,7 +51,7 @@ function createPathResolver(root) {
46
51
  else if (entry.name.endsWith('.canvas.jsonl')) results.push(relPath)
47
52
  }
48
53
  }
49
- walk(root, '')
54
+ walk(scanRoot, '')
50
55
  return results
51
56
  }
52
57
 
@@ -224,11 +224,17 @@ function parseExportNames(filePath) {
224
224
  */
225
225
  function findCanvasPath(root, canvasId) {
226
226
  const files = findCanvasFiles(root)
227
+ // `findCanvasFiles` walks the content root and returns content-relative
228
+ // paths. In the `.backstage` layout the Vite `root` is the system root
229
+ // (`.backstage`), which is a sibling of the content root — resolving the
230
+ // returned paths against `root` would point at a non-existent
231
+ // `.backstage/canvas/...` file. Resolve against the content root instead.
232
+ const { contentRoot } = findSystemRoot(root)
227
233
 
228
234
  for (const file of files) {
229
235
  const id = toCanvasId(file)
230
236
  if (id === canvasId) {
231
- return path.resolve(root, file)
237
+ return path.resolve(contentRoot, file)
232
238
  }
233
239
  }
234
240
 
@@ -1025,13 +1031,14 @@ export function createCanvasHandler(ctx) {
1025
1031
  // GET /list — list all canvases
1026
1032
  if (routePath === '/list' && method === 'GET') {
1027
1033
  const files = findCanvasFiles(root)
1034
+ const { contentRoot } = findSystemRoot(root)
1028
1035
  const canvases = files.map((file) => {
1029
1036
  const id = toCanvasId(file)
1030
1037
  if (!id) return null
1031
1038
  const { segments } = parseCanvasId(id)
1032
1039
  const group = segments.length > 1 ? segments.slice(0, -1).join('/') : null
1033
1040
  try {
1034
- const data = readCanvas(path.resolve(root, file))
1041
+ const data = readCanvas(path.resolve(contentRoot, file))
1035
1042
  return {
1036
1043
  name: id,
1037
1044
  title: data.title || segments[segments.length - 1],
@@ -3127,8 +3134,9 @@ export function Default() {
3127
3134
 
3128
3135
  // ── Image routes ──────────────────────────────────────────────────
3129
3136
 
3130
- const imagesDir = path.join(root, 'assets', 'canvas', 'images')
3131
- const snapshotsDir = path.join(root, 'assets', 'canvas', 'snapshots')
3137
+ const assetsDir = contentDir(root, 'assets')
3138
+ const imagesDir = path.join(assetsDir, 'canvas', 'images')
3139
+ const snapshotsDir = path.join(assetsDir, 'canvas', 'snapshots')
3132
3140
 
3133
3141
  const MIME_TO_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
3134
3142
  const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
@@ -847,3 +847,67 @@ describe('GET /folders and POST /create with folderKind', () => {
847
847
  expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
848
848
  })
849
849
  })
850
+
851
+ describe('.backstage layout — content paths anchored at the content root', () => {
852
+ let root, systemRoot, canvasDir, invoke, lastResponse
853
+
854
+ // In the backstage layout the Vite `root` handed to the canvas handler is the
855
+ // `.backstage` system root, but the user's canvas/asset content lives one
856
+ // level up at the content root. Regression coverage for the paste/add bug
857
+ // where widget writes resolved against `.backstage/...` and 500'd (ENOENT).
858
+ beforeEach(() => {
859
+ const contentRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-backstage-test-'))
860
+ systemRoot = path.join(contentRoot, '.backstage')
861
+ fs.mkdirSync(systemRoot, { recursive: true })
862
+ // Backstage content layout: canvas/ at the content root (no src/ segment).
863
+ canvasDir = path.join(contentRoot, 'canvas')
864
+ fs.mkdirSync(canvasDir, { recursive: true })
865
+ root = contentRoot
866
+
867
+ const lr = { status: null, body: null, bytes: null, headers: null }
868
+ const handler = createCanvasHandler({
869
+ // Hand the handler the system root, exactly like vite (root === .backstage)
870
+ root: systemRoot,
871
+ sendJson: (_res, status, body) => { lr.status = status; lr.body = body },
872
+ })
873
+ const makeRes = () => ({ writeHead() {}, end() {} })
874
+ invoke = (routePath, method, body = {}) =>
875
+ handler(null, makeRes(), { path: routePath, method, body })
876
+ lastResponse = lr
877
+ })
878
+
879
+ afterEach(() => {
880
+ fs.rmSync(root, { recursive: true, force: true })
881
+ })
882
+
883
+ it('POST /widget writes to the content-root canvas file (not under .backstage)', async () => {
884
+ writeCanvas(canvasDir, 'design-system', { title: 'Design System' })
885
+
886
+ await invoke('/widget', 'POST', {
887
+ name: 'design-system',
888
+ type: 'sticky-note',
889
+ props: { text: '# hello' },
890
+ position: { x: 120, y: 80 },
891
+ })
892
+
893
+ expect(lastResponse.status).toBe(201)
894
+ expect(lastResponse.body.success).toBe(true)
895
+ expect(lastResponse.body.widget.type).toBe('sticky-note')
896
+
897
+ // The event must land in the content-root file, and NOT create a stray
898
+ // `.backstage/canvas/...` file.
899
+ const contentFile = path.join(canvasDir, 'design-system.canvas.jsonl')
900
+ expect(fs.readFileSync(contentFile, 'utf-8')).toContain('widget_added')
901
+ expect(fs.existsSync(path.join(systemRoot, 'canvas', 'design-system.canvas.jsonl'))).toBe(false)
902
+ })
903
+
904
+ it('GET /list resolves canvases from the content root', async () => {
905
+ writeCanvas(canvasDir, 'design-system', { title: 'Design System' })
906
+
907
+ await invoke('/list', 'GET')
908
+
909
+ expect(lastResponse.status).toBe(200)
910
+ const names = lastResponse.body.canvases.map((c) => c.name)
911
+ expect(names).toContain('design-system')
912
+ })
913
+ })
@@ -519,7 +519,7 @@ function processDraftsFlipImages(root, flip, canvasFiles) {
519
519
  }
520
520
  if (!state.widgets || state.widgets.length === 0) return null
521
521
 
522
- const imagesDir = path.join(root, 'assets', 'canvas', 'images')
522
+ const imagesDir = path.join(contentDir(root, 'assets'), 'canvas', 'images')
523
523
  const draftsDir = path.join(imagesDir, 'drafts')
524
524
  const sharedRefs = collectImageRefs(canvasFiles, newAbsPath)
525
525
 
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs'
12
12
  import path from 'node:path'
13
13
  import { execSync } from 'node:child_process'
14
+ import { findSystemRoot, resolvePaths } from '../paths/resolvePaths.js'
14
15
 
15
16
  const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md']
16
17
  const SOURCE_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts'])
@@ -61,6 +62,11 @@ export async function collectFiles(dir, rootDir) {
61
62
  * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, opts: { path: string, method: string }) => Promise<void>}
62
63
  */
63
64
  export function docsHandler({ root, sendJson }) {
65
+ // Content (README, prototypes, components, config, git) lives at the content
66
+ // root, which equals `root` in the legacy layout but is the parent of the
67
+ // Vite/system root in the `.backstage` layout.
68
+ const { contentRoot } = findSystemRoot(root)
69
+ root = contentRoot
64
70
  return async (req, res, { path: routePath, method }) => {
65
71
  // --- GET /readme ---
66
72
  if (routePath === '/readme' && method === 'GET') {
@@ -96,10 +102,17 @@ export function docsHandler({ root, sendJson }) {
96
102
  return
97
103
  }
98
104
 
99
- // Security: only allow files within src/
105
+ // Security: restrict to content directories (prototypes, components,
106
+ // canvas, assets). In the legacy layout these all live under `src/`; in
107
+ // the `.backstage` layout they sit directly at the content root.
100
108
  const relative = path.relative(root, resolved)
101
- if (!relative.startsWith('src' + path.sep)) {
102
- sendJson(res, 403, { error: 'Only files within src/ are accessible' })
109
+ const { dirs } = resolvePaths(findSystemRoot(root))
110
+ const allowedRoots = [dirs.prototypes, dirs.components, dirs.canvas]
111
+ const isAllowed = allowedRoots.some(
112
+ (d) => resolved === d || resolved.startsWith(d + path.sep),
113
+ )
114
+ if (!isAllowed) {
115
+ sendJson(res, 403, { error: 'Only files within content directories are accessible' })
103
116
  return
104
117
  }
105
118
 
@@ -114,8 +127,8 @@ export function docsHandler({ root, sendJson }) {
114
127
 
115
128
  // --- GET /files ---
116
129
  if (routePath === '/files' && method === 'GET') {
117
- const prototypesDir = path.join(root, 'src', 'prototypes')
118
- const files = await collectFiles(prototypesDir, root)
130
+ const { dirs } = resolvePaths(findSystemRoot(root))
131
+ const files = await collectFiles(dirs.prototypes, root)
119
132
  sendJson(res, 200, { files: files.sort() })
120
133
  return
121
134
  }
@@ -38,6 +38,7 @@ import { startMaintenance, stopMaintenance } from '../messaging/hub-maintenance.
38
38
  import { createArtifactRoutes } from '../artifact/routes.js'
39
39
  import { createFileHandler } from '../file/server.js'
40
40
  import { buildArtifactManifest, resolveManifestEnv } from '../data/artifactManifest.js'
41
+ import { findSystemRoot, contentDir } from '../paths/resolvePaths.js'
41
42
  import { buildDataDiscovery } from '../../internals/vite/data-plugin.js'
42
43
 
43
44
  const API_PREFIX = '/_storyboard/'
@@ -413,9 +414,10 @@ export default function storyboardServer() {
413
414
  }
414
415
 
415
416
  // Ignore assets/canvas/ so image/snapshot writes don't trigger reloads
416
- server.watcher.unwatch(path.join(root, 'assets', 'canvas', 'images'))
417
- server.watcher.unwatch(path.join(root, 'assets', 'canvas', 'snapshots'))
418
- server.watcher.unwatch(path.join(root, 'assets', '.storyboard-public', 'terminal-snapshots'))
417
+ const assetsDir = contentDir(root, 'assets')
418
+ server.watcher.unwatch(path.join(assetsDir, 'canvas', 'images'))
419
+ server.watcher.unwatch(path.join(assetsDir, 'canvas', 'snapshots'))
420
+ server.watcher.unwatch(path.join(assetsDir, '.storyboard-public', 'terminal-snapshots'))
419
421
  // The entire `.storyboard/` directory is gitignored runtime state
420
422
  // (terminal buffers + snapshots, hub messages, selectedwidgets,
421
423
  // logs, server registry). None of it should ever feed Vite's
@@ -930,14 +932,26 @@ export default function storyboardServer() {
930
932
  // Build-time: emit a static JSON with source files so the inspector
931
933
  // works in deployed environments without the dev middleware.
932
934
  async generateBundle() {
933
- const srcDir = path.join(root, 'src')
934
- const prototypesDir = path.join(root, 'src', 'prototypes')
935
-
936
- // Collect file lists (prototypes for the files index, all src/ for sources)
937
- const [prototypeFiles, allSrcFiles] = await Promise.all([
938
- collectFiles(prototypesDir, root),
939
- collectFiles(srcDir, root),
935
+ // Content (prototypes/components/canvas, README, config, git) lives at the
936
+ // content root — equal to `root` in legacy, the parent of the Vite/system
937
+ // root in the `.backstage` layout.
938
+ const contentRoot = findSystemRoot(root).contentRoot
939
+ const dirs = {
940
+ prototypes: contentDir(root, 'prototypes'),
941
+ components: contentDir(root, 'components'),
942
+ canvas: contentDir(root, 'canvas'),
943
+ }
944
+ const prototypesDir = dirs.prototypes
945
+
946
+ // Collect file lists (prototypes for the files index; all content role
947
+ // dirs for the inspector source map — mirrors the docs /source allowlist).
948
+ const [prototypeFiles, ...roleFileLists] = await Promise.all([
949
+ collectFiles(prototypesDir, contentRoot),
950
+ collectFiles(dirs.prototypes, contentRoot),
951
+ collectFiles(dirs.components, contentRoot),
952
+ collectFiles(dirs.canvas, contentRoot),
940
953
  ])
954
+ const allSrcFiles = [...new Set(roleFileLists.flat())]
941
955
 
942
956
  // Read all source file contents
943
957
  const sources = {}
@@ -945,7 +959,7 @@ export default function storyboardServer() {
945
959
  allSrcFiles.map(async (relPath) => {
946
960
  try {
947
961
  sources[relPath] = await fs.promises.readFile(
948
- path.join(root, relPath),
962
+ path.join(contentRoot, relPath),
949
963
  'utf-8'
950
964
  )
951
965
  } catch { /* skip unreadable files */ }
@@ -957,7 +971,7 @@ export default function storyboardServer() {
957
971
  try {
958
972
  const { execSync } = await import('node:child_process')
959
973
  const remote = execSync('git remote get-url origin', {
960
- cwd: root,
974
+ cwd: contentRoot,
961
975
  encoding: 'utf-8',
962
976
  }).trim()
963
977
  const match = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
@@ -965,7 +979,7 @@ export default function storyboardServer() {
965
979
  } catch { /* no git or no remote */ }
966
980
 
967
981
  if (!repo) {
968
- const configPath = path.join(root, 'storyboard.config.json')
982
+ const configPath = path.join(contentRoot, 'storyboard.config.json')
969
983
  try {
970
984
  const raw = await fs.promises.readFile(configPath, 'utf-8')
971
985
  const cfg = JSON.parse(raw)
@@ -990,7 +1004,7 @@ export default function storyboardServer() {
990
1004
  let readmeContent = null
991
1005
  for (const candidate of ['README.md', 'readme.md', 'Readme.md']) {
992
1006
  try {
993
- readmeContent = await fs.promises.readFile(path.join(root, candidate), 'utf-8')
1007
+ readmeContent = await fs.promises.readFile(path.join(contentRoot, candidate), 'utf-8')
994
1008
  break
995
1009
  } catch { /* try next */ }
996
1010
  }
@@ -1033,8 +1047,8 @@ export default function storyboardServer() {
1033
1047
  // Dev server serves these dynamically; production needs the static files.
1034
1048
  // Private images (prefixed with ~) are excluded from the build.
1035
1049
  for (const dir of [
1036
- path.join(root, 'assets', 'canvas', 'images'),
1037
- path.join(root, 'assets', 'canvas', 'snapshots'),
1050
+ path.join(contentDir(root, 'assets'), 'canvas', 'images'),
1051
+ path.join(contentDir(root, 'assets'), 'canvas', 'snapshots'),
1038
1052
  ]) {
1039
1053
  try {
1040
1054
  const imageFiles = await fs.promises.readdir(dir)
@@ -22,6 +22,7 @@ import {
22
22
  resolveTemplateRecipeEntry,
23
23
  } from '../templateIndex.js'
24
24
  import { readWorkshopPartials } from '../partialRender.js'
25
+ import { contentDir, contentBase, findSystemRoot } from '../../../paths/resolvePaths.js'
25
26
 
26
27
  const FLOW_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
27
28
 
@@ -74,7 +75,7 @@ function validatePrototypeName(name) {
74
75
  }
75
76
 
76
77
  function listFolders(root) {
77
- const prototypesDir = path.join(root, 'src', 'prototypes')
78
+ const prototypesDir = contentDir(root, 'prototypes')
78
79
  if (!fs.existsSync(prototypesDir)) return []
79
80
 
80
81
  return fs.readdirSync(prototypesDir, { withFileTypes: true })
@@ -237,7 +238,7 @@ export function createPrototypesHandler(ctx) {
237
238
  const isExternal = Boolean(url)
238
239
 
239
240
  // Determine target directory
240
- const prototypesDir = path.join(root, 'src', 'prototypes')
241
+ const prototypesDir = contentDir(root, 'prototypes')
241
242
  let targetDir
242
243
 
243
244
  if (folder) {
@@ -266,7 +267,7 @@ export function createPrototypesHandler(ctx) {
266
267
  generatePrototypeJson({ title, author, description, partialEntry: null, url: isExternal ? url : undefined }),
267
268
  )
268
269
 
269
- const relDir = targetDir.replace(root + '/', '')
270
+ const relDir = targetDir.replace(findSystemRoot(root).contentRoot + '/', '')
270
271
  const result = {
271
272
  success: true,
272
273
  path: relDir,
@@ -306,7 +307,7 @@ export function createPrototypesHandler(ctx) {
306
307
  if (!partialEntry) {
307
308
  content = generateBlankIndexJsx(componentName, title)
308
309
  } else {
309
- const partialDir = path.join(root, 'src', partialEntry.baseDir, partialEntry.name)
310
+ const partialDir = contentBase(root, partialEntry.baseDir, partialEntry.name)
310
311
  const componentFile = findComponentFile(partialDir)
311
312
  if (!componentFile) {
312
313
  sendJson(res, 400, { error: `No .jsx or .tsx file found in src/${partialEntry.baseDir}/${partialEntry.name}/` })
@@ -343,7 +344,7 @@ export function createPrototypesHandler(ctx) {
343
344
  return
344
345
  }
345
346
 
346
- const prototypesDir = path.join(root, 'src', 'prototypes')
347
+ const prototypesDir = contentDir(root, 'prototypes')
347
348
  let targetDir
348
349
 
349
350
  if (folder) {
@@ -385,7 +386,7 @@ export function createPrototypesHandler(ctx) {
385
386
  return
386
387
  }
387
388
 
388
- const prototypesDir = path.join(root, 'src', 'prototypes')
389
+ const prototypesDir = contentDir(root, 'prototypes')
389
390
  let targetDir
390
391
 
391
392
  if (folder) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { parse as parseJsonc } from 'jsonc-parser'
4
+ import { contentDir, contentBase } from '../../paths/resolvePaths.js'
4
5
 
5
6
  const TEMPLATE_DIR_NAMES = new Set(['template', 'templates'])
6
7
  const RECIPE_DIR_NAMES = new Set(['recipe', 'recipes'])
@@ -31,7 +32,7 @@ function toPartialKind(directory) {
31
32
  }
32
33
 
33
34
  function listPrototypeDirs(root) {
34
- const prototypesDir = path.join(root, 'src', 'prototypes')
35
+ const prototypesDir = contentDir(root, 'prototypes')
35
36
  if (!fs.existsSync(prototypesDir)) return []
36
37
 
37
38
  const results = []
@@ -91,7 +92,7 @@ function resolveGlobalPartialBaseDir(srcRoot, configDirectory, name) {
91
92
  }
92
93
 
93
94
  export function buildTemplateRecipeIndex(root, configPartials = []) {
94
- const srcRoot = path.join(root, 'src')
95
+ const srcRoot = contentBase(root)
95
96
  const results = []
96
97
  const seenIds = new Set()
97
98