@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,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level DSL parsing primitives for the descriptive markdown format.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally free of markdown-structure concerns (headings,
|
|
5
|
+
* bullet lists, indentation, frontmatter). It deals only with turning a single
|
|
6
|
+
* line of text into typed key/value attributes:
|
|
7
|
+
*
|
|
8
|
+
* "video src:a.mp4 duration:2 effects:[fadeIn]" → { src, duration, effects }
|
|
9
|
+
*
|
|
10
|
+
* The markdown structure layer (`markdown.ts`) uses remark for the hard part
|
|
11
|
+
* (nesting, code fences, frontmatter) and delegates each line here.
|
|
12
|
+
*
|
|
13
|
+
* Error handling: all thrown errors are {@link DslError} instances carrying
|
|
14
|
+
* optional source position so the markdown layer can pinpoint failures.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DescriptiveMapWaypoint } from "./compiler";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Validation constant sets
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export const LAYOUT_VALUES = new Set([
|
|
26
|
+
"series",
|
|
27
|
+
"parallel",
|
|
28
|
+
"transitionSeries",
|
|
29
|
+
"transition",
|
|
30
|
+
] as const);
|
|
31
|
+
|
|
32
|
+
export const TRANSITION_VALUES = new Set([
|
|
33
|
+
"fade",
|
|
34
|
+
"slide",
|
|
35
|
+
"wipe",
|
|
36
|
+
"flip",
|
|
37
|
+
"clockWipe",
|
|
38
|
+
] as const);
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Error handling
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Optional source-location context threaded through the DSL parsers. */
|
|
45
|
+
export interface ParseContext {
|
|
46
|
+
/** 1-based source line number, for error messages. */
|
|
47
|
+
line?: number;
|
|
48
|
+
/** Raw source text of the line being parsed, for error display. */
|
|
49
|
+
lineText?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Error thrown by the DSL layer. Carries optional source context so callers
|
|
54
|
+
* (the markdown structure layer) can report precise locations to users.
|
|
55
|
+
*
|
|
56
|
+
* Example rendered message:
|
|
57
|
+
* invalid layout value: sideays at line 8
|
|
58
|
+
* | layout:sideays
|
|
59
|
+
*/
|
|
60
|
+
export class DslError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
public readonly context?: ParseContext & { token?: string },
|
|
64
|
+
) {
|
|
65
|
+
const parts: string[] = [message];
|
|
66
|
+
if (context?.line) parts.push(`at line ${context.line}`);
|
|
67
|
+
if (context?.token) parts.push(`near "${context.token}"`);
|
|
68
|
+
const head = parts.join(" ");
|
|
69
|
+
const src = context?.lineText ? `\n | ${context.lineText}` : "";
|
|
70
|
+
super(src ? `${head}\n${src}` : head);
|
|
71
|
+
this.name = "DslError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fail(message: string, ctx?: ParseContext & { token?: string }): never {
|
|
76
|
+
throw new DslError(message, ctx);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// String helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** True when a token is wrapped in double quotes (e.g. `"hello world"`). */
|
|
84
|
+
export function isQuoted(token: string): boolean {
|
|
85
|
+
return token.length >= 2 && token.startsWith('"') && token.endsWith('"');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Strip surrounding double quotes from a token, if present. */
|
|
89
|
+
export function unquote(token: string): string {
|
|
90
|
+
return isQuoted(token) ? token.slice(1, -1) : token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Tokenizer
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split a line into whitespace-delimited tokens while respecting:
|
|
99
|
+
* - double-quoted spans: `a "b c" d` → ["a", "\"b c\"", "d"]
|
|
100
|
+
* - bracket nesting: `subtitle:{a:1 b:2}` → one token
|
|
101
|
+
* - paren nesting: `on:(start, x.y=1)` → one token
|
|
102
|
+
*
|
|
103
|
+
* Quotes and brackets inside the respective delimiters are kept intact.
|
|
104
|
+
*/
|
|
105
|
+
export function splitTokens(line: string): string[] {
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
let cur = "";
|
|
108
|
+
let quote = false;
|
|
109
|
+
let depth = 0;
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < line.length; i++) {
|
|
112
|
+
const ch = line[i]!;
|
|
113
|
+
if (ch === '"') {
|
|
114
|
+
cur += ch;
|
|
115
|
+
quote = !quote;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!quote && (ch === "{" || ch === "[" || ch === "(")) {
|
|
119
|
+
depth++;
|
|
120
|
+
cur += ch;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!quote && (ch === "}" || ch === "]" || ch === ")")) {
|
|
124
|
+
depth = Math.max(0, depth - 1);
|
|
125
|
+
cur += ch;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!quote && depth === 0 && /\s/.test(ch)) {
|
|
129
|
+
if (cur) {
|
|
130
|
+
out.push(cur);
|
|
131
|
+
cur = "";
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
cur += ch;
|
|
136
|
+
}
|
|
137
|
+
if (cur) out.push(cur);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Scalar coercion
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Coerce a raw string into a number/boolean when it looks like one, otherwise
|
|
147
|
+
* return the string unchanged. Used for unquoted scalar values like
|
|
148
|
+
* `duration:2`, `loop:true`, `volume:0.8`.
|
|
149
|
+
*/
|
|
150
|
+
export function parseNumberMaybe(v: string): number | string | boolean {
|
|
151
|
+
if (v === "true") return true;
|
|
152
|
+
if (v === "false") return false;
|
|
153
|
+
const n = Number(v);
|
|
154
|
+
return Number.isFinite(n) ? n : v;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Structured value parsers (return empty/undefined on malformed input)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse a map `waypoints:[...]` value into an array of waypoints.
|
|
163
|
+
*
|
|
164
|
+
* Format: `[lat,lng,"Label"; lat,lng,"Label"]` — semicolon-separated entries,
|
|
165
|
+
* comma-separated fields. Returns `[]` on any structural mismatch.
|
|
166
|
+
*/
|
|
167
|
+
export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
|
|
168
|
+
const s = raw.trim();
|
|
169
|
+
if (!s.startsWith("[") || !s.endsWith("]")) return [];
|
|
170
|
+
const body = s.slice(1, -1).trim();
|
|
171
|
+
if (!body) return [];
|
|
172
|
+
return body.split(";").map((part) => {
|
|
173
|
+
const bits = splitTokens(part.replace(/,/g, " "));
|
|
174
|
+
const lat = Number(bits[0] ?? 0);
|
|
175
|
+
const lng = Number(bits[1] ?? 0);
|
|
176
|
+
const labelRaw = bits[2];
|
|
177
|
+
const label = labelRaw ? unquote(labelRaw) : undefined;
|
|
178
|
+
return { lat, lng, label };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse a JSON-like props/imports string into an object or array.
|
|
184
|
+
*
|
|
185
|
+
* Accepts standard JSON, then falls back to a lenient two-pass normalization
|
|
186
|
+
* that quotes bare keys (`{foo:` → `{"foo":`) and bare string values, and
|
|
187
|
+
* finally to `eval` for JSX-like expressions. Returns `{}` on total failure.
|
|
188
|
+
*/
|
|
189
|
+
export function parseProps(raw: string): unknown {
|
|
190
|
+
const s = raw.trim();
|
|
191
|
+
if (!s.startsWith("{") && !s.startsWith("[")) return {};
|
|
192
|
+
if (!s.endsWith("}") && !s.endsWith("]")) return {};
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(s);
|
|
195
|
+
} catch {
|
|
196
|
+
// Lenient parse: add quotes around unquoted keys and string values.
|
|
197
|
+
// First pass: quote bare keys: {foo: → {"foo":
|
|
198
|
+
let normalized = s.replace(
|
|
199
|
+
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
|
|
200
|
+
'$1"$2":',
|
|
201
|
+
);
|
|
202
|
+
// Second pass: quote bare string values that aren't numbers, booleans, or null
|
|
203
|
+
// Matches :value followed by , } ] or end
|
|
204
|
+
normalized = normalized.replace(
|
|
205
|
+
/:\s*([a-zA-Z_$][a-zA-Z0-9_$.+-]*)\s*(?=[,}\]\s]|$)/g,
|
|
206
|
+
(_match, value: string) => {
|
|
207
|
+
if (value === "true" || value === "false" || value === "null") return `: ${value}`;
|
|
208
|
+
if (/^[+-]?\d+(\.\d+)?$/.test(value)) return `: ${value}`;
|
|
209
|
+
return `: "${value}"`;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(normalized);
|
|
214
|
+
} catch {
|
|
215
|
+
// Last resort: eval (safe since this is a CLI tool)
|
|
216
|
+
try {
|
|
217
|
+
const result = (0, eval)("(" + s + ")");
|
|
218
|
+
return typeof result === "object" && result !== null ? result : {};
|
|
219
|
+
} catch {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parse an `on:(when, code)` event spec.
|
|
228
|
+
*
|
|
229
|
+
* Positional arguments: first is the when-trigger, second is the state code.
|
|
230
|
+
* Example: `on:(start, slide1.current=1)` → `{ when: "start", state: "slide1.current=1" }`.
|
|
231
|
+
* Returns `undefined` on structural mismatch.
|
|
232
|
+
*/
|
|
233
|
+
export function parseOnSpec(
|
|
234
|
+
raw: string,
|
|
235
|
+
): { when: string; state: string } | undefined {
|
|
236
|
+
const s = raw.trim();
|
|
237
|
+
if (!s.startsWith("(") || !s.endsWith(")")) return undefined;
|
|
238
|
+
const body = s.slice(1, -1).trim();
|
|
239
|
+
if (!body) return undefined;
|
|
240
|
+
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
let current = "";
|
|
243
|
+
let depth = 0;
|
|
244
|
+
let inQuote = false;
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < body.length; i++) {
|
|
247
|
+
const ch = body[i]!;
|
|
248
|
+
if (ch === '"') {
|
|
249
|
+
inQuote = !inQuote;
|
|
250
|
+
current += ch;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!inQuote) {
|
|
254
|
+
if (ch === "(") {
|
|
255
|
+
depth++;
|
|
256
|
+
current += ch;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === ")") {
|
|
260
|
+
depth--;
|
|
261
|
+
current += ch;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (ch === "," && depth === 0) {
|
|
265
|
+
parts.push(current.trim());
|
|
266
|
+
current = "";
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
current += ch;
|
|
271
|
+
}
|
|
272
|
+
if (current.trim()) parts.push(current.trim());
|
|
273
|
+
|
|
274
|
+
if (parts.length >= 2) {
|
|
275
|
+
return { when: parts[0]!, state: parts.slice(1).join(",") };
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Parse an `effects:[...]` value into an array of effect spec strings.
|
|
282
|
+
*
|
|
283
|
+
* Supports:
|
|
284
|
+
* effects:[fadeIn]
|
|
285
|
+
* effects:[fadeIn, bounceIn]
|
|
286
|
+
* effects:[fadeIn(timingFunction:ease-out iterationCount:2)]
|
|
287
|
+
*
|
|
288
|
+
* Each element is kept as a raw string — the compiler's `normalizeEffectSpec`
|
|
289
|
+
* handles parsing the paren-based params. Returns `[]` on structural mismatch.
|
|
290
|
+
*/
|
|
291
|
+
export function parseEffects(raw: string): string[] {
|
|
292
|
+
const s = raw.trim();
|
|
293
|
+
if (!s.startsWith("[") || !s.endsWith("]")) return [];
|
|
294
|
+
const body = s.slice(1, -1).trim();
|
|
295
|
+
if (!body) return [];
|
|
296
|
+
|
|
297
|
+
const results: string[] = [];
|
|
298
|
+
let current = "";
|
|
299
|
+
let depth = 0; // brackets/braces
|
|
300
|
+
let parenDepth = 0; // parens for params
|
|
301
|
+
let inQuote = false;
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < body.length; i++) {
|
|
304
|
+
const ch = body[i]!;
|
|
305
|
+
if (ch === '"') {
|
|
306
|
+
inQuote = !inQuote;
|
|
307
|
+
current += ch;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (!inQuote) {
|
|
311
|
+
if (ch === "[" || ch === "{") {
|
|
312
|
+
depth++;
|
|
313
|
+
current += ch;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (ch === "]" || ch === "}") {
|
|
317
|
+
depth = Math.max(0, depth - 1);
|
|
318
|
+
current += ch;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (ch === "(") {
|
|
322
|
+
parenDepth++;
|
|
323
|
+
current += ch;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (ch === ")") {
|
|
327
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
328
|
+
current += ch;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === "," && depth === 0 && parenDepth === 0) {
|
|
332
|
+
const trimmed = current.trim();
|
|
333
|
+
if (trimmed) results.push(trimmed);
|
|
334
|
+
current = "";
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
current += ch;
|
|
339
|
+
}
|
|
340
|
+
const trimmed = current.trim();
|
|
341
|
+
if (trimmed) results.push(trimmed);
|
|
342
|
+
|
|
343
|
+
return results;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Key/value dispatcher
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Keys whose raw values should be preserved verbatim (no scalar coercion or
|
|
352
|
+
* object parsing). These are free-form text fields.
|
|
353
|
+
*/
|
|
354
|
+
const RAW_STRING_KEYS = new Set(["instruction", "tts", "stt", "jsx", "prompt"]);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Keys whose values are lenient JSON objects/arrays.
|
|
358
|
+
*/
|
|
359
|
+
const OBJECT_KEYS = new Set([
|
|
360
|
+
"props",
|
|
361
|
+
"imports",
|
|
362
|
+
"components",
|
|
363
|
+
"spots",
|
|
364
|
+
"customKeyframes",
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Parse an array of tokens (from {@link splitTokens}) into a key/value bag.
|
|
369
|
+
*
|
|
370
|
+
* All attributes use the `key:value` form:
|
|
371
|
+
* `src:a.mp4`, `duration:2`, `on:(start, x.y=1)`, `effects:[fadeIn]`
|
|
372
|
+
*
|
|
373
|
+
* Value normalization is key-specific:
|
|
374
|
+
* - `layout` / `transition`: validated against the enum sets (throws on
|
|
375
|
+
* invalid), with `transition` accepting the merged `fade(0.5)` form
|
|
376
|
+
* - `waypoints` / `on` / `effects` / object keys: dispatched to their
|
|
377
|
+
* dedicated parsers
|
|
378
|
+
* - inline `{...}` / `[...]` values: parsed as lenient JSON
|
|
379
|
+
* - everything else: scalar coercion via {@link parseNumberMaybe}
|
|
380
|
+
*
|
|
381
|
+
* Throws {@link DslError} (with optional context) on unrecognized tokens or
|
|
382
|
+
* invalid enum values.
|
|
383
|
+
*
|
|
384
|
+
* @param tokens Token array, typically from `splitTokens(line)`.
|
|
385
|
+
* @param ctx Optional source context for error messages.
|
|
386
|
+
*/
|
|
387
|
+
export function parseKeyValueTokens(
|
|
388
|
+
tokens: string[],
|
|
389
|
+
ctx?: ParseContext,
|
|
390
|
+
): Record<string, unknown> {
|
|
391
|
+
const out: Record<string, unknown> = {};
|
|
392
|
+
let i = 0;
|
|
393
|
+
|
|
394
|
+
while (i < tokens.length) {
|
|
395
|
+
const token = tokens[i]!;
|
|
396
|
+
const idx = token.indexOf(":");
|
|
397
|
+
if (idx > 0) {
|
|
398
|
+
const key = token.slice(0, idx);
|
|
399
|
+
let rawVal = token.slice(idx + 1);
|
|
400
|
+
// If value after colon is empty, peek at next quoted token
|
|
401
|
+
if (!rawVal && i + 1 < tokens.length) {
|
|
402
|
+
const next = tokens[i + 1]!;
|
|
403
|
+
if (isQuoted(next)) {
|
|
404
|
+
rawVal = next;
|
|
405
|
+
i++; // consume the next token
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
let val: unknown = unquote(rawVal);
|
|
409
|
+
|
|
410
|
+
if (key === "layout") {
|
|
411
|
+
const s = String(val);
|
|
412
|
+
if (!LAYOUT_VALUES.has(s as (typeof LAYOUT_VALUES extends Set<infer T> ? T : never)))
|
|
413
|
+
fail(`invalid layout value: ${s}`, { ...ctx, token });
|
|
414
|
+
val = s;
|
|
415
|
+
}
|
|
416
|
+
if (key === "transition") {
|
|
417
|
+
const s = String(val);
|
|
418
|
+
// Support "name(time)" format e.g. "fade(0.5)" to merge transition + transitionTime
|
|
419
|
+
const parenMatch = s.match(/^(\w+)\((\d+(?:\.\d+)?)\)$/);
|
|
420
|
+
if (parenMatch) {
|
|
421
|
+
const [, name, timeStr] = parenMatch;
|
|
422
|
+
if (!TRANSITION_VALUES.has(name as (typeof TRANSITION_VALUES extends Set<infer T> ? T : never)))
|
|
423
|
+
fail(`invalid transition value: ${name}`, { ...ctx, token });
|
|
424
|
+
out["transition"] = name;
|
|
425
|
+
out["transitionTime"] = Number(timeStr);
|
|
426
|
+
i++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!TRANSITION_VALUES.has(s as (typeof TRANSITION_VALUES extends Set<infer T> ? T : never)))
|
|
430
|
+
fail(`invalid transition value: ${s}`, { ...ctx, token });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (key === "waypoints") val = parseWaypoints(String(val));
|
|
434
|
+
else if (OBJECT_KEYS.has(key)) val = parseProps(String(val));
|
|
435
|
+
else if (key === "on") val = parseOnSpec(String(val));
|
|
436
|
+
else if (key === "effects") val = parseEffects(String(val));
|
|
437
|
+
else if (!RAW_STRING_KEYS.has(key)) {
|
|
438
|
+
const strVal = String(val);
|
|
439
|
+
// If the value looks like an inline JSON object/array, parse it as props
|
|
440
|
+
if (strVal.startsWith("{") || strVal.startsWith("[")) {
|
|
441
|
+
val = parseProps(strVal);
|
|
442
|
+
} else {
|
|
443
|
+
val = parseNumberMaybe(strVal);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
out[key] = val;
|
|
447
|
+
i++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
fail(`unrecognized token: ${token}`, { ...ctx, token });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return out;
|
|
455
|
+
}
|