@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.
@@ -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
+ }