@lalalic/markcut 1.0.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/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the Markdown Descriptive pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Tests the full pipeline: markdown → parse → compile → stream tree.
|
|
5
|
+
* Each fixture is a `.md` file in `tests/fixtures/md/` that exercises
|
|
6
|
+
* specific features of the descriptive markdown format.
|
|
7
|
+
*
|
|
8
|
+
* These tests verify:
|
|
9
|
+
* - Correct parsing of markdown into DescriptiveRoot
|
|
10
|
+
* - Correct compilation into stream tree (Root)
|
|
11
|
+
* - Structural correctness (types, durations, nesting)
|
|
12
|
+
* - Frontmatter and imports resolution
|
|
13
|
+
* - Code fence parsing (```js imports and ```jsx Name)
|
|
14
|
+
* - Edge cases (empty input, special characters, etc.)
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { parseMarkdownDescriptive } from "../src/descriptive/markdown";
|
|
20
|
+
import { compileDescriptiveRoot } from "../src/descriptive/compiler";
|
|
21
|
+
|
|
22
|
+
const MD_FIXTURES_DIR = resolve(__dirname, "fixtures/md");
|
|
23
|
+
|
|
24
|
+
interface MdFixture {
|
|
25
|
+
name: string;
|
|
26
|
+
source: string;
|
|
27
|
+
path: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadAllFixtures(): MdFixture[] {
|
|
31
|
+
if (!existsSync(MD_FIXTURES_DIR)) return [];
|
|
32
|
+
return readdirSync(MD_FIXTURES_DIR)
|
|
33
|
+
.filter((f) => f.endsWith(".md"))
|
|
34
|
+
.map((f) => ({
|
|
35
|
+
name: f.replace(/\.md$/, ""),
|
|
36
|
+
source: readFileSync(resolve(MD_FIXTURES_DIR, f), "utf-8"),
|
|
37
|
+
path: resolve(MD_FIXTURES_DIR, f),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadFixture(name: string): MdFixture {
|
|
42
|
+
const path = resolve(MD_FIXTURES_DIR, `${name}.md`);
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
source: readFileSync(path, "utf-8"),
|
|
46
|
+
path,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Parse & Compile each fixture ──────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("markdown fixtures — parse & compile", () => {
|
|
53
|
+
const fixtures = loadAllFixtures();
|
|
54
|
+
|
|
55
|
+
it("has fixtures to test", () => {
|
|
56
|
+
expect(fixtures.length).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
for (const fx of fixtures) {
|
|
60
|
+
it(`parses and compiles: ${fx.name}`, () => {
|
|
61
|
+
// Parse markdown → DescriptiveRoot
|
|
62
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
63
|
+
expect(parsed).toBeDefined();
|
|
64
|
+
expect(parsed.children).toBeDefined();
|
|
65
|
+
|
|
66
|
+
// Compile DescriptiveRoot → stream tree (Root)
|
|
67
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
68
|
+
expect(compiled).toBeDefined();
|
|
69
|
+
expect(compiled.type).toBe("root");
|
|
70
|
+
expect(compiled.id).toBe("root");
|
|
71
|
+
expect(typeof compiled.width).toBe("number");
|
|
72
|
+
expect(typeof compiled.height).toBe("number");
|
|
73
|
+
expect(typeof compiled.fps).toBe("number");
|
|
74
|
+
|
|
75
|
+
// Must have at least one child
|
|
76
|
+
expect(compiled.children.length).toBeGreaterThanOrEqual(0);
|
|
77
|
+
|
|
78
|
+
// All children must have valid types
|
|
79
|
+
for (const child of compiled.children) {
|
|
80
|
+
expect(child.type).toBeDefined();
|
|
81
|
+
expect(["folder", "scene", "video", "audio", "image", "component", "effect", "rhythm", "map", "include", "root"]).toContain(child.type);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── Basic ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("basic fixture", () => {
|
|
90
|
+
const fx = loadFixture("basic");
|
|
91
|
+
|
|
92
|
+
it("parses root metadata correctly", () => {
|
|
93
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
94
|
+
expect(parsed.width).toBe(640);
|
|
95
|
+
expect(parsed.height).toBe(480);
|
|
96
|
+
expect(parsed.fps).toBe(30);
|
|
97
|
+
expect(parsed.layout).toBe("series");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("compiles root with correct dimensions", () => {
|
|
101
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
102
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
103
|
+
expect(compiled.width).toBe(640);
|
|
104
|
+
expect(compiled.height).toBe(480);
|
|
105
|
+
expect(compiled.fps).toBe(30);
|
|
106
|
+
expect(compiled.isSeries).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("parses scene with children", () => {
|
|
110
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
111
|
+
expect(parsed.children).toHaveLength(1);
|
|
112
|
+
const scene = parsed.children[0] as any;
|
|
113
|
+
expect(scene.type).toBe("scene");
|
|
114
|
+
expect(scene.name).toBe("Intro");
|
|
115
|
+
expect(scene.layout).toBe("parallel");
|
|
116
|
+
expect(scene.children).toHaveLength(2);
|
|
117
|
+
expect(scene.children[0].type).toBe("image");
|
|
118
|
+
expect(scene.children[1].type).toBe("video");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── All Nodes ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("all-nodes fixture", () => {
|
|
125
|
+
const fx = loadFixture("all-nodes");
|
|
126
|
+
|
|
127
|
+
it("parses all node types", () => {
|
|
128
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
129
|
+
// Intro scene with root instruction
|
|
130
|
+
expect(parsed.instruction).toBe("Full node type demo");
|
|
131
|
+
expect(parsed.metadata).toBe("all-nodes");
|
|
132
|
+
expect(parsed.children).toHaveLength(6);
|
|
133
|
+
|
|
134
|
+
const sceneTypes = parsed.children.map((s: any) => s.name);
|
|
135
|
+
expect(sceneTypes).toContain("Media");
|
|
136
|
+
expect(sceneTypes).toContain("Components");
|
|
137
|
+
expect(sceneTypes).toContain("Effects");
|
|
138
|
+
expect(sceneTypes).toContain("Rhythm");
|
|
139
|
+
expect(sceneTypes).toContain("Map");
|
|
140
|
+
expect(sceneTypes).toContain("Include");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("parses image with fit", () => {
|
|
144
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
145
|
+
const mediaScene = parsed.children[0] as any;
|
|
146
|
+
const img = mediaScene.children[0];
|
|
147
|
+
expect(img.type).toBe("image");
|
|
148
|
+
expect(img.src).toBe("photo.jpg");
|
|
149
|
+
expect(img.duration).toBe(3);
|
|
150
|
+
expect(img.fit).toBe("cover");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("parses video with trim and playbackRate", () => {
|
|
154
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
155
|
+
const mediaScene = parsed.children[0] as any;
|
|
156
|
+
const vid = mediaScene.children[1];
|
|
157
|
+
expect(vid.type).toBe("video");
|
|
158
|
+
expect(vid.src).toBe("movie.mp4");
|
|
159
|
+
expect(vid.startFrom).toBe(0.5);
|
|
160
|
+
expect(vid.endAt).toBe(3.5);
|
|
161
|
+
expect(vid.volume).toBe(0.9);
|
|
162
|
+
expect(vid.playbackRate).toBe(1.2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("parses audio with foreground", () => {
|
|
166
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
167
|
+
const mediaScene = parsed.children[0] as any;
|
|
168
|
+
const aud = mediaScene.children[2];
|
|
169
|
+
expect(aud.type).toBe("audio");
|
|
170
|
+
expect(aud.foreground).toBe(true);
|
|
171
|
+
expect(aud.volume).toBe(0.5);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("parses component with jsx", () => {
|
|
175
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
176
|
+
const compScene = parsed.children[1] as any;
|
|
177
|
+
const c1 = compScene.children[0];
|
|
178
|
+
expect(c1.type).toBe("component");
|
|
179
|
+
expect(c1.jsx).toContain("AnimatedHeadline");
|
|
180
|
+
expect(c1.duration).toBe(3);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("parses effect with animation", () => {
|
|
184
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
185
|
+
const fxScene = parsed.children[2] as any;
|
|
186
|
+
const e1 = fxScene.children[0];
|
|
187
|
+
expect(e1.type).toBe("image");
|
|
188
|
+
expect(e1.effects).toEqual(["fadeIn(3)"]);
|
|
189
|
+
|
|
190
|
+
const e2 = fxScene.children[1];
|
|
191
|
+
expect(e2.type).toBe("image");
|
|
192
|
+
expect(e2.effects).toEqual(["bounceIn(,ease-out,2)"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("parses rhythm with spots", () => {
|
|
196
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
197
|
+
const rhythmScene = parsed.children.find((s: any) => s.name === "Rhythm");
|
|
198
|
+
const r = rhythmScene!.children[0];
|
|
199
|
+
expect(r.type).toBe("rhythm");
|
|
200
|
+
expect(r.src).toBe("beat.mp3");
|
|
201
|
+
expect(r.spots).toEqual([0.5, 1.2, 1.9, 2.8]);
|
|
202
|
+
expect(r.volume).toBe(0.8);
|
|
203
|
+
expect(r.children).toHaveLength(4);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("parses map with waypoints", () => {
|
|
207
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
208
|
+
const mapScene = parsed.children[4] as any;
|
|
209
|
+
const m = mapScene.children[0];
|
|
210
|
+
expect(m.type).toBe("map");
|
|
211
|
+
expect(m.duration).toBe(4);
|
|
212
|
+
expect(m.travelMode).toBe("DRIVING");
|
|
213
|
+
expect(m.waypoints).toHaveLength(3);
|
|
214
|
+
expect(m.waypoints[0].label).toBe("SF");
|
|
215
|
+
expect(m.waypoints[1].label).toBe("LA");
|
|
216
|
+
expect(m.waypoints[2].label).toBe("LV");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("parses include with src", () => {
|
|
220
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
221
|
+
const includeScene = parsed.children[5] as any;
|
|
222
|
+
const inc = includeScene.children[0];
|
|
223
|
+
expect(inc.type).toBe("include");
|
|
224
|
+
expect(inc.src).toBe("./child.json");
|
|
225
|
+
expect(inc.duration).toBe(3);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── Scenes ────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("scenes fixture", () => {
|
|
232
|
+
const fx = loadFixture("scenes");
|
|
233
|
+
|
|
234
|
+
it("parses multiple scenes with different layouts", () => {
|
|
235
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
236
|
+
expect(parsed.children).toHaveLength(3);
|
|
237
|
+
|
|
238
|
+
const [hook, journey, wrap] = parsed.children as any[];
|
|
239
|
+
expect(hook.name).toBe("Hook");
|
|
240
|
+
expect(hook.layout).toBe("parallel");
|
|
241
|
+
expect(hook.instruction).toBe("Grab attention fast");
|
|
242
|
+
|
|
243
|
+
expect(journey.name).toBe("Journey");
|
|
244
|
+
expect(journey.layout).toBe("transitionSeries");
|
|
245
|
+
expect(journey.transition).toBe("fade");
|
|
246
|
+
expect(journey.transitionTime).toBe(0.5);
|
|
247
|
+
// Script is now an audio child instead of a property on the scene
|
|
248
|
+
expect(journey.children[0].type).toBe("audio");
|
|
249
|
+
expect(journey.children[0].script).toBe("Follow along on this adventure");
|
|
250
|
+
expect(journey.children).toHaveLength(4); // 1 audio + 3 images
|
|
251
|
+
|
|
252
|
+
expect(wrap.name).toBe("WrapUp");
|
|
253
|
+
expect(wrap.layout).toBe("parallel");
|
|
254
|
+
// Script is now an audio child instead of a property on the scene
|
|
255
|
+
expect(wrap.children[0].type).toBe("audio");
|
|
256
|
+
expect(wrap.children[0].script).toBe("Thanks for watching");
|
|
257
|
+
expect(wrap.children).toHaveLength(2); // 1 audio + 1 image
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("compiles transitionSeries with overlap", () => {
|
|
261
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
262
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
263
|
+
// Journey scene: 3 images of 2,2,3 with transition 0.5 → total = 2+2+3 - 2*0.5 = 6
|
|
264
|
+
const journey = compiled.children[1] as any;
|
|
265
|
+
expect(journey.type).toBe("scene" || "folder");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── Frontmatter ───────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
describe("frontmatter fixture", () => {
|
|
272
|
+
const fx = loadFixture("frontmatter");
|
|
273
|
+
|
|
274
|
+
it("parses root attrs from config line (not frontmatter)", () => {
|
|
275
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
276
|
+
expect(parsed.width).toBe(640);
|
|
277
|
+
expect(parsed.height).toBe(480);
|
|
278
|
+
expect(parsed.fps).toBe(30);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("parses imports from ```js imports block", () => {
|
|
282
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
283
|
+
// importsBlock is set, not frontmatter imports
|
|
284
|
+
expect(parsed.imports).toBeUndefined();
|
|
285
|
+
expect(parsed.importsBlock).toBeDefined();
|
|
286
|
+
expect(parsed.importsBlock).toContain("StatCounter");
|
|
287
|
+
expect(parsed.importsBlock).toContain("github:foo/bar/src/Logo.tsx");
|
|
288
|
+
expect(parsed.importsBlock).toContain("InlineBadge");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("parses tts config from root config line", () => {
|
|
292
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
293
|
+
expect(parsed.tts).toBeDefined();
|
|
294
|
+
expect(parsed.tts).toContain("edge-tts");
|
|
295
|
+
expect(parsed.tts).toContain("{input}");
|
|
296
|
+
expect(parsed.tts).toContain("{output}");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("parses stt config from root config line", () => {
|
|
300
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
301
|
+
expect(parsed.stt).toBeDefined();
|
|
302
|
+
expect(parsed.stt).toContain("whisper");
|
|
303
|
+
expect(parsed.stt).toContain("{input}");
|
|
304
|
+
expect(parsed.stt).toContain("{output}");
|
|
305
|
+
expect(parsed.stt).toContain("--language zh");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("compiles with resolved imports", () => {
|
|
309
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
310
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
311
|
+
const scene = compiled.children[0] as any;
|
|
312
|
+
const statComp = scene.children[0];
|
|
313
|
+
// imports removed from component schema — now at root level
|
|
314
|
+
// imports removed from component schema — now at root level
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("subtitle from root config line", () => {
|
|
319
|
+
const src = `# video
|
|
320
|
+
width:640 height:480 fps:30 layout:series subtitle:captions.vtt
|
|
321
|
+
## Scene
|
|
322
|
+
- image src:bg.jpg duration:3
|
|
323
|
+
`;
|
|
324
|
+
|
|
325
|
+
it("parses subtitle from root config line", () => {
|
|
326
|
+
const parsed = parseMarkdownDescriptive(src);
|
|
327
|
+
expect(parsed.subtitle).toBeDefined();
|
|
328
|
+
expect(parsed.subtitle!.src).toBe("captions.vtt");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("compiles subtitle into root stream tree", () => {
|
|
332
|
+
const parsed = parseMarkdownDescriptive(src);
|
|
333
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
334
|
+
expect(compiled.subtitle).toBeDefined();
|
|
335
|
+
expect(compiled.subtitle!.src).toBe("captions.vtt");
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Imports Block ─────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
describe("imports-block fixture", () => {
|
|
342
|
+
const fx = loadFixture("imports-block");
|
|
343
|
+
|
|
344
|
+
it("parses ```js imports code fence", () => {
|
|
345
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
346
|
+
expect(parsed.importsBlock).toBeDefined();
|
|
347
|
+
expect(parsed.importsBlock).toContain("import { PieChart } from");
|
|
348
|
+
expect(parsed.importsBlock).toContain("export function Hello");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("compiles with importsBlock resolution", () => {
|
|
352
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
353
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
354
|
+
const scene = compiled.children[0] as any;
|
|
355
|
+
const pieChart = scene.children[0];
|
|
356
|
+
// imports removed from component schema
|
|
357
|
+
const hello = scene.children[1];
|
|
358
|
+
// imports removed from component schema
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ── JSX Code Fence ────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
describe("jsx-code-fence fixture", () => {
|
|
365
|
+
const fx = loadFixture("jsx-code-fence");
|
|
366
|
+
|
|
367
|
+
it("parses inline component definitions from ```js imports block", () => {
|
|
368
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
369
|
+
expect(parsed.imports).toBeUndefined();
|
|
370
|
+
expect(parsed.importsBlock).toBeDefined();
|
|
371
|
+
expect(parsed.importsBlock).toContain("function Greeting");
|
|
372
|
+
expect(parsed.importsBlock).toContain("function Counter");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("compiles with inline component definitions from imports block", () => {
|
|
376
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
377
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
378
|
+
const scene = compiled.children[0] as any;
|
|
379
|
+
const greeting = scene.children[0];
|
|
380
|
+
const counter = scene.children[1];
|
|
381
|
+
// imports removed from component schema
|
|
382
|
+
expect(greeting.jsx).toContain("Greeting");
|
|
383
|
+
// imports removed from component schema
|
|
384
|
+
expect(counter.jsx).toContain("Counter");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── Nested Scenes ─────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
describe("nested-scenes fixture", () => {
|
|
391
|
+
const fx = loadFixture("nested-scenes");
|
|
392
|
+
|
|
393
|
+
it("parses nested scenes by heading depth", () => {
|
|
394
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
395
|
+
expect(parsed.children).toHaveLength(3);
|
|
396
|
+
|
|
397
|
+
const ch1 = parsed.children[0] as any;
|
|
398
|
+
expect(ch1.name).toBe("Chapter1");
|
|
399
|
+
expect(ch1.title).toBe("The Beginning");
|
|
400
|
+
expect(ch1.layout).toBe("series");
|
|
401
|
+
|
|
402
|
+
// Chapter1 has 2 nested scenes (Shot1, Shot2) + their leaf children
|
|
403
|
+
const shot1 = ch1.children[0] as any;
|
|
404
|
+
expect(shot1.type).toBe("scene");
|
|
405
|
+
expect(shot1.name).toBe("Shot1");
|
|
406
|
+
expect(shot1.children).toHaveLength(2);
|
|
407
|
+
|
|
408
|
+
const ch2 = parsed.children[1] as any;
|
|
409
|
+
expect(ch2.name).toBe("Chapter2");
|
|
410
|
+
expect(ch2.layout).toBe("series");
|
|
411
|
+
|
|
412
|
+
const ch3 = parsed.children[2] as any;
|
|
413
|
+
expect(ch3.name).toBe("Chapter3");
|
|
414
|
+
expect(ch3.layout).toBe("parallel");
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ── Effects ───────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
describe("effects fixture", () => {
|
|
421
|
+
const fx = loadFixture("effects");
|
|
422
|
+
|
|
423
|
+
it("parses effects as arrays on leaf nodes", () => {
|
|
424
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
425
|
+
expect(parsed.children).toHaveLength(4);
|
|
426
|
+
|
|
427
|
+
const fades = parsed.children[0] as any;
|
|
428
|
+
expect(fades.children[0].effects).toEqual(["fadeIn(3)"]);
|
|
429
|
+
expect(fades.children[1].effects).toEqual(["fadeOut(3)"]);
|
|
430
|
+
expect(fades.children[0].type).toBe("image");
|
|
431
|
+
expect(fades.children[1].type).toBe("image");
|
|
432
|
+
|
|
433
|
+
const slides = parsed.children[1] as any;
|
|
434
|
+
expect(slides.children[0].effects).toEqual(["slideInLeft(3)"]);
|
|
435
|
+
expect(slides.children[1].effects).toEqual(["slideInRight(3)"]);
|
|
436
|
+
|
|
437
|
+
const attention = parsed.children[2] as any;
|
|
438
|
+
expect(attention.children[0].effects).toEqual(["bounceIn(,ease-out,2)"]);
|
|
439
|
+
expect(attention.children[1].effects).toEqual(["pulse(,ease-in-out)"]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("compiles effects into Effect wrapper streams", () => {
|
|
443
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
444
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
445
|
+
|
|
446
|
+
// Fades: two images with fadeIn/fadeOut — each is an Effect wrapper
|
|
447
|
+
const fadesScene = compiled.children[0] as any;
|
|
448
|
+
expect(fadesScene.children[0].type).toBe("effect");
|
|
449
|
+
expect(fadesScene.children[0].animation).toBe("fadeIn");
|
|
450
|
+
expect(fadesScene.children[1].type).toBe("effect");
|
|
451
|
+
expect(fadesScene.children[1].animation).toBe("fadeOut");
|
|
452
|
+
|
|
453
|
+
// Slides: slideInLeft / slideInRight
|
|
454
|
+
const slidesScene = compiled.children[1] as any;
|
|
455
|
+
expect(slidesScene.children[0].animation).toBe("slideInLeft");
|
|
456
|
+
expect(slidesScene.children[1].animation).toBe("slideInRight");
|
|
457
|
+
|
|
458
|
+
// Attention: bounceIn + ease-out + 2 iterations
|
|
459
|
+
const attnScene = compiled.children[2] as any;
|
|
460
|
+
expect(attnScene.children[0].animation).toBe("bounceIn");
|
|
461
|
+
expect(attnScene.children[0].animationTimingFunction).toBe("ease-out");
|
|
462
|
+
expect(attnScene.children[0].animationIterationCount).toBe(2);
|
|
463
|
+
expect(attnScene.children[1].animation).toBe("pulse");
|
|
464
|
+
expect(attnScene.children[1].animationTimingFunction).toBe("ease-in-out");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("compiles custom keyframes via JSON object effects syntax", () => {
|
|
468
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
469
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
470
|
+
const customScene = compiled.children[3] as any;
|
|
471
|
+
const e = customScene.children[0]; // Effect wrapper
|
|
472
|
+
expect(e.type).toBe("effect");
|
|
473
|
+
expect(e.animation).toBe("custom");
|
|
474
|
+
expect(e.customKeyframes).toBeDefined();
|
|
475
|
+
expect(e.customKeyframes["0"]).toBeDefined();
|
|
476
|
+
expect(e.customKeyframes["50"]).toBeDefined();
|
|
477
|
+
expect(e.customKeyframes["100"]).toBeDefined();
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ── Map ───────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
describe("map fixture", () => {
|
|
484
|
+
const fx = loadFixture("map");
|
|
485
|
+
|
|
486
|
+
it("parses multiple map configurations", () => {
|
|
487
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
488
|
+
expect(parsed.children).toHaveLength(3);
|
|
489
|
+
|
|
490
|
+
const roadTrip = parsed.children[0] as any;
|
|
491
|
+
const m1 = roadTrip.children[0];
|
|
492
|
+
expect(m1.type).toBe("map");
|
|
493
|
+
expect(m1.waypoints).toHaveLength(3);
|
|
494
|
+
expect(m1.travelMode).toBe("DRIVING");
|
|
495
|
+
expect(m1.routeColor).toBe("#FF5733");
|
|
496
|
+
expect(m1.routeWeight).toBe(6);
|
|
497
|
+
expect(m1.zoom).toBe(8);
|
|
498
|
+
expect(m1.mapType).toBe("roadmap");
|
|
499
|
+
expect(m1.routeMarker).toBe("🚗");
|
|
500
|
+
|
|
501
|
+
const walking = parsed.children[1] as any;
|
|
502
|
+
const m2 = walking.children[0];
|
|
503
|
+
expect(m2.travelMode).toBe("WALKING");
|
|
504
|
+
expect(m2.mapType).toBe("hybrid");
|
|
505
|
+
expect(m2.zoom).toBe(15);
|
|
506
|
+
expect(m2.routeMarker).toBe("🚶");
|
|
507
|
+
|
|
508
|
+
const transit = parsed.children[2] as any;
|
|
509
|
+
const m3 = transit.children[0];
|
|
510
|
+
expect(m3.travelMode).toBe("TRANSIT");
|
|
511
|
+
expect(m3.mapType).toBe("satellite");
|
|
512
|
+
expect(m3.zoom).toBe(12);
|
|
513
|
+
expect(m3.routeMarker).toBe("🗼");
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ── Rhythm ────────────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
describe("rhythm fixture", () => {
|
|
520
|
+
const fx = loadFixture("rhythm");
|
|
521
|
+
|
|
522
|
+
it("parses rhythm with beat-synced children", () => {
|
|
523
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
524
|
+
expect(parsed.children).toHaveLength(2);
|
|
525
|
+
|
|
526
|
+
const drop = parsed.children[0] as any;
|
|
527
|
+
const r1 = drop.children[0];
|
|
528
|
+
expect(r1.type).toBe("rhythm");
|
|
529
|
+
expect(r1.spots).toEqual([0.0, 0.5, 1.0, 1.5]);
|
|
530
|
+
expect(r1.volume).toBe(0.9);
|
|
531
|
+
expect(r1.children).toHaveLength(4);
|
|
532
|
+
|
|
533
|
+
const slow = parsed.children[1] as any;
|
|
534
|
+
const r2 = slow.children[0];
|
|
535
|
+
expect(r2.spots).toEqual([0.0, 1.2, 2.5, 3.8, 5.0]);
|
|
536
|
+
expect(r2.volume).toBe(0.6);
|
|
537
|
+
expect(r2.children).toHaveLength(5);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("compiles rhythm with derived durations", () => {
|
|
541
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
542
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
543
|
+
// The rhythm node should compile to a rhythm stream
|
|
544
|
+
const rootChildren = compiled.children;
|
|
545
|
+
expect(rootChildren.length).toBeGreaterThan(0);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ── Tween ─────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
describe("tween fixture", () => {
|
|
552
|
+
const fx = loadFixture("tween");
|
|
553
|
+
|
|
554
|
+
it("parses components with tween() expressions", () => {
|
|
555
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
556
|
+
expect(parsed.children).toHaveLength(3);
|
|
557
|
+
|
|
558
|
+
const bar = parsed.children[0] as any;
|
|
559
|
+
expect(bar.children[0].type).toBe("component");
|
|
560
|
+
expect(bar.children[0].jsx).toContain("tween(");
|
|
561
|
+
|
|
562
|
+
const nivo = parsed.children[1] as any;
|
|
563
|
+
expect(nivo.children[0].jsx).toContain("tween(0,80)");
|
|
564
|
+
|
|
565
|
+
const color = parsed.children[2] as any;
|
|
566
|
+
expect(color.children[0].jsx).toContain("tween('#000','#FFF')");
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ── Edge Cases ────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
describe("edge-cases fixture", () => {
|
|
573
|
+
const fx = loadFixture("edge-cases");
|
|
574
|
+
|
|
575
|
+
it("parses empty include", () => {
|
|
576
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
577
|
+
const emptyScene = parsed.children.find((s: any) => s.name === "Empty");
|
|
578
|
+
expect(emptyScene!.children[0].type).toBe("include");
|
|
579
|
+
// include without src is valid
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("handles special characters in src", () => {
|
|
583
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
584
|
+
const specialScene = parsed.children.find((s: any) => s.name === "SpecialChars");
|
|
585
|
+
expect(specialScene!.children[0].src).toBe("my photo.jpg");
|
|
586
|
+
expect(specialScene!.children[1].src).toBe("bgm (copy).mp3");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("parses quoted scripts as audio siblings", () => {
|
|
590
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
591
|
+
const quotedScene = parsed.children.find((s: any) => s.name === "QuotedScripts");
|
|
592
|
+
// Script on image: converts to audio sibling
|
|
593
|
+
expect(quotedScene!.children[0].type).toBe("image");
|
|
594
|
+
expect(quotedScene!.children[1].type).toBe("audio");
|
|
595
|
+
expect(quotedScene!.children[1].script).toBe("Special chars: #hash, $dollar, %percent, &ersand");
|
|
596
|
+
// Script on video: converts to audio sibling
|
|
597
|
+
expect(quotedScene!.children[2].type).toBe("video");
|
|
598
|
+
expect(quotedScene!.children[3].type).toBe("audio");
|
|
599
|
+
expect(quotedScene!.children[3].script).toBe("Path with spaces: /Users/me/my video.mp4");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("handles long durations", () => {
|
|
603
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
604
|
+
const longScene = parsed.children.find((s: any) => s.name === "LongDuration");
|
|
605
|
+
expect(longScene!.children[0].duration).toBe(60);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("handles visible:false", () => {
|
|
609
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
610
|
+
const visibleScene = parsed.children.find((s: any) => s.name === "VisibleFalse");
|
|
611
|
+
expect(visibleScene!.children[0].visible).toBe(false);
|
|
612
|
+
// Second image has no visible key; defaults to true at compile time
|
|
613
|
+
expect(visibleScene!.children[1].visible).toBeUndefined();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("parses isBackground on audio", () => {
|
|
617
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
618
|
+
const bgScene = parsed.children.find((s: any) => s.name === "BackgroundAudio");
|
|
619
|
+
expect(bgScene!.children[0].isBackground).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("parses start offsets in parallel", () => {
|
|
623
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
624
|
+
const parallelScene = parsed.children.find((s: any) => s.name === "StartOffsets");
|
|
625
|
+
expect(parallelScene!.children[0].start).toBe(1);
|
|
626
|
+
expect(parallelScene!.children[0].duration).toBe(3);
|
|
627
|
+
expect(parallelScene!.children[1].start).toBe(0);
|
|
628
|
+
expect(parallelScene!.children[1].duration).toBe(2);
|
|
629
|
+
expect(parallelScene!.children[2].start).toBe(2);
|
|
630
|
+
expect(parallelScene!.children[2].duration).toBe(4);
|
|
631
|
+
|
|
632
|
+
// Compile should succeed (start is only valid in parallel)
|
|
633
|
+
const compiled = compileDescriptiveRoot(
|
|
634
|
+
parseMarkdownDescriptive(fx.source),
|
|
635
|
+
);
|
|
636
|
+
expect(compiled.type).toBe("root");
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// ── Full Feature ──────────────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
describe("full-feature fixture", () => {
|
|
643
|
+
const fx = loadFixture("full-feature");
|
|
644
|
+
|
|
645
|
+
it("parses complete video with all features", () => {
|
|
646
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
647
|
+
// Frontmatter
|
|
648
|
+
expect(parsed.width).toBe(1080);
|
|
649
|
+
expect(parsed.height).toBe(1920);
|
|
650
|
+
expect(parsed.fps).toBe(30);
|
|
651
|
+
expect(parsed.instruction).toBe("Full feature demo video");
|
|
652
|
+
expect(parsed.metadata).toBe("v2.0-feature-test");
|
|
653
|
+
|
|
654
|
+
// Imports are in ```js imports block (not frontmatter)
|
|
655
|
+
expect(parsed.imports).toBeUndefined();
|
|
656
|
+
expect(parsed.importsBlock).toBeDefined();
|
|
657
|
+
expect(parsed.importsBlock).toContain("AnimatedHeadline");
|
|
658
|
+
expect(parsed.importsBlock).toContain("StatCounter");
|
|
659
|
+
|
|
660
|
+
// TTS
|
|
661
|
+
expect(parsed.tts).toBeDefined();
|
|
662
|
+
expect(parsed.tts).toContain("edge-tts");
|
|
663
|
+
expect(parsed.tts).toContain("{input}");
|
|
664
|
+
|
|
665
|
+
// 9 scenes (including nested ones)
|
|
666
|
+
const topLevel = parsed.children.filter((s: any) => s.type === "scene");
|
|
667
|
+
expect(topLevel.length).toBeGreaterThanOrEqual(5);
|
|
668
|
+
const names = topLevel.map((s: any) => s.name);
|
|
669
|
+
expect(names).toContain("OpeningHook");
|
|
670
|
+
expect(names).toContain("StatShowcase");
|
|
671
|
+
expect(names).toContain("MediaMontage");
|
|
672
|
+
expect(names).toContain("AnimatedEffects");
|
|
673
|
+
expect(names).toContain("InteractiveMap");
|
|
674
|
+
expect(names).toContain("BeatSync");
|
|
675
|
+
expect(names).toContain("Closing");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("compiles full feature without errors", () => {
|
|
679
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
680
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
681
|
+
expect(compiled.type).toBe("root");
|
|
682
|
+
expect(compiled.children.length).toBeGreaterThan(0);
|
|
683
|
+
|
|
684
|
+
// Verify all children compile to valid stream types
|
|
685
|
+
for (const child of compiled.children) {
|
|
686
|
+
expect(child.type).toBeDefined();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("resolves inline component definitions from imports block in full feature", () => {
|
|
691
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
692
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
693
|
+
// The last scene has a Logo component defined inline in the ```js imports block
|
|
694
|
+
const closingScene = compiled.children[compiled.children.length - 1] as any;
|
|
695
|
+
// Find the component in the closing scene
|
|
696
|
+
const comps = findComponents(closingScene);
|
|
697
|
+
const logo = comps.find((c: any) => c.jsx?.includes("Logo"));
|
|
698
|
+
if (logo) {
|
|
699
|
+
// imports removed from component schema
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// ── component-imports fixture ──────────────────────────────────────────────
|
|
705
|
+
// Tests imports block with npm package, git repo, and inline function
|
|
706
|
+
|
|
707
|
+
describe("component-imports fixture", () => {
|
|
708
|
+
const fx = loadFixture("component-imports");
|
|
709
|
+
|
|
710
|
+
it("parses import specifiers correctly", () => {
|
|
711
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
712
|
+
expect(parsed.importsBlock).toBeDefined();
|
|
713
|
+
expect(parsed.importsBlock).toContain("npm:react-minimal-pie-chart");
|
|
714
|
+
expect(parsed.importsBlock).toContain("git:user/repo/path/to/Hello.tsx");
|
|
715
|
+
expect(parsed.importsBlock).toContain("export function Hello");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("compiles with resolved imports registry", () => {
|
|
719
|
+
const parsed = parseMarkdownDescriptive(fx.source);
|
|
720
|
+
const compiled = compileDescriptiveRoot(parsed);
|
|
721
|
+
// Root has 1 scene child (series layout)
|
|
722
|
+
expect(compiled.children).toHaveLength(1);
|
|
723
|
+
const scene = compiled.children[0];
|
|
724
|
+
expect(scene.type).toBe("scene");
|
|
725
|
+
const comps = scene.children.filter((c: any) => c.type === "component");
|
|
726
|
+
expect(comps).toHaveLength(2); // Hello + PieChart
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// ── Helper ────────────────────────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
function findComponents(node: any): any[] {
|
|
733
|
+
const results: any[] = [];
|
|
734
|
+
if (!node) return results;
|
|
735
|
+
if (node.type === "component") results.push(node);
|
|
736
|
+
if (node.children) {
|
|
737
|
+
for (const child of node.children) {
|
|
738
|
+
results.push(...findComponents(child));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return results;
|
|
742
|
+
}
|