@lalalic/markcut 2.9.0 → 3.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 (36) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/components.md +45 -2
  4. package/skills/markcut/docs/map-dynamic-camera.md +244 -0
  5. package/skills/markcut/docs/markdown-descriptive.md +7 -2
  6. package/src/components/Markdown.tsx +138 -24
  7. package/src/components/Mermaid.tsx +223 -22
  8. package/src/context/EventContext.tsx +3 -0
  9. package/src/descriptive/compiler.ts +105 -29
  10. package/src/descriptive/dsl.ts +42 -5
  11. package/src/descriptive/markdown.ts +23 -0
  12. package/src/descriptive/resolve.test.ts +5 -5
  13. package/src/descriptive/resolve.ts +51 -12
  14. package/src/player/bundle/player.js +751 -143
  15. package/src/player/pipeline.mjs +130 -32
  16. package/src/player/pipeline.ts +5 -4
  17. package/src/player/server.mjs +22 -42
  18. package/src/render/cli.mjs +54 -3
  19. package/src/render/validate-assets.mjs +140 -0
  20. package/src/schema/index.ts +58 -2
  21. package/src/spots/cli.mjs +266 -0
  22. package/src/types/Component.tsx +27 -1
  23. package/src/types/Effect.tsx +13 -6
  24. package/src/types/Folder.tsx +1 -1
  25. package/src/types/Map.tsx +501 -127
  26. package/src/utils/index.ts +14 -2
  27. package/src/utils/tween.ts +49 -1
  28. package/tests/dsl.test.ts +43 -0
  29. package/tests/fixtures/map-dynamic.json +52 -0
  30. package/tests/fixtures/md/animate-diagrams.md +42 -0
  31. package/tests/fixtures/md/electricity-grow.md +130 -0
  32. package/tests/fixtures/md/map-all-views.md +28 -0
  33. package/tests/md-descriptive.test.ts +58 -0
  34. package/tests/render.test.ts +1 -0
  35. package/tests/schema.test.ts +58 -1
  36. package/tests/validate-assets.test.ts +106 -0
@@ -1,5 +1,4 @@
1
1
  import * as React from "react";
2
- import { delayRender, continueRender } from "remotion";
3
2
  import mermaid from "mermaid";
4
3
 
5
4
  /**
@@ -10,13 +9,23 @@ import mermaid from "mermaid";
10
9
  * <Mermaid source={diagram} theme="dark" />
11
10
  *
12
11
  * Props:
13
- * source — Mermaid diagram definition string
14
- * theme — Mermaid theme: "default" | "dark" | "forest" | "neutral" (default: "dark")
15
- * className — Optional container className
12
+ * source — Mermaid diagram definition string
13
+ * theme — Mermaid theme: "default" | "dark" | "forest" | "neutral" (default: "dark")
14
+ * className — Optional container className
15
+ * style — Container inline style
16
+ * highlight — Node name(s) to highlight. String or array of strings.
17
+ * Toggles CSS class `highlight` on matching SVG elements.
18
+ * The diagram source should define the class, e.g.:
19
+ * classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px
20
+ * animateEdges — Edge(s) to animate with a flowing dash effect.
21
+ * true = animate all edges.
22
+ * string[] = animate specific edges by source->target alias,
23
+ * e.g. ["A->B", "D->C"]. Edge paths are found via Mermaid's
24
+ * SVG id pattern: {prefix}-L_{source}_{target}_{index}.
25
+ * Adds CSS class `edge-animated` with a stroke-dashoffset
26
+ * keyframe animation.
16
27
  *
17
28
  * The diagram is rendered asynchronously via the mermaid library.
18
- * Uses delayRender/continueRender to ensure the SVG is ready before
19
- * the frame is captured by Remotion.
20
29
  *
21
30
  * Built-in — no imports or frontmatter registration needed.
22
31
  */
