@markdstage/markdstage 0.1.1

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 (67) hide show
  1. package/README.md +90 -0
  2. package/bin/markdstage.mjs +12 -0
  3. package/package.json +45 -0
  4. package/shared/README.md +1014 -0
  5. package/shared/THIRD-PARTY-NOTICES.md +19 -0
  6. package/shared/deck-state.mjs +105 -0
  7. package/shared/docs/custom-theme-authoring.md +208 -0
  8. package/shared/markdown-deck.mjs +220 -0
  9. package/shared/markdstage-guide.mjs +276 -0
  10. package/shared/presenter-window.mjs +17 -0
  11. package/shared/renderer/architecture-document.mjs +596 -0
  12. package/shared/renderer/architecture-edit.mjs +298 -0
  13. package/shared/renderer/architecture-editor.mjs +449 -0
  14. package/shared/renderer/architecture.mjs +4033 -0
  15. package/shared/renderer/import-path.mjs +11 -0
  16. package/shared/renderer/index.html +106 -0
  17. package/shared/renderer/renderer.js +2082 -0
  18. package/shared/renderer/slides.css +614 -0
  19. package/shared/renderer/speaker-notes.mjs +106 -0
  20. package/shared/renderer/theme.mjs +205 -0
  21. package/shared/runtime/browser.mjs +539 -0
  22. package/shared/runtime/custom-theme.mjs +135 -0
  23. package/shared/runtime/deck-session.mjs +188 -0
  24. package/shared/runtime/errors.mjs +17 -0
  25. package/shared/runtime/output-paths.mjs +159 -0
  26. package/shared/runtime/output.mjs +385 -0
  27. package/shared/runtime/presentation-server.mjs +505 -0
  28. package/shared/runtime/static-files.mjs +70 -0
  29. package/shared/schema/README.md +228 -0
  30. package/shared/schema/architecture-v1.schema.json +664 -0
  31. package/shared/schema/examples/web-app.architecture.json +119 -0
  32. package/shared/schema/theme-metadata-v1.schema.json +75 -0
  33. package/shared/schema/theme-v1.json +84 -0
  34. package/shared/scripts/architecture-assets.mjs +226 -0
  35. package/shared/scripts/asset-paths.mjs +92 -0
  36. package/shared/scripts/atomic-markdown-replace.mjs +46 -0
  37. package/shared/scripts/markdown-blocks.mjs +182 -0
  38. package/shared/scripts/markdown-files.mjs +63 -0
  39. package/shared/scripts/markdown-save-coordinator.mjs +18 -0
  40. package/shared/scripts/markdown-watcher.mjs +80 -0
  41. package/shared/scripts/theme-paths.mjs +108 -0
  42. package/shared/scripts/vendor-assets.mjs +132 -0
  43. package/shared/scripts/workspace-root.mjs +32 -0
  44. package/shared/vendor/highlight.LICENSE +29 -0
  45. package/shared/vendor/highlight.min.js +1244 -0
  46. package/shared/vendor/marked.min.js +6 -0
  47. package/shared/vendor/mermaid.min.js.part-0001 +268 -0
  48. package/shared/vendor/mermaid.min.js.part-0002 +304 -0
  49. package/shared/vendor/mermaid.min.js.part-0003 +324 -0
  50. package/shared/vendor/mermaid.min.js.part-0004 +374 -0
  51. package/shared/vendor/mermaid.min.js.part-0005 +564 -0
  52. package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
  53. package/shared/vendor/mermaid.min.js.part-0007 +269 -0
  54. package/shared/vendor/purify.min.js +3 -0
  55. package/shared/vendor/vendor-assets.lock.json +60 -0
  56. package/src/cli.mjs +347 -0
  57. package/src/commands/capture.mjs +23 -0
  58. package/src/commands/export.mjs +18 -0
  59. package/src/commands/guide.mjs +23 -0
  60. package/src/commands/inspect.mjs +35 -0
  61. package/src/commands/present.mjs +91 -0
  62. package/src/commands/skill.mjs +114 -0
  63. package/src/commands/validate.mjs +79 -0
  64. package/src/deck.mjs +63 -0
  65. package/src/exit.mjs +58 -0
  66. package/src/runtime.mjs +77 -0
  67. package/src/skills.mjs +155 -0
