@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.5
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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.5",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.5",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error boundary for canvas component widgets.
|
|
5
|
+
* Catches render-time errors so a single broken component
|
|
6
|
+
* doesn't crash the entire canvas page.
|
|
7
|
+
*
|
|
8
|
+
* Used as a production fallback when iframe isolation is not available.
|
|
9
|
+
*/
|
|
10
|
+
export default class ComponentErrorBoundary extends Component {
|
|
11
|
+
constructor(props) {
|
|
12
|
+
super(props)
|
|
13
|
+
this.state = { error: null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static getDerivedStateFromError(error) {
|
|
17
|
+
return { error }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
componentDidCatch(error, info) {
|
|
21
|
+
console.error(
|
|
22
|
+
`[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
|
|
23
|
+
error,
|
|
24
|
+
info?.componentStack,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (this.state.error) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{
|
|
32
|
+
padding: '16px',
|
|
33
|
+
color: '#cf222e',
|
|
34
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
35
|
+
fontSize: '13px',
|
|
36
|
+
lineHeight: 1.5,
|
|
37
|
+
whiteSpace: 'pre-wrap',
|
|
38
|
+
wordBreak: 'break-word',
|
|
39
|
+
minWidth: 200,
|
|
40
|
+
minHeight: 60,
|
|
41
|
+
}}>
|
|
42
|
+
<strong>{this.props.name || 'Component'}</strong>
|
|
43
|
+
<br />
|
|
44
|
+
{String(this.state.error.message || this.state.error)}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return this.props.children
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
|
|
3
|
+
|
|
4
|
+
describe('canvasReloadGuard', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
disableCanvasGuard()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('starts inactive', () => {
|
|
10
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('can be enabled and disabled', () => {
|
|
14
|
+
enableCanvasGuard()
|
|
15
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
16
|
+
disableCanvasGuard()
|
|
17
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('enable is idempotent', () => {
|
|
21
|
+
enableCanvasGuard()
|
|
22
|
+
enableCanvasGuard()
|
|
23
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
24
|
+
disableCanvasGuard()
|
|
25
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Component Isolate — iframe entry point.
|
|
3
|
+
*
|
|
4
|
+
* Renders a single named export from a .canvas.jsx module inside an
|
|
5
|
+
* isolated document. The parent CanvasPage embeds this via an iframe
|
|
6
|
+
* so a broken component cannot crash the entire canvas.
|
|
7
|
+
*
|
|
8
|
+
* Query params:
|
|
9
|
+
* module — absolute or base-relative path to the .canvas.jsx file
|
|
10
|
+
* export — the named export to render
|
|
11
|
+
* theme — canvas theme (light / dark / dark_dimmed)
|
|
12
|
+
*/
|
|
13
|
+
import { createElement, Component as ReactComponent } from 'react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
|
|
16
|
+
// ── Error Boundary ──────────────────────────────────────────────────
|
|
17
|
+
class IsolateErrorBoundary extends ReactComponent {
|
|
18
|
+
constructor(props) {
|
|
19
|
+
super(props)
|
|
20
|
+
this.state = { error: null }
|
|
21
|
+
}
|
|
22
|
+
static getDerivedStateFromError(error) {
|
|
23
|
+
return { error }
|
|
24
|
+
}
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.error) {
|
|
27
|
+
return createElement('div', { style: errorStyle },
|
|
28
|
+
createElement('strong', null, this.props.name || 'Component'),
|
|
29
|
+
createElement('br'),
|
|
30
|
+
String(this.state.error.message || this.state.error),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return this.props.children
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Styles ──────────────────────────────────────────────────────────
|
|
38
|
+
const errorStyle = {
|
|
39
|
+
padding: '16px',
|
|
40
|
+
color: '#cf222e',
|
|
41
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
42
|
+
fontSize: '13px',
|
|
43
|
+
lineHeight: 1.5,
|
|
44
|
+
whiteSpace: 'pre-wrap',
|
|
45
|
+
wordBreak: 'break-word',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Resolve module path (mirrors useCanvas.resolveCanvasModuleImport) ─
|
|
49
|
+
function resolveModulePath(raw) {
|
|
50
|
+
if (!raw) return raw
|
|
51
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
|
|
52
|
+
if (!raw.startsWith('/')) return raw
|
|
53
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
54
|
+
if (!base) return raw
|
|
55
|
+
if (raw.startsWith(base)) return raw
|
|
56
|
+
return `${base}${raw}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
60
|
+
const params = new URLSearchParams(window.location.search)
|
|
61
|
+
const modulePath = params.get('module')
|
|
62
|
+
const exportName = params.get('export')
|
|
63
|
+
const theme = params.get('theme') || 'light'
|
|
64
|
+
|
|
65
|
+
// Apply theme to document for Primer / CSS-var inheritance
|
|
66
|
+
document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
|
|
67
|
+
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
68
|
+
document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
|
|
69
|
+
|
|
70
|
+
const root = createRoot(document.getElementById('root'))
|
|
71
|
+
|
|
72
|
+
async function mount() {
|
|
73
|
+
if (!modulePath || !exportName) {
|
|
74
|
+
root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validate: only allow .canvas.jsx modules
|
|
79
|
+
if (!modulePath.endsWith('.canvas.jsx')) {
|
|
80
|
+
root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx files are allowed'))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const resolved = resolveModulePath(modulePath)
|
|
86
|
+
const mod = await import(/* @vite-ignore */ resolved)
|
|
87
|
+
const Component = mod[exportName]
|
|
88
|
+
|
|
89
|
+
if (!Component || typeof Component !== 'function') {
|
|
90
|
+
throw new Error(`Export "${exportName}" not found or is not a component`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
root.render(
|
|
94
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
95
|
+
createElement(Component),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
root.render(
|
|
100
|
+
createElement('div', { style: errorStyle },
|
|
101
|
+
createElement('strong', null, exportName),
|
|
102
|
+
createElement('br'),
|
|
103
|
+
String(err.message || err),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
mount()
|
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
position: absolute;
|
|
237
237
|
top: calc(100% + 10px);
|
|
238
238
|
right: 0;
|
|
239
|
-
min-width:
|
|
239
|
+
min-width: max-content;
|
|
240
240
|
padding: 4px;
|
|
241
241
|
background: var(--bgColor-default, #ffffff);
|
|
242
242
|
border-radius: 10px;
|
|
@@ -265,6 +265,7 @@
|
|
|
265
265
|
color: var(--fgColor-default, #1f2328);
|
|
266
266
|
border-radius: 6px;
|
|
267
267
|
box-sizing: border-box;
|
|
268
|
+
white-space: nowrap;
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
:global([data-sb-canvas-theme^='dark']) .overflowItem {
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -155,14 +155,101 @@ function getLastModified(root, dirPath) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Batch-fetch git metadata (author + lastModified) for multiple files in a
|
|
160
|
+
* single subprocess, avoiding per-file git overhead during startup.
|
|
161
|
+
*
|
|
162
|
+
* Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
|
|
163
|
+
*/
|
|
164
|
+
function batchGitMetadata(root, filePaths) {
|
|
165
|
+
const result = new Map()
|
|
166
|
+
if (filePaths.length === 0) return result
|
|
167
|
+
|
|
168
|
+
// Initialize all entries
|
|
169
|
+
for (const fp of filePaths) {
|
|
170
|
+
result.set(fp, { gitAuthor: null, lastModified: null })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// Batch lastModified: one git log call with all paths
|
|
175
|
+
// git log -1 gives the most recent commit touching any of these paths,
|
|
176
|
+
// but we need per-path data. Use --name-only to correlate.
|
|
177
|
+
// For efficiency, use a single git log with --format and --name-only
|
|
178
|
+
// that outputs one record per commit touching these files.
|
|
179
|
+
const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
|
|
180
|
+
const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
|
|
181
|
+
|
|
182
|
+
// Get lastModified per directory in one call using git log --format
|
|
183
|
+
// We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
|
|
184
|
+
const logResult = execSync(
|
|
185
|
+
`git log --format="%aI" --name-only -- ${dirsArg}`,
|
|
186
|
+
{ cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
|
|
187
|
+
).trim()
|
|
188
|
+
|
|
189
|
+
if (logResult) {
|
|
190
|
+
// Parse: alternating date lines and filename lines separated by blank lines
|
|
191
|
+
const blocks = logResult.split('\n\n')
|
|
192
|
+
const dirDates = new Map() // dir → most recent date
|
|
193
|
+
for (const block of blocks) {
|
|
194
|
+
const lines = block.split('\n').filter(Boolean)
|
|
195
|
+
if (lines.length < 2) continue
|
|
196
|
+
const date = lines[0]
|
|
197
|
+
for (let li = 1; li < lines.length; li++) {
|
|
198
|
+
const fileLine = lines[li].trim()
|
|
199
|
+
if (!fileLine) continue
|
|
200
|
+
const dir = path.dirname(path.resolve(root, fileLine))
|
|
201
|
+
if (!dirDates.has(dir)) {
|
|
202
|
+
dirDates.set(dir, date)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const fp of filePaths) {
|
|
207
|
+
const dir = path.dirname(fp)
|
|
208
|
+
const entry = result.get(fp)
|
|
209
|
+
if (dirDates.has(dir) && entry) {
|
|
210
|
+
entry.lastModified = dirDates.get(dir)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch { /* git not available or failed — leave nulls */ }
|
|
215
|
+
|
|
216
|
+
// Batch gitAuthor: use git log for each file's creation author.
|
|
217
|
+
// Unfortunately --follow --diff-filter=A doesn't combine well with multiple
|
|
218
|
+
// paths, so batch them in a single shell invocation using a for loop.
|
|
219
|
+
try {
|
|
220
|
+
const relPaths = filePaths.map(fp => path.relative(root, fp))
|
|
221
|
+
// Build a shell script that outputs "PATH<tab>AUTHOR" per file
|
|
222
|
+
const cmds = relPaths.map(rp =>
|
|
223
|
+
`echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
|
|
224
|
+
).join('; ')
|
|
225
|
+
const authorResult = execSync(cmds, {
|
|
226
|
+
cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
|
|
227
|
+
}).trim()
|
|
228
|
+
|
|
229
|
+
if (authorResult) {
|
|
230
|
+
for (const line of authorResult.split('\n')) {
|
|
231
|
+
const tabIdx = line.indexOf('\t')
|
|
232
|
+
if (tabIdx < 0) continue
|
|
233
|
+
const relPath = line.slice(0, tabIdx)
|
|
234
|
+
const author = line.slice(tabIdx + 1).trim()
|
|
235
|
+
if (!author) continue
|
|
236
|
+
const absPath2 = path.resolve(root, relPath)
|
|
237
|
+
const entry = result.get(absPath2)
|
|
238
|
+
if (entry) entry.gitAuthor = author
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch { /* git not available */ }
|
|
242
|
+
|
|
243
|
+
return result
|
|
244
|
+
}
|
|
245
|
+
|
|
158
246
|
/**
|
|
159
247
|
* Scan the repo for all data files, validate uniqueness, return the index.
|
|
160
248
|
*/
|
|
161
249
|
function buildIndex(root) {
|
|
162
|
-
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const canvasFiles = globSync(`src/${CANVAS_GLOB_PATTERN}`, { cwd: root, ignore, absolute: false })
|
|
250
|
+
const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
|
|
251
|
+
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
252
|
+
const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
166
253
|
|
|
167
254
|
// Detect nested .folder/ directories (not supported)
|
|
168
255
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -340,6 +427,13 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
340
427
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
341
428
|
let i = 0
|
|
342
429
|
|
|
430
|
+
// Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
|
|
431
|
+
const gitPaths = [
|
|
432
|
+
...Object.values(index.prototype || {}),
|
|
433
|
+
...Object.values(index.canvas || {}),
|
|
434
|
+
]
|
|
435
|
+
const gitMeta = batchGitMetadata(root, gitPaths)
|
|
436
|
+
|
|
343
437
|
for (const suffix of INDEX_KEYS) {
|
|
344
438
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
345
439
|
const varName = `_d${i++}`
|
|
@@ -350,18 +444,17 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
350
444
|
|
|
351
445
|
// Auto-fill gitAuthor for prototype metadata from git history
|
|
352
446
|
if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
|
|
353
|
-
const
|
|
354
|
-
if (gitAuthor) {
|
|
355
|
-
parsed = { ...parsed, gitAuthor }
|
|
447
|
+
const meta = gitMeta.get(absPath)
|
|
448
|
+
if (meta?.gitAuthor) {
|
|
449
|
+
parsed = { ...parsed, gitAuthor: meta.gitAuthor }
|
|
356
450
|
}
|
|
357
451
|
}
|
|
358
452
|
|
|
359
453
|
// Auto-fill lastModified from git history for prototypes
|
|
360
454
|
if (suffix === 'prototype' && parsed) {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
parsed = { ...parsed, lastModified }
|
|
455
|
+
const meta = gitMeta.get(absPath)
|
|
456
|
+
if (meta?.lastModified) {
|
|
457
|
+
parsed = { ...parsed, lastModified: meta.lastModified }
|
|
365
458
|
}
|
|
366
459
|
}
|
|
367
460
|
|
|
@@ -400,9 +493,9 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
400
493
|
|
|
401
494
|
// Auto-fill gitAuthor for canvas metadata from git history
|
|
402
495
|
if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
|
|
403
|
-
const
|
|
404
|
-
if (gitAuthor) {
|
|
405
|
-
parsed = { ...parsed, gitAuthor }
|
|
496
|
+
const meta = gitMeta.get(absPath)
|
|
497
|
+
if (meta?.gitAuthor) {
|
|
498
|
+
parsed = { ...parsed, gitAuthor: meta.gitAuthor }
|
|
406
499
|
}
|
|
407
500
|
}
|
|
408
501
|
|