@@ -24,20 +33,147 @@ export interface MermaidProps {
24
33
  source?: string;
25
34
  children?: string;
26
35
  theme?: "default" | "dark" | "forest" | "neutral";
27
- className?: string;
36
+ className?: string;
37
+ style?: React.CSSProperties;
38
+ highlight?: string | string[];
39
+ animateEdges?: boolean | string[];
28
40
  }
29
41
 
30
42
  let initialized = false;
31
43
 
32
- export function Mermaid({ children, source=children, theme = "dark", className}: MermaidProps) {
44
+ /**
45
+ * Recursively extract plain text from JsxParser children.
46
+ * JsxParser may wrap template literals in nested React elements.
47
+ */
48
+ function extractText(x: unknown): string {
49
+ if (typeof x === "string") return x;
50
+ if (Array.isArray(x)) return x.map(extractText).join("");
51
+ if (x && typeof x === "object" && "props" in (x as any)) {
52
+ return extractText((x as any).props?.children);
53
+ }
54
+ return "";
55
+ }
56
+
57
+ /**
58
+ * Find the SVG element representing a named node.
59
+ * Strategy: search <text> + <title> elements by textContent,
60
+ * walk up to the containing <g> cluster.
61
+ */
62
+ function findNodeGroup(svg: SVGSVGElement, name: string): Element | null {
63
+ // Strategy 1: SVG id containing the alias (e.g. `flowchart-A-1234` for "A")
64
+ // Mermaid embeds node aliases in auto-generated IDs.
65
+ const byId = svg.querySelector(`[id*="-${CSS.escape(name)}-"]`);
66
+ if (byId) {
67
+ let el: Element | null = byId;
68
+ while (el && el.tagName !== "g") el = el.parentElement;
69
+ return el || byId;
70
+ }
71
+ // Strategy 2: <text> elements whose text starts with the name
72
+ for (const t of svg.querySelectorAll<SVGTextElement>("text")) {
73
+ const text = t.textContent?.trim() ?? "";
74
+ if (text.startsWith(name)) {
75
+ let el: Element | null = t;
76
+ while (el && el.tagName !== "g") el = el.parentElement;
77
+ return el || t;
78
+ }
79
+ }
80
+ // Strategy 3: <title> elements (class diagram titles, state labels)
81
+ for (const t of svg.querySelectorAll("title")) {
82
+ if ((t.textContent?.trim() ?? "").startsWith(name) && t.parentElement) {
83
+ return t.parentElement;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Apply edge animation to matching paths in the rendered Mermaid SVG.
91
+ * Mermaid edge paths have IDs like `{prefix}-L_{source}_{target}_{index}`.
92
+ *
93
+ * @param svg - The rendered SVG element
94
+ * @param spec - true = animate all edges, string[] = specific edges by "A->B" pattern
95
+ */
96
+ function applyEdgeAnimation(svg: SVGSVGElement, spec: boolean | string[] | undefined): void {
97
+ if (!spec) return;
98
+
99
+ const allEdges = Array.from(svg.querySelectorAll<SVGPathElement>(
100
+ 'path[id*="-L_"]',
101
+ ));
102
+
103
+ if (spec === true) {
104
+ // Animate all edges
105
+ for (const path of allEdges) {
106
+ path.classList.add("edge-animated");
107
+ }
108
+ return;
109
+ }
110
+
111
+ // spec is string[] — parse patterns like "A->B"
112
+ for (const pattern of spec) {
113
+ const match = pattern.match(/^(\w+)\s*->\s*(\w+)$/);
114
+ if (!match) continue;
115
+ const source = match[1]!;
116
+ const target = match[2]!;
117
+
118
+ // Find matching path by ID pattern: *L_{source}_{target}_*
119
+ const suffix = `L_${source}_${target}_`;
120
+ for (const path of allEdges) {
121
+ if (path.id.includes(suffix)) {
122
+ path.classList.add("edge-animated");
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ export function Mermaid({
129
+ children,
130
+ source: sourceProp,
131
+ theme = "dark",
132
+ className,
133
+ style,
134
+ highlight,
135
+ animateEdges,
136
+ }: MermaidProps) {
33
137
  const ref = React.useRef<HTMLDivElement>(null);
34
- const [handle] = React.useState(() => delayRender("Mermaid rendering"));
138
+ const renderedRef = React.useRef(false);
139
+ const styleRef = React.useRef<HTMLStyleElement | null>(null);
35
140
 
141
+ // Inject edge animation CSS once
36
142
  React.useEffect(() => {
143
+ if (!styleRef.current) {
144
+ const el = document.createElement("style");
145
+ el.textContent = `
146
+ @keyframes mermaid-edge-flow {
147
+ to { stroke-dashoffset: -24; }
148
+ }
149
+ .edge-animated {
150
+ stroke-dasharray: 8 6 !important;
151
+ animation: mermaid-edge-flow 0.5s linear infinite !important;
152
+ }
153
+ `;
154
+ document.head.appendChild(el);
155
+ styleRef.current = el;
156
+ }
157
+ return () => {
158
+ if (styleRef.current) {
159
+ styleRef.current.remove();
160
+ styleRef.current = null;
161
+ }
162
+ };
163
+ }, []);
164
+
165
+ // Resolve source string: children from JsxParser may be nested React
166
+ // elements wrapping template literals.
167
+ const source = React.useMemo(
168
+ () => extractText(sourceProp ?? children),
169
+ [sourceProp, children],
170
+ );
171
+
172
+ // Render effect — runs once when source/theme changes
173
+ React.useEffect(() => {
174
+ let cancelled = false;
37
175
  if (!source || !ref.current) return;
38
176
 
39
- // Initialize once — mermaid.initialize is idempotent but we guard to avoid
40
- // redundant config writes on re-renders.
41
177
  if (!initialized) {
42
178
  mermaid.initialize({
43
179
  startOnLoad: false,
@@ -52,25 +188,90 @@ export function Mermaid({ children, source=children, theme = "dark", className}:
52
188
  mermaid
53
189
  .render(id, source)
54
190
  .then((result) => {
55
- if (ref.current) ref.current.innerHTML = result.svg;
56
- continueRender(handle);
191
+ if (cancelled || !ref.current) return;
192
+ ref.current.innerHTML = result.svg;
193
+ const svg = ref.current.querySelector<SVGSVGElement>("svg");
194
+ if (svg) {
195
+ svg.removeAttribute("width");
196
+ svg.removeAttribute("height");
197
+ svg.style.width = "100%";
198
+ svg.style.height = "100%";
199
+
200
+ // Apply initial highlight now that SVG exists.
201
+ // The highlight effect below only fires on prop changes, so the
202
+ // initial highlight would be missed if SVG wasn't ready yet.
203
+ if (highlight) {
204
+ const names = Array.isArray(highlight) ? highlight : [highlight];
205
+ for (const name of names) {
206
+ const node = findNodeGroup(svg, name);
207
+ if (node) node.classList.add("highlight");
208
+ }
209
+ }
210
+
211
+ // Apply edge animation now that SVG exists.
212
+ applyEdgeAnimation(svg, animateEdges);
213
+ }
214
+ renderedRef.current = true;
57
215
  })
58
216
  .catch((err) => {
217
+ if (cancelled) return;
59
218
  console.error("Mermaid error:", err);
60
- // Show error inline so the user can diagnose diagram syntax
61
219
  if (ref.current) {
62
220
  ref.current.innerHTML = `<div style="color:#f87171;padding:1em;border:2px dashed #f87171;border-radius:8px;font-family:monospace;font-size:14px;">
63
221
  <strong>⚠ Mermaid Error</strong><br/>${String(err).replace(/</g, "&lt;").replace(/>/g, "&gt;")}
64
222
  </div>`;
65
223
  }
66
- continueRender(handle);
67
224
  });
68
- }, [source, theme, handle]);
69
225
 
70
- return (
71
- <div
72
- ref={ref}
73
- className={className}
74
- />
75
- );
226
+ return () => { cancelled = true; };
227
+ }, [source, theme]);
228
+
229
+ // Highlight effect — toggles CSS class `highlight` on matching nodes.
230
+ // The diagram source must define this class with desired styles, e.g.:
231
+ // classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px
232
+ React.useEffect(() => {
233
+ const svg = ref.current?.querySelector<SVGSVGElement>("svg");
234
+ if (!svg) return;
235
+
236
+ // Remove class from all nodes
237
+ svg.querySelectorAll(".highlight").forEach((el) => {
238
+ el.classList.remove("highlight");
239
+ });
240
+
241
+ // Apply class to current highlight target(s)
242
+ const names = Array.isArray(highlight) ? highlight : (highlight ? [highlight] : []);
243
+ for (const name of names) {
244
+ const node = findNodeGroup(svg, name);
245
+ if (node) {
246
+ node.classList.add("highlight");
247
+ }
248
+ }
249
+ }, [highlight]);
250
+
251
+ // AnimateEdges effect — re-applies edge animation on prop changes.
252
+ // Runs in addition to the initial application in the render callback.
253
+ React.useEffect(() => {
254
+ const svg = ref.current?.querySelector<SVGSVGElement>("svg");
255
+ if (!svg) return;
256
+
257
+ // Remove animated class from all edges first
258
+ svg.querySelectorAll(".edge-animated").forEach((el) => {
259
+ el.classList.remove("edge-animated");
260
+ });
261
+
262
+ // Re-apply to current spec
263
+ applyEdgeAnimation(svg, animateEdges);
264
+ }, [animateEdges]);
265
+
266
+ // Default: center the SVG in the container. User style overrides individual properties.
267
+ const containerStyle: React.CSSProperties = {
268
+ display: "flex",
269
+ justifyContent: "center",
270
+ alignItems: "center",
271
+ width: "100%",
272
+ height: "100%",
273
+ ...style,
274
+ };
275
+
276
+ return <div ref={ref} className={className} style={containerStyle} />;
76
277
  }
@@ -74,6 +74,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
74
74
  const keys = Object.keys(scope);
75
75
  const vals = Object.values(scope);
76
76
  try {
77
+ // Debug: log the exact code string
78
+ const codeChars = Array.from(code).map((c: string) => c.charCodeAt(0));
79
+ console.log(`EventContext.debug: keys=${JSON.stringify(keys)}, codeLen=${code.length}, codeChars=${JSON.stringify(codeChars)}, codeStr=${JSON.stringify(code)}`);
77
80
  const fn = new Function(...keys, code);
78
81
  fn(...vals);
79
82
  console.info(`Event evaluation succeeded: "${code}"`);
@@ -139,9 +139,18 @@ export interface DescriptiveMapWaypoint {
139
139
  media?: string;
140
140
  }
141
141
 
142
+ /** A tween expression `tween(from, to, easing?)` parsed into a tagged spec. */
143
+ export interface DescriptiveTween {
144
+ __tween: Array<number | string>;
145
+ }
146
+
147
+ /** A static number OR an animated tween expression. */
148
+ export type DescriptiveTweenable = number | DescriptiveTween;
149
+
142
150
  export interface DescriptiveMap extends DescriptiveBaseNode {
143
151
  type: "map";
144
152
  waypoints: DescriptiveMapWaypoint[];
153
+ view?: "overview" | "route" | "cinematic" | "streetview";
145
154
  routeColor?: string;
146
155
  routeWeight?: number;
147
156
  zoom?: number;
@@ -151,6 +160,34 @@ export interface DescriptiveMap extends DescriptiveBaseNode {
151
160
  region?: string;
152
161
  travelMode?: "DRIVING" | "WALKING" | "BICYCLING" | "TRANSIT";
153
162
  routeMarker?: string;
163
+ camera?: {
164
+ zoom?: DescriptiveTweenable;
165
+ center?: { lat: DescriptiveTweenable; lng: DescriptiveTweenable };
166
+ heading?: DescriptiveTweenable;
167
+ tilt?: DescriptiveTweenable;
168
+ };
169
+ cinematic?: {
170
+ mode?: "flyAlong" | "flyTo" | "orbit";
171
+ followRoute?: boolean;
172
+ headingFollow?: boolean;
173
+ tilt?: DescriptiveTweenable;
174
+ range?: DescriptiveTweenable;
175
+ altitude?: number;
176
+ roll?: DescriptiveTweenable;
177
+ fallback?: "2d" | "none";
178
+ };
179
+ streetView?: {
180
+ pano?: string;
181
+ location?: { lat: number; lng: number };
182
+ route?: Array<{ lat: number; lng: number }>;
183
+ radius?: number;
184
+ source?: "default" | "outdoor" | "indoor";
185
+ zoom?: DescriptiveTweenable;
186
+ pov?: {
187
+ heading?: DescriptiveTweenable;
188
+ pitch?: DescriptiveTweenable;
189
+ };
190
+ };
154
191
  }
155
192
 
156
193
  export interface DescriptiveContainer extends DescriptiveBaseNode {
@@ -448,15 +485,21 @@ function wrapWithEffects(
448
485
  const absEnd = innerStream.end ?? result.duration;
449
486
  const duration = absEnd - absStart;
450
487
 
451
- // Reset inner stream's timing to be relative (start=0) so the effect
452
- // wrapper owns the absolute timing. The EffectWrapper renders children
453
- // with their relative timing inside its own Sequence.
454
- const resetStream = {
455
- ...innerStream,
456
- start: 0,
457
- end: duration,
458
- durationInSeconds: duration,
459
- } as any;
488
+ // For background nodes without explicit end, keep original timing
489
+ // (start/end undefined) so parent back-propagation fills the correct
490
+ // scene duration. The effect wrapper handles animation timing via
491
+ // durationInSeconds and the parent fills end for proper visibility span.
492
+ // Other nodes: reset to relative timing so the effect wrapper owns
493
+ // the absolute positioning in the parent timeline.
494
+ const isBgNoEnd = innerStream.isBackground && innerStream.end == null;
495
+ const resetStream = isBgNoEnd
496
+ ? { ...innerStream }
497
+ : {
498
+ ...innerStream,
499
+ start: 0,
500
+ end: duration,
501
+ durationInSeconds: duration,
502
+ } as any;
460
503
 
461
504
  // Build nested effect wrappers from innermost → outermost.
462
505
  // The outermost effect uses the original absolute timing;
@@ -476,14 +519,18 @@ function wrapWithEffects(
476
519
  id: uid(),
477
520
  type: "effect",
478
521
  animation: spec.animation,
522
+ animationDurationSeconds: spec.duration,
479
523
  durationInSeconds: spec.duration,
480
524
  animationTimingFunction: spec.animationTimingFunction,
481
525
  animationIterationCount: spec.animationIterationCount ?? 1,
482
526
  customKeyframes: spec.customKeyframes,
483
527
  children: [currentStream],
484
- start: effStart,
485
- end: effEnd,
486
- visible: true,
528
+ // For background inner nodes: propagate start/end as-is so parent
529
+ // back-propagation fills the correct scene duration. The effect's
530
+ // durationInSeconds (animation spec) controls animation timing.
531
+ start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
532
+ end: isOutermost && isBgNoEnd ? undefined : effEnd,
533
+ visible: innerStream.visible ?? true,
487
534
  ...pickOn(node),
488
535
  } as Effect;
489
536
  }
@@ -492,7 +539,10 @@ function wrapWithEffects(
492
539
  }
493
540
 
494
541
  function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | DescriptiveScene | DescriptiveInclude>, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
495
- const id = node.id ?? uid();
542
+ // Only set id when explicitly provided — auto-generated uids would register
543
+ // in EventContext and may create invalid JS identifiers (starting with digit).
544
+ const id = node.id;
545
+ const hasExplicitId = node.id != null;
496
546
 
497
547
  // Background nodes without explicit duration/endAt: let parent fill timing later
498
548
  const hasOwnDuration = typeof node.duration === "number" || typeof node.endAt === "number";
@@ -506,7 +556,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
506
556
  const end = duration != null ? start! + duration : undefined;
507
557
 
508
558
  const base = {
509
- id,
559
+ ...(id ? { id } : {}),
510
560
  style: node.style,
511
561
  visible: node.visible ?? true,
512
562
  isBackground: node.isBackground,
@@ -557,11 +607,10 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
557
607
  "type", "jsx", "id", "instruction", "style", "visible",
558
608
  "isBackground", "duration", "start", "on",
559
609
  ]);
560
- const bindings: Record<string, string> = {};
610
+ const bindings: Record<string, unknown> = {};
561
611
  for (const key of Object.keys(node)) {
562
612
  if (!KNOWN_COMPONENT_KEYS.has(key)) {
563
- const val = (node as any)[key];
564
- if (typeof val === "string") bindings[key] = val;
613
+ bindings[key] = (node as any)[key];
565
614
  }
566
615
  }
567
616
 
@@ -589,6 +638,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
589
638
  const stream: MapStream = {
590
639
  ...base,
591
640
  type: "map",
641
+ view: node.view ?? "route",
592
642
  waypoints: node.waypoints,
593
643
  routeColor: node.routeColor ?? "#4285F4",
594
644
  routeWeight: node.routeWeight ?? 4,
@@ -599,6 +649,9 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
599
649
  region: node.region,
600
650
  travelMode: node.travelMode ?? "DRIVING",
601
651
  routeMarker: node.routeMarker ?? "🚗",
652
+ camera: node.camera,
653
+ cinematic: node.cinematic,
654
+ streetView: node.streetView,
602
655
  googleMapsApiKey: ctx.googleMapsApiKey,
603
656
  };
604
657
  return { stream, duration: end ?? 0 };
@@ -690,13 +743,26 @@ function compileScene(
690
743
  : aggregateDuration(compiledChildren, sceneKind, resolved.time);
691
744
  const localDuration = Math.max(node.duration ?? 0, sceneContentDuration);
692
745
 
693
- // Back-propagate parent duration to background children without own timing
694
- for (const c of compiledChildren) {
695
- if (c.stream.isBackground && c.stream.end == null) {
696
- c.stream.end = localDuration;
697
- c.stream.durationInSeconds = localDuration;
698
- if (c.stream.start == null) c.stream.start = 0;
746
+ // Back-propagate parent duration to nodes without own timing.
747
+ // Also recurses into effect wrappers so nested background children
748
+ // (wrapped by effects) get the correct scene duration.
749
+ function backpropagate(stream: Record<string, any>, dur: number): void {
750
+ if (stream.end == null) {
751
+ stream.end = dur;
752
+ if (stream.durationInSeconds == null) {
753
+ stream.durationInSeconds = dur;
754
+ }
755
+ if (stream.start == null) stream.start = 0;
699
756
  }
757
+ // Recursively walk into effect wrapper children
758
+ if (stream.type === "effect" && Array.isArray(stream.children)) {
759
+ for (const child of stream.children) {
760
+ backpropagate(child, dur);
761
+ }
762
+ }
763
+ }
764
+ for (const c of compiledChildren) {
765
+ backpropagate(c.stream, localDuration);
700
766
  }
701
767
 
702
768
  const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
@@ -837,13 +903,23 @@ function compileContainer(node: DescriptiveContainer, ctx: CompileContext, paren
837
903
  const children = compileChildren(node.children, ctx, node.type);
838
904
  const duration = aggregateDuration(children, node.type, resolved.time);
839
905
 
840
- // Back-propagate parent duration to background children without own timing
841
- for (const c of children) {
842
- if (c.stream.isBackground && c.stream.end == null) {
843
- c.stream.end = duration;
844
- c.stream.durationInSeconds = duration;
845
- if (c.stream.start == null) c.stream.start = 0;
906
+ // Back-propagate parent duration to nodes without own timing.
907
+ function backpropagate(stream: Record<string, any>, dur: number): void {
908
+ if (stream.end == null) {
909
+ stream.end = dur;
910
+ if (stream.durationInSeconds == null) {
911
+ stream.durationInSeconds = dur;
912
+ }
913
+ if (stream.start == null) stream.start = 0;
846
914
  }
915
+ if (stream.type === "effect" && Array.isArray(stream.children)) {
916
+ for (const child of stream.children) {
917
+ backpropagate(child, dur);
918
+ }
919
+ }
920
+ }
921
+ for (const c of children) {
922
+ backpropagate(c.stream, duration);
847
923
  }
848
924
 
849
925
  const stream: Folder = {
@@ -174,28 +174,65 @@ export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
174
174
  const lat = Number(bits[0] ?? 0);
175
175
  const lng = Number(bits[1] ?? 0);
176
176
  const labelRaw = bits[2];
177
- const label = labelRaw ? unquote(labelRaw) : undefined;
178
- return { lat, lng, label };
177
+ const labelRawUq = labelRaw ? unquote(labelRaw) : undefined;
178
+ const label = labelRawUq ? labelRawUq : undefined;
179
+ const mediaRaw = bits[3];
180
+ const media = mediaRaw ? unquote(mediaRaw) : undefined;
181
+ return { lat, lng, label, media };
179
182
  });
180
183
  }
181
184
 
185
+ /**
186
+ * Rewrite `tween(from, to, easing?)` expressions inside a JSON-ish string into
187
+ * a tagged literal `{"__tween":[from,to,"easing"]}` so the regular JSON parser
188
+ * can handle them. `from`/`to` may be numbers or quoted strings (e.g. colors);
189
+ * `easing` is a bare word or quoted string. No eval — deterministic, no scope.
190
+ *
191
+ * tween(6, 12, easeInOut) → {"__tween":[6,12,"easeInOut"]}
192
+ */
193
+ function rewriteTweenExprs(s: string): string {
194
+ return s.replace(
195
+ /tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
196
+ (_match, fromRaw: string, toRaw: string, easingRaw?: string) => {
197
+ // Scalar → JSON literal: numbers stay, quoted strings stay, bare words get quoted.
198
+ const scalar = (v: string): string => {
199
+ const t = v.trim();
200
+ if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
201
+ if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
202
+ if (t === "true" || t === "false" || t === "null") return t;
203
+ return JSON.stringify(t);
204
+ };
205
+ const items = [scalar(fromRaw), scalar(toRaw)];
206
+ if (easingRaw !== undefined && easingRaw.trim()) {
207
+ items.push(scalar(easingRaw));
208
+ }
209
+ return `{"__tween":[${items.join(",")}]}`;
210
+ },
211
+ );
212
+ }
213
+
182
214
  /**
183
215
  * Parse a JSON-like props/imports string into an object or array.
184
216
  *
185
217
  * Accepts standard JSON, then falls back to a lenient two-pass normalization
186
218
  * that quotes bare keys (`{foo:` → `{"foo":`) and bare string values, and
187
219
  * finally to `eval` for JSX-like expressions. Returns `{}` on total failure.
220
+ *
221
+ * Also understands `tween(from, to, easing?)` expressions (see
222
+ * {@link rewriteTweenExprs}), which are rewritten to a tagged JSON literal
223
+ * before parsing so camera/map configs can animate values.
188
224
  */
189
225
  export function parseProps(raw: string): unknown {
190
226
  const s = raw.trim();
191
227
  if (!s.startsWith("{") && !s.startsWith("[")) return {};
192
228
  if (!s.endsWith("}") && !s.endsWith("]")) return {};
229
+ const withTweens = rewriteTweenExprs(s);
193
230
  try {
194
- return JSON.parse(s);
231
+ return JSON.parse(withTweens);
195
232
  } catch {
196
233
  // Lenient parse: add quotes around unquoted keys and string values.
197
234
  // First pass: quote bare keys: {foo: → {"foo":
198
- let normalized = s.replace(
235
+ let normalized = withTweens.replace(
199
236
  /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
200
237
  '$1"$2":',
201
238
  );
@@ -214,7 +251,7 @@ export function parseProps(raw: string): unknown {
214
251
  } catch {
215
252
  // Last resort: eval (safe since this is a CLI tool)
216
253
  try {
217
- const result = (0, eval)("(" + s + ")");
254
+ const result = (0, eval)("(" + withTweens + ")");
218
255
  return typeof result === "object" && result !== null ? result : {};
219
256
  } catch {
220
257
  return {};
@@ -38,6 +38,7 @@ const TYPE_TOKENS: Record<string, string> = {
38
38
  video: "video",
39
39
  audio: "audio",
40
40
  component: "component",
41
+ event: "event",
41
42
  rhythm: "rhythm",
42
43
  include: "include",
43
44
  map: "map",
@@ -100,6 +101,7 @@ function preserveVariantAttrs(node: Record<string, unknown>, attrs: Record<strin
100
101
  "foreground", "visible", "isBackground", "instruction", "style", "effects", "on",
101
102
  "spots", "waypoints", "routeColor", "routeWeight", "routeMarker",
102
103
  "travelMode", "zoom", "center", "mapType", "data", "prompt",
104
+ "view", "camera", "cinematic", "streetView",
103
105
  "name", "title", "transition", "transitionTime", "layout",
104
106
  "componentName", "props", "speaker",
105
107
  ]);
@@ -251,6 +253,23 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
251
253
  preserveVariantAttrs(node, attrs);
252
254
  return node;
253
255
  }
256
+ case "event": {
257
+ // Event-only stub: compiles to a component with empty JSX (renders nothing),
258
+ // just fires events on other registered components via `on`.
259
+ const node: DescriptiveComponent = {
260
+ type: "component",
261
+ id: attrs.id as any,
262
+ jsx: "",
263
+ duration: attrs.duration as any,
264
+ start: attrs.start as any,
265
+ instruction: attrs.instruction as any,
266
+ style: attrs.style as any,
267
+ effects: attrs.effects as any,
268
+ on: attrs.on as any,
269
+ };
270
+ preserveVariantAttrs(node, attrs);
271
+ return node;
272
+ }
254
273
  case "rhythm": {
255
274
  const src = firstPositional ?? (attrs.src as string | undefined);
256
275
  if (!src) throw new DslError("rhythm requires src", ctx);
@@ -320,6 +339,7 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
320
339
  waypoints: (attrs.waypoints as DescriptiveMapWaypoint[] | undefined) ?? [],
321
340
  duration: attrs.duration as any,
322
341
  start: attrs.start as any,
342
+ view: attrs.view as any,
323
343
  routeMarker: attrs.routeMarker as any,
324
344
  travelMode: attrs.travelMode as any,
325
345
  routeColor: attrs.routeColor as any,
@@ -327,6 +347,9 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
327
347
  zoom: attrs.zoom as any,
328
348
  center: attrs.center as any,
329
349
  mapType: attrs.mapType as any,
350
+ camera: attrs.camera as any,
351
+ cinematic: attrs.cinematic as any,
352
+ streetView: attrs.streetView as any,
330
353
  language: (attrs.language as any) ?? (attrs.lang as any),
331
354
  region: attrs.region as any,
332
355
  instruction: attrs.instruction as any,