@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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/components.md +45 -2
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +7 -2
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +105 -29
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +23 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +751 -143
- package/src/player/pipeline.mjs +130 -32
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +58 -2
- package/src/spots/cli.mjs +266 -0
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/types/Map.tsx +501 -127
- package/src/utils/index.ts +14 -2
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +42 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -1
- 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
|
|
14
|
-
* theme
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
56
|
-
|
|
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, "<").replace(/>/g, ">")}
|
|
64
222
|
</div>`;
|
|
65
223
|
}
|
|
66
|
-
continueRender(handle);
|
|
67
224
|
});
|
|
68
|
-
}, [source, theme, handle]);
|
|
69
225
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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,
|
|
610
|
+
const bindings: Record<string, unknown> = {};
|
|
561
611
|
for (const key of Object.keys(node)) {
|
|
562
612
|
if (!KNOWN_COMPONENT_KEYS.has(key)) {
|
|
563
|
-
|
|
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
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
841
|
-
|
|
842
|
-
if (
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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 = {
|
package/src/descriptive/dsl.ts
CHANGED
|
@@ -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
|
|
178
|
-
|
|
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(
|
|
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 =
|
|
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)("(" +
|
|
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,
|