@cutcraft/kit 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1932 @@
1
+ import { Bt as resolveTheme, C as RENDERED_VALUES, Fr as capabilitiesOf, In as STYLED_KINDS, Jt as MODULE_SHADERS, Ln as TOKEN_PATTERN, S as NOT_RENDERED, Vt as themeToken, Wt as lookupAsset, Yt as collectShaders, b as MASKED_KINDS, d as collectFonts, dn as ENGINE_LAYERS, h as isBundledFont, ht as nodeSpecOf, n as src_exports, qt as resolveTimeRef, u as MODULE_FONTS, un as ELEMENTS, x as MASK_NOT_RENDERED, yt as INTERIOR_PREFIX } from "../src-CcFjz_DV.mjs";
2
+ import { GRAPHIC_BASE_PROPS, GRAPHIC_KINDS, GRAPHIC_KIND_NAMES, isIcon } from "@cutcraft/graphics";
3
+ import * as zod from "zod";
4
+ import * as React from "react";
5
+ import traverseModule from "@babel/traverse";
6
+ import * as t from "@babel/types";
7
+ import { parse } from "@babel/parser";
8
+ import * as jsxRuntime from "react/jsx-runtime";
9
+ import { transform } from "sucrase";
10
+ // src/source/edits.ts
11
+ /** Splice edits into the text, last range first, refusing overlaps. */
12
+ function applyEdits(text, edits) {
13
+ const splices = [];
14
+ for (const e of edits) if (e.kind === "move") {
15
+ splices.push({
16
+ range: e.from,
17
+ text: ""
18
+ });
19
+ splices.push({
20
+ range: e.range,
21
+ text: e.text
22
+ });
23
+ } else splices.push({
24
+ range: e.range,
25
+ text: e.text
26
+ });
27
+ splices.sort((a, b) => b.range[0] - a.range[0] || b.range[1] - a.range[1]);
28
+ let out = text;
29
+ let lastStart = Number.POSITIVE_INFINITY;
30
+ for (const s of splices) {
31
+ if (s.range[1] > lastStart) throw new Error(`overlapping edits at ${s.range[0]}..${s.range[1]}`);
32
+ out = out.slice(0, s.range[0]) + s.text + out.slice(s.range[1]);
33
+ lastStart = s.range[0];
34
+ }
35
+ return out;
36
+ }
37
+ /** Widen an element range to swallow its own line(s): leading indentation and the trailing newline. */
38
+ function lineRange(text, range) {
39
+ let start = range[0];
40
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
41
+ if (/^\s*$/.test(text.slice(lineStart, start))) start = lineStart;
42
+ let end = range[1];
43
+ const rest = text.slice(end).match(/^[ \t]*\r?\n/);
44
+ if (rest) end += rest[0].length;
45
+ return [start, end];
46
+ }
47
+ // src/source/parse.ts
48
+ function parseSource(text) {
49
+ return {
50
+ text,
51
+ ast: parse(text, {
52
+ sourceType: "module",
53
+ plugins: ["jsx", "typescript"],
54
+ errorRecovery: true,
55
+ ranges: false
56
+ })
57
+ };
58
+ }
59
+ function rangeOf(node) {
60
+ return [node.start ?? 0, node.end ?? 0];
61
+ }
62
+ // src/source/index.ts
63
+ const traverse$1 = traverseModule.default ?? traverseModule;
64
+ const KIND_BY_NAME = new Map([...Object.entries(ELEMENTS), ...Object.entries(ENGINE_LAYERS)].map(([name, c]) => [name, c.kind]));
65
+ /**
66
+ * Index every vocabulary JSX element with a literal id so patches can be
67
+ * written back as text edits. Elements inside helper functions are indexed
68
+ * too; whether they are literal or generated at runtime is decided by
69
+ * `collect`, which sees how often the helper is used.
70
+ */
71
+ function buildSourceIndex(text) {
72
+ const { ast } = parseSource(text);
73
+ const elements = /* @__PURE__ */ new Map();
74
+ const calls = [];
75
+ const helpers = /* @__PURE__ */ new Map();
76
+ const anonymous = [];
77
+ const dynamic = [];
78
+ const idSites = [];
79
+ const strays = [];
80
+ let defaults = null;
81
+ let hasSchema = false;
82
+ let hasDefaultExport = false;
83
+ let compositionId = null;
84
+ let schemaFn = null;
85
+ const paramKeys = /* @__PURE__ */ new Map();
86
+ let propsName = null;
87
+ for (const node of ast.program.body) {
88
+ if (!t.isExportDefaultDeclaration(node)) continue;
89
+ const decl = node.declaration;
90
+ const fn = t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl) ? decl : null;
91
+ if (!fn) continue;
92
+ schemaFn = fn;
93
+ const param = fn.params[0];
94
+ if (t.isIdentifier(param)) propsName = param.name;
95
+ else if (t.isObjectPattern(param)) for (const p of param.properties) {
96
+ if (!t.isObjectProperty(p) || !t.isIdentifier(p.key)) continue;
97
+ const local = t.isIdentifier(p.value) ? p.value : t.isAssignmentPattern(p.value) && t.isIdentifier(p.value.left) ? p.value.left : null;
98
+ if (local) paramKeys.set(local.name, p.key.name);
99
+ }
100
+ }
101
+ const bindingOf = (expr) => {
102
+ if (t.isIdentifier(expr)) return paramKeys.get(expr.name) ?? null;
103
+ if (t.isMemberExpression(expr) && !expr.computed && t.isIdentifier(expr.object) && expr.object.name === propsName && t.isIdentifier(expr.property)) return expr.property.name;
104
+ return null;
105
+ };
106
+ const helperNames = /* @__PURE__ */ new Set();
107
+ for (const node of ast.program.body) {
108
+ if (t.isFunctionDeclaration(node) && node.id) helperNames.add(node.id.name);
109
+ if (t.isVariableDeclaration(node)) {
110
+ for (const d of node.declarations) if (t.isIdentifier(d.id) && (t.isArrowFunctionExpression(d.init) || t.isFunctionExpression(d.init))) helperNames.add(d.id.name);
111
+ }
112
+ }
113
+ traverse$1(ast, {
114
+ ExportNamedDeclaration(path) {
115
+ const decl = path.node.declaration;
116
+ if (!t.isVariableDeclaration(decl)) return;
117
+ for (const d of decl.declarations) {
118
+ if (!t.isIdentifier(d.id)) continue;
119
+ if (d.id.name === "schema") hasSchema = true;
120
+ if (d.id.name === "defaults" && t.isObjectExpression(d.init)) {
121
+ const props = /* @__PURE__ */ new Map();
122
+ for (const p of d.init.properties) {
123
+ if (!t.isObjectProperty(p)) continue;
124
+ const key = t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : null;
125
+ if (key === null) continue;
126
+ const lit = literalOf(p.value);
127
+ props.set(key, {
128
+ name: key,
129
+ range: rangeOf(p),
130
+ valueRange: rangeOf(p.value),
131
+ literal: lit.value,
132
+ isExpression: !lit.ok
133
+ });
134
+ }
135
+ defaults = {
136
+ range: rangeOf(d.init),
137
+ props
138
+ };
139
+ }
140
+ }
141
+ },
142
+ ExportDefaultDeclaration() {
143
+ hasDefaultExport = true;
144
+ },
145
+ FunctionDeclaration(path) {
146
+ if (!path.node.id || path.parentPath.isExportDefaultDeclaration()) return;
147
+ const hooks = hookCalls(path.node);
148
+ helpers.set(path.node.id.name, {
149
+ name: path.node.id.name,
150
+ range: rangeOf(path.node),
151
+ pure: hooks.length === 0,
152
+ hooks,
153
+ producesElements: namesElements(path.node)
154
+ });
155
+ },
156
+ VariableDeclarator(path) {
157
+ const init = path.node.init;
158
+ if (!t.isIdentifier(path.node.id) || !(t.isArrowFunctionExpression(init) || t.isFunctionExpression(init))) return;
159
+ if (!helperNames.has(path.node.id.name)) return;
160
+ const hooks = hookCalls(init);
161
+ helpers.set(path.node.id.name, {
162
+ name: path.node.id.name,
163
+ range: rangeOf(path.node),
164
+ pure: hooks.length === 0,
165
+ hooks,
166
+ producesElements: namesElements(init)
167
+ });
168
+ },
169
+ JSXElement(path) {
170
+ const opening = path.node.openingElement;
171
+ const name = jsxName(opening.name);
172
+ if (name === null) return;
173
+ if (/^[a-z]/.test(name)) {
174
+ const host = nearestElement(path);
175
+ if (host === "Track" || host === "Composition") strays.push({
176
+ name,
177
+ range: rangeOf(path.node),
178
+ parent: host
179
+ });
180
+ return;
181
+ }
182
+ const parent = enclosingElementId(path);
183
+ const siblingIndex = siblingIndexOf(path);
184
+ const indent = indentOf(text, path.node.start ?? 0);
185
+ if (helperNames.has(name) && !KIND_BY_NAME.has(name)) {
186
+ const callIndex = calls.filter((c) => c.helper === name).length;
187
+ calls.push({
188
+ helper: name,
189
+ callIndex,
190
+ range: rangeOf(path.node),
191
+ parent,
192
+ siblingIndex,
193
+ indent
194
+ });
195
+ return;
196
+ }
197
+ const kind = KIND_BY_NAME.get(name);
198
+ if (!kind) return;
199
+ const inSchemaFn = schemaFn !== null && path.getFunctionParent()?.node === schemaFn;
200
+ const { props, locked, bound } = attributesOf(opening, inSchemaFn);
201
+ const idSite = props.get("id");
202
+ let id = typeof idSite?.literal === "string" ? idSite.literal : null;
203
+ if (id === null && kind === "composition" && !idSite) id = "composition";
204
+ if (id === null) {
205
+ if (idSite?.isExpression && idSite.valueRange) dynamic.push({
206
+ kind,
207
+ range: rangeOf(path.node),
208
+ idText: text.slice(idSite.valueRange[0], idSite.valueRange[1])
209
+ });
210
+ const fn = path.getFunctionParent();
211
+ if (!(Boolean(fn && !fn.isExportDefaultDeclaration() && !fn.parentPath?.isExportDefaultDeclaration()) && idSite?.isExpression)) anonymous.push({
212
+ kind,
213
+ range: rangeOf(path.node),
214
+ reason: idSite ? "id must be a string literal" : "missing id"
215
+ });
216
+ return;
217
+ }
218
+ if (kind === "composition") compositionId = id;
219
+ if (idSite) idSites.push({
220
+ id,
221
+ range: idSite.range
222
+ });
223
+ const closing = path.node.closingElement;
224
+ const childrenRange = closing ? [opening.end ?? 0, closing.start ?? 0] : null;
225
+ const interior = name === "Layer" ? interiorOf(path.node, inSchemaFn) : null;
226
+ if (interior) {
227
+ for (const prop of interior.lockedProps) locked.push(`${INTERIOR_PREFIX}${prop}`);
228
+ for (const [prop, key] of Object.entries(interior.boundProps)) bound[`${INTERIOR_PREFIX}${prop}`] = key;
229
+ }
230
+ elements.set(id, {
231
+ generated: false,
232
+ id,
233
+ kind,
234
+ name,
235
+ range: rangeOf(path.node),
236
+ openingTag: rangeOf(opening),
237
+ closingTag: closing ? rangeOf(closing) : null,
238
+ childrenRange,
239
+ props,
240
+ parent,
241
+ siblingIndex,
242
+ lockedProps: locked,
243
+ boundProps: bound,
244
+ indent,
245
+ ...interior ? { interior } : {}
246
+ });
247
+ }
248
+ });
249
+ /** A tag's attributes as prop sites, with schema bindings resolved when the tag sits in the default export. */
250
+ function attributesOf(opening, inSchemaFn) {
251
+ const props = /* @__PURE__ */ new Map();
252
+ const locked = [];
253
+ const bound = {};
254
+ for (const attr of opening.attributes) {
255
+ if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) continue;
256
+ let site = propSite(attr);
257
+ const binding = inSchemaFn && site.isExpression && t.isJSXExpressionContainer(attr.value) ? bindingOf(attr.value.expression) : null;
258
+ if (binding !== null) {
259
+ site = {
260
+ ...site,
261
+ binding
262
+ };
263
+ bound[site.name] = binding;
264
+ }
265
+ props.set(site.name, site);
266
+ if (site.isExpression && binding === null) locked.push(site.name);
267
+ }
268
+ return {
269
+ props,
270
+ locked,
271
+ bound
272
+ };
273
+ }
274
+ /** The Layer's handles: exactly one element child whose tag names a file-level function. */
275
+ function interiorOf(layer, inSchemaFn) {
276
+ const elements = layer.children.filter((c) => !(t.isJSXText(c) && c.value.trim() === ""));
277
+ const only = elements.length === 1 ? elements[0] : null;
278
+ if (!only || !t.isJSXElement(only)) return null;
279
+ const name = jsxName(only.openingElement.name);
280
+ if (name === null || !helperNames.has(name) || KIND_BY_NAME.has(name)) return null;
281
+ const { props, locked, bound } = attributesOf(only.openingElement, inSchemaFn);
282
+ return {
283
+ component: name,
284
+ range: rangeOf(only),
285
+ openingTag: rangeOf(only.openingElement),
286
+ props,
287
+ lockedProps: locked,
288
+ boundProps: bound
289
+ };
290
+ }
291
+ return {
292
+ text,
293
+ ast,
294
+ elements,
295
+ calls,
296
+ helpers,
297
+ defaults,
298
+ hasSchema,
299
+ hasDefaultExport,
300
+ compositionId,
301
+ anonymous,
302
+ dynamic,
303
+ idSites,
304
+ strays
305
+ };
306
+ }
307
+ function jsxName(name) {
308
+ if (t.isJSXIdentifier(name)) return name.name;
309
+ if (t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.property)) return name.property.name;
310
+ return null;
311
+ }
312
+ function propSite(attr) {
313
+ const name = attr.name.name;
314
+ const value = attr.value;
315
+ if (!value) return {
316
+ name,
317
+ range: rangeOf(attr),
318
+ valueRange: null,
319
+ literal: true,
320
+ isExpression: false
321
+ };
322
+ if (t.isStringLiteral(value)) return {
323
+ name,
324
+ range: rangeOf(attr),
325
+ valueRange: rangeOf(value),
326
+ literal: value.value,
327
+ isExpression: false
328
+ };
329
+ if (t.isJSXExpressionContainer(value)) {
330
+ const expr = value.expression;
331
+ const lit = literalOf(expr);
332
+ return {
333
+ name,
334
+ range: rangeOf(attr),
335
+ valueRange: rangeOf(expr),
336
+ literal: lit.value,
337
+ isExpression: !lit.ok
338
+ };
339
+ }
340
+ return {
341
+ name,
342
+ range: rangeOf(attr),
343
+ valueRange: rangeOf(value),
344
+ literal: void 0,
345
+ isExpression: true
346
+ };
347
+ }
348
+ /** Evaluate a literal expression (numbers, strings, booleans, null, arrays and objects of literals, template strings without holes). */
349
+ function literalOf(node) {
350
+ if (!node) return {
351
+ ok: false,
352
+ value: void 0
353
+ };
354
+ if (t.isStringLiteral(node)) return {
355
+ ok: true,
356
+ value: node.value
357
+ };
358
+ if (t.isNumericLiteral(node)) return {
359
+ ok: true,
360
+ value: node.value
361
+ };
362
+ if (t.isBooleanLiteral(node)) return {
363
+ ok: true,
364
+ value: node.value
365
+ };
366
+ if (t.isNullLiteral(node)) return {
367
+ ok: true,
368
+ value: null
369
+ };
370
+ if (t.isTemplateLiteral(node) && node.expressions.length === 0) return {
371
+ ok: true,
372
+ value: node.quasis.map((q) => q.value.cooked ?? "").join("")
373
+ };
374
+ if (t.isUnaryExpression(node) && node.operator === "-" && t.isNumericLiteral(node.argument)) return {
375
+ ok: true,
376
+ value: -node.argument.value
377
+ };
378
+ if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isParenthesizedExpression(node)) return literalOf(node.expression);
379
+ if (t.isArrayExpression(node)) {
380
+ const out = [];
381
+ for (const el of node.elements) {
382
+ const r = literalOf(el);
383
+ if (!r.ok) return {
384
+ ok: false,
385
+ value: void 0
386
+ };
387
+ out.push(r.value);
388
+ }
389
+ return {
390
+ ok: true,
391
+ value: out
392
+ };
393
+ }
394
+ if (t.isObjectExpression(node)) {
395
+ const out = {};
396
+ for (const p of node.properties) {
397
+ if (!t.isObjectProperty(p) || p.computed) return {
398
+ ok: false,
399
+ value: void 0
400
+ };
401
+ const key = t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : null;
402
+ if (key === null) return {
403
+ ok: false,
404
+ value: void 0
405
+ };
406
+ const r = literalOf(p.value);
407
+ if (!r.ok) return {
408
+ ok: false,
409
+ value: void 0
410
+ };
411
+ out[key] = r.value;
412
+ }
413
+ return {
414
+ ok: true,
415
+ value: out
416
+ };
417
+ }
418
+ return {
419
+ ok: false,
420
+ value: void 0
421
+ };
422
+ }
423
+ function isVocabularyTag(node) {
424
+ const name = jsxName(node.openingElement.name);
425
+ return name !== null && KIND_BY_NAME.has(name);
426
+ }
427
+ /** Whether a function's JSX names any vocabulary element. */
428
+ function namesElements(fn) {
429
+ let found = false;
430
+ t.traverseFast(fn.body, (node) => {
431
+ if (!found && t.isJSXElement(node) && isVocabularyTag(node)) found = true;
432
+ });
433
+ return found;
434
+ }
435
+ /** The tag name of the JSX element this one sits in, within the same function; null at the function's edge. */
436
+ function nearestElement(path) {
437
+ let p = path.parentPath;
438
+ while (p && !t.isFunction(p.node)) {
439
+ if (t.isJSXElement(p.node)) return jsxName(p.node.openingElement.name);
440
+ p = p.parentPath;
441
+ }
442
+ return null;
443
+ }
444
+ function hookCalls(fn) {
445
+ const hooks = [];
446
+ t.traverseFast(fn.body, (node) => {
447
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee) && /^use[A-Z]/.test(node.callee.name)) hooks.push(node.callee.name);
448
+ });
449
+ return hooks;
450
+ }
451
+ /** The id of the vocabulary element this JSX sits in, looking through HTML and other components as `collect` does. */
452
+ function enclosingElementId(path) {
453
+ let p = path.parentPath;
454
+ while (p) {
455
+ if (t.isJSXElement(p.node) && !isVocabularyTag(p.node)) {
456
+ p = p.parentPath;
457
+ continue;
458
+ }
459
+ if (t.isJSXElement(p.node)) {
460
+ const attrs = p.node.openingElement.attributes;
461
+ for (const attr of attrs) if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === "id") {
462
+ if (t.isStringLiteral(attr.value)) return attr.value.value;
463
+ if (t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)) return attr.value.expression.value;
464
+ }
465
+ if (jsxName(p.node.openingElement.name) === "Composition") return "composition";
466
+ return null;
467
+ }
468
+ p = p.parentPath;
469
+ }
470
+ return null;
471
+ }
472
+ function siblingIndexOf(path) {
473
+ const parent = path.parentPath?.node;
474
+ if (!parent || !(t.isJSXElement(parent) || t.isJSXFragment(parent))) return 0;
475
+ let i = 0;
476
+ for (const child of parent.children) {
477
+ if (child === path.node) return i;
478
+ if (t.isJSXElement(child)) i += 1;
479
+ }
480
+ return i;
481
+ }
482
+ function indentOf(text, pos) {
483
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
484
+ const prefix = text.slice(lineStart, pos);
485
+ return /^\s*$/.test(prefix) ? prefix : "";
486
+ }
487
+ // src/source/print.ts
488
+ const NAME_BY_KIND = new Map(Object.entries(ELEMENTS).map(([name, c]) => [c.kind, name]));
489
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
490
+ /** JavaScript source for a JSON value, compact, double-quoted. */
491
+ function printLiteral(value) {
492
+ if (value === void 0) return "undefined";
493
+ if (typeof value === "function") return value.name || "undefined";
494
+ if (value === null) return "null";
495
+ if (typeof value === "string") return JSON.stringify(value);
496
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
497
+ if (Array.isArray(value)) return `[${value.map(printLiteral).join(", ")}]`;
498
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0);
499
+ if (entries.length === 0) return "{}";
500
+ return `{ ${entries.map(([k, v]) => `${IDENT.test(k) ? k : JSON.stringify(k)}: ${printLiteral(v)}`).join(", ")} }`;
501
+ }
502
+ /** One JSX attribute. Strings as `name="…"`, everything else as `name={…}`, `true` as a bare attribute. */
503
+ function printProp(name, value) {
504
+ if (value === void 0) return "";
505
+ if (value === true) return name;
506
+ if (typeof value === "function") return `${name}={${printLiteral(value)}}`;
507
+ if (typeof value === "string" && !value.includes("\"") && !value.includes("\n")) return `${name}="${value}"`;
508
+ return `${name}={${printLiteral(value)}}`;
509
+ }
510
+ function elementName(kind) {
511
+ return NAME_BY_KIND.get(kind) ?? kind;
512
+ }
513
+ /** A whole element as JSX text, indented with `indent`, children on their own lines. */
514
+ function printElement(spec, indent = "", step = " ") {
515
+ const name = elementName(spec.kind);
516
+ const attrs = Object.entries(spec.props).filter(([k, v]) => v !== void 0 && true).map(([k, v]) => printProp(k, v)).filter(Boolean);
517
+ const open = `${indent}<${name}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
518
+ const children = spec.children ?? [];
519
+ if (children.length === 0 && spec.text === void 0) return `${open} />`;
520
+ if (children.length === 0) return `${open}>${escapeText(spec.text ?? "")}</${name}>`;
521
+ const inner = children.map((c) => printElement(c, indent + step, step)).join("\n");
522
+ return `${open}>${spec.text !== void 0 ? `\n${indent + step}${escapeText(spec.text)}` : ""}\n${inner}\n${indent}</${name}>`;
523
+ }
524
+ function escapeText(text) {
525
+ return /[{}<>]/.test(text) ? `{${JSON.stringify(text)}}` : text;
526
+ }
527
+ // src/source/bake.ts
528
+ /**
529
+ * Replace the helper call that produced a generated element with the literal
530
+ * elements that call produced, so the editor can address them. Multicam cuts
531
+ * are baked by appending their generated clips after the generator.
532
+ */
533
+ function bake(text, id, state, index = buildSourceIndex(text)) {
534
+ const node = state.tree.byId.get(id);
535
+ if (!node) {
536
+ const generated = state.items.get(id);
537
+ return generated?.editable === "generated" && generated.parent ? bakeGeneratedChildren(text, generated.parent, state, index) : [];
538
+ }
539
+ if (node.editable !== "generated") return node.children.filter((c) => c.editable === "generated").flatMap((c) => bake(text, c.id, state, index));
540
+ const origin = node.origin;
541
+ if (!origin) return [];
542
+ const call = index.calls.find((c) => c.helper === origin.helper && c.callIndex === origin.callIndex);
543
+ if (!call) return [];
544
+ const printed = topLevelFromCall(state, origin.helper, origin.callIndex).map((n) => printElement(nodeSpecOf(n), call.indent)).join("\n");
545
+ return [{
546
+ kind: "bake",
547
+ id,
548
+ range: lineRange(text, call.range),
549
+ text: `${printed}\n`
550
+ }];
551
+ }
552
+ /** The outermost nodes a helper call produced: those whose parent is not itself from the same call. */
553
+ function topLevelFromCall(state, helper, callIndex) {
554
+ const fromCall = (n) => Boolean(n?.origin && n.origin.helper === helper && n.origin.callIndex === callIndex);
555
+ return [...state.tree.byId.values()].filter((n) => fromCall(n) && !fromCall(n.parent ? state.tree.byId.get(n.parent) : void 0));
556
+ }
557
+ function bakeGeneratedChildren(text, parentId, state, index) {
558
+ const site = index.elements.get(parentId);
559
+ if (!site) return [];
560
+ const generated = [...state.items.values()].filter((p) => p.editable === "generated" && p.parent === parentId && !state.tree.byId.has(p.id)).sort((a, b) => a.start - b.start);
561
+ const parentKind = state.tree.byId.get(parentId)?.kind ?? site.kind;
562
+ const asChildren = generated.length > 0 && generated.every((p) => capabilitiesOf(parentKind).childKinds.includes(p.kind));
563
+ const indent = asChildren ? `${site.indent} ` : site.indent;
564
+ const printed = generated.map((p) => printElement({
565
+ id: p.id,
566
+ kind: p.kind,
567
+ props: JSON.parse(JSON.stringify(p.props))
568
+ }, indent)).join("\n");
569
+ if (!asChildren) {
570
+ const end = lineRange(text, site.range)[1];
571
+ return [{
572
+ kind: "bake",
573
+ id: parentId,
574
+ range: [end, end],
575
+ text: `${printed}\n`
576
+ }];
577
+ }
578
+ if (site.childrenRange) return [{
579
+ kind: "bake",
580
+ id: parentId,
581
+ range: site.childrenRange,
582
+ text: `\n${printed}\n${site.indent}`
583
+ }];
584
+ const open = site.openingTag;
585
+ const slash = text.lastIndexOf("/>", open[1]);
586
+ return [{
587
+ kind: "bake",
588
+ id: parentId,
589
+ range: [text[slash - 1] === " " ? slash - 1 : slash, open[1]],
590
+ text: `>\n${printed}\n${site.indent}</${site.name}>`
591
+ }];
592
+ }
593
+ // src/source/fold.ts
594
+ const traverse = traverseModule.default ?? traverseModule;
595
+ /** Structural edits can introduce vocabulary tags the original file never imported. */
596
+ function elementImports(text) {
597
+ const { ast } = parseSource(text);
598
+ const missing = /* @__PURE__ */ new Set();
599
+ traverse(ast, { JSXOpeningElement(path) {
600
+ const name = path.node.name;
601
+ if (t.isJSXIdentifier(name) && Object.hasOwn(ELEMENTS, name.name) && !path.scope.hasBinding(name.name)) missing.add(name.name);
602
+ } });
603
+ if (!missing.size) return [];
604
+ const imports = ast.program.body.filter(t.isImportDeclaration);
605
+ const kitImport = imports.find((node) => node.source.value === "@cutcraft/kit" || node.source.value === "@cut/kit");
606
+ const pos = imports.at(-1)?.end ?? ast.program.directives.at(-1)?.end ?? 0;
607
+ const moduleName = kitImport?.source.value ?? "@cutcraft/kit";
608
+ return [{
609
+ kind: "insert",
610
+ id: "composition",
611
+ range: [pos, pos],
612
+ text: `\nimport { ${[...missing].sort().join(", ")} } from ${JSON.stringify(moduleName)};\n`
613
+ }];
614
+ }
615
+ /**
616
+ * Turn the patch log's net effect into text edits against the index.
617
+ * Targets without a literal site are reported, not folded; `foldToSource`
618
+ * bakes them first and folds again.
619
+ */
620
+ function fold(log, index, state) {
621
+ const edits = [];
622
+ const needsBake = [];
623
+ const skipped = [];
624
+ const text = index.text;
625
+ const groups = /* @__PURE__ */ new Map();
626
+ /**
627
+ * Queue a placed node (inserted or moved) at its insertion point. Siblings
628
+ * that are themselves placed in this net have no usable site yet, so the
629
+ * point is taken from the nearest sibling that sits in the text, and the
630
+ * group keeps the order the overlay produces: an element placed after that
631
+ * anchor goes first, one placed after a queued sibling goes right behind it.
632
+ */
633
+ const place = (id, at, print, from) => {
634
+ let anchor = at.after;
635
+ while (anchor !== null) {
636
+ const sibling = log.net.get(anchor);
637
+ if (!sibling?.place || sibling.place.parent !== at.parent) break;
638
+ anchor = sibling.place.after;
639
+ }
640
+ const point = insertionPoint(index, at.parent, anchor);
641
+ if (!point) return false;
642
+ const key = `${at.parent}\u0000${anchor ?? ""}`;
643
+ let group = groups.get(key);
644
+ if (!group) {
645
+ group = {
646
+ point,
647
+ ids: [],
648
+ texts: /* @__PURE__ */ new Map(),
649
+ moved: /* @__PURE__ */ new Map()
650
+ };
651
+ groups.set(key, group);
652
+ }
653
+ group.texts.set(id, print(point.indent));
654
+ if (from) group.moved.set(id, from);
655
+ const behind = at.after === anchor ? -1 : group.ids.indexOf(at.after);
656
+ group.ids.splice(behind + 1, 0, id);
657
+ return true;
658
+ };
659
+ /** The nearest ancestor inserted by this net: its printed subtree already carries this node. */
660
+ const insertedAncestor = (id) => {
661
+ for (let node = state.tree.byId.get(id); node && node.parent !== null; node = state.tree.byId.get(node.parent)) if (log.net.get(node.parent)?.presence === "inserted") return node.parent;
662
+ return null;
663
+ };
664
+ for (const [id, net] of log.net) {
665
+ if (net.bake) {
666
+ needsBake.push(id);
667
+ continue;
668
+ }
669
+ if (id === "composition" && net.props.has("$defaults")) {
670
+ const value = net.props.get("$defaults");
671
+ if (value === void 0) {} else if (index.defaults) edits.push({
672
+ kind: "defaults",
673
+ id,
674
+ range: index.defaults.range,
675
+ text: printLiteral(value)
676
+ });
677
+ else skipped.push({
678
+ id,
679
+ reason: "no `export const defaults` literal to rewrite"
680
+ });
681
+ net.props.delete("$defaults");
682
+ if (net.props.size === 0 && net.presence === "present" && !net.place && net.text === void 0) continue;
683
+ }
684
+ switch (net.presence) {
685
+ case "removed": {
686
+ const site = index.elements.get(id);
687
+ if (!site) {
688
+ needsBake.push(id);
689
+ break;
690
+ }
691
+ edits.push({
692
+ kind: "remove",
693
+ id,
694
+ range: lineRange(text, site.range),
695
+ text: ""
696
+ });
697
+ break;
698
+ }
699
+ case "inserted": {
700
+ if (!net.node || !net.place) {
701
+ skipped.push({
702
+ id,
703
+ reason: "insert without a node"
704
+ });
705
+ break;
706
+ }
707
+ if (insertedAncestor(id)) break;
708
+ const node = state.tree.byId.get(id) ?? net.node;
709
+ if (!place(id, net.place, (indent) => printElement(nodeSpecOf(node), indent), null)) needsBake.push(net.place.parent);
710
+ break;
711
+ }
712
+ case "present": {
713
+ const site = index.elements.get(id);
714
+ if (!site) {
715
+ needsBake.push(id);
716
+ break;
717
+ }
718
+ if (insertedAncestor(id)) {
719
+ edits.push({
720
+ kind: "remove",
721
+ id,
722
+ range: lineRange(text, site.range),
723
+ text: ""
724
+ });
725
+ break;
726
+ }
727
+ if (net.place) {
728
+ const original = text.slice(site.range[0], site.range[1]);
729
+ if (!place(id, net.place, (indent) => reindent(original, site.indent, indent), lineRange(text, site.range))) needsBake.push(net.place.parent);
730
+ }
731
+ let newAttrs = -1;
732
+ for (const [prop, value] of net.props) {
733
+ const key = site.boundProps[prop];
734
+ const handle = prop.startsWith(INTERIOR_PREFIX);
735
+ if (handle && !site.interior && key === void 0) {
736
+ skipped.push({
737
+ id,
738
+ reason: `"${id}" has no handles`
739
+ });
740
+ continue;
741
+ }
742
+ const target = handle ? {
743
+ id,
744
+ props: site.interior.props,
745
+ openingTag: site.interior.openingTag
746
+ } : site;
747
+ const e = key !== void 0 ? bindingEdit(index, id, prop, key, value, skipped) : propEdit(target, handle ? prop.slice(INTERIOR_PREFIX.length) : prop, value, text);
748
+ if (!e) continue;
749
+ if (e.kind === "prop" && e.range[0] === e.range[1]) {
750
+ const prev = edits[newAttrs];
751
+ if (prev?.kind === "prop" && prev.range[0] === e.range[0]) {
752
+ edits[newAttrs] = {
753
+ ...prev,
754
+ text: prev.text + e.text
755
+ };
756
+ continue;
757
+ }
758
+ newAttrs = edits.length;
759
+ }
760
+ edits.push(e);
761
+ }
762
+ if (net.text !== void 0) {
763
+ const e = textEdit(site, net.text);
764
+ if (e) edits.push(e);
765
+ else skipped.push({
766
+ id,
767
+ reason: "cannot set text on this element"
768
+ });
769
+ }
770
+ break;
771
+ }
772
+ }
773
+ }
774
+ for (const group of groups.values()) {
775
+ const printed = group.ids.map((id) => group.texts.get(id)).join("\n");
776
+ edits.push(insertEdit(index, group.ids[0], group.point, printed));
777
+ for (const [id, from] of group.moved) edits.push({
778
+ kind: "remove",
779
+ id,
780
+ range: from,
781
+ text: ""
782
+ });
783
+ }
784
+ return {
785
+ edits: lastWriteWins(edits),
786
+ needsBake: [...new Set(needsBake)],
787
+ skipped
788
+ };
789
+ }
790
+ function lastWriteWins(edits) {
791
+ const byRange = /* @__PURE__ */ new Map();
792
+ const keep = [];
793
+ for (const e of edits) {
794
+ if (e.kind !== "defaults") {
795
+ keep.push(e);
796
+ continue;
797
+ }
798
+ const k = `${e.range[0]}:${e.range[1]}`;
799
+ const prev = byRange.get(k);
800
+ if (prev !== void 0) keep[prev] = e;
801
+ else {
802
+ byRange.set(k, keep.length);
803
+ keep.push(e);
804
+ }
805
+ }
806
+ return keep;
807
+ }
808
+ /** Fold with baking: generated targets are baked into literals, the file is re-indexed, and the fold is retried. */
809
+ function foldToSource(text, log, state, maxRounds = 4) {
810
+ let current = text;
811
+ const baked = [];
812
+ let allEdits = [];
813
+ for (let round = 0; round < maxRounds; round++) {
814
+ const index = buildSourceIndex(current);
815
+ const { edits, needsBake, skipped } = fold(log, index, state);
816
+ if (needsBake.length === 0) {
817
+ allEdits = [...allEdits, ...edits];
818
+ const folded = applyEdits(current, edits);
819
+ const imports = elementImports(folded);
820
+ return {
821
+ text: applyEdits(folded, imports),
822
+ edits: [...allEdits, ...imports],
823
+ baked,
824
+ skipped
825
+ };
826
+ }
827
+ let bakeEdits = [];
828
+ for (const id of needsBake) {
829
+ const result = bake(current, id, state, index);
830
+ if (result.length === 0) return {
831
+ text: applyEdits(current, edits),
832
+ edits: [...allEdits, ...edits],
833
+ baked,
834
+ skipped: [...skipped, {
835
+ id,
836
+ reason: "cannot bake"
837
+ }]
838
+ };
839
+ bakeEdits = [...bakeEdits, ...result];
840
+ baked.push(id);
841
+ }
842
+ current = applyEdits(current, dedupe(bakeEdits));
843
+ allEdits = [...allEdits, ...bakeEdits];
844
+ }
845
+ return {
846
+ text: current,
847
+ edits: allEdits,
848
+ baked,
849
+ skipped: [{
850
+ id: "*",
851
+ reason: "bake did not converge"
852
+ }]
853
+ };
854
+ }
855
+ function dedupe(edits) {
856
+ const seen = /* @__PURE__ */ new Set();
857
+ return edits.filter((e) => {
858
+ const k = `${e.range[0]}:${e.range[1]}`;
859
+ if (seen.has(k)) return false;
860
+ seen.add(k);
861
+ return true;
862
+ });
863
+ }
864
+ function insertionPoint(index, parentId, after) {
865
+ const text = index.text;
866
+ const parent = index.elements.get(parentId);
867
+ if (!parent) return null;
868
+ const step = " ";
869
+ if (after !== null) {
870
+ const sibling = index.elements.get(after);
871
+ if (!sibling || sibling.parent !== parentId) return null;
872
+ return {
873
+ pos: lineRange(text, sibling.range)[1],
874
+ indent: sibling.indent,
875
+ wrap: (p) => `${p}\n`
876
+ };
877
+ }
878
+ if (parent.childrenRange) {
879
+ const afterOpen = parent.openingTag[1];
880
+ const nl = text.slice(afterOpen).match(/^[ \t]*\r?\n/);
881
+ const childIndent = parent.indent + step;
882
+ if (nl) return {
883
+ pos: afterOpen + nl[0].length,
884
+ indent: childIndent,
885
+ wrap: (p) => `${p}\n`
886
+ };
887
+ return {
888
+ pos: afterOpen,
889
+ indent: childIndent,
890
+ wrap: (p) => `\n${p}\n${parent.indent}`
891
+ };
892
+ }
893
+ const open = parent.openingTag;
894
+ const slash = text.lastIndexOf("/>", open[1]);
895
+ if (slash < open[0]) return null;
896
+ return {
897
+ pos: slash,
898
+ indent: parent.indent + step,
899
+ wrap: (p) => `>\n${p}\n${parent.indent}</${parent.name}>`,
900
+ convertsParent: parent
901
+ };
902
+ }
903
+ function insertEdit(index, id, target, printed) {
904
+ if (target.convertsParent) {
905
+ const open = target.convertsParent.openingTag;
906
+ const before = index.text.slice(open[1] - 2, open[1]) === "/>" ? open[1] - 2 : target.pos;
907
+ return {
908
+ kind: "insert",
909
+ id,
910
+ range: [index.text[before - 1] === " " ? before - 1 : before, open[1]],
911
+ text: target.wrap(printed)
912
+ };
913
+ }
914
+ return {
915
+ kind: "insert",
916
+ id,
917
+ range: [target.pos, target.pos],
918
+ text: target.wrap(printed)
919
+ };
920
+ }
921
+ /**
922
+ * A prop bound to a `schema` key keeps its reference; the value goes into
923
+ * the `defaults` literal, so every element reading that key follows. Two
924
+ * elements writing the same key in one fold would collide on the same
925
+ * range, so the later write wins and the earlier edit is dropped.
926
+ */
927
+ function bindingEdit(index, id, prop, key, value, skipped) {
928
+ const target = index.defaults?.props.get(key);
929
+ if (value === void 0) {
930
+ skipped.push({
931
+ id,
932
+ reason: `cannot unset "${prop}": it is bound to defaults.${key}`
933
+ });
934
+ return null;
935
+ }
936
+ if (!target?.valueRange) {
937
+ skipped.push({
938
+ id,
939
+ reason: `"${prop}" is bound to defaults.${key}, which has no literal`
940
+ });
941
+ return null;
942
+ }
943
+ return {
944
+ kind: "defaults",
945
+ id,
946
+ range: target.valueRange,
947
+ text: printLiteral(value)
948
+ };
949
+ }
950
+ function propEdit(site, prop, value, text) {
951
+ const existing = site.props.get(prop);
952
+ if (value === void 0) {
953
+ if (!existing) return null;
954
+ let start = existing.range[0];
955
+ while (start > site.openingTag[0] && /\s/.test(text[start - 1])) start -= 1;
956
+ return {
957
+ kind: "prop",
958
+ id: site.id,
959
+ prop,
960
+ range: [start, existing.range[1]],
961
+ text: ""
962
+ };
963
+ }
964
+ if (existing) {
965
+ if (existing.valueRange === null) return {
966
+ kind: "prop",
967
+ id: site.id,
968
+ prop,
969
+ range: existing.range,
970
+ text: printProp(prop, value)
971
+ };
972
+ const asString = typeof value === "string" && text[existing.valueRange[0]] === "\"" && !value.includes("\"") && !value.includes("\n");
973
+ const replacement = asString ? `"${value}"` : printLiteral(value);
974
+ if (asString) return {
975
+ kind: "prop",
976
+ id: site.id,
977
+ prop,
978
+ range: existing.valueRange,
979
+ text: replacement
980
+ };
981
+ const r = existing.valueRange;
982
+ const wrapped = text[r[0] - 1] === "{" && text[r[1]] === "}";
983
+ if (wrapped && typeof value === "string" && !value.includes("\"") && !value.includes("\n")) return {
984
+ kind: "prop",
985
+ id: site.id,
986
+ prop,
987
+ range: [r[0] - 1, r[1] + 1],
988
+ text: `"${value}"`
989
+ };
990
+ if (wrapped) return {
991
+ kind: "prop",
992
+ id: site.id,
993
+ prop,
994
+ range: r,
995
+ text: replacement
996
+ };
997
+ return {
998
+ kind: "prop",
999
+ id: site.id,
1000
+ prop,
1001
+ range: r,
1002
+ text: `{${replacement}}`
1003
+ };
1004
+ }
1005
+ const open = site.openingTag;
1006
+ let pos = text.slice(open[1] - 2, open[1]) === "/>" ? open[1] - 2 : open[1] - 1;
1007
+ while (pos > open[0] && /\s/.test(text[pos - 1])) pos -= 1;
1008
+ return {
1009
+ kind: "prop",
1010
+ id: site.id,
1011
+ prop,
1012
+ range: [pos, pos],
1013
+ text: ` ${printProp(prop, value)}`
1014
+ };
1015
+ }
1016
+ function textEdit(site, value) {
1017
+ if (site.childrenRange) return {
1018
+ kind: "text",
1019
+ id: site.id,
1020
+ range: site.childrenRange,
1021
+ text: value
1022
+ };
1023
+ const open = site.openingTag;
1024
+ const slash = site.range[1] - 2;
1025
+ let start = slash;
1026
+ while (start > open[0] && /\s/.test(site.indent) && /\s/.test(value[start - 1] ?? "")) start -= 1;
1027
+ return {
1028
+ kind: "text",
1029
+ id: site.id,
1030
+ range: [slash, site.range[1]],
1031
+ text: `>${value}</${site.name}>`
1032
+ };
1033
+ }
1034
+ function reindent(source, from, to) {
1035
+ if (from === to) return source;
1036
+ return source.split("\n").map((line, i) => i === 0 ? line : line.startsWith(from) ? to + line.slice(from.length) : line).join("\n");
1037
+ }
1038
+ // src/source/lint/rules/fonts.ts
1039
+ const DECLARERS = /* @__PURE__ */ new Set([
1040
+ "font",
1041
+ "googleFont",
1042
+ "defineEffect",
1043
+ "defineTransition"
1044
+ ]);
1045
+ /** Declarations whose arguments hold code (a zod schema): only their name and shader need be literal. */
1046
+ const SHADERS = /* @__PURE__ */ new Set(["defineEffect", "defineTransition"]);
1047
+ /** Calls to the kit's declarations (`font()`, `googleFont()`, `defineEffect()`, `defineTransition()`), under whatever local names the file imports them as. */
1048
+ function declarations(index) {
1049
+ const local = /* @__PURE__ */ new Map();
1050
+ for (const node of index.ast.program.body) {
1051
+ if (!t.isImportDeclaration(node) || node.source.value !== "@cutcraft/kit") continue;
1052
+ for (const spec of node.specifiers) {
1053
+ if (!t.isImportSpecifier(spec)) continue;
1054
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
1055
+ if (DECLARERS.has(imported)) local.set(spec.local.name, imported);
1056
+ }
1057
+ }
1058
+ if (local.size === 0) return [];
1059
+ const top = /* @__PURE__ */ new Set();
1060
+ for (const node of index.ast.program.body) {
1061
+ const decl = t.isExportNamedDeclaration(node) ? node.declaration : node;
1062
+ if (t.isVariableDeclaration(decl)) {
1063
+ for (const d of decl.declarations) if (d.init) top.add(d.init);
1064
+ }
1065
+ }
1066
+ const out = [];
1067
+ t.traverseFast(index.ast.program, (node) => {
1068
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee) && local.has(node.callee.name)) out.push({
1069
+ call: node,
1070
+ topLevel: top.has(node),
1071
+ declarer: local.get(node.callee.name)
1072
+ });
1073
+ });
1074
+ return out;
1075
+ }
1076
+ /** A value written out in the source: a string, number or boolean, or an array or object of them. */
1077
+ function literal(node) {
1078
+ if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node)) return true;
1079
+ if (t.isTemplateLiteral(node)) return node.expressions.length === 0;
1080
+ if (t.isArrayExpression(node)) return node.elements.every((e) => e !== null && literal(e));
1081
+ if (t.isObjectExpression(node)) return node.properties.every((p) => t.isObjectProperty(p) && !p.computed && (t.isIdentifier(p.key) || t.isStringLiteral(p.key)) && literal(p.value));
1082
+ return false;
1083
+ }
1084
+ /** The families a file declares with literal arguments: `font({ family: "…" })` and `googleFont("…")`. */
1085
+ function declaredFamilies(index) {
1086
+ const out = /* @__PURE__ */ new Set();
1087
+ for (const { call, declarer } of declarations(index)) {
1088
+ if (SHADERS.has(declarer)) continue;
1089
+ const [first] = call.arguments;
1090
+ if (t.isStringLiteral(first)) out.add(first.value);
1091
+ else if (t.isObjectExpression(first)) {
1092
+ for (const p of first.properties) if (t.isObjectProperty(p) && t.isIdentifier(p.key, { name: "family" }) && t.isStringLiteral(p.value)) out.add(p.value.value);
1093
+ }
1094
+ }
1095
+ return out;
1096
+ }
1097
+ /** A `name` and `shader` written out in a `defineEffect()` / `defineTransition()` call's options. */
1098
+ function literalShader(call) {
1099
+ const [options] = call.arguments;
1100
+ if (!t.isObjectExpression(options)) return false;
1101
+ const field = (key) => options.properties.find((p) => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: key }))?.value;
1102
+ const name = field("name");
1103
+ const shader = field("shader");
1104
+ return !!name && !!shader && literal(name) && literal(shader);
1105
+ }
1106
+ /**
1107
+ * The kit's declarations (`font()`, `googleFont()`, `defineEffect()`, `defineTransition()`) at the top of the file,
1108
+ * with what names them written out, where the editor and lint can read them.
1109
+ */
1110
+ const declaration = {
1111
+ name: "declaration",
1112
+ check: (index) => declarations(index).flatMap(({ call, topLevel, declarer }) => {
1113
+ const name = call.callee.name;
1114
+ if (!topLevel) return [{
1115
+ rule: "declaration",
1116
+ severity: "warning",
1117
+ range: rangeOf(call),
1118
+ message: `${name}() inside a function or expression is hidden from the editor; declare it at the top of the file: const x = ${name}(…)`
1119
+ }];
1120
+ if (SHADERS.has(declarer) ? !literalShader(call) : !call.arguments.every(literal)) return [{
1121
+ rule: "declaration",
1122
+ severity: "warning",
1123
+ range: rangeOf(call),
1124
+ message: SHADERS.has(declarer) ? `${name}() takes a literal name and shader, so the editor and lint can read them from the source` : `${name}() takes literal arguments, so the editor and lint can read the font from the source`
1125
+ }];
1126
+ return [];
1127
+ })
1128
+ };
1129
+ // src/source/lint/rules/free-code.ts
1130
+ /** HTML straight in a `<Track>` or `<Composition>`: the resolver walks past it and nothing draws it. */
1131
+ const strayHtml = {
1132
+ name: "stray-html",
1133
+ check: (index) => index.strays.map(({ name, range, parent }) => ({
1134
+ rule: "stray-html",
1135
+ severity: "warning",
1136
+ range,
1137
+ message: `<${name}> sits straight in a <${parent}>, where nothing draws it; put free code inside a <Layer> (any React, CSS or WebGL renders there)`
1138
+ }))
1139
+ };
1140
+ /**
1141
+ * Free code renders frame by frame against the video's own clock. `useT()`, requestAnimationFrame, Date, performance
1142
+ * and CSS animations follow it; timers, media elements playing on their own and canvases kept on the CPU do not.
1143
+ */
1144
+ /** Libraries with a clock of their own, by module: the imports that run on it, and the adapter that puts them on the video's. */
1145
+ const OWN_CLOCK = {
1146
+ "@react-three/fiber": {
1147
+ names: ["Canvas"],
1148
+ use: "<ThreeScene> from @cutcraft/graphics/three"
1149
+ },
1150
+ motion: {
1151
+ names: ["animate"],
1152
+ use: "useTimeline from @cutcraft/graphics/motion"
1153
+ },
1154
+ "motion/react": {
1155
+ names: ["animate", "useAnimate"],
1156
+ use: "useTimeline or useLayerTime from @cutcraft/graphics/motion"
1157
+ },
1158
+ "framer-motion": {
1159
+ names: ["animate", "useAnimate"],
1160
+ use: "useTimeline or useLayerTime from @cutcraft/graphics/motion"
1161
+ }
1162
+ };
1163
+ /** Props that start a Motion component's own animation. */
1164
+ const MOTION_PLAYS = /* @__PURE__ */ new Set(["animate", "whileInView"]);
1165
+ const freeCodeClock = {
1166
+ name: "free-code-clock",
1167
+ check: (index) => {
1168
+ const out = [];
1169
+ const warn = (node, message) => {
1170
+ out.push({
1171
+ rule: "free-code-clock",
1172
+ severity: "warning",
1173
+ range: rangeOf(node),
1174
+ message
1175
+ });
1176
+ };
1177
+ const ownClock = /* @__PURE__ */ new Map();
1178
+ const motionFactories = /* @__PURE__ */ new Set();
1179
+ for (const node of index.ast.program.body) {
1180
+ if (!t.isImportDeclaration(node)) continue;
1181
+ const lib = OWN_CLOCK[node.source.value];
1182
+ if (!lib) continue;
1183
+ for (const spec of node.specifiers) {
1184
+ if (!t.isImportSpecifier(spec)) continue;
1185
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
1186
+ if (lib.names.includes(imported)) ownClock.set(spec.local.name, {
1187
+ imported,
1188
+ use: lib.use
1189
+ });
1190
+ if (node.source.value !== "motion" && (imported === "motion" || imported === "m")) motionFactories.add(spec.local.name);
1191
+ }
1192
+ }
1193
+ t.traverseFast(index.ast.program, (node) => {
1194
+ if (t.isCallExpression(node)) {
1195
+ const callee = node.callee;
1196
+ const name = t.isIdentifier(callee) ? callee.name : t.isMemberExpression(callee) && t.isIdentifier(callee.property) ? callee.property.name : null;
1197
+ if (name === "setTimeout" || name === "setInterval") warn(node, `${name} runs on the wall clock, so an export would not repeat it frame for frame; derive the change from useT() instead`);
1198
+ const own = t.isIdentifier(callee) ? ownClock.get(callee.name) : void 0;
1199
+ if (own) warn(node, `${own.imported}() plays on its library's own clock, so a scrub or an export does not follow it; use ${own.use}`);
1200
+ } else if (t.isJSXOpeningElement(node)) {
1201
+ const el = node.name;
1202
+ if (t.isJSXIdentifier(el) && (el.name === "video" || el.name === "audio")) warn(node, `<${el.name}> plays on its own clock and its sound is not in the export; use a <Clip> or <Audio>, and useClipFrame for a clip's picture in free code`);
1203
+ else if (t.isJSXIdentifier(el) && ownClock.has(el.name)) warn(node, `<${el.name}> draws on its own clock, so a scrub or an export does not follow it; use ${ownClock.get(el.name).use}`);
1204
+ else if (t.isJSXMemberExpression(el) && t.isJSXIdentifier(el.object) && motionFactories.has(el.object.name)) {
1205
+ const plays = node.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && MOTION_PLAYS.has(a.name.name));
1206
+ if (plays) warn(plays, `<${el.object.name}.${el.property.name}> animates on Motion's own clock, so a scrub or an export does not follow it; drive it with useTimeline or useLayerTime from @cutcraft/graphics/motion`);
1207
+ }
1208
+ } else if (t.isObjectProperty(node) && t.isIdentifier(node.key) && node.key.name === "willReadFrequently" && t.isBooleanLiteral(node.value, { value: true })) warn(node, "a canvas with willReadFrequently is kept on the CPU and the export does not capture it; leave the option out");
1209
+ });
1210
+ return out;
1211
+ }
1212
+ };
1213
+ // src/source/lint/rules/graphic.ts
1214
+ /** `<Graphic>` kinds and their literal props, and `icon:` sticker names, checked before anything renders. */
1215
+ const graphicProps = {
1216
+ name: "graphic-props",
1217
+ check: (index) => {
1218
+ const out = [];
1219
+ for (const site of index.elements.values()) {
1220
+ if (site.kind === "sticker") {
1221
+ const src = site.props.get("src");
1222
+ if (typeof src?.literal === "string" && src.literal.startsWith("icon:") && !isIcon(src.literal.slice(5))) out.push({
1223
+ rule: "graphic-props",
1224
+ severity: "warning",
1225
+ message: `"${site.id}": no Lucide icon "${src.literal.slice(5)}"; the sticker shows a placeholder`,
1226
+ range: src.range,
1227
+ id: site.id,
1228
+ prop: "src"
1229
+ });
1230
+ continue;
1231
+ }
1232
+ if (site.kind !== "graphic") continue;
1233
+ const kind = site.props.get("kind");
1234
+ if (typeof kind?.literal !== "string") continue;
1235
+ if (!GRAPHIC_KIND_NAMES.includes(kind.literal)) {
1236
+ const near = GRAPHIC_KIND_NAMES.filter((k) => k.includes(kind.literal) || kind.literal.includes(k.split("-")[0]));
1237
+ out.push({
1238
+ rule: "graphic-props",
1239
+ severity: "error",
1240
+ message: `"${site.id}": no graphic kind "${kind.literal}"${near.length ? `; did you mean ${near.map((k) => `"${k}"`).join(", ")}?` : ` (see the graphics reference)`}`,
1241
+ range: kind.range,
1242
+ id: site.id,
1243
+ prop: "kind"
1244
+ });
1245
+ continue;
1246
+ }
1247
+ const shape = GRAPHIC_KINDS[kind.literal].shape;
1248
+ for (const [name, prop] of site.props) {
1249
+ if (name === "kind" || GRAPHIC_BASE_PROPS.includes(name)) continue;
1250
+ if (!Object.hasOwn(shape, name)) {
1251
+ out.push({
1252
+ rule: "graphic-props",
1253
+ severity: "error",
1254
+ message: `"${site.id}": ${kind.literal} has no prop "${name}" (${Object.keys(shape).join(", ")})`,
1255
+ range: prop.range,
1256
+ id: site.id,
1257
+ prop: name
1258
+ });
1259
+ continue;
1260
+ }
1261
+ if (prop.literal === void 0 || prop.isExpression && prop.binding !== void 0) continue;
1262
+ const result = shape[name].safeParse(prop.literal);
1263
+ if (!result.success) out.push({
1264
+ rule: "graphic-props",
1265
+ severity: "error",
1266
+ message: `"${site.id}.${name}": ${result.error.issues[0]?.message ?? "invalid"}`,
1267
+ range: prop.range,
1268
+ id: site.id,
1269
+ prop: name
1270
+ });
1271
+ }
1272
+ }
1273
+ return out;
1274
+ }
1275
+ };
1276
+ /** `<Graphic>` is deprecated: each one warns, pointing to the free code that replaces it. */
1277
+ const deprecatedGraphic = {
1278
+ name: "deprecated-graphic",
1279
+ check: (index) => {
1280
+ const out = [];
1281
+ for (const site of index.elements.values()) {
1282
+ if (site.kind !== "graphic") continue;
1283
+ const kind = site.props.get("kind")?.literal;
1284
+ out.push({
1285
+ rule: "deprecated-graphic",
1286
+ severity: "warning",
1287
+ message: `"${site.id}": <Graphic${typeof kind === "string" ? ` kind="${kind}"` : ""}> is deprecated and will be removed; draw it with <Motion>, <Three> or a <Layer> (see the examples)`,
1288
+ range: site.openingTag,
1289
+ id: site.id
1290
+ });
1291
+ }
1292
+ return out;
1293
+ }
1294
+ };
1295
+ // src/source/lint/rules/not-rendered.ts
1296
+ /** Values that ask for nothing, so writing them is never reported. */
1297
+ const OFF = [
1298
+ false,
1299
+ 0,
1300
+ "none",
1301
+ "all"
1302
+ ];
1303
+ /** `prop` or `prop.field`: the written literal, or undefined when it is absent or not a literal. */
1304
+ function literalAt(site, path) {
1305
+ const [prop, ...fields] = path.split(".");
1306
+ const p = site.props.get(prop);
1307
+ if (!p) return null;
1308
+ let value = p.literal;
1309
+ for (const f of fields) value = value !== null && typeof value === "object" && !Array.isArray(value) ? value[f] : void 0;
1310
+ return {
1311
+ value,
1312
+ range: p.range,
1313
+ prop
1314
+ };
1315
+ }
1316
+ /** Props, values and elements the schemas accept but the player and renderer do not draw or play; see `jsx/rendering.ts`. */
1317
+ const notRendered = {
1318
+ name: "not-rendered",
1319
+ check: (index) => {
1320
+ const out = [];
1321
+ const warn = (site, what, reason, range = site.range, prop) => out.push({
1322
+ rule: "not-rendered",
1323
+ severity: "warning",
1324
+ message: `"${site.id}" <${site.name}>${what ? ` ${what}` : ""} ${reason}`,
1325
+ range,
1326
+ id: site.id,
1327
+ ...prop ? { prop } : {}
1328
+ });
1329
+ const masks = /* @__PURE__ */ new Map();
1330
+ for (const site of index.elements.values()) {
1331
+ for (const v of RENDERED_VALUES) {
1332
+ if (v.kind !== site.kind) continue;
1333
+ const at = literalAt(site, v.prop);
1334
+ if (typeof at?.value !== "string" || v.values.includes(at.value)) continue;
1335
+ if (NOT_RENDERED.some((n) => n.kind === site.kind && n.prop === v.prop && n.value === at.value)) continue;
1336
+ warn(site, `${v.prop} "${at.value}"`, v.reason, at.range, at.prop);
1337
+ }
1338
+ for (const n of NOT_RENDERED) {
1339
+ if (n.kind !== "*" && n.kind !== site.kind) continue;
1340
+ if (n.parent && index.elements.get(site.parent ?? "")?.kind !== n.parent) continue;
1341
+ if (!n.prop) {
1342
+ warn(site, "", n.reason);
1343
+ continue;
1344
+ }
1345
+ const at = literalAt(site, n.prop);
1346
+ if (!at || n.prop.includes(".") && at.value === void 0) continue;
1347
+ if (n.value !== void 0 ? at.value !== n.value : OFF.includes(at.value)) continue;
1348
+ warn(site, n.value !== void 0 ? `${n.prop} "${n.value}"` : n.prop, n.reason, at.range, at.prop);
1349
+ }
1350
+ if (site.kind === "mask" && site.parent) {
1351
+ const parent = index.elements.get(site.parent);
1352
+ const count = (masks.get(site.parent) ?? 0) + 1;
1353
+ masks.set(site.parent, count);
1354
+ if (parent && !MASKED_KINDS.includes(parent.kind)) warn(site, `on ${parent.kind} "${parent.id}"`, MASK_NOT_RENDERED.kind);
1355
+ else if (count > 1) warn(site, "", MASK_NOT_RENDERED.second);
1356
+ }
1357
+ }
1358
+ return out;
1359
+ }
1360
+ };
1361
+ // src/source/lint/rules/shots.ts
1362
+ /** Closer than this to a shot change, on the wrong side of it, a cut shows a few frames of the other shot. */
1363
+ const NEAR_S = .3;
1364
+ const NO_MARKERS = /* @__PURE__ */ new Map();
1365
+ const r2 = (t) => Math.round(t * 100) / 100;
1366
+ /** The written `in`/`out` as source seconds: a number or a word/shot/silence reference; undefined when absent or unresolvable. */
1367
+ function sourceTime(site, prop, asset) {
1368
+ const p = site.props.get(prop);
1369
+ if (p === void 0) return void 0;
1370
+ const t = resolveTimeRef(p.literal, asset, NO_MARKERS, {
1371
+ id: site.id,
1372
+ prop
1373
+ });
1374
+ return typeof t === "number" ? t : void 0;
1375
+ }
1376
+ /** Clips that draw a picture, with their asset's shot list. */
1377
+ function* cutClips(index, manifest, needShots = true) {
1378
+ for (const site of index.elements.values()) {
1379
+ if (site.kind !== "clip") continue;
1380
+ const src = site.props.get("src")?.literal;
1381
+ if (typeof src !== "string") continue;
1382
+ const asset = lookupAsset(manifest, src);
1383
+ if (asset && (!needShots || asset.shots?.length)) yield {
1384
+ site,
1385
+ asset,
1386
+ shots: asset.shots ?? []
1387
+ };
1388
+ }
1389
+ }
1390
+ /** A cut a few frames off a source shot change: `in` just before one shows the previous shot first, `out` just after one shows the next shot last. */
1391
+ const cutNearShot = {
1392
+ name: "cut-near-shot",
1393
+ check: (index, options) => {
1394
+ if (!options.manifest) return [];
1395
+ const out = [];
1396
+ for (const { site, asset, shots } of cutClips(index, options.manifest)) for (const prop of ["in", "out"]) {
1397
+ const p = site.props.get(prop);
1398
+ const t = sourceTime(site, prop, asset);
1399
+ if (!p || t === void 0) continue;
1400
+ for (const shot of shots) {
1401
+ const gap = prop === "in" ? shot.start - t : t - shot.start;
1402
+ if (gap <= 0 || gap >= NEAR_S || shot.i === 0) continue;
1403
+ const shown = prop === "in" ? `shot:${shot.i - 1} shows first` : `shot:${shot.i} shows last`;
1404
+ const use = prop === "in" ? `in="shot:${shot.i}"` : `out="shot:${shot.i - 1}.end"`;
1405
+ out.push({
1406
+ rule: "cut-near-shot",
1407
+ severity: "warning",
1408
+ message: `"${site.id}.${prop}" = ${r2(t)} is ${r2(gap)} s ${prop === "in" ? "before" : "after"} shot:${shot.i} starts (${r2(shot.start)}); a flash of ${shown}; use ${use}`,
1409
+ range: p.range,
1410
+ id: site.id,
1411
+ prop
1412
+ });
1413
+ }
1414
+ }
1415
+ return out;
1416
+ }
1417
+ };
1418
+ /** One crop over a source range that contains a shot change: the framing the crop was set for ends mid-clip. */
1419
+ const shotChangeInsideCrop = {
1420
+ name: "shot-change-inside-crop",
1421
+ check: (index, options) => {
1422
+ if (!options.manifest) return [];
1423
+ const out = [];
1424
+ for (const { site, asset, shots } of cutClips(index, options.manifest)) {
1425
+ const transform = site.props.get("transform");
1426
+ const literal = transform?.literal;
1427
+ if (!transform || literal === null || typeof literal !== "object" || Array.isArray(literal) || !("crop" in literal)) continue;
1428
+ const start = sourceTime(site, "in", asset) ?? 0;
1429
+ const end = sourceTime(site, "out", asset) ?? asset.duration;
1430
+ const inside = shots.filter((s) => s.start > start + NEAR_S && s.start < end - NEAR_S);
1431
+ if (inside.length === 0) continue;
1432
+ const names = inside.map((s) => `shot:${s.i} (${r2(s.start)})`).join(", ");
1433
+ out.push({
1434
+ rule: "shot-change-inside-crop",
1435
+ severity: "warning",
1436
+ message: `"${site.id}" has one crop over ${r2(start)}–${r2(end)}, but the camera cuts inside it at ${names}; the framing changes there, so split the clip at the shot (out="shot:N.end", then in="shot:N+1") and crop each part`,
1437
+ range: transform.range,
1438
+ id: site.id,
1439
+ prop: "transform"
1440
+ });
1441
+ }
1442
+ return out;
1443
+ }
1444
+ };
1445
+ /** Two clips on one track playing overlapping stretches of the same asset: the same footage twice, usually a split that left the old line in. */
1446
+ const repeatedSource = {
1447
+ name: "repeated-source",
1448
+ check: (index, options) => {
1449
+ if (!options.manifest) return [];
1450
+ const out = [];
1451
+ const byTrack = /* @__PURE__ */ new Map();
1452
+ for (const { site, asset } of cutClips(index, options.manifest, false)) {
1453
+ const start = sourceTime(site, "in", asset) ?? 0;
1454
+ const end = sourceTime(site, "out", asset) ?? asset.duration;
1455
+ const list = byTrack.get(site.parent ?? "") ?? [];
1456
+ list.push({
1457
+ site,
1458
+ src: asset.id,
1459
+ start,
1460
+ end
1461
+ });
1462
+ byTrack.set(site.parent ?? "", list);
1463
+ }
1464
+ for (const clips of byTrack.values()) for (let i = 1; i < clips.length; i++) {
1465
+ const b = clips[i];
1466
+ const a = clips.slice(0, i).find((x) => x.src === b.src && Math.min(x.end, b.end) - Math.max(x.start, b.start) > 1);
1467
+ if (!a) continue;
1468
+ const from = Math.max(a.start, b.start);
1469
+ const to = Math.min(a.end, b.end);
1470
+ out.push({
1471
+ rule: "repeated-source",
1472
+ severity: "warning",
1473
+ message: `"${b.site.id}" plays ${r2(from)}–${r2(to)} of ${b.src} again after "${a.site.id}" did; the same footage twice on one track is usually a split that kept the old clip. Remove one.`,
1474
+ range: b.site.range,
1475
+ id: b.site.id
1476
+ });
1477
+ }
1478
+ return out;
1479
+ }
1480
+ };
1481
+ // src/source/lint/rules/theme.ts
1482
+ /** Keys whose string values are colours. */
1483
+ const COLOR_KEYS = /* @__PURE__ */ new Set([
1484
+ "color",
1485
+ "fill",
1486
+ "background",
1487
+ "tint",
1488
+ "highlight",
1489
+ "upcoming",
1490
+ "frontColor",
1491
+ "sideColor"
1492
+ ]);
1493
+ /** Subtrees where a literal colour is expected even with a theme: shadows are usually black, whatever the palette. */
1494
+ const SKIP_KEYS = /* @__PURE__ */ new Set([
1495
+ "shadow",
1496
+ "shadows",
1497
+ "theme",
1498
+ "text",
1499
+ "content",
1500
+ "src"
1501
+ ]);
1502
+ const THEMED_KINDS = /* @__PURE__ */ new Set([
1503
+ ...STYLED_KINDS,
1504
+ "composition",
1505
+ "marker"
1506
+ ]);
1507
+ const NOT_COLORS = /* @__PURE__ */ new Set([
1508
+ "none",
1509
+ "transparent",
1510
+ "currentColor"
1511
+ ]);
1512
+ /** `$tokens` the theme does not define, and `style` names it has no preset for. */
1513
+ const themeRefs = {
1514
+ name: "theme-refs",
1515
+ check: (index) => {
1516
+ const theme = fileTheme(index);
1517
+ if (!theme) return [];
1518
+ const out = [];
1519
+ for (const site of index.elements.values()) {
1520
+ for (const [name, prop] of site.props) {
1521
+ if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
1522
+ walk(prop.literal, name, (value, path) => {
1523
+ const m = typeof value === "string" ? TOKEN_PATTERN.exec(value) : null;
1524
+ if (m && themeToken(theme, m[1]) === void 0) out.push({
1525
+ rule: "theme-refs",
1526
+ severity: "error",
1527
+ message: `"${site.id}.${path}": ${value} is not a theme token (${Object.keys(theme.tokens).map((k) => `$${k}`).join(", ")})`,
1528
+ range: prop.range,
1529
+ id: site.id,
1530
+ prop: name
1531
+ });
1532
+ });
1533
+ }
1534
+ const style = site.props.get("style");
1535
+ if (STYLED_KINDS.includes(site.kind) && typeof style?.literal === "string" && !theme.styles[site.kind]?.[style.literal]) {
1536
+ const known = Object.keys(theme.styles[site.kind] ?? {});
1537
+ out.push({
1538
+ rule: "theme-refs",
1539
+ severity: "error",
1540
+ message: `"${site.id}": no ${site.kind} style "${style.literal}" in the theme${known.length ? ` (${known.join(", ")})` : ""}`,
1541
+ range: style.range,
1542
+ id: site.id,
1543
+ prop: "style"
1544
+ });
1545
+ }
1546
+ }
1547
+ return out;
1548
+ }
1549
+ };
1550
+ /** With a theme declared, literal colours and fonts on graphics drift from it; name a token instead. */
1551
+ const themeLiterals = {
1552
+ name: "theme-literals",
1553
+ check: (index) => {
1554
+ const theme = fileTheme(index);
1555
+ if (!theme?.declared) return [];
1556
+ const out = [];
1557
+ const byValue = /* @__PURE__ */ new Map();
1558
+ for (const [k, v] of Object.entries(theme.palette)) if (!byValue.has(v.toLowerCase())) byValue.set(v.toLowerCase(), k);
1559
+ for (const site of index.elements.values()) {
1560
+ if (!THEMED_KINDS.has(site.kind)) continue;
1561
+ for (const [name, prop] of site.props) {
1562
+ if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
1563
+ walk(prop.literal, name, (value, path, key) => {
1564
+ if (typeof value !== "string" || value.startsWith("$")) return;
1565
+ if (key === "font") out.push({
1566
+ rule: "theme-literals",
1567
+ severity: "warning",
1568
+ message: `"${site.id}.${path}" names font "${value}"; use $heading, $body or $mono so the theme sets it`,
1569
+ range: prop.range,
1570
+ id: site.id,
1571
+ prop: name
1572
+ });
1573
+ else if (COLOR_KEYS.has(key) && !NOT_COLORS.has(value)) {
1574
+ const token = byValue.get(value.toLowerCase());
1575
+ out.push({
1576
+ rule: "theme-literals",
1577
+ severity: "warning",
1578
+ message: `"${site.id}.${path}" is the literal colour ${value}; use ${token ? `$${token}` : "a palette token"} so a theme change reaches it`,
1579
+ range: prop.range,
1580
+ id: site.id,
1581
+ prop: name
1582
+ });
1583
+ }
1584
+ });
1585
+ }
1586
+ }
1587
+ return out;
1588
+ }
1589
+ };
1590
+ /** Fonts no renderer ships and the file does not declare (`font()`, `googleFont()`): the export sets them in Inter. */
1591
+ const fontAvailable = {
1592
+ name: "font-available",
1593
+ check: (index) => {
1594
+ const theme = fileTheme(index) ?? resolveTheme(void 0);
1595
+ const listed = declaredFamilies(index);
1596
+ const out = [];
1597
+ const seen = /* @__PURE__ */ new Set();
1598
+ const check = (family, site, prop, name, path) => {
1599
+ if (isBundledFont(family) || listed.has(family) || seen.has(`${site.id}.${path}`)) return;
1600
+ seen.add(`${site.id}.${path}`);
1601
+ out.push({
1602
+ rule: "font-available",
1603
+ severity: "warning",
1604
+ message: `"${site.id}.${path}" uses font "${family}", which is neither bundled nor declared with font() or googleFont(); the export sets it in Inter`,
1605
+ range: [prop.range[0], prop.range[1]],
1606
+ id: site.id,
1607
+ prop: name
1608
+ });
1609
+ };
1610
+ for (const site of index.elements.values()) for (const [name, prop] of site.props) {
1611
+ if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
1612
+ walk(prop.literal, name, (value, path, key) => {
1613
+ if (key !== "font" || typeof value !== "string") return;
1614
+ const m = TOKEN_PATTERN.exec(value);
1615
+ const family = m ? themeToken(theme, m[1]) : value;
1616
+ if (typeof family === "string") check(family, site, prop, name, path);
1617
+ });
1618
+ }
1619
+ const comp = index.compositionId ? index.elements.get(index.compositionId) : void 0;
1620
+ const themeProp = comp?.props.get("theme");
1621
+ if (comp && themeProp && theme.declared) for (const [role, family] of Object.entries(theme.fonts)) check(family, comp, themeProp, "theme", `theme.fonts.${role}`);
1622
+ return out;
1623
+ }
1624
+ };
1625
+ /** The file's theme when the composition's `theme` is a literal (or absent); null when it is an expression. */
1626
+ function fileTheme(index) {
1627
+ const prop = (index.compositionId ? index.elements.get(index.compositionId) : void 0)?.props.get("theme");
1628
+ if (!prop) return resolveTheme(void 0);
1629
+ if (prop.literal === void 0 || typeof prop.literal !== "object" || prop.literal === null) return null;
1630
+ return resolveTheme(prop.literal);
1631
+ }
1632
+ function walk(value, path, visit, key = path) {
1633
+ if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}.${i}`, visit, key));
1634
+ else if (typeof value === "object" && value !== null) {
1635
+ for (const [k, v] of Object.entries(value)) if (!SKIP_KEYS.has(k)) walk(v, `${path}.${k}`, visit, k);
1636
+ } else visit(value, path, key);
1637
+ }
1638
+ // src/source/lint/rules/index.ts
1639
+ const TIMING_PROPS = [
1640
+ "in",
1641
+ "out",
1642
+ "duration",
1643
+ "start",
1644
+ "offset",
1645
+ "anchor",
1646
+ "at"
1647
+ ];
1648
+ const literalIds = {
1649
+ name: "literal-ids",
1650
+ check: (index) => index.anonymous.map((a) => ({
1651
+ rule: "literal-ids",
1652
+ severity: "error",
1653
+ message: `<${a.kind}>: ${a.reason}; every element needs a literal id so the editor can address it`,
1654
+ range: a.range
1655
+ }))
1656
+ };
1657
+ const uniqueIds = {
1658
+ name: "unique-ids",
1659
+ check: (index) => {
1660
+ const seen = /* @__PURE__ */ new Map();
1661
+ const out = [];
1662
+ for (const { id, range } of index.idSites) {
1663
+ const count = (seen.get(id) ?? 0) + 1;
1664
+ seen.set(id, count);
1665
+ if (count === 2) out.push({
1666
+ rule: "unique-ids",
1667
+ severity: "error",
1668
+ message: `id "${id}" is used more than once`,
1669
+ range,
1670
+ id
1671
+ });
1672
+ }
1673
+ return out;
1674
+ }
1675
+ };
1676
+ const noStartOnSequence = {
1677
+ name: "no-start-on-sequence",
1678
+ check: (index) => {
1679
+ const out = [];
1680
+ for (const site of index.elements.values()) {
1681
+ const start = site.props.get("start");
1682
+ if (!start || !site.parent) continue;
1683
+ const parent = index.elements.get(site.parent);
1684
+ if (parent?.kind !== "track") continue;
1685
+ if (parent.props.get("layout")?.literal === "free") continue;
1686
+ if (site.props.has("anchor")) continue;
1687
+ out.push({
1688
+ rule: "no-start-on-sequence",
1689
+ severity: "error",
1690
+ message: `"${site.id}" has start on a sequence track; order decides start there, or use layout="free"`,
1691
+ range: start.range,
1692
+ id: site.id,
1693
+ prop: "start"
1694
+ });
1695
+ }
1696
+ return out;
1697
+ }
1698
+ };
1699
+ /** The hooks a helper that makes elements may call: they read the player's clock, nothing else. */
1700
+ const LAYER_HOOKS = /* @__PURE__ */ new Set(["useT", "useLayer"]);
1701
+ const rules = [
1702
+ literalIds,
1703
+ uniqueIds,
1704
+ noStartOnSequence,
1705
+ {
1706
+ name: "pure-helpers",
1707
+ check: (index) => [...index.helpers.values()].filter((h) => h.producesElements && h.hooks.some((n) => !LAYER_HOOKS.has(n))).map((h) => ({
1708
+ rule: "pure-helpers",
1709
+ severity: "error",
1710
+ message: `helper ${h.name} makes timeline elements and calls ${h.hooks.filter((n) => !LAYER_HOOKS.has(n)).join(", ")}; the resolver expands it as a plain function, so keep hooks in the free code inside a <Layer>`,
1711
+ range: h.range
1712
+ }))
1713
+ },
1714
+ strayHtml,
1715
+ freeCodeClock,
1716
+ {
1717
+ name: "expression-locked",
1718
+ check: (index) => {
1719
+ const out = [];
1720
+ for (const site of index.elements.values()) for (const prop of TIMING_PROPS) {
1721
+ const p = site.props.get(prop);
1722
+ if (p?.isExpression && p.binding === void 0) out.push({
1723
+ rule: "expression-locked",
1724
+ severity: "warning",
1725
+ message: `"${site.id}.${prop}" is an expression; the timeline will show it locked`,
1726
+ range: p.range,
1727
+ id: site.id,
1728
+ prop
1729
+ });
1730
+ }
1731
+ return out;
1732
+ }
1733
+ },
1734
+ {
1735
+ name: "one-captions",
1736
+ check: (index) => {
1737
+ return [...index.elements.values()].filter((s) => s.kind === "captions").slice(1).map((s) => ({
1738
+ rule: "one-captions",
1739
+ severity: "error",
1740
+ message: "a composition has one <Captions>",
1741
+ range: s.range,
1742
+ id: s.id
1743
+ }));
1744
+ }
1745
+ },
1746
+ {
1747
+ name: "ambiguous-duration",
1748
+ check: (index) => {
1749
+ const out = [];
1750
+ for (const site of index.elements.values()) {
1751
+ if (![
1752
+ "clip",
1753
+ "audio",
1754
+ "nested"
1755
+ ].includes(site.kind)) continue;
1756
+ const d = site.props.get("duration");
1757
+ if (d && site.props.has("out")) out.push({
1758
+ rule: "ambiguous-duration",
1759
+ severity: "error",
1760
+ message: `"${site.id}" gives both out and duration`,
1761
+ range: d.range,
1762
+ id: site.id,
1763
+ prop: "duration"
1764
+ });
1765
+ }
1766
+ return out;
1767
+ }
1768
+ },
1769
+ {
1770
+ name: "file-shape",
1771
+ check: (index) => {
1772
+ const out = [];
1773
+ if (!index.hasDefaultExport) out.push({
1774
+ rule: "file-shape",
1775
+ severity: "error",
1776
+ message: "the file must default-export the video component",
1777
+ range: [0, 0]
1778
+ });
1779
+ if (index.hasSchema && !index.defaults) out.push({
1780
+ rule: "file-shape",
1781
+ severity: "error",
1782
+ message: "`export const schema` needs an `export const defaults = {…}` object literal",
1783
+ range: [0, 0]
1784
+ });
1785
+ if (!index.compositionId) out.push({
1786
+ rule: "file-shape",
1787
+ severity: "error",
1788
+ message: "no <Composition> found",
1789
+ range: [0, 0]
1790
+ });
1791
+ return out;
1792
+ }
1793
+ },
1794
+ {
1795
+ name: "missing-asset",
1796
+ check: (index, options) => {
1797
+ if (!options.manifest) return [];
1798
+ const out = [];
1799
+ for (const site of index.elements.values()) {
1800
+ const src = site.props.get("src");
1801
+ if (typeof src?.literal !== "string" || !src.literal.startsWith("asset:")) continue;
1802
+ if (!options.manifest.assets[src.literal.slice(6)]) out.push({
1803
+ rule: "missing-asset",
1804
+ severity: "error",
1805
+ message: `${src.literal} is not in the manifest`,
1806
+ range: src.range,
1807
+ id: site.id,
1808
+ prop: "src"
1809
+ });
1810
+ }
1811
+ return out;
1812
+ }
1813
+ },
1814
+ {
1815
+ name: "sequence-gap",
1816
+ check: (index) => {
1817
+ const main = firstSequenceTrack(index);
1818
+ if (!main) return [];
1819
+ const out = [];
1820
+ for (const site of index.elements.values()) {
1821
+ if (site.kind !== "gap" || site.parent !== main.id) continue;
1822
+ out.push({
1823
+ rule: "sequence-gap",
1824
+ severity: "warning",
1825
+ message: `"${site.id}" is a gap on the first sequence track "${main.id}"; nothing is under it, so those frames are black unless another track covers them`,
1826
+ range: site.range,
1827
+ id: site.id
1828
+ });
1829
+ }
1830
+ return out;
1831
+ }
1832
+ },
1833
+ {
1834
+ name: "cut-inside-word",
1835
+ check: (index, options) => {
1836
+ if (!options.manifest) return [];
1837
+ const out = [];
1838
+ for (const site of index.elements.values()) {
1839
+ if (site.kind !== "clip" && site.kind !== "audio") continue;
1840
+ const src = site.props.get("src")?.literal;
1841
+ if (typeof src !== "string") continue;
1842
+ const words = lookupAsset(options.manifest, src)?.transcript?.words;
1843
+ if (!words || words.length === 0) continue;
1844
+ for (const prop of ["in", "out"]) {
1845
+ const p = site.props.get(prop);
1846
+ if (typeof p?.literal !== "number") continue;
1847
+ const t = p.literal;
1848
+ const word = words.find((w) => t > w.start && t < w.end);
1849
+ if (!word) continue;
1850
+ const ref = prop === "in" ? `word:${word.i}` : `word:${word.i}.end`;
1851
+ out.push({
1852
+ rule: "cut-inside-word",
1853
+ severity: "warning",
1854
+ message: `"${site.id}.${prop}" = ${t} cuts inside "${word.text}" (${word.start}–${word.end}); use ${ref} or a time outside the word`,
1855
+ range: p.range,
1856
+ id: site.id,
1857
+ prop
1858
+ });
1859
+ }
1860
+ }
1861
+ return out;
1862
+ }
1863
+ },
1864
+ cutNearShot,
1865
+ shotChangeInsideCrop,
1866
+ repeatedSource,
1867
+ themeRefs,
1868
+ themeLiterals,
1869
+ fontAvailable,
1870
+ declaration,
1871
+ graphicProps,
1872
+ deprecatedGraphic,
1873
+ notRendered
1874
+ ];
1875
+ /** The composition's first track in document order that lays out as a sequence and is not hidden: the one under everything else. */
1876
+ function firstSequenceTrack(index) {
1877
+ let first = null;
1878
+ for (const site of index.elements.values()) {
1879
+ if (site.kind !== "track" || site.parent !== index.compositionId) continue;
1880
+ if (site.props.get("layout")?.literal === "free" || site.props.get("hidden")?.literal === true) continue;
1881
+ if (!first || site.siblingIndex < first.siblingIndex) first = site;
1882
+ }
1883
+ return first;
1884
+ }
1885
+ // src/source/lint/index.ts
1886
+ /** Static checks on a video file: the conventions that keep it editable by the UI. */
1887
+ function lint(text, options = {}) {
1888
+ const index = buildSourceIndex(text);
1889
+ const enabled = options.rules ? rules.filter((r) => options.rules.includes(r.name)) : rules;
1890
+ const out = [];
1891
+ for (const rule of enabled) out.push(...rule.check(index, options));
1892
+ return out.sort((a, b) => a.range[0] - b.range[0]);
1893
+ }
1894
+ // src/source/load.ts
1895
+ /** The modules a cut file may import. `@cut/kit` is the kit's old name, still accepted from older files. */
1896
+ const ALLOWED_IMPORTS = {
1897
+ react: React,
1898
+ "react/jsx-runtime": jsxRuntime,
1899
+ zod,
1900
+ "@cutcraft/kit": src_exports,
1901
+ "@cut/kit": src_exports
1902
+ };
1903
+ function loadModule(source, options = {}) {
1904
+ const { code } = transform(source, {
1905
+ transforms: [
1906
+ "typescript",
1907
+ "jsx",
1908
+ "imports"
1909
+ ],
1910
+ keepUnusedImports: true,
1911
+ jsxRuntime: "automatic",
1912
+ production: true
1913
+ });
1914
+ const module = { exports: {} };
1915
+ const offered = {
1916
+ ...ALLOWED_IMPORTS,
1917
+ ...options.imports
1918
+ };
1919
+ const require = (name) => {
1920
+ if (!(name in offered)) throw new Error(`The file imports “${name}”, which this host does not offer (it offers ${Object.keys(offered).join(", ")}).`);
1921
+ return offered[name];
1922
+ };
1923
+ let shaders = [];
1924
+ const fonts = collectFonts(() => {
1925
+ shaders = collectShaders(() => new Function("require", "exports", "module", code)(require, module.exports, module));
1926
+ });
1927
+ if (typeof module.exports.default !== "function") throw new Error("The file has no default export (the video).");
1928
+ Object.defineProperty(module.exports, MODULE_FONTS, { value: fonts });
1929
+ Object.defineProperty(module.exports, MODULE_SHADERS, { value: shaders });
1930
+ return module.exports;
1931
+ }
1932
+ export { ALLOWED_IMPORTS, applyEdits, bake, buildSourceIndex, elementName, fold, foldToSource, indentOf, lineRange, lint, literalOf, loadModule, parseSource, printElement, printLiteral, printProp, rangeOf, rules };