@dfosco/storyboard 0.6.11 → 0.6.13
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/tailwind.css +1 -1
- package/package.json +6 -2
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/README.md +18 -0
- package/scaffold/gitignore +7 -14
- package/scaffold/{manifest.json → scaffold.config.json} +7 -2
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +245 -46
- package/src/core/canvas/server.test.js +226 -0
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/dev.js +5 -10
- package/src/core/cli/index.js +6 -0
- package/src/core/cli/setup.js +14 -26
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +244 -0
- package/src/core/scaffold.js +4 -97
- package/src/internals/canvas/CanvasPage.jsx +247 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/canvasApi.js +29 -0
- package/src/internals/canvas/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/prototypesEntry.jsx +66 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/vite/data-plugin.js +72 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseFragments,
|
|
4
|
+
extractFragment,
|
|
5
|
+
applyFragments,
|
|
6
|
+
stripLibraryMarkers,
|
|
7
|
+
} from './fragments.js'
|
|
8
|
+
|
|
9
|
+
// Helpers -------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function mkLookup(entries) {
|
|
12
|
+
return (src, id) => {
|
|
13
|
+
const key = `${src}:${id}`
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(entries, key) ? entries[key] : null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RUNTIME_BODY = '.storyboard/\nsrc/canvas/**/drafts/\n'
|
|
19
|
+
|
|
20
|
+
const CLIENT_GITIGNORE = `# top of file
|
|
21
|
+
/node_modules
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# <!-- storyboard:scaffold/gitignore:runtime-state --start-->
|
|
25
|
+
old-content
|
|
26
|
+
that-will-be-replaced
|
|
27
|
+
# <!-- storyboard:scaffold/gitignore:runtime-state --end-->
|
|
28
|
+
|
|
29
|
+
# bottom of file
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
const LIBRARY_GITIGNORE = `# packages/storyboard/scaffold/gitignore
|
|
33
|
+
/some/library/comment
|
|
34
|
+
|
|
35
|
+
# <!-- runtime-state --start-->
|
|
36
|
+
.storyboard/
|
|
37
|
+
src/canvas/**/drafts/
|
|
38
|
+
# <!-- runtime-state --end-->
|
|
39
|
+
|
|
40
|
+
# trailing library comment
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
// parseFragments ------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe('parseFragments', () => {
|
|
46
|
+
it('returns [] on a file with no markers', () => {
|
|
47
|
+
expect(parseFragments('hello\nworld\n')).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns [] on an empty string', () => {
|
|
51
|
+
expect(parseFragments('')).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('parses a single fragment with start/end on their own lines', () => {
|
|
55
|
+
const out = parseFragments(CLIENT_GITIGNORE)
|
|
56
|
+
expect(out).toHaveLength(1)
|
|
57
|
+
expect(out[0]).toMatchObject({
|
|
58
|
+
sourcePath: 'scaffold/gitignore',
|
|
59
|
+
id: 'runtime-state',
|
|
60
|
+
})
|
|
61
|
+
expect(out[0].body).toBe('old-content\nthat-will-be-replaced\n')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('parses multiple fragments in the same file', () => {
|
|
65
|
+
const text = [
|
|
66
|
+
'# <!-- storyboard:scaffold/a:one --start-->',
|
|
67
|
+
'A1',
|
|
68
|
+
'# <!-- storyboard:scaffold/a:one --end-->',
|
|
69
|
+
'middle',
|
|
70
|
+
'# <!-- storyboard:scaffold/b:two --start-->',
|
|
71
|
+
'B1',
|
|
72
|
+
'B2',
|
|
73
|
+
'# <!-- storyboard:scaffold/b:two --end-->',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n')
|
|
76
|
+
const out = parseFragments(text)
|
|
77
|
+
expect(out).toHaveLength(2)
|
|
78
|
+
expect(out[0].id).toBe('one')
|
|
79
|
+
expect(out[1].id).toBe('two')
|
|
80
|
+
expect(out[1].body).toBe('B1\nB2\n')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('handles an empty fragment body', () => {
|
|
84
|
+
const text = [
|
|
85
|
+
'# <!-- storyboard:src:id --start-->',
|
|
86
|
+
'# <!-- storyboard:src:id --end-->',
|
|
87
|
+
'',
|
|
88
|
+
].join('\n')
|
|
89
|
+
const out = parseFragments(text)
|
|
90
|
+
expect(out[0].body).toBe('')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('rejects duplicate start without intervening end', () => {
|
|
94
|
+
const text = [
|
|
95
|
+
'# <!-- storyboard:src:id --start-->',
|
|
96
|
+
'x',
|
|
97
|
+
'# <!-- storyboard:src:id --start-->',
|
|
98
|
+
'',
|
|
99
|
+
].join('\n')
|
|
100
|
+
expect(() => parseFragments(text)).toThrow(/Duplicate or nested/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('rejects stray end with no matching start', () => {
|
|
104
|
+
const text = [
|
|
105
|
+
'a',
|
|
106
|
+
'# <!-- storyboard:src:id --end-->',
|
|
107
|
+
'',
|
|
108
|
+
].join('\n')
|
|
109
|
+
expect(() => parseFragments(text)).toThrow(/Stray storyboard:src:id --end--/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rejects unclosed start', () => {
|
|
113
|
+
const text = [
|
|
114
|
+
'# <!-- storyboard:src:id --start-->',
|
|
115
|
+
'body',
|
|
116
|
+
'',
|
|
117
|
+
].join('\n')
|
|
118
|
+
expect(() => parseFragments(text)).toThrow(/Unclosed storyboard:src:id/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('ignores comment prefix style (works for #, //, <!--, etc.)', () => {
|
|
122
|
+
const text = [
|
|
123
|
+
'// <!-- storyboard:a:one --start-->',
|
|
124
|
+
'x',
|
|
125
|
+
'// <!-- storyboard:a:one --end-->',
|
|
126
|
+
'<!-- storyboard:b:two --start-->',
|
|
127
|
+
'y',
|
|
128
|
+
'<!-- storyboard:b:two --end-->',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n')
|
|
131
|
+
const out = parseFragments(text)
|
|
132
|
+
expect(out).toHaveLength(2)
|
|
133
|
+
expect(out.map(f => f.id)).toEqual(['one', 'two'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('handles CRLF line endings', () => {
|
|
137
|
+
const text =
|
|
138
|
+
'# <!-- storyboard:src:id --start-->\r\n' +
|
|
139
|
+
'body\r\n' +
|
|
140
|
+
'# <!-- storyboard:src:id --end-->\r\n'
|
|
141
|
+
const out = parseFragments(text)
|
|
142
|
+
expect(out).toHaveLength(1)
|
|
143
|
+
expect(out[0].body).toBe('body\r\n')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// extractFragment -----------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe('extractFragment', () => {
|
|
150
|
+
it('returns the body for a matching bare marker', () => {
|
|
151
|
+
expect(extractFragment(LIBRARY_GITIGNORE, 'runtime-state')).toBe(RUNTIME_BODY)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('returns null when the fragment id is not present', () => {
|
|
155
|
+
expect(extractFragment(LIBRARY_GITIGNORE, 'nope')).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns null on an empty source file', () => {
|
|
159
|
+
expect(extractFragment('', 'whatever')).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('handles an empty body', () => {
|
|
163
|
+
const src = '<!-- empty --start-->\n<!-- empty --end-->\n'
|
|
164
|
+
expect(extractFragment(src, 'empty')).toBe('')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('rejects duplicate --start-- for the same id', () => {
|
|
168
|
+
const src = [
|
|
169
|
+
'<!-- dup --start-->',
|
|
170
|
+
'<!-- dup --start-->',
|
|
171
|
+
'<!-- dup --end-->',
|
|
172
|
+
'',
|
|
173
|
+
].join('\n')
|
|
174
|
+
expect(() => extractFragment(src, 'dup')).toThrow(/Duplicate --start--/)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('rejects stray --end-- for the same id', () => {
|
|
178
|
+
const src = '<!-- ghost --end-->\n'
|
|
179
|
+
expect(() => extractFragment(src, 'ghost')).toThrow(/Stray --end--/)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('rejects unclosed --start--', () => {
|
|
183
|
+
const src = '<!-- open --start-->\nstuff\n'
|
|
184
|
+
expect(() => extractFragment(src, 'open')).toThrow(/Unclosed --start--/)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('ignores markers for other ids', () => {
|
|
188
|
+
const src = [
|
|
189
|
+
'<!-- other --start-->',
|
|
190
|
+
'OTHER',
|
|
191
|
+
'<!-- other --end-->',
|
|
192
|
+
'<!-- target --start-->',
|
|
193
|
+
'TARGET',
|
|
194
|
+
'<!-- target --end-->',
|
|
195
|
+
'',
|
|
196
|
+
].join('\n')
|
|
197
|
+
expect(extractFragment(src, 'target')).toBe('TARGET\n')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// applyFragments ------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('applyFragments', () => {
|
|
204
|
+
it('rewrites a single fragment body and reports replaced count', () => {
|
|
205
|
+
const lookup = mkLookup({ 'scaffold/gitignore:runtime-state': RUNTIME_BODY })
|
|
206
|
+
const out = applyFragments(CLIENT_GITIGNORE, lookup)
|
|
207
|
+
expect(out.replaced).toBe(1)
|
|
208
|
+
expect(out.unchanged).toBe(0)
|
|
209
|
+
expect(out.text).toContain(RUNTIME_BODY)
|
|
210
|
+
expect(out.text).not.toContain('old-content')
|
|
211
|
+
// Outside-marker content preserved exactly
|
|
212
|
+
expect(out.text).toContain('# top of file')
|
|
213
|
+
expect(out.text).toContain('# bottom of file')
|
|
214
|
+
expect(out.text.split('\n')[0]).toBe('# top of file')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('is idempotent: applying twice produces the same text', () => {
|
|
218
|
+
const lookup = mkLookup({ 'scaffold/gitignore:runtime-state': RUNTIME_BODY })
|
|
219
|
+
const once = applyFragments(CLIENT_GITIGNORE, lookup).text
|
|
220
|
+
const twice = applyFragments(once, lookup)
|
|
221
|
+
expect(twice.text).toBe(once)
|
|
222
|
+
expect(twice.replaced).toBe(0)
|
|
223
|
+
expect(twice.unchanged).toBe(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('returns text unchanged when there are no fragments', () => {
|
|
227
|
+
const out = applyFragments('just some text\n', mkLookup({}))
|
|
228
|
+
expect(out.text).toBe('just some text\n')
|
|
229
|
+
expect(out.replaced).toBe(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('throws when a fragment references an unknown source/id', () => {
|
|
233
|
+
expect(() => applyFragments(CLIENT_GITIGNORE, mkLookup({}))).toThrow(
|
|
234
|
+
/No fragment "runtime-state" found in scaffold\/gitignore/
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('rewrites multiple fragments without index drift', () => {
|
|
239
|
+
const text = [
|
|
240
|
+
'header',
|
|
241
|
+
'# <!-- storyboard:src:one --start-->',
|
|
242
|
+
'OLD-ONE',
|
|
243
|
+
'# <!-- storyboard:src:one --end-->',
|
|
244
|
+
'middle',
|
|
245
|
+
'# <!-- storyboard:src:two --start-->',
|
|
246
|
+
'OLD-TWO-A',
|
|
247
|
+
'OLD-TWO-B',
|
|
248
|
+
'# <!-- storyboard:src:two --end-->',
|
|
249
|
+
'footer',
|
|
250
|
+
'',
|
|
251
|
+
].join('\n')
|
|
252
|
+
const lookup = mkLookup({
|
|
253
|
+
'src:one': 'NEW-ONE-A\nNEW-ONE-B\nNEW-ONE-C\n',
|
|
254
|
+
'src:two': 'NEW-TWO\n',
|
|
255
|
+
})
|
|
256
|
+
const out = applyFragments(text, lookup)
|
|
257
|
+
expect(out.replaced).toBe(2)
|
|
258
|
+
expect(out.text).toContain('NEW-ONE-A\nNEW-ONE-B\nNEW-ONE-C\n')
|
|
259
|
+
expect(out.text).toContain('NEW-TWO\n')
|
|
260
|
+
expect(out.text).not.toContain('OLD-')
|
|
261
|
+
expect(out.text.split('\n')[0]).toBe('header')
|
|
262
|
+
expect(out.text).toMatch(/footer\n$/)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('preserves CRLF line endings around the fragment', () => {
|
|
266
|
+
const text =
|
|
267
|
+
'header\r\n' +
|
|
268
|
+
'# <!-- storyboard:src:id --start-->\r\n' +
|
|
269
|
+
'OLD\r\n' +
|
|
270
|
+
'# <!-- storyboard:src:id --end-->\r\n' +
|
|
271
|
+
'footer\r\n'
|
|
272
|
+
const out = applyFragments(text, mkLookup({ 'src:id': 'NEW\r\n' }))
|
|
273
|
+
expect(out.text).toBe(
|
|
274
|
+
'header\r\n' +
|
|
275
|
+
'# <!-- storyboard:src:id --start-->\r\n' +
|
|
276
|
+
'NEW\r\n' +
|
|
277
|
+
'# <!-- storyboard:src:id --end-->\r\n' +
|
|
278
|
+
'footer\r\n'
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('counts unchanged fragments separately from replaced', () => {
|
|
283
|
+
const text = [
|
|
284
|
+
'# <!-- storyboard:src:one --start-->',
|
|
285
|
+
'KEEP-ME',
|
|
286
|
+
'# <!-- storyboard:src:one --end-->',
|
|
287
|
+
'# <!-- storyboard:src:two --start-->',
|
|
288
|
+
'OLD',
|
|
289
|
+
'# <!-- storyboard:src:two --end-->',
|
|
290
|
+
'',
|
|
291
|
+
].join('\n')
|
|
292
|
+
const lookup = mkLookup({
|
|
293
|
+
'src:one': 'KEEP-ME\n',
|
|
294
|
+
'src:two': 'NEW\n',
|
|
295
|
+
})
|
|
296
|
+
const out = applyFragments(text, lookup)
|
|
297
|
+
expect(out.replaced).toBe(1)
|
|
298
|
+
expect(out.unchanged).toBe(1)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// stripLibraryMarkers -------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
describe('stripLibraryMarkers', () => {
|
|
305
|
+
it('removes bare --start-- and --end-- marker lines', () => {
|
|
306
|
+
const out = stripLibraryMarkers(LIBRARY_GITIGNORE)
|
|
307
|
+
expect(out.stripped).toBe(2)
|
|
308
|
+
expect(out.text).not.toMatch(/--start--/)
|
|
309
|
+
expect(out.text).not.toMatch(/--end--/)
|
|
310
|
+
expect(out.text).toContain('.storyboard/')
|
|
311
|
+
expect(out.text).toContain('src/canvas/**/drafts/')
|
|
312
|
+
expect(out.text).toContain('# trailing library comment')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('leaves non-marker content untouched', () => {
|
|
316
|
+
const text = 'a\nb\nc\n'
|
|
317
|
+
const out = stripLibraryMarkers(text)
|
|
318
|
+
expect(out.stripped).toBe(0)
|
|
319
|
+
expect(out.text).toBe('a\nb\nc\n')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('leaves storyboard:-namespaced markers intact (defensive)', () => {
|
|
323
|
+
const text = [
|
|
324
|
+
'# <!-- storyboard:scaffold/x:foo --start-->',
|
|
325
|
+
'body',
|
|
326
|
+
'# <!-- storyboard:scaffold/x:foo --end-->',
|
|
327
|
+
'',
|
|
328
|
+
].join('\n')
|
|
329
|
+
const out = stripLibraryMarkers(text)
|
|
330
|
+
expect(out.stripped).toBe(0)
|
|
331
|
+
expect(out.text).toBe(text)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('collapses runs of blank lines left behind', () => {
|
|
335
|
+
const text = [
|
|
336
|
+
'first',
|
|
337
|
+
'',
|
|
338
|
+
'<!-- a --start-->',
|
|
339
|
+
'body',
|
|
340
|
+
'<!-- a --end-->',
|
|
341
|
+
'',
|
|
342
|
+
'last',
|
|
343
|
+
'',
|
|
344
|
+
].join('\n')
|
|
345
|
+
const out = stripLibraryMarkers(text)
|
|
346
|
+
expect(out.stripped).toBe(2)
|
|
347
|
+
// The two blank lines that bracketed the markers should collapse to one.
|
|
348
|
+
expect(out.text.split('\n\n').length).toBeLessThanOrEqual(3)
|
|
349
|
+
expect(out.text).toContain('first')
|
|
350
|
+
expect(out.text).toContain('body')
|
|
351
|
+
expect(out.text).toContain('last')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('handles multiple bare fragments in one file', () => {
|
|
355
|
+
const text = [
|
|
356
|
+
'<!-- one --start-->',
|
|
357
|
+
'A',
|
|
358
|
+
'<!-- one --end-->',
|
|
359
|
+
'<!-- two --start-->',
|
|
360
|
+
'B',
|
|
361
|
+
'<!-- two --end-->',
|
|
362
|
+
'',
|
|
363
|
+
].join('\n')
|
|
364
|
+
const out = stripLibraryMarkers(text)
|
|
365
|
+
expect(out.stripped).toBe(4)
|
|
366
|
+
expect(out.text).toContain('A')
|
|
367
|
+
expect(out.text).toContain('B')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('is idempotent', () => {
|
|
371
|
+
const once = stripLibraryMarkers(LIBRARY_GITIGNORE).text
|
|
372
|
+
const twice = stripLibraryMarkers(once)
|
|
373
|
+
expect(twice.text).toBe(once)
|
|
374
|
+
expect(twice.stripped).toBe(0)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Cross-function integration -----------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe('integration: client.apply ↔ library.extract', () => {
|
|
381
|
+
it('round-trips through apply + extract', () => {
|
|
382
|
+
const lookup = (src, id) => {
|
|
383
|
+
if (src === 'scaffold/gitignore') return extractFragment(LIBRARY_GITIGNORE, id)
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
const applied = applyFragments(CLIENT_GITIGNORE, lookup)
|
|
387
|
+
const fragments = parseFragments(applied.text)
|
|
388
|
+
expect(fragments).toHaveLength(1)
|
|
389
|
+
expect(fragments[0].body).toBe(RUNTIME_BODY)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('whole-file strip yields content equivalent to the fragment body for a single-fragment file', () => {
|
|
393
|
+
// A trivial library file containing nothing but the fragment.
|
|
394
|
+
const src = '<!-- only --start-->\nA\nB\n<!-- only --end-->\n'
|
|
395
|
+
const stripped = stripLibraryMarkers(src).text
|
|
396
|
+
const body = extractFragment(src, 'only')
|
|
397
|
+
// Stripping leaves the body intact (plus the trailing newline that was
|
|
398
|
+
// after the closing marker on its own line).
|
|
399
|
+
expect(stripped).toContain(body)
|
|
400
|
+
})
|
|
401
|
+
})
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storyboard-scaffold — sync scaffold files from @dfosco/storyboard.
|
|
3
|
+
*
|
|
4
|
+
* Two passes per run, driven by `packages/storyboard/scaffold/scaffold.config.json`:
|
|
5
|
+
*
|
|
6
|
+
* 1. Whole-file pass (`files[]`):
|
|
7
|
+
* - `mode: "scaffold"` — copy library file to client only if target is missing
|
|
8
|
+
* - `mode: "updateable"` — always overwrite client file with library version
|
|
9
|
+
* Both call `stripLibraryMarkers()` on the source content first so library
|
|
10
|
+
* bare markers (`<!-- <id> --start-->`) never leak into client output.
|
|
11
|
+
*
|
|
12
|
+
* 2. Fragment-replace pass (`fragmentSources[]`):
|
|
13
|
+
* - For every client file that contains `<!-- storyboard:<src>:<id> --start-->`
|
|
14
|
+
* markers, rewrite each fragment body from the corresponding library
|
|
15
|
+
* source. Content outside markers is preserved exactly.
|
|
16
|
+
* - Scans `files[]` targets plus a curated set of well-known config files
|
|
17
|
+
* (`.gitignore`, `AGENTS.md`, `README.md`) so clients can opt in to
|
|
18
|
+
* fragment-only updates without listing the file in `files[]`.
|
|
19
|
+
*
|
|
20
|
+
* A one-shot legacy migration runs before pass 2: any `.gitignore` containing
|
|
21
|
+
* the pre-0.6.10 ad-hoc storyboard banner is rewritten into marker form so
|
|
22
|
+
* subsequent scaffolds keep it in sync.
|
|
23
|
+
*
|
|
24
|
+
* Backwards-compat: if `scaffold.config.json` is missing but the old
|
|
25
|
+
* `manifest.json` exists, we read it and emit a deprecation warning.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* npx storyboard-scaffold # both passes
|
|
29
|
+
* npx storyboard-scaffold --fragments-only # skip pass 1, only rewrite fragments
|
|
30
|
+
* npx storyboard-scaffold --files-only # skip pass 2, only copy whole files
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { applyFragments, extractFragment, stripLibraryMarkers } from './fragments.js'
|
|
36
|
+
import { migrateLegacyGitignoreFile } from './migrateLegacyGitignore.js'
|
|
37
|
+
|
|
38
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
|
39
|
+
|
|
40
|
+
// We're at packages/storyboard/src/core/scaffold/index.js — the library
|
|
41
|
+
// scaffold tree lives at packages/storyboard/scaffold/
|
|
42
|
+
const scaffoldRoot = path.resolve(__dirname, '..', '..', '..', 'scaffold')
|
|
43
|
+
const consumerRoot = process.cwd()
|
|
44
|
+
|
|
45
|
+
// Targets that clients commonly mark up with fragment markers but might not
|
|
46
|
+
// have in `files[]`. Always scanned during the fragment pass.
|
|
47
|
+
const WELL_KNOWN_FRAGMENT_TARGETS = ['.gitignore', 'AGENTS.md', 'README.md']
|
|
48
|
+
|
|
49
|
+
const args = new Set(process.argv.slice(2))
|
|
50
|
+
const filesOnly = args.has('--files-only')
|
|
51
|
+
const fragmentsOnly = args.has('--fragments-only')
|
|
52
|
+
|
|
53
|
+
function loadConfig() {
|
|
54
|
+
const cfgPath = path.join(scaffoldRoot, 'scaffold.config.json')
|
|
55
|
+
if (fs.existsSync(cfgPath)) {
|
|
56
|
+
return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
|
57
|
+
}
|
|
58
|
+
// Backcompat fallback for one release — manifest.json was the old name.
|
|
59
|
+
const legacyPath = path.join(scaffoldRoot, 'manifest.json')
|
|
60
|
+
if (fs.existsSync(legacyPath)) {
|
|
61
|
+
console.warn(
|
|
62
|
+
' ⚠ Reading legacy scaffold/manifest.json — rename to scaffold.config.json (this fallback is removed in the next release)'
|
|
63
|
+
)
|
|
64
|
+
return JSON.parse(fs.readFileSync(legacyPath, 'utf-8'))
|
|
65
|
+
}
|
|
66
|
+
console.error('❌ Could not find scaffold/scaffold.config.json in @dfosco/storyboard')
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const config = loadConfig()
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// FS helpers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function ensureDir(dest) {
|
|
77
|
+
const dir = path.dirname(dest)
|
|
78
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeStripped(srcPath, destPath) {
|
|
82
|
+
ensureDir(destPath)
|
|
83
|
+
const text = fs.readFileSync(srcPath, 'utf-8')
|
|
84
|
+
const { text: stripped, stripped: count } = stripLibraryMarkers(text)
|
|
85
|
+
fs.writeFileSync(destPath, stripped)
|
|
86
|
+
if (destPath.endsWith('.sh')) fs.chmodSync(destPath, 0o755)
|
|
87
|
+
return count
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function copyFileStripped(srcPath, destPath) {
|
|
91
|
+
// Binary files: just copy bytes.
|
|
92
|
+
if (isLikelyBinary(srcPath)) {
|
|
93
|
+
ensureDir(destPath)
|
|
94
|
+
fs.copyFileSync(srcPath, destPath)
|
|
95
|
+
if (destPath.endsWith('.sh')) fs.chmodSync(destPath, 0o755)
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
return writeStripped(srcPath, destPath)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function copyDirStripped(srcDir, destDir) {
|
|
102
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true })
|
|
103
|
+
let stripped = 0
|
|
104
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
105
|
+
const s = path.join(srcDir, entry.name)
|
|
106
|
+
const d = path.join(destDir, entry.name)
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
stripped += copyDirStripped(s, d)
|
|
109
|
+
} else {
|
|
110
|
+
stripped += copyFileStripped(s, d)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return stripped
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isLikelyBinary(filePath) {
|
|
117
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
118
|
+
return [
|
|
119
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.icns',
|
|
120
|
+
'.woff', '.woff2', '.ttf', '.otf',
|
|
121
|
+
'.zip', '.gz', '.tgz', '.tar',
|
|
122
|
+
'.mp4', '.mov', '.webm',
|
|
123
|
+
].includes(ext)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Pass 1: whole-file copies
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function runFilesPass() {
|
|
131
|
+
let created = 0
|
|
132
|
+
let updated = 0
|
|
133
|
+
let skipped = 0
|
|
134
|
+
let strippedTotal = 0
|
|
135
|
+
|
|
136
|
+
for (const file of config.files) {
|
|
137
|
+
const srcPath = path.join(scaffoldRoot, path.relative('scaffold', file.source))
|
|
138
|
+
const destPath = path.join(consumerRoot, file.target)
|
|
139
|
+
|
|
140
|
+
if (file.directory) {
|
|
141
|
+
if (file.mode === 'updateable') {
|
|
142
|
+
strippedTotal += copyDirStripped(srcPath, destPath)
|
|
143
|
+
updated++
|
|
144
|
+
console.log(` ✔ Updated ${file.target} (sync)`)
|
|
145
|
+
} else if (!fs.existsSync(destPath)) {
|
|
146
|
+
strippedTotal += copyDirStripped(srcPath, destPath)
|
|
147
|
+
created++
|
|
148
|
+
console.log(` ✔ Created ${file.target} (scaffold)`)
|
|
149
|
+
} else {
|
|
150
|
+
skipped++
|
|
151
|
+
console.log(` ⏭ Skipped ${file.target} (already exists)`)
|
|
152
|
+
}
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (file.mode === 'scaffold') {
|
|
157
|
+
if (fs.existsSync(destPath)) {
|
|
158
|
+
skipped++
|
|
159
|
+
console.log(` ⏭ Skipped ${file.target} (already exists)`)
|
|
160
|
+
} else {
|
|
161
|
+
strippedTotal += copyFileStripped(srcPath, destPath)
|
|
162
|
+
created++
|
|
163
|
+
console.log(` ✔ Created ${file.target} (scaffold)`)
|
|
164
|
+
}
|
|
165
|
+
} else if (file.mode === 'updateable') {
|
|
166
|
+
strippedTotal += copyFileStripped(srcPath, destPath)
|
|
167
|
+
updated++
|
|
168
|
+
console.log(` ✔ Updated ${file.target} (sync)`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { created, updated, skipped, strippedTotal }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Pass 2: fragment-replace on client files
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
// Cache parsed fragment bodies per (srcRelPath, fragmentId).
|
|
180
|
+
const fragmentCache = new Map()
|
|
181
|
+
|
|
182
|
+
function lookupFragment(srcRelPath, fragmentId) {
|
|
183
|
+
const key = `${srcRelPath}::${fragmentId}`
|
|
184
|
+
if (fragmentCache.has(key)) return fragmentCache.get(key)
|
|
185
|
+
|
|
186
|
+
// srcRelPath looks like "scaffold/gitignore". Resolve against the library
|
|
187
|
+
// scaffold root which already starts with .../packages/storyboard/scaffold,
|
|
188
|
+
// so we strip the leading "scaffold/" before joining.
|
|
189
|
+
const rel = srcRelPath.replace(/^scaffold\//, '')
|
|
190
|
+
const absPath = path.join(scaffoldRoot, rel)
|
|
191
|
+
if (!fs.existsSync(absPath)) {
|
|
192
|
+
fragmentCache.set(key, null)
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
const srcText = fs.readFileSync(absPath, 'utf-8')
|
|
196
|
+
const body = extractFragment(srcText, fragmentId)
|
|
197
|
+
fragmentCache.set(key, body)
|
|
198
|
+
return body
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function gatherFragmentTargets() {
|
|
202
|
+
const set = new Set(WELL_KNOWN_FRAGMENT_TARGETS)
|
|
203
|
+
for (const file of config.files) {
|
|
204
|
+
if (file.directory) continue
|
|
205
|
+
set.add(file.target)
|
|
206
|
+
}
|
|
207
|
+
return [...set]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runFragmentsPass() {
|
|
211
|
+
let synced = 0
|
|
212
|
+
let unchanged = 0
|
|
213
|
+
const errors = []
|
|
214
|
+
|
|
215
|
+
for (const rel of gatherFragmentTargets()) {
|
|
216
|
+
const destPath = path.join(consumerRoot, rel)
|
|
217
|
+
if (!fs.existsSync(destPath)) continue
|
|
218
|
+
if (isLikelyBinary(destPath)) continue
|
|
219
|
+
|
|
220
|
+
let clientText
|
|
221
|
+
try {
|
|
222
|
+
clientText = fs.readFileSync(destPath, 'utf-8')
|
|
223
|
+
} catch {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (!clientText.includes('storyboard:')) continue
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const result = applyFragments(clientText, lookupFragment)
|
|
230
|
+
if (result.replaced > 0) {
|
|
231
|
+
fs.writeFileSync(destPath, result.text)
|
|
232
|
+
synced += result.replaced
|
|
233
|
+
console.log(` ✔ Synced ${result.replaced} fragment(s) in ${rel}`)
|
|
234
|
+
}
|
|
235
|
+
unchanged += result.unchanged
|
|
236
|
+
} catch (err) {
|
|
237
|
+
errors.push({ path: rel, message: err.message })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { synced, unchanged, errors }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Entry
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
let filesStats = { created: 0, updated: 0, skipped: 0, strippedTotal: 0 }
|
|
249
|
+
let fragmentStats = { synced: 0, unchanged: 0, errors: [] }
|
|
250
|
+
let migratedLegacy = false
|
|
251
|
+
|
|
252
|
+
if (!fragmentsOnly) {
|
|
253
|
+
filesStats = runFilesPass()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!filesOnly) {
|
|
257
|
+
// One-shot migration of pre-0.6.10 ad-hoc gitignore banner.
|
|
258
|
+
try {
|
|
259
|
+
migratedLegacy = migrateLegacyGitignoreFile(path.join(consumerRoot, '.gitignore'))
|
|
260
|
+
if (migratedLegacy) {
|
|
261
|
+
console.log(' ✔ Migrated legacy .gitignore block to fragment markers')
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.warn(` ⚠ Failed to migrate legacy .gitignore: ${err.message}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fragmentStats = runFragmentsPass()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log('')
|
|
271
|
+
const parts = []
|
|
272
|
+
if (!fragmentsOnly) {
|
|
273
|
+
parts.push(`${filesStats.created} created`)
|
|
274
|
+
parts.push(`${filesStats.updated} updated`)
|
|
275
|
+
parts.push(`${filesStats.skipped} skipped`)
|
|
276
|
+
if (filesStats.strippedTotal > 0) parts.push(`${filesStats.strippedTotal} markers stripped`)
|
|
277
|
+
}
|
|
278
|
+
if (!filesOnly) {
|
|
279
|
+
parts.push(`${fragmentStats.synced} fragment(s) synced`)
|
|
280
|
+
if (fragmentStats.unchanged > 0) parts.push(`${fragmentStats.unchanged} unchanged`)
|
|
281
|
+
}
|
|
282
|
+
console.log(`✔ Scaffold complete: ${parts.join(', ')}.`)
|
|
283
|
+
|
|
284
|
+
if (fragmentStats.errors.length > 0) {
|
|
285
|
+
console.error('')
|
|
286
|
+
console.error(`❌ ${fragmentStats.errors.length} fragment error(s):`)
|
|
287
|
+
for (const e of fragmentStats.errors) {
|
|
288
|
+
console.error(` ${e.path}: ${e.message}`)
|
|
289
|
+
}
|
|
290
|
+
process.exit(1)
|
|
291
|
+
}
|