stagecraft 0.1.0

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.
Files changed (133) hide show
  1. package/AGENT.md +792 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -0
  4. package/bin/cli.js +51 -0
  5. package/bin/export.js +137 -0
  6. package/bin/init.js +52 -0
  7. package/bin/lib/edit-ops.js +405 -0
  8. package/bin/serve.js +278 -0
  9. package/dist/stagecraft.bundle.css +4443 -0
  10. package/dist/stagecraft.bundle.js +7621 -0
  11. package/dist/themes/brand.bundle.css +5262 -0
  12. package/dist/themes/neon.bundle.css +5289 -0
  13. package/dist/themes/paper.bundle.css +5276 -0
  14. package/dist/themes/phosphor.bundle.css +4443 -0
  15. package/dist/themes/shopware.bundle.css +5850 -0
  16. package/examples/closing-card.js +74 -0
  17. package/examples/orchestration-graph.js +156 -0
  18. package/examples/terminal-log.js +109 -0
  19. package/examples/token-stream.js +96 -0
  20. package/examples/whoami.js +90 -0
  21. package/package.json +41 -0
  22. package/src/components/activity-list.js +75 -0
  23. package/src/components/agenda.js +79 -0
  24. package/src/components/bar-chart.js +162 -0
  25. package/src/components/before-after.js +135 -0
  26. package/src/components/bento.js +73 -0
  27. package/src/components/big-number.js +87 -0
  28. package/src/components/callout.js +75 -0
  29. package/src/components/checklist.js +81 -0
  30. package/src/components/code-block.js +141 -0
  31. package/src/components/code-diff.js +98 -0
  32. package/src/components/compare.js +85 -0
  33. package/src/components/counter.js +80 -0
  34. package/src/components/cta.js +69 -0
  35. package/src/components/cycle.js +146 -0
  36. package/src/components/definition.js +96 -0
  37. package/src/components/donut-chart.js +179 -0
  38. package/src/components/full-image.js +82 -0
  39. package/src/components/funnel.js +111 -0
  40. package/src/components/gauge.js +147 -0
  41. package/src/components/heatmap.js +141 -0
  42. package/src/components/image-grid.js +80 -0
  43. package/src/components/image-text.js +96 -0
  44. package/src/components/kinetic-text.js +72 -0
  45. package/src/components/kpi.js +106 -0
  46. package/src/components/line-chart.js +215 -0
  47. package/src/components/manifesto.js +104 -0
  48. package/src/components/marquee.js +63 -0
  49. package/src/components/matrix2x2.js +151 -0
  50. package/src/components/pillars.js +80 -0
  51. package/src/components/pricing.js +90 -0
  52. package/src/components/process-flow.js +133 -0
  53. package/src/components/progress.js +136 -0
  54. package/src/components/punchline.js +82 -0
  55. package/src/components/pyramid.js +107 -0
  56. package/src/components/qanda.js +60 -0
  57. package/src/components/quote.js +70 -0
  58. package/src/components/roadmap.js +130 -0
  59. package/src/components/section-card.js +45 -0
  60. package/src/components/shift-arrow.js +41 -0
  61. package/src/components/spark-line.js +147 -0
  62. package/src/components/spotlight.js +85 -0
  63. package/src/components/statement.js +106 -0
  64. package/src/components/stats.js +91 -0
  65. package/src/components/steps.js +83 -0
  66. package/src/components/swot.js +110 -0
  67. package/src/components/team-grid.js +87 -0
  68. package/src/components/testimonial.js +99 -0
  69. package/src/components/timeline.js +91 -0
  70. package/src/components/tip.js +63 -0
  71. package/src/components/venn.js +198 -0
  72. package/src/edit-mode.js +1256 -0
  73. package/src/engine.js +823 -0
  74. package/src/helpers.js +169 -0
  75. package/src/transitions.js +101 -0
  76. package/starter/index.html +40 -0
  77. package/starter/slides/00-title.js +12 -0
  78. package/starter/stagecraft.config.js +8 -0
  79. package/themes/brand/base.css +4 -0
  80. package/themes/brand/components-business.css +173 -0
  81. package/themes/brand/components-chart.css +65 -0
  82. package/themes/brand/components-content.css +126 -0
  83. package/themes/brand/components-data.css +162 -0
  84. package/themes/brand/components-diagram.css +115 -0
  85. package/themes/brand/components-layout.css +112 -0
  86. package/themes/brand/components.css +46 -0
  87. package/themes/brand/manifest.json +20 -0
  88. package/themes/brand/tokens.css +20 -0
  89. package/themes/brand/transitions.css +4 -0
  90. package/themes/neon/base.css +10 -0
  91. package/themes/neon/components-business.css +189 -0
  92. package/themes/neon/components-chart.css +70 -0
  93. package/themes/neon/components-content.css +112 -0
  94. package/themes/neon/components-data.css +160 -0
  95. package/themes/neon/components-diagram.css +109 -0
  96. package/themes/neon/components-layout.css +87 -0
  97. package/themes/neon/components.css +87 -0
  98. package/themes/neon/manifest.json +21 -0
  99. package/themes/neon/tokens.css +17 -0
  100. package/themes/neon/transitions.css +13 -0
  101. package/themes/paper/base.css +9 -0
  102. package/themes/paper/components-business.css +196 -0
  103. package/themes/paper/components-chart.css +74 -0
  104. package/themes/paper/components-content.css +108 -0
  105. package/themes/paper/components-data.css +168 -0
  106. package/themes/paper/components-diagram.css +89 -0
  107. package/themes/paper/components-layout.css +105 -0
  108. package/themes/paper/components.css +60 -0
  109. package/themes/paper/manifest.json +10 -0
  110. package/themes/paper/tokens.css +21 -0
  111. package/themes/paper/transitions.css +11 -0
  112. package/themes/phosphor/base.css +511 -0
  113. package/themes/phosphor/components-business.css +818 -0
  114. package/themes/phosphor/components-chart.css +415 -0
  115. package/themes/phosphor/components-content.css +530 -0
  116. package/themes/phosphor/components-data.css +824 -0
  117. package/themes/phosphor/components-diagram.css +427 -0
  118. package/themes/phosphor/components-layout.css +450 -0
  119. package/themes/phosphor/components.css +223 -0
  120. package/themes/phosphor/manifest.json +11 -0
  121. package/themes/phosphor/tokens.css +17 -0
  122. package/themes/phosphor/transitions.css +213 -0
  123. package/themes/shopware/base.css +94 -0
  124. package/themes/shopware/components-business.css +344 -0
  125. package/themes/shopware/components-chart.css +121 -0
  126. package/themes/shopware/components-content.css +169 -0
  127. package/themes/shopware/components-data.css +219 -0
  128. package/themes/shopware/components-diagram.css +129 -0
  129. package/themes/shopware/components-layout.css +166 -0
  130. package/themes/shopware/components.css +83 -0
  131. package/themes/shopware/manifest.json +21 -0
  132. package/themes/shopware/tokens.css +68 -0
  133. package/themes/shopware/transitions.css +22 -0