@@ -0,0 +1,449 @@
1
+ // Editing UI for ```architecture blocks (DOM layer).
2
+ //
3
+ // The core (architecture-edit.mjs) converts the complete DSL to a new complete DSL.
4
+ // This module handles only pointer / keyboard input and rerendering.
5
+ //
6
+ // Every rerender uses renderArchitectureBlock so planConnectorRoutes runs again
7
+ // and connectors follow automatically. The PoC failed by transforming only nodes,
8
+ // leaving connector lines behind.
9
+ //
10
+ // Importers call this module only in editing mode. Normal view, presenter view,
11
+ // and printing never call attach, so the editing UI cannot leak into them.
12
+
13
+ import { renderArchitectureBlock } from "./architecture.mjs";
14
+ import {
15
+ EDIT_FINE_STEP,
16
+ EDIT_STEP,
17
+ createArchitectureEditSession,
18
+ } from "./architecture-edit.mjs";
19
+
20
+ const ARROW_DELTAS = {
21
+ ArrowLeft: [-1, 0],
22
+ ArrowRight: [1, 0],
23
+ ArrowUp: [0, -1],
24
+ ArrowDown: [0, 1],
25
+ };
26
+
27
+ const SELECTABLE = '[data-architecture-type="node"],[data-architecture-type="group"]';
28
+
29
+ /** Screen pixels per SVG user unit, used to convert drag distance. */
30
+ function viewBoxScale(svg) {
31
+ const ctm = svg.getScreenCTM?.();
32
+ if (ctm && Number.isFinite(ctm.a) && ctm.a !== 0 && Number.isFinite(ctm.d) && ctm.d !== 0) {
33
+ return { x: ctm.a, y: ctm.d };
34
+ }
35
+ const rect = svg.getBoundingClientRect?.();
36
+ const box = svg.viewBox?.baseVal;
37
+ if (!rect?.width || !box?.width || !box?.height) return { x: 1, y: 1 };
38
+ const scale = Math.min(rect.width / box.width, rect.height / box.height);
39
+ return { x: scale || 1, y: scale || 1 };
40
+ }
41
+
42
+ /**
43
+ * Attach the editing UI to container.
44
+ *
45
+ * @param {Element} container Element dedicated to the editing UI
46
+ * @param {object} options
47
+ * - source: Complete DSL being edited
48
+ * - documentRef: DOM document
49
+ * - onCommit: Callback receiving the committed complete DSL, used for persistence
50
+ * - canOpenDetail: true when the source Markdown can be identified
51
+ * - onOpenDetail: Callback that opens the dedicated Architecture Editor canvas
52
+ * @returns {{destroy():void, getSource():string}|null} null when the diagram is invalid
53
+ */
54
+ export function attachArchitectureEditor(container, options = {}) {
55
+ const documentRef = options.documentRef ?? globalThis.document;
56
+ const onCommit = typeof options.onCommit === "function" ? options.onCommit : null;
57
+ const onOpenDetail =
58
+ typeof options.onOpenDetail === "function" ? options.onOpenDetail : null;
59
+ const canOpenDetail = Boolean(options.canOpenDetail && onOpenDetail);
60
+ let session;
61
+ try {
62
+ session = createArchitectureEditSession(options.source);
63
+ } catch (_) {
64
+ // Do not allow editing an invalid diagram; the renderer already displays the error.
65
+ return null;
66
+ }
67
+
68
+ container.classList.add("architecture-editor");
69
+ container.setAttribute("data-architecture-edit", "on");
70
+
71
+ const toolbar = documentRef.createElement("div");
72
+ toolbar.className = "architecture-editor-toolbar";
73
+ toolbar.setAttribute("role", "toolbar");
74
+ toolbar.setAttribute("aria-label", "Edit diagram");
75
+
76
+ const undoButton = createButton("Undo (Ctrl+Z)", "undo");
77
+ const redoButton = createButton("Redo (Ctrl+Y)", "redo");
78
+ const releaseButton = createButton("Release layout (L)", "release");
79
+ const detailButton = createButton("Advanced edit", "detail");
80
+ detailButton.disabled = !canOpenDetail;
81
+ detailButton.title = canOpenDetail
82
+ ? "Open in the dedicated Architecture Editor"
83
+ : "Available after loading Markdown with the canvas file picker";
84
+ const status = documentRef.createElement("span");
85
+ status.className = "architecture-editor-status";
86
+ status.setAttribute("role", "status");
87
+ status.setAttribute("aria-live", "polite");
88
+ status.setAttribute("data-architecture-edit-status", "idle");
89
+
90
+ // Save result display. Keep it separate from status because the next operation
91
+ // immediately overwrites status and would hide the failure. A failed save means
92
+ // edits were actually lost, so display it until the next successful save.
93
+ const saveState = documentRef.createElement("span");
94
+ saveState.className = "architecture-editor-save";
95
+ saveState.setAttribute("role", "status");
96
+ saveState.setAttribute("aria-live", "polite");
97
+ saveState.setAttribute("data-architecture-save-state", "idle");
98
+
99
+ toolbar.append(undoButton, redoButton, releaseButton, detailButton, status, saveState);
100
+
101
+ const surface = documentRef.createElement("div");
102
+ surface.className = "architecture-editor-surface";
103
+
104
+ container.append(toolbar, surface);
105
+
106
+ let selectedId = null;
107
+ let drag = null;
108
+ let svg = null;
109
+ // Prevent out-of-order save responses; only the latest request may update the display.
110
+ let saveToken = 0;
111
+ // Even during repeated moves, wait for the previous save response (deckVersion) before sending the next.
112
+ let commitQueue = Promise.resolve();
113
+
114
+ function createButton(label, action) {
115
+ const button = documentRef.createElement("button");
116
+ button.type = "button";
117
+ button.className = "architecture-editor-button";
118
+ button.textContent = label;
119
+ button.setAttribute("data-architecture-edit-action", action);
120
+ return button;
121
+ }
122
+
123
+ function announce(text, reason) {
124
+ status.textContent = text;
125
+ status.setAttribute("data-architecture-edit-status", reason);
126
+ }
127
+
128
+ /** Display the save result to the user. Keep failures visible until the next success. */
129
+ function reportSave(state, text) {
130
+ saveState.textContent = text;
131
+ saveState.setAttribute("data-architecture-save-state", state);
132
+ }
133
+
134
+ /**
135
+ * Persist committed DSL and always display the result.
136
+ *
137
+ * Fire-and-forget would make a server rejection with 409 / 404 / 413 look
138
+ * successful, silently discarding the edit. Always display success or failure
139
+ * to eliminate precisely that silent-ignore behavior.
140
+ */
141
+ async function commitAndReport(source) {
142
+ if (!onCommit) return;
143
+ const token = ++saveToken;
144
+ reportSave("saving", "Saving…");
145
+ let result;
146
+ try {
147
+ const pending = commitQueue.catch(() => {}).then(() => onCommit(source));
148
+ commitQueue = pending;
149
+ result = await pending;
150
+ } catch (e) {
151
+ result = { ok: false, message: e?.message || "Unknown error" };
152
+ }
153
+ // Do not let an overtaken response overwrite newer state.
154
+ if (token !== saveToken) return;
155
+ // Report success **only** when confirmed. Treat a missing onCommit result as
156
+ // failure; it is safer to report an unsaved edit than falsely claim success.
157
+ if (result?.ok === true) {
158
+ reportSave(
159
+ "saved",
160
+ result.fileSaved ? "Saved to the source Markdown." : "Saved to the canvas.",
161
+ );
162
+ return;
163
+ }
164
+ const message = result?.message || "Could not verify the save result";
165
+ reportSave("failed", `Could not save: ${message}. This edit has not been saved.`);
166
+ }
167
+
168
+ function refreshToolbar() {
169
+ undoButton.disabled = !session.canUndo;
170
+ redoButton.disabled = !session.canRedo;
171
+ const placement = selectedId ? session.describe(selectedId) : null;
172
+ releaseButton.disabled = !placement || placement.reason !== "layout-managed";
173
+ }
174
+
175
+ /** Rebuild the diagram. Connector rerouting occurs automatically here. */
176
+ function renderDiagram() {
177
+ const wrapper = renderArchitectureBlock(session.source, documentRef);
178
+ surface.replaceChildren(wrapper);
179
+ svg = wrapper.querySelector("svg");
180
+ if (svg) {
181
+ wireSvg(svg);
182
+ svg.setAttribute("data-architecture-edit-surface", "true");
183
+ }
184
+ restoreSelection();
185
+ refreshToolbar();
186
+ }
187
+
188
+ function nodeFor(id) {
189
+ if (!svg || !id) return null;
190
+ return svg.querySelector(`[data-architecture-id="${CSS.escape(id)}"]`);
191
+ }
192
+
193
+ function restoreSelection() {
194
+ if (!selectedId) return;
195
+ const node = nodeFor(selectedId);
196
+ if (!node) {
197
+ selectedId = null;
198
+ return;
199
+ }
200
+ node.classList.add("architecture-selected");
201
+ node.setAttribute("data-architecture-selected", "true");
202
+ }
203
+
204
+ function select(id, { focus = false } = {}) {
205
+ if (selectedId && selectedId !== id) {
206
+ const previous = nodeFor(selectedId);
207
+ previous?.classList.remove("architecture-selected");
208
+ previous?.removeAttribute("data-architecture-selected");
209
+ }
210
+ selectedId = id;
211
+ const node = nodeFor(id);
212
+ if (node) {
213
+ node.classList.add("architecture-selected");
214
+ node.setAttribute("data-architecture-selected", "true");
215
+ if (focus) node.focus?.();
216
+ }
217
+ refreshToolbar();
218
+ if (!id) {
219
+ announce("", "idle");
220
+ return;
221
+ }
222
+ const placement = session.describe(id);
223
+ if (placement.reason === "layout-managed") {
224
+ announcePlacement(placement);
225
+ } else {
226
+ announce(`Selected ${id}. Use the arrow keys to move it.`, "selected");
227
+ }
228
+ }
229
+
230
+ function announcePlacement(placement) {
231
+ announce(
232
+ `${placement.id} cannot move directly because the layout (${placement.layoutType}) of group ` +
233
+ `"${placement.layoutOwner}" controls its position. Press L (Release layout) to write ` +
234
+ "the current placement as coordinates, then move the element.",
235
+ "layout-managed",
236
+ );
237
+ }
238
+
239
+ /** Shared path for applying session results to the UI. */
240
+ function applyResult(result) {
241
+ if (!result?.ok) {
242
+ if (result?.reason === "layout-managed") announcePlacement(result);
243
+ else if (result?.reason === "no-history") announce("No more undo or redo history.", "no-history");
244
+ else if (result?.reason === "not-layout-managed") announce("This element is not managed by a layout.", "not-layout-managed");
245
+ else if (result?.reason === "rejected") announce(`Could not apply edit: ${result.message}`, "rejected");
246
+ else if (result?.reason === "unchanged") announce("The position did not change.", "unchanged");
247
+ refreshToolbar();
248
+ return false;
249
+ }
250
+ renderDiagram();
251
+ const node = nodeFor(selectedId);
252
+ node?.focus?.();
253
+ if (result.reason === "moved") {
254
+ announce(`Moved ${result.id} to x ${result.x}, y ${result.y}.`, "moved");
255
+ } else if (result.reason === "layout-released") {
256
+ announce(
257
+ `Released layout (${result.layoutType}) from group "${result.id}" and wrote ` +
258
+ `coordinates for ${result.released} child elements. They can now move.`,
259
+ "layout-released",
260
+ );
261
+ } else if (result.reason === "undone") {
262
+ announce("Undid the previous edit.", "undone");
263
+ } else if (result.reason === "redone") {
264
+ announce("Redid the edit.", "redone");
265
+ }
266
+ void commitAndReport(session.source);
267
+ return true;
268
+ }
269
+
270
+ function moveSelected(dx, dy) {
271
+ if (!selectedId) return;
272
+ applyResult(session.move(selectedId, dx, dy));
273
+ }
274
+
275
+ function releaseSelected() {
276
+ if (!selectedId) return;
277
+ const placement = session.describe(selectedId);
278
+ if (placement.reason !== "layout-managed") {
279
+ announce("This element is not managed by a layout.", "not-layout-managed");
280
+ return;
281
+ }
282
+ // Release the group controlling placement, not the selected element.
283
+ const result = session.releaseLayout(placement.layoutOwner);
284
+ applyResult(result);
285
+ }
286
+
287
+ function onKeyDown(event) {
288
+ const id = event.currentTarget?.dataset?.architectureId;
289
+ if (!id) return;
290
+ if (event.ctrlKey || event.metaKey) {
291
+ const key = event.key.toLowerCase();
292
+ if (key === "z") {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ applyResult(event.shiftKey ? session.redo() : session.undo());
296
+ } else if (key === "y") {
297
+ event.preventDefault();
298
+ event.stopPropagation();
299
+ applyResult(session.redo());
300
+ }
301
+ return;
302
+ }
303
+ const delta = ARROW_DELTAS[event.key];
304
+ if (delta) {
305
+ // Prevent propagation to slide navigation because ← and → conflict with navigation.
306
+ event.preventDefault();
307
+ event.stopPropagation();
308
+ select(id);
309
+ const step = event.shiftKey ? EDIT_FINE_STEP : EDIT_STEP;
310
+ moveSelected(delta[0] * step, delta[1] * step);
311
+ return;
312
+ }
313
+ if (event.key === "l" || event.key === "L") {
314
+ event.preventDefault();
315
+ event.stopPropagation();
316
+ select(id);
317
+ releaseSelected();
318
+ return;
319
+ }
320
+ if (event.key === "Escape") {
321
+ event.stopPropagation();
322
+ select(null);
323
+ }
324
+ }
325
+
326
+ function onPointerDown(event) {
327
+ const target = event.currentTarget;
328
+ const id = target?.dataset?.architectureId;
329
+ if (!id) return;
330
+ if (typeof event.button === "number" && event.button !== 0) return;
331
+ event.stopPropagation();
332
+ select(id, { focus: true });
333
+ const placement = session.describe(id);
334
+ if (!placement.movable) return;
335
+ event.preventDefault();
336
+ target.setPointerCapture?.(event.pointerId);
337
+ drag = {
338
+ id,
339
+ target,
340
+ pointerId: event.pointerId,
341
+ startX: event.clientX,
342
+ startY: event.clientY,
343
+ dx: 0,
344
+ dy: 0,
345
+ moved: false,
346
+ };
347
+ }
348
+
349
+ function onPointerMove(event) {
350
+ if (!drag || event.pointerId !== drag.pointerId) return;
351
+ const scale = viewBoxScale(svg);
352
+ drag.dx = (event.clientX - drag.startX) / (scale.x || 1);
353
+ drag.dy = (event.clientY - drag.startY) / (scale.y || 1);
354
+ if (Math.abs(drag.dx) >= 0.5 || Math.abs(drag.dy) >= 0.5) drag.moved = true;
355
+ // Move only the visual transform until commit; do not reparse every frame.
356
+ drag.target.setAttribute("transform", `translate(${drag.dx} ${drag.dy})`);
357
+ }
358
+
359
+ function onPointerUp(event) {
360
+ if (!drag || event.pointerId !== drag.pointerId) return;
361
+ const pending = drag;
362
+ drag = null;
363
+ pending.target.releasePointerCapture?.(pending.pointerId);
364
+ pending.target.removeAttribute("transform");
365
+ if (!pending.moved) return;
366
+ applyResult(session.move(pending.id, pending.dx, pending.dy));
367
+ }
368
+
369
+ function wireSvg(target) {
370
+ // In normal view, architecture.mjs adds tabindex="0" to the root <svg> so the
371
+ // complete diagram is one tab stop. Editing mode makes each element a tab stop;
372
+ // retaining the root would add an empty stop between the diagram and first
373
+ // element, so remove it here.
374
+ target.removeAttribute("tabindex");
375
+ // NOTE: Tab order follows DOM order (rendering/z order), not declaration order.
376
+ // SVG DOM order is also stacking order, so sorting by declaration would change
377
+ // appearance. Code requiring declaration order must read data-architecture-order
378
+ // (see "Reading order" in the README).
379
+ target.querySelectorAll(SELECTABLE).forEach((node) => {
380
+ const placement = session.describe(node.dataset.architectureId);
381
+ node.setAttribute("tabindex", "0");
382
+ node.setAttribute("aria-keyshortcuts", "ArrowUp ArrowRight ArrowDown ArrowLeft L");
383
+ node.setAttribute(
384
+ "data-architecture-movable",
385
+ placement.movable ? "true" : "false",
386
+ );
387
+ if (!placement.movable) {
388
+ node.setAttribute("data-architecture-layout-owner", placement.layoutOwner ?? "");
389
+ }
390
+ node.addEventListener("pointerdown", onPointerDown);
391
+ node.addEventListener("keydown", onKeyDown);
392
+ node.addEventListener("focus", () => select(node.dataset.architectureId));
393
+ });
394
+ target.addEventListener("pointerdown", () => select(null));
395
+ }
396
+
397
+ undoButton.addEventListener("click", () => applyResult(session.undo()));
398
+ redoButton.addEventListener("click", () => applyResult(session.redo()));
399
+ releaseButton.addEventListener("click", () => releaseSelected());
400
+ detailButton.addEventListener("click", async () => {
401
+ if (!canOpenDetail) {
402
+ announce(
403
+ "Advanced editing requires a source Markdown association. Load Markdown with the canvas file picker.",
404
+ "source-not-available",
405
+ );
406
+ return;
407
+ }
408
+ detailButton.disabled = true;
409
+ announce("Opening the dedicated Architecture Editor…", "opening-detail");
410
+ try {
411
+ const result = await onOpenDetail();
412
+ if (result?.ok === true) {
413
+ announce("Opened the dedicated Architecture Editor.", "detail-opened");
414
+ } else {
415
+ announce(
416
+ result?.message || "Could not open the dedicated Architecture Editor.",
417
+ "detail-open-failed",
418
+ );
419
+ }
420
+ } catch (error) {
421
+ announce(
422
+ error?.message || "Could not open the dedicated Architecture Editor.",
423
+ "detail-open-failed",
424
+ );
425
+ } finally {
426
+ detailButton.disabled = !canOpenDetail;
427
+ }
428
+ });
429
+
430
+ const ownerDocument = container.ownerDocument ?? documentRef;
431
+ ownerDocument.addEventListener("pointermove", onPointerMove);
432
+ ownerDocument.addEventListener("pointerup", onPointerUp);
433
+ ownerDocument.addEventListener("pointercancel", onPointerUp);
434
+
435
+ renderDiagram();
436
+ announce("Editing mode. Select a diagram element and move it with the arrow keys or by dragging.", "ready");
437
+
438
+ return {
439
+ getSource: () => session.source,
440
+ destroy() {
441
+ ownerDocument.removeEventListener("pointermove", onPointerMove);
442
+ ownerDocument.removeEventListener("pointerup", onPointerUp);
443
+ ownerDocument.removeEventListener("pointercancel", onPointerUp);
444
+ container.replaceChildren();
445
+ container.classList.remove("architecture-editor");
446
+ container.removeAttribute("data-architecture-edit");
447
+ },
448
+ };
449
+ }