@@ -0,0 +1,405 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Edit operations on stagecraft slide files.
5
+ *
6
+ * - writeSlideNote(root, file, text) — write @note: comment above Stage.register(
7
+ * - writeElementNote(root, file, key, t) — write @note[stage-key=...]: comment above register
8
+ * - writeInlineEdit(root, file, path, v) — AST-aware: replace string literal at prop path
9
+ * - reorderManifest(root, newOrder) — rewrite stagecraft.config.js slides array
10
+ * - setManifestTransition(root, idx, t) — set transition on slide at idx in manifest
11
+ */
12
+
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { parse } from '@babel/parser';
16
+ import _traverseMod from '@babel/traverse';
17
+ import _generateMod from '@babel/generator';
18
+ import * as t from '@babel/types';
19
+
20
+ const traverse = _traverseMod.default || _traverseMod;
21
+ const generate = _generateMod.default || _generateMod;
22
+
23
+ function resolveFile(root, file) {
24
+ // file may already be absolute or relative
25
+ const p = path.isAbsolute(file) ? file : path.resolve(root, file);
26
+ if (!p.startsWith(root)) throw new Error('refused: file outside project root');
27
+ return p;
28
+ }
29
+
30
+ function parseFile(src) {
31
+ return parse(src, {
32
+ sourceType: 'unambiguous',
33
+ plugins: [],
34
+ errorRecovery: false
35
+ });
36
+ }
37
+
38
+ function regenerate(ast, src) {
39
+ // Use retainLines+compact:false to preserve formatting as much as possible
40
+ return generate(ast, { retainLines: false, compact: false, jsescOption: { minimal: true } }, src).code;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Slide-level note: insert/replace `// @note: ...` immediately above the first
45
+ // Stage.register(...) call. Multi-line note becomes consecutive comment lines.
46
+ // ---------------------------------------------------------------------------
47
+ export async function writeSlideNote(root, file, text) {
48
+ const p = resolveFile(root, file);
49
+ let src = await fs.readFile(p, 'utf8');
50
+ src = removeNoteComments(src, /\/\/\s*@note:\s*[\s\S]*?(?=\n[^/]|\nStage\.register|\nStage\.\w+\()/g);
51
+ src = removeSimpleNoteLines(src);
52
+ const noteLines = String(text).split(/\r?\n/).map(l => `// @note: ${l}`).join('\n');
53
+ // Find first occurrence of "Stage.register(" or "stage.register(" or "Stage.<Cap>(" used as register arg
54
+ const m = src.match(/(^|\n)([ \t]*)(Stage\.register\s*\()/);
55
+ if (!m) {
56
+ // Prepend at top of file (after first comment block if any)
57
+ src = noteLines + '\n' + src;
58
+ } else {
59
+ const indent = m[2];
60
+ const noteWithIndent = noteLines.split('\n').map(l => indent + l).join('\n');
61
+ const insertion = (m[1] === '\n' ? '\n' : '') + noteWithIndent + '\n';
62
+ src = src.slice(0, m.index + (m[1] === '\n' ? 1 : 0))
63
+ + noteWithIndent + '\n'
64
+ + src.slice(m.index + (m[1] === '\n' ? 1 : 0));
65
+ }
66
+ await fs.writeFile(p, src, 'utf8');
67
+ }
68
+
69
+ function removeSimpleNoteLines(src) {
70
+ // Remove standalone "// @note: ..." lines (single-line form)
71
+ return src.split('\n').filter(l => !/^\s*\/\/\s*@note(\[|:)/.test(l)).join('\n');
72
+ }
73
+
74
+ function removeNoteComments(src) {
75
+ // Generic removal of any line starting with // @note ... (covers Level 1 + 2)
76
+ return removeSimpleNoteLines(src);
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Element-pin note: `// @note[stage-key="K"]: text` placed above Stage.register
81
+ // ---------------------------------------------------------------------------
82
+ export async function writeElementNote(root, file, stageKey, text) {
83
+ const p = resolveFile(root, file);
84
+ let src = await fs.readFile(p, 'utf8');
85
+ // Remove any existing note for this same key
86
+ const escKey = stageKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const existingRe = new RegExp(`^\\s*//\\s*@note\\[stage-key=\\"${escKey}\\"\\]:.*$`, 'gm');
88
+ src = src.replace(existingRe, '');
89
+ // Build new note
90
+ const noteLines = String(text).split(/\r?\n/).map((l, i) =>
91
+ i === 0
92
+ ? `// @note[stage-key="${stageKey}"]: ${l}`
93
+ : `// @note[stage-key="${stageKey}"]: ${l}`
94
+ ).join('\n');
95
+ const m = src.match(/(^|\n)([ \t]*)(Stage\.register\s*\()/);
96
+ if (!m) {
97
+ src = noteLines + '\n' + src;
98
+ } else {
99
+ const indent = m[2];
100
+ const noteWithIndent = noteLines.split('\n').map(l => indent + l).join('\n');
101
+ src = src.slice(0, m.index + (m[1] === '\n' ? 1 : 0))
102
+ + noteWithIndent + '\n'
103
+ + src.slice(m.index + (m[1] === '\n' ? 1 : 0));
104
+ }
105
+ // Clean up double blank lines
106
+ src = src.replace(/\n{3,}/g, '\n\n');
107
+ await fs.writeFile(p, src, 'utf8');
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Inline edit: parse the file, find the Stage.<Component>({...}) call argument
112
+ // (the props object), navigate the propPath, replace the string literal.
113
+ //
114
+ // propPath examples:
115
+ // "lines[0].text"
116
+ // "items[2].name"
117
+ // "title"
118
+ // "left.items[1]"
119
+ // ---------------------------------------------------------------------------
120
+ export async function writeInlineEdit(root, file, propPath, value) {
121
+ const p = resolveFile(root, file);
122
+ const src = await fs.readFile(p, 'utf8');
123
+ const ast = parseFile(src);
124
+
125
+ const tokens = parsePropPath(propPath);
126
+ let updated = false;
127
+ let error = null;
128
+
129
+ traverse(ast, {
130
+ CallExpression(pathNode) {
131
+ if (updated) return;
132
+ const callee = pathNode.node.callee;
133
+ // Match Stage.register(Stage.X({...})) or Stage.X({...}) directly
134
+ // We want to find the deepest ObjectExpression that's an argument to
135
+ // a Stage.<Component>(...) call or the argument of Stage.register({...})
136
+ const targetObj = findPropsObject(pathNode.node);
137
+ if (!targetObj) return;
138
+
139
+ // Try to navigate the propPath
140
+ try {
141
+ const node = navigatePath(targetObj, tokens);
142
+ if (!node) return;
143
+ if (!t.isStringLiteral(node) && !t.isTemplateLiteral(node)) {
144
+ error = new Error(`prop path "${propPath}" does not resolve to a string literal (got ${node.type})`);
145
+ return;
146
+ }
147
+ if (t.isTemplateLiteral(node) && (node.expressions.length > 0 || node.quasis.length !== 1)) {
148
+ error = new Error(`prop path "${propPath}" is an interpolated template literal — cannot inline-edit`);
149
+ return;
150
+ }
151
+ // Replace the value
152
+ if (t.isStringLiteral(node)) {
153
+ node.value = String(value);
154
+ } else {
155
+ // single-quasi template literal
156
+ node.quasis[0].value.raw = String(value);
157
+ node.quasis[0].value.cooked = String(value);
158
+ }
159
+ updated = true;
160
+ } catch (e) {
161
+ // path doesn't apply here, continue to next call
162
+ }
163
+ }
164
+ });
165
+
166
+ if (error) throw error;
167
+ if (!updated) throw new Error(`no matching call/prop path "${propPath}" found in ${file}`);
168
+
169
+ const out = regenerate(ast, src);
170
+ await fs.writeFile(p, out, 'utf8');
171
+ }
172
+
173
+ function findPropsObject(callNode) {
174
+ // Common shapes:
175
+ // Stage.register({...}) → args[0] is the obj
176
+ // Stage.register(Stage.X({...})) → args[0].arguments[0] is the obj
177
+ // Stage.X({...}) → args[0] is the obj (when matched independently)
178
+ const callee = callNode.callee;
179
+ if (!t.isMemberExpression(callee)) return null;
180
+ const obj = callee.object;
181
+ const prop = callee.property;
182
+ if (!t.isIdentifier(obj) || obj.name !== 'Stage') return null;
183
+ if (!t.isIdentifier(prop)) return null;
184
+
185
+ // Stage.register(...)
186
+ if (prop.name === 'register') {
187
+ const arg = callNode.arguments[0];
188
+ if (!arg) return null;
189
+ if (t.isObjectExpression(arg)) return arg;
190
+ if (t.isCallExpression(arg)) {
191
+ // nested Stage.X({...}) — recurse
192
+ return findPropsObject(arg);
193
+ }
194
+ return null;
195
+ }
196
+ // Stage.X(...) where X starts with an uppercase
197
+ if (/^[A-Z]/.test(prop.name)) {
198
+ const arg = callNode.arguments[0];
199
+ if (arg && t.isObjectExpression(arg)) return arg;
200
+ }
201
+ return null;
202
+ }
203
+
204
+ function parsePropPath(p) {
205
+ // 'a.b[2].c' → ['a', 'b', 2, 'c']
206
+ const tokens = [];
207
+ const re = /([a-zA-Z_$][\w$]*)|\[(\d+)\]/g;
208
+ let m;
209
+ while ((m = re.exec(p)) !== null) {
210
+ if (m[1] !== undefined) tokens.push(m[1]);
211
+ else tokens.push(Number(m[2]));
212
+ }
213
+ return tokens;
214
+ }
215
+
216
+ function navigatePath(node, tokens) {
217
+ let cur = node;
218
+ for (const tok of tokens) {
219
+ if (typeof tok === 'string') {
220
+ if (!t.isObjectExpression(cur)) return null;
221
+ const prop = cur.properties.find(p =>
222
+ (t.isObjectProperty(p) || t.isObjectMethod(p)) &&
223
+ ((t.isIdentifier(p.key) && p.key.name === tok) ||
224
+ (t.isStringLiteral(p.key) && p.key.value === tok))
225
+ );
226
+ if (!prop || !t.isObjectProperty(prop)) return null;
227
+ cur = prop.value;
228
+ } else {
229
+ if (!t.isArrayExpression(cur)) return null;
230
+ if (tok < 0 || tok >= cur.elements.length) return null;
231
+ cur = cur.elements[tok];
232
+ }
233
+ }
234
+ return cur;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Manifest operations
239
+ // ---------------------------------------------------------------------------
240
+
241
+ async function readManifest(root) {
242
+ const p = path.resolve(root, 'stagecraft.config.js');
243
+ return { path: p, src: await fs.readFile(p, 'utf8') };
244
+ }
245
+
246
+ async function rewriteManifest(p, src, mutator) {
247
+ const ast = parseFile(src);
248
+ let updated = false;
249
+ traverse(ast, {
250
+ CallExpression(pathNode) {
251
+ const callee = pathNode.node.callee;
252
+ if (!t.isMemberExpression(callee)) return;
253
+ if (!t.isIdentifier(callee.object, { name: 'Stage' })) return;
254
+ if (!t.isIdentifier(callee.property, { name: 'deck' })) return;
255
+ const arg = pathNode.node.arguments[0];
256
+ if (!arg || !t.isObjectExpression(arg)) return;
257
+ const slidesProp = arg.properties.find(p =>
258
+ t.isObjectProperty(p) && t.isIdentifier(p.key, { name: 'slides' })
259
+ );
260
+ if (!slidesProp || !t.isArrayExpression(slidesProp.value)) return;
261
+ mutator(slidesProp.value);
262
+ updated = true;
263
+ }
264
+ });
265
+ if (!updated) throw new Error('no Stage.deck({ slides: [...] }) call found in manifest');
266
+ await fs.writeFile(p, regenerate(ast, src), 'utf8');
267
+ }
268
+
269
+ export async function reorderManifest(root, newOrder) {
270
+ const { path: p, src } = await readManifest(root);
271
+ await rewriteManifest(p, src, arr => {
272
+ const original = arr.elements.slice();
273
+ arr.elements = newOrder.map(i => original[i]).filter(Boolean);
274
+ });
275
+ }
276
+
277
+ export async function setManifestTransition(root, idx, transition) {
278
+ const { path: p, src } = await readManifest(root);
279
+ await rewriteManifest(p, src, arr => {
280
+ const target = arr.elements[idx];
281
+ if (!target || !t.isObjectExpression(target)) {
282
+ throw new Error('manifest entry at ' + idx + ' is not an object');
283
+ }
284
+ const existing = target.properties.find(pr =>
285
+ t.isObjectProperty(pr) && t.isIdentifier(pr.key, { name: 'transition' })
286
+ );
287
+ const valueNode = t.stringLiteral(String(transition));
288
+ if (existing) existing.value = valueNode;
289
+ else target.properties.push(t.objectProperty(t.identifier('transition'), valueNode));
290
+ });
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Theme — update the top-level `theme:` prop on Stage.deck({...})
295
+ // ---------------------------------------------------------------------------
296
+ export async function setManifestTheme(root, theme) {
297
+ const { path: p, src } = await readManifest(root);
298
+ const ast = parseFile(src);
299
+ let updated = false;
300
+ traverse(ast, {
301
+ CallExpression(pathNode) {
302
+ const callee = pathNode.node.callee;
303
+ if (!t.isMemberExpression(callee)) return;
304
+ if (!t.isIdentifier(callee.object, { name: 'Stage' })) return;
305
+ if (!t.isIdentifier(callee.property, { name: 'deck' })) return;
306
+ const arg = pathNode.node.arguments[0];
307
+ if (!arg || !t.isObjectExpression(arg)) return;
308
+ const themeProp = arg.properties.find(pr =>
309
+ t.isObjectProperty(pr) && t.isIdentifier(pr.key, { name: 'theme' })
310
+ );
311
+ const valueNode = t.stringLiteral(String(theme));
312
+ if (themeProp) themeProp.value = valueNode;
313
+ else arg.properties.unshift(t.objectProperty(t.identifier('theme'), valueNode));
314
+ updated = true;
315
+ }
316
+ });
317
+ if (!updated) throw new Error('no Stage.deck({...}) call found');
318
+ await fs.writeFile(p, regenerate(ast, src), 'utf8');
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // CRUD: add / remove a slide entry in the manifest
323
+ // ---------------------------------------------------------------------------
324
+ export async function addSlideToManifest(root, atIdx, srcPath, transition) {
325
+ const { path: p, src } = await readManifest(root);
326
+ await rewriteManifest(p, src, arr => {
327
+ const props = [t.objectProperty(t.identifier('src'), t.stringLiteral(String(srcPath)))];
328
+ if (transition) {
329
+ props.push(t.objectProperty(t.identifier('transition'), t.stringLiteral(String(transition))));
330
+ }
331
+ const newEntry = t.objectExpression(props);
332
+ const insertAt = typeof atIdx === 'number'
333
+ ? Math.max(0, Math.min(arr.elements.length, atIdx))
334
+ : arr.elements.length;
335
+ arr.elements.splice(insertAt, 0, newEntry);
336
+ });
337
+ }
338
+
339
+ export async function removeSlideFromManifest(root, idx) {
340
+ const { path: p, src } = await readManifest(root);
341
+ await rewriteManifest(p, src, arr => {
342
+ if (idx < 0 || idx >= arr.elements.length) {
343
+ throw new Error('slide index out of range: ' + idx);
344
+ }
345
+ arr.elements.splice(idx, 1);
346
+ });
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Speaker notes — update the second arg of Stage.register(slide, { notes: ... })
351
+ // Creates the meta arg if missing.
352
+ // ---------------------------------------------------------------------------
353
+ export async function writeSpeakerNotes(root, file, notes) {
354
+ const p = resolveFile(root, file);
355
+ const src = await fs.readFile(p, 'utf8');
356
+ const ast = parseFile(src);
357
+ let updated = false;
358
+
359
+ traverse(ast, {
360
+ CallExpression(pathNode) {
361
+ if (updated) return;
362
+ const callee = pathNode.node.callee;
363
+ if (!t.isMemberExpression(callee)) return;
364
+ if (!t.isIdentifier(callee.object, { name: 'Stage' })) return;
365
+ if (!t.isIdentifier(callee.property, { name: 'register' })) return;
366
+
367
+ let meta = pathNode.node.arguments[1];
368
+ const value = t.stringLiteral(String(notes));
369
+ if (!meta) {
370
+ meta = t.objectExpression([t.objectProperty(t.identifier('notes'), value)]);
371
+ pathNode.node.arguments.push(meta);
372
+ } else if (t.isObjectExpression(meta)) {
373
+ const existing = meta.properties.find(pr =>
374
+ t.isObjectProperty(pr) && t.isIdentifier(pr.key, { name: 'notes' })
375
+ );
376
+ if (existing) existing.value = value;
377
+ else meta.properties.push(t.objectProperty(t.identifier('notes'), value));
378
+ } else {
379
+ // The 2nd arg is something we don't understand — refuse to mutate.
380
+ throw new Error('Stage.register 2nd arg is not an object literal');
381
+ }
382
+ updated = true;
383
+ }
384
+ });
385
+
386
+ if (!updated) throw new Error('no Stage.register(...) call found in ' + file);
387
+ await fs.writeFile(p, regenerate(ast, src), 'utf8');
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Element-pin notes — scan a slide file for `@note[stage-key="..."]:` comments
392
+ // and return them as { stageKey, text } objects. Used by present-mode to
393
+ // render pin markers on the slide.
394
+ // ---------------------------------------------------------------------------
395
+ export async function readElementNotes(root, file) {
396
+ const p = resolveFile(root, file);
397
+ const src = await fs.readFile(p, 'utf8');
398
+ const re = /^\s*\/\/\s*@note\[stage-key="([^"]+)"\]:\s*(.*)$/gm;
399
+ const out = [];
400
+ let m;
401
+ while ((m = re.exec(src)) !== null) {
402
+ out.push({ stageKey: m[1], text: m[2].trim() });
403
+ }
404
+ return out;
405
+ }
package/bin/serve.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * stagecraft serve — local dev server with hot reload + edit-mode API.
6
+ *
7
+ * Serves the project directory over HTTP, opens a WebSocket on
8
+ * /stagecraft for the browser to talk to. Endpoints:
9
+ *
10
+ * POST /api/note/slide { file, text }
11
+ * POST /api/note/element { file, stageKey, text }
12
+ * POST /api/edit/inline { file, propPath, value }
13
+ * POST /api/manifest/reorder { newOrder: [oldIdx, ...] }
14
+ * POST /api/manifest/transition { idx, transition }
15
+ *
16
+ * Watches all source files; broadcasts granular reload events.
17
+ */
18
+
19
+ import http from 'node:http';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import url from 'node:url';
23
+ import { WebSocketServer } from 'ws';
24
+ import chokidar from 'chokidar';
25
+ import mime from 'mime-types';
26
+
27
+ import {
28
+ writeSlideNote, writeElementNote, writeInlineEdit,
29
+ reorderManifest, setManifestTransition, setManifestTheme,
30
+ addSlideToManifest, removeSlideFromManifest,
31
+ writeSpeakerNotes, readElementNotes
32
+ } from './lib/edit-ops.js';
33
+ import fsp from 'node:fs/promises';
34
+
35
+ // --- args ---
36
+ const args = process.argv.slice(2);
37
+ let rootArg = '.';
38
+ let port = 3000;
39
+ for (let i = 0; i < args.length; i++) {
40
+ if (args[i] === '--root') rootArg = args[++i];
41
+ else if (args[i] === '--port') port = parseInt(args[++i], 10);
42
+ }
43
+ const ROOT = path.resolve(rootArg);
44
+
45
+ if (!fs.existsSync(ROOT)) {
46
+ console.error(`[stagecraft] root directory not found: ${ROOT}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ console.log(`[stagecraft] serving ${ROOT}`);
51
+
52
+ // --- HTTP server ---
53
+ const server = http.createServer(async (req, res) => {
54
+ try {
55
+ const parsed = url.parse(req.url, true);
56
+ if (req.method === 'POST' && parsed.pathname.startsWith('/api/')) {
57
+ return handleApi(req, res, parsed);
58
+ }
59
+ return serveStatic(req, res, parsed);
60
+ } catch (e) {
61
+ console.error('[stagecraft] error', e);
62
+ res.statusCode = 500;
63
+ res.end('Internal error');
64
+ }
65
+ });
66
+
67
+ // --- WebSocket ---
68
+ const wss = new WebSocketServer({ noServer: true });
69
+ const clients = new Set();
70
+
71
+ server.on('upgrade', (req, socket, head) => {
72
+ if (req.url !== '/stagecraft') {
73
+ socket.destroy();
74
+ return;
75
+ }
76
+ // Refuse non-loopback
77
+ const addr = req.socket.remoteAddress;
78
+ if (!isLoopback(addr)) {
79
+ socket.destroy();
80
+ return;
81
+ }
82
+ wss.handleUpgrade(req, socket, head, ws => {
83
+ clients.add(ws);
84
+ ws.on('close', () => clients.delete(ws));
85
+ ws.send(JSON.stringify({ type: 'hello' }));
86
+ });
87
+ });
88
+
89
+ function broadcast(msg) {
90
+ const s = JSON.stringify(msg);
91
+ clients.forEach(c => { if (c.readyState === 1) c.send(s); });
92
+ }
93
+
94
+ // --- File watching ---
95
+ const watcher = chokidar.watch(['slides/**/*.js', 'stagecraft.config.js', 'index.html', '../themes/**/*.css'], {
96
+ cwd: ROOT,
97
+ ignored: /node_modules/,
98
+ ignoreInitial: true
99
+ });
100
+ watcher.on('all', (event, filePath) => {
101
+ const ext = path.extname(filePath);
102
+ let target = 'slide';
103
+ if (filePath.endsWith('stagecraft.config.js')) target = 'manifest';
104
+ else if (ext === '.css') target = 'theme-css';
105
+ else if (filePath.includes('themes/') && ext === '.js') target = 'theme-js';
106
+ console.log(`[stagecraft] ${event}: ${filePath} → reload ${target}`);
107
+ broadcast({ type: 'reload', target, file: filePath });
108
+ });
109
+
110
+ // --- Static file serving ---
111
+ function serveStatic(req, res, parsed) {
112
+ let p = decodeURIComponent(parsed.pathname);
113
+ if (p === '/') p = '/index.html';
114
+
115
+ // First try project root
116
+ let full = safeJoin(ROOT, p);
117
+
118
+ // If not found, try the stagecraft package root (so /src/engine.js works
119
+ // for the starter without npm install in dev)
120
+ if (!full || !fs.existsSync(full)) {
121
+ const pkgRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
122
+ const pkgPath = safeJoin(pkgRoot, p);
123
+ if (pkgPath && fs.existsSync(pkgPath)) full = pkgPath;
124
+ }
125
+
126
+ if (!full || !fs.existsSync(full)) {
127
+ res.statusCode = 404;
128
+ res.end('Not found: ' + p);
129
+ return;
130
+ }
131
+ const stat = fs.statSync(full);
132
+ if (stat.isDirectory()) {
133
+ full = path.join(full, 'index.html');
134
+ if (!fs.existsSync(full)) { res.statusCode = 404; res.end(); return; }
135
+ }
136
+ const type = mime.lookup(full) || 'application/octet-stream';
137
+ res.setHeader('Content-Type', type);
138
+ res.setHeader('Cache-Control', 'no-store');
139
+ fs.createReadStream(full).pipe(res);
140
+ }
141
+
142
+ function safeJoin(root, p) {
143
+ const resolved = path.resolve(root, '.' + p);
144
+ if (!resolved.startsWith(root)) return null;
145
+ return resolved;
146
+ }
147
+
148
+ function isLoopback(addr) {
149
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
150
+ }
151
+
152
+ // Create a new slide file from a small template. Used by /api/manifest/add-slide.
153
+ async function createSlideFile(root, filePath, template) {
154
+ const full = path.resolve(root, filePath);
155
+ if (!full.startsWith(root)) throw new Error('refused: file outside project');
156
+ await fsp.mkdir(path.dirname(full), { recursive: true });
157
+ let body;
158
+ switch (template || 'kinetic-text') {
159
+ case 'section-card':
160
+ body = `'use strict';
161
+
162
+ Stage.register(Stage.SectionCard({
163
+ section: 1,
164
+ number: '00',
165
+ title: 'New section',
166
+ tag: 'edit this in the storyboard'
167
+ }));
168
+ `;
169
+ break;
170
+ case 'blank':
171
+ body = `'use strict';
172
+
173
+ Stage.register({
174
+ section: 1,
175
+ title: 'New slide',
176
+ render(el) {
177
+ el.innerHTML = '<div class="hero">Edit me</div>';
178
+ }
179
+ });
180
+ `;
181
+ break;
182
+ case 'kinetic-text':
183
+ default:
184
+ body = `'use strict';
185
+
186
+ Stage.register(Stage.KineticText({
187
+ section: 1,
188
+ title: 'New slide',
189
+ pace: 700,
190
+ lines: [
191
+ { text: 'A new slide.', color: 'fg' },
192
+ { text: 'Ready to be edited.', color: 'accent', pause: 300 }
193
+ ]
194
+ }));
195
+ `;
196
+ break;
197
+ }
198
+ await fsp.writeFile(full, body, 'utf8');
199
+ }
200
+
201
+ // --- API handlers ---
202
+ async function handleApi(req, res, parsed) {
203
+ const body = await readBody(req);
204
+ let data;
205
+ try { data = JSON.parse(body); } catch (e) {
206
+ return sendJson(res, 400, { error: 'invalid JSON' });
207
+ }
208
+ try {
209
+ switch (parsed.pathname) {
210
+ case '/api/note/slide':
211
+ await writeSlideNote(ROOT, data.file, data.text);
212
+ return sendJson(res, 200, { ok: true });
213
+ case '/api/note/element':
214
+ await writeElementNote(ROOT, data.file, data.stageKey, data.text);
215
+ return sendJson(res, 200, { ok: true });
216
+ case '/api/edit/inline':
217
+ await writeInlineEdit(ROOT, data.file, data.propPath, data.value);
218
+ return sendJson(res, 200, { ok: true });
219
+ case '/api/manifest/reorder':
220
+ await reorderManifest(ROOT, data.newOrder);
221
+ return sendJson(res, 200, { ok: true });
222
+ case '/api/manifest/transition':
223
+ await setManifestTransition(ROOT, data.idx, data.transition);
224
+ return sendJson(res, 200, { ok: true });
225
+ case '/api/manifest/theme':
226
+ await setManifestTheme(ROOT, data.theme);
227
+ return sendJson(res, 200, { ok: true });
228
+ case '/api/manifest/add-slide':
229
+ // Create the slide file from a template, then prepend to manifest
230
+ await createSlideFile(ROOT, data.file, data.template);
231
+ await addSlideToManifest(ROOT, data.atIdx, data.file, data.transition);
232
+ return sendJson(res, 200, { ok: true });
233
+ case '/api/manifest/remove-slide':
234
+ // Remove from manifest. Optionally delete the file.
235
+ await removeSlideFromManifest(ROOT, data.idx);
236
+ if (data.deleteFile && data.file) {
237
+ try {
238
+ const full = path.resolve(ROOT, data.file);
239
+ if (full.startsWith(ROOT)) await fsp.unlink(full);
240
+ } catch (e) { /* ignore */ }
241
+ }
242
+ return sendJson(res, 200, { ok: true });
243
+ case '/api/edit/notes':
244
+ await writeSpeakerNotes(ROOT, data.file, data.notes);
245
+ return sendJson(res, 200, { ok: true });
246
+ case '/api/notes/element':
247
+ // GET-style via POST — read pin notes for a slide file
248
+ const pins = await readElementNotes(ROOT, data.file);
249
+ return sendJson(res, 200, { ok: true, pins });
250
+ default:
251
+ return sendJson(res, 404, { error: 'unknown endpoint' });
252
+ }
253
+ } catch (e) {
254
+ console.error('[stagecraft] api error', e);
255
+ return sendJson(res, 500, { error: e.message });
256
+ }
257
+ }
258
+
259
+ function readBody(req) {
260
+ return new Promise((resolve, reject) => {
261
+ let chunks = '';
262
+ req.on('data', c => chunks += c);
263
+ req.on('end', () => resolve(chunks));
264
+ req.on('error', reject);
265
+ });
266
+ }
267
+
268
+ function sendJson(res, status, obj) {
269
+ res.statusCode = status;
270
+ res.setHeader('Content-Type', 'application/json');
271
+ res.end(JSON.stringify(obj));
272
+ }
273
+
274
+ // --- Start ---
275
+ server.listen(port, '127.0.0.1', () => {
276
+ console.log(`[stagecraft] http://localhost:${port}`);
277
+ console.log(`[stagecraft] websocket ws://localhost:${port}/stagecraft`);
278
+ });