@cutcraft/kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/index-CLhXqOpF.d.mts +1281 -0
- package/dist/index.d.mts +48103 -0
- package/dist/index.mjs +1 -0
- package/dist/source/index-public.d.mts +236 -0
- package/dist/source/index-public.mjs +1768 -0
- package/dist/src-BsBU2JLf.mjs +5101 -0
- package/package.json +60 -3
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
import { $ as resolveTheme, Dn as NOT_RENDERED, En as MASK_NOT_RENDERED, Fn as RENDERED_VALUES, J as isBundledFont, Kt as ELEMENTS, Ln as capabilitiesOf, Tn as MASKED_KINDS, dt as INTERIOR_PREFIX, et as themeToken, gt as resolveTimeRef, mn as TOKEN_PATTERN, n as src_exports, pn as STYLED_KINDS, yt as lookupAsset, z as nodeSpecOf } from "../src-BsBU2JLf.mjs";
|
|
2
|
+
import * as zod from "zod";
|
|
3
|
+
import { GRAPHIC_BASE_PROPS, GRAPHIC_KINDS, GRAPHIC_KIND_NAMES, isIcon } from "@cutcraft/graphics";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
import traverseModule from "@babel/traverse";
|
|
7
|
+
import * as t from "@babel/types";
|
|
8
|
+
import * as jsxRuntime from "react/jsx-runtime";
|
|
9
|
+
import { transform } from "sucrase";
|
|
10
|
+
// src/source/parse.ts
|
|
11
|
+
function parseSource(text) {
|
|
12
|
+
return {
|
|
13
|
+
text,
|
|
14
|
+
ast: parse(text, {
|
|
15
|
+
sourceType: "module",
|
|
16
|
+
plugins: ["jsx", "typescript"],
|
|
17
|
+
errorRecovery: true,
|
|
18
|
+
ranges: false
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function rangeOf(node) {
|
|
23
|
+
return [node.start ?? 0, node.end ?? 0];
|
|
24
|
+
}
|
|
25
|
+
// src/source/index.ts
|
|
26
|
+
const traverse$1 = traverseModule.default ?? traverseModule;
|
|
27
|
+
const KIND_BY_NAME = new Map(Object.entries(ELEMENTS).map(([name, c]) => [name, c.kind]));
|
|
28
|
+
/**
|
|
29
|
+
* Index every vocabulary JSX element with a literal id so patches can be
|
|
30
|
+
* written back as text edits. Elements inside helper functions are indexed
|
|
31
|
+
* too; whether they are literal or generated at runtime is decided by
|
|
32
|
+
* `collect`, which sees how often the helper is used.
|
|
33
|
+
*/
|
|
34
|
+
function buildSourceIndex(text) {
|
|
35
|
+
const { ast } = parseSource(text);
|
|
36
|
+
const elements = /* @__PURE__ */ new Map();
|
|
37
|
+
const calls = [];
|
|
38
|
+
const helpers = /* @__PURE__ */ new Map();
|
|
39
|
+
const anonymous = [];
|
|
40
|
+
const dynamic = [];
|
|
41
|
+
const idSites = [];
|
|
42
|
+
const strays = [];
|
|
43
|
+
let defaults = null;
|
|
44
|
+
let hasSchema = false;
|
|
45
|
+
let hasDefaultExport = false;
|
|
46
|
+
let compositionId = null;
|
|
47
|
+
let schemaFn = null;
|
|
48
|
+
const paramKeys = /* @__PURE__ */ new Map();
|
|
49
|
+
let propsName = null;
|
|
50
|
+
for (const node of ast.program.body) {
|
|
51
|
+
if (!t.isExportDefaultDeclaration(node)) continue;
|
|
52
|
+
const decl = node.declaration;
|
|
53
|
+
const fn = t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl) ? decl : null;
|
|
54
|
+
if (!fn) continue;
|
|
55
|
+
schemaFn = fn;
|
|
56
|
+
const param = fn.params[0];
|
|
57
|
+
if (t.isIdentifier(param)) propsName = param.name;
|
|
58
|
+
else if (t.isObjectPattern(param)) for (const p of param.properties) {
|
|
59
|
+
if (!t.isObjectProperty(p) || !t.isIdentifier(p.key)) continue;
|
|
60
|
+
const local = t.isIdentifier(p.value) ? p.value : t.isAssignmentPattern(p.value) && t.isIdentifier(p.value.left) ? p.value.left : null;
|
|
61
|
+
if (local) paramKeys.set(local.name, p.key.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const bindingOf = (expr) => {
|
|
65
|
+
if (t.isIdentifier(expr)) return paramKeys.get(expr.name) ?? null;
|
|
66
|
+
if (t.isMemberExpression(expr) && !expr.computed && t.isIdentifier(expr.object) && expr.object.name === propsName && t.isIdentifier(expr.property)) return expr.property.name;
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const helperNames = /* @__PURE__ */ new Set();
|
|
70
|
+
for (const node of ast.program.body) {
|
|
71
|
+
if (t.isFunctionDeclaration(node) && node.id) helperNames.add(node.id.name);
|
|
72
|
+
if (t.isVariableDeclaration(node)) {
|
|
73
|
+
for (const d of node.declarations) if (t.isIdentifier(d.id) && (t.isArrowFunctionExpression(d.init) || t.isFunctionExpression(d.init))) helperNames.add(d.id.name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
traverse$1(ast, {
|
|
77
|
+
ExportNamedDeclaration(path) {
|
|
78
|
+
const decl = path.node.declaration;
|
|
79
|
+
if (!t.isVariableDeclaration(decl)) return;
|
|
80
|
+
for (const d of decl.declarations) {
|
|
81
|
+
if (!t.isIdentifier(d.id)) continue;
|
|
82
|
+
if (d.id.name === "schema") hasSchema = true;
|
|
83
|
+
if (d.id.name === "defaults" && t.isObjectExpression(d.init)) {
|
|
84
|
+
const props = /* @__PURE__ */ new Map();
|
|
85
|
+
for (const p of d.init.properties) {
|
|
86
|
+
if (!t.isObjectProperty(p)) continue;
|
|
87
|
+
const key = t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : null;
|
|
88
|
+
if (key === null) continue;
|
|
89
|
+
const lit = literalOf(p.value);
|
|
90
|
+
props.set(key, {
|
|
91
|
+
name: key,
|
|
92
|
+
range: rangeOf(p),
|
|
93
|
+
valueRange: rangeOf(p.value),
|
|
94
|
+
literal: lit.value,
|
|
95
|
+
isExpression: !lit.ok
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
defaults = {
|
|
99
|
+
range: rangeOf(d.init),
|
|
100
|
+
props
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
ExportDefaultDeclaration() {
|
|
106
|
+
hasDefaultExport = true;
|
|
107
|
+
},
|
|
108
|
+
FunctionDeclaration(path) {
|
|
109
|
+
if (!path.node.id || path.parentPath.isExportDefaultDeclaration()) return;
|
|
110
|
+
const hooks = hookCalls(path.node);
|
|
111
|
+
helpers.set(path.node.id.name, {
|
|
112
|
+
name: path.node.id.name,
|
|
113
|
+
range: rangeOf(path.node),
|
|
114
|
+
pure: hooks.length === 0,
|
|
115
|
+
hooks,
|
|
116
|
+
producesElements: namesElements(path.node)
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
VariableDeclarator(path) {
|
|
120
|
+
const init = path.node.init;
|
|
121
|
+
if (!t.isIdentifier(path.node.id) || !(t.isArrowFunctionExpression(init) || t.isFunctionExpression(init))) return;
|
|
122
|
+
if (!helperNames.has(path.node.id.name)) return;
|
|
123
|
+
const hooks = hookCalls(init);
|
|
124
|
+
helpers.set(path.node.id.name, {
|
|
125
|
+
name: path.node.id.name,
|
|
126
|
+
range: rangeOf(path.node),
|
|
127
|
+
pure: hooks.length === 0,
|
|
128
|
+
hooks,
|
|
129
|
+
producesElements: namesElements(init)
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
JSXElement(path) {
|
|
133
|
+
const opening = path.node.openingElement;
|
|
134
|
+
const name = jsxName(opening.name);
|
|
135
|
+
if (name === null) return;
|
|
136
|
+
if (/^[a-z]/.test(name)) {
|
|
137
|
+
const host = nearestElement(path);
|
|
138
|
+
if (host === "Track" || host === "Composition") strays.push({
|
|
139
|
+
name,
|
|
140
|
+
range: rangeOf(path.node),
|
|
141
|
+
parent: host
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const parent = enclosingElementId(path);
|
|
146
|
+
const siblingIndex = siblingIndexOf(path);
|
|
147
|
+
const indent = indentOf(text, path.node.start ?? 0);
|
|
148
|
+
if (helperNames.has(name) && !KIND_BY_NAME.has(name)) {
|
|
149
|
+
const callIndex = calls.filter((c) => c.helper === name).length;
|
|
150
|
+
calls.push({
|
|
151
|
+
helper: name,
|
|
152
|
+
callIndex,
|
|
153
|
+
range: rangeOf(path.node),
|
|
154
|
+
parent,
|
|
155
|
+
siblingIndex,
|
|
156
|
+
indent
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const kind = KIND_BY_NAME.get(name);
|
|
161
|
+
if (!kind) return;
|
|
162
|
+
const inSchemaFn = schemaFn !== null && path.getFunctionParent()?.node === schemaFn;
|
|
163
|
+
const { props, locked, bound } = attributesOf(opening, inSchemaFn);
|
|
164
|
+
const idSite = props.get("id");
|
|
165
|
+
let id = typeof idSite?.literal === "string" ? idSite.literal : null;
|
|
166
|
+
if (id === null && kind === "composition" && !idSite) id = "composition";
|
|
167
|
+
if (id === null) {
|
|
168
|
+
if (idSite?.isExpression && idSite.valueRange) dynamic.push({
|
|
169
|
+
kind,
|
|
170
|
+
range: rangeOf(path.node),
|
|
171
|
+
idText: text.slice(idSite.valueRange[0], idSite.valueRange[1])
|
|
172
|
+
});
|
|
173
|
+
const fn = path.getFunctionParent();
|
|
174
|
+
if (!(Boolean(fn && !fn.isExportDefaultDeclaration() && !fn.parentPath?.isExportDefaultDeclaration()) && idSite?.isExpression)) anonymous.push({
|
|
175
|
+
kind,
|
|
176
|
+
range: rangeOf(path.node),
|
|
177
|
+
reason: idSite ? "id must be a string literal" : "missing id"
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (kind === "composition") compositionId = id;
|
|
182
|
+
if (idSite) idSites.push({
|
|
183
|
+
id,
|
|
184
|
+
range: idSite.range
|
|
185
|
+
});
|
|
186
|
+
const closing = path.node.closingElement;
|
|
187
|
+
const childrenRange = closing ? [opening.end ?? 0, closing.start ?? 0] : null;
|
|
188
|
+
const interior = kind === "layer" ? interiorOf(path.node, inSchemaFn) : null;
|
|
189
|
+
if (interior) {
|
|
190
|
+
for (const prop of interior.lockedProps) locked.push(`${INTERIOR_PREFIX}${prop}`);
|
|
191
|
+
for (const [prop, key] of Object.entries(interior.boundProps)) bound[`${INTERIOR_PREFIX}${prop}`] = key;
|
|
192
|
+
}
|
|
193
|
+
elements.set(id, {
|
|
194
|
+
generated: false,
|
|
195
|
+
id,
|
|
196
|
+
kind,
|
|
197
|
+
name,
|
|
198
|
+
range: rangeOf(path.node),
|
|
199
|
+
openingTag: rangeOf(opening),
|
|
200
|
+
closingTag: closing ? rangeOf(closing) : null,
|
|
201
|
+
childrenRange,
|
|
202
|
+
props,
|
|
203
|
+
parent,
|
|
204
|
+
siblingIndex,
|
|
205
|
+
lockedProps: locked,
|
|
206
|
+
boundProps: bound,
|
|
207
|
+
indent,
|
|
208
|
+
...interior ? { interior } : {}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
/** A tag's attributes as prop sites, with schema bindings resolved when the tag sits in the default export. */
|
|
213
|
+
function attributesOf(opening, inSchemaFn) {
|
|
214
|
+
const props = /* @__PURE__ */ new Map();
|
|
215
|
+
const locked = [];
|
|
216
|
+
const bound = {};
|
|
217
|
+
for (const attr of opening.attributes) {
|
|
218
|
+
if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) continue;
|
|
219
|
+
let site = propSite(attr);
|
|
220
|
+
const binding = inSchemaFn && site.isExpression && t.isJSXExpressionContainer(attr.value) ? bindingOf(attr.value.expression) : null;
|
|
221
|
+
if (binding !== null) {
|
|
222
|
+
site = {
|
|
223
|
+
...site,
|
|
224
|
+
binding
|
|
225
|
+
};
|
|
226
|
+
bound[site.name] = binding;
|
|
227
|
+
}
|
|
228
|
+
props.set(site.name, site);
|
|
229
|
+
if (site.isExpression && binding === null) locked.push(site.name);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
props,
|
|
233
|
+
locked,
|
|
234
|
+
bound
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** The Layer's handles: exactly one element child whose tag names a file-level function. */
|
|
238
|
+
function interiorOf(layer, inSchemaFn) {
|
|
239
|
+
const elements = layer.children.filter((c) => !(t.isJSXText(c) && c.value.trim() === ""));
|
|
240
|
+
const only = elements.length === 1 ? elements[0] : null;
|
|
241
|
+
if (!only || !t.isJSXElement(only)) return null;
|
|
242
|
+
const name = jsxName(only.openingElement.name);
|
|
243
|
+
if (name === null || !helperNames.has(name) || KIND_BY_NAME.has(name)) return null;
|
|
244
|
+
const { props, locked, bound } = attributesOf(only.openingElement, inSchemaFn);
|
|
245
|
+
return {
|
|
246
|
+
component: name,
|
|
247
|
+
range: rangeOf(only),
|
|
248
|
+
openingTag: rangeOf(only.openingElement),
|
|
249
|
+
props,
|
|
250
|
+
lockedProps: locked,
|
|
251
|
+
boundProps: bound
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
text,
|
|
256
|
+
ast,
|
|
257
|
+
elements,
|
|
258
|
+
calls,
|
|
259
|
+
helpers,
|
|
260
|
+
defaults,
|
|
261
|
+
hasSchema,
|
|
262
|
+
hasDefaultExport,
|
|
263
|
+
compositionId,
|
|
264
|
+
anonymous,
|
|
265
|
+
dynamic,
|
|
266
|
+
idSites,
|
|
267
|
+
strays
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function jsxName(name) {
|
|
271
|
+
if (t.isJSXIdentifier(name)) return name.name;
|
|
272
|
+
if (t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.property)) return name.property.name;
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function propSite(attr) {
|
|
276
|
+
const name = attr.name.name;
|
|
277
|
+
const value = attr.value;
|
|
278
|
+
if (!value) return {
|
|
279
|
+
name,
|
|
280
|
+
range: rangeOf(attr),
|
|
281
|
+
valueRange: null,
|
|
282
|
+
literal: true,
|
|
283
|
+
isExpression: false
|
|
284
|
+
};
|
|
285
|
+
if (t.isStringLiteral(value)) return {
|
|
286
|
+
name,
|
|
287
|
+
range: rangeOf(attr),
|
|
288
|
+
valueRange: rangeOf(value),
|
|
289
|
+
literal: value.value,
|
|
290
|
+
isExpression: false
|
|
291
|
+
};
|
|
292
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
293
|
+
const expr = value.expression;
|
|
294
|
+
const lit = literalOf(expr);
|
|
295
|
+
return {
|
|
296
|
+
name,
|
|
297
|
+
range: rangeOf(attr),
|
|
298
|
+
valueRange: rangeOf(expr),
|
|
299
|
+
literal: lit.value,
|
|
300
|
+
isExpression: !lit.ok
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
name,
|
|
305
|
+
range: rangeOf(attr),
|
|
306
|
+
valueRange: rangeOf(value),
|
|
307
|
+
literal: void 0,
|
|
308
|
+
isExpression: true
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Evaluate a literal expression (numbers, strings, booleans, null, arrays and objects of literals, template strings without holes). */
|
|
312
|
+
function literalOf(node) {
|
|
313
|
+
if (!node) return {
|
|
314
|
+
ok: false,
|
|
315
|
+
value: void 0
|
|
316
|
+
};
|
|
317
|
+
if (t.isStringLiteral(node)) return {
|
|
318
|
+
ok: true,
|
|
319
|
+
value: node.value
|
|
320
|
+
};
|
|
321
|
+
if (t.isNumericLiteral(node)) return {
|
|
322
|
+
ok: true,
|
|
323
|
+
value: node.value
|
|
324
|
+
};
|
|
325
|
+
if (t.isBooleanLiteral(node)) return {
|
|
326
|
+
ok: true,
|
|
327
|
+
value: node.value
|
|
328
|
+
};
|
|
329
|
+
if (t.isNullLiteral(node)) return {
|
|
330
|
+
ok: true,
|
|
331
|
+
value: null
|
|
332
|
+
};
|
|
333
|
+
if (t.isTemplateLiteral(node) && node.expressions.length === 0) return {
|
|
334
|
+
ok: true,
|
|
335
|
+
value: node.quasis.map((q) => q.value.cooked ?? "").join("")
|
|
336
|
+
};
|
|
337
|
+
if (t.isUnaryExpression(node) && node.operator === "-" && t.isNumericLiteral(node.argument)) return {
|
|
338
|
+
ok: true,
|
|
339
|
+
value: -node.argument.value
|
|
340
|
+
};
|
|
341
|
+
if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isParenthesizedExpression(node)) return literalOf(node.expression);
|
|
342
|
+
if (t.isArrayExpression(node)) {
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const el of node.elements) {
|
|
345
|
+
const r = literalOf(el);
|
|
346
|
+
if (!r.ok) return {
|
|
347
|
+
ok: false,
|
|
348
|
+
value: void 0
|
|
349
|
+
};
|
|
350
|
+
out.push(r.value);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
value: out
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (t.isObjectExpression(node)) {
|
|
358
|
+
const out = {};
|
|
359
|
+
for (const p of node.properties) {
|
|
360
|
+
if (!t.isObjectProperty(p) || p.computed) return {
|
|
361
|
+
ok: false,
|
|
362
|
+
value: void 0
|
|
363
|
+
};
|
|
364
|
+
const key = t.isIdentifier(p.key) ? p.key.name : t.isStringLiteral(p.key) ? p.key.value : null;
|
|
365
|
+
if (key === null) return {
|
|
366
|
+
ok: false,
|
|
367
|
+
value: void 0
|
|
368
|
+
};
|
|
369
|
+
const r = literalOf(p.value);
|
|
370
|
+
if (!r.ok) return {
|
|
371
|
+
ok: false,
|
|
372
|
+
value: void 0
|
|
373
|
+
};
|
|
374
|
+
out[key] = r.value;
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
value: out
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
value: void 0
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function isVocabularyTag(node) {
|
|
387
|
+
const name = jsxName(node.openingElement.name);
|
|
388
|
+
return name !== null && KIND_BY_NAME.has(name);
|
|
389
|
+
}
|
|
390
|
+
/** Whether a function's JSX names any vocabulary element. */
|
|
391
|
+
function namesElements(fn) {
|
|
392
|
+
let found = false;
|
|
393
|
+
t.traverseFast(fn.body, (node) => {
|
|
394
|
+
if (!found && t.isJSXElement(node) && isVocabularyTag(node)) found = true;
|
|
395
|
+
});
|
|
396
|
+
return found;
|
|
397
|
+
}
|
|
398
|
+
/** The tag name of the JSX element this one sits in, within the same function; null at the function's edge. */
|
|
399
|
+
function nearestElement(path) {
|
|
400
|
+
let p = path.parentPath;
|
|
401
|
+
while (p && !t.isFunction(p.node)) {
|
|
402
|
+
if (t.isJSXElement(p.node)) return jsxName(p.node.openingElement.name);
|
|
403
|
+
p = p.parentPath;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
function hookCalls(fn) {
|
|
408
|
+
const hooks = [];
|
|
409
|
+
t.traverseFast(fn.body, (node) => {
|
|
410
|
+
if (t.isCallExpression(node) && t.isIdentifier(node.callee) && /^use[A-Z]/.test(node.callee.name)) hooks.push(node.callee.name);
|
|
411
|
+
});
|
|
412
|
+
return hooks;
|
|
413
|
+
}
|
|
414
|
+
/** The id of the vocabulary element this JSX sits in, looking through HTML and other components as `collect` does. */
|
|
415
|
+
function enclosingElementId(path) {
|
|
416
|
+
let p = path.parentPath;
|
|
417
|
+
while (p) {
|
|
418
|
+
if (t.isJSXElement(p.node) && !isVocabularyTag(p.node)) {
|
|
419
|
+
p = p.parentPath;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (t.isJSXElement(p.node)) {
|
|
423
|
+
const attrs = p.node.openingElement.attributes;
|
|
424
|
+
for (const attr of attrs) if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === "id") {
|
|
425
|
+
if (t.isStringLiteral(attr.value)) return attr.value.value;
|
|
426
|
+
if (t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)) return attr.value.expression.value;
|
|
427
|
+
}
|
|
428
|
+
if (jsxName(p.node.openingElement.name) === "Composition") return "composition";
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
p = p.parentPath;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
function siblingIndexOf(path) {
|
|
436
|
+
const parent = path.parentPath?.node;
|
|
437
|
+
if (!parent || !(t.isJSXElement(parent) || t.isJSXFragment(parent))) return 0;
|
|
438
|
+
let i = 0;
|
|
439
|
+
for (const child of parent.children) {
|
|
440
|
+
if (child === path.node) return i;
|
|
441
|
+
if (t.isJSXElement(child)) i += 1;
|
|
442
|
+
}
|
|
443
|
+
return i;
|
|
444
|
+
}
|
|
445
|
+
function indentOf(text, pos) {
|
|
446
|
+
const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
|
|
447
|
+
const prefix = text.slice(lineStart, pos);
|
|
448
|
+
return /^\s*$/.test(prefix) ? prefix : "";
|
|
449
|
+
}
|
|
450
|
+
// src/source/edits.ts
|
|
451
|
+
/** Splice edits into the text, last range first, refusing overlaps. */
|
|
452
|
+
function applyEdits(text, edits) {
|
|
453
|
+
const splices = [];
|
|
454
|
+
for (const e of edits) if (e.kind === "move") {
|
|
455
|
+
splices.push({
|
|
456
|
+
range: e.from,
|
|
457
|
+
text: ""
|
|
458
|
+
});
|
|
459
|
+
splices.push({
|
|
460
|
+
range: e.range,
|
|
461
|
+
text: e.text
|
|
462
|
+
});
|
|
463
|
+
} else splices.push({
|
|
464
|
+
range: e.range,
|
|
465
|
+
text: e.text
|
|
466
|
+
});
|
|
467
|
+
splices.sort((a, b) => b.range[0] - a.range[0] || b.range[1] - a.range[1]);
|
|
468
|
+
let out = text;
|
|
469
|
+
let lastStart = Number.POSITIVE_INFINITY;
|
|
470
|
+
for (const s of splices) {
|
|
471
|
+
if (s.range[1] > lastStart) throw new Error(`overlapping edits at ${s.range[0]}..${s.range[1]}`);
|
|
472
|
+
out = out.slice(0, s.range[0]) + s.text + out.slice(s.range[1]);
|
|
473
|
+
lastStart = s.range[0];
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
/** Widen an element range to swallow its own line(s): leading indentation and the trailing newline. */
|
|
478
|
+
function lineRange(text, range) {
|
|
479
|
+
let start = range[0];
|
|
480
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
|
481
|
+
if (/^\s*$/.test(text.slice(lineStart, start))) start = lineStart;
|
|
482
|
+
let end = range[1];
|
|
483
|
+
const rest = text.slice(end).match(/^[ \t]*\r?\n/);
|
|
484
|
+
if (rest) end += rest[0].length;
|
|
485
|
+
return [start, end];
|
|
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 || !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/free-code.ts
|
|
1039
|
+
/** HTML straight in a `<Track>` or `<Composition>`: the resolver walks past it and nothing draws it. */
|
|
1040
|
+
const strayHtml = {
|
|
1041
|
+
name: "stray-html",
|
|
1042
|
+
check: (index) => index.strays.map(({ name, range, parent }) => ({
|
|
1043
|
+
rule: "stray-html",
|
|
1044
|
+
severity: "warning",
|
|
1045
|
+
range,
|
|
1046
|
+
message: `<${name}> sits straight in a <${parent}>, where nothing draws it; put free code inside a <Layer> (any React, CSS or WebGL renders there)`
|
|
1047
|
+
}))
|
|
1048
|
+
};
|
|
1049
|
+
/**
|
|
1050
|
+
* Free code renders frame by frame against the video's own clock. `useT()`, requestAnimationFrame, Date, performance
|
|
1051
|
+
* and CSS animations follow it; timers, media elements playing on their own and canvases kept on the CPU do not.
|
|
1052
|
+
*/
|
|
1053
|
+
const freeCodeClock = {
|
|
1054
|
+
name: "free-code-clock",
|
|
1055
|
+
check: (index) => {
|
|
1056
|
+
const out = [];
|
|
1057
|
+
const warn = (node, message) => {
|
|
1058
|
+
out.push({
|
|
1059
|
+
rule: "free-code-clock",
|
|
1060
|
+
severity: "warning",
|
|
1061
|
+
range: rangeOf(node),
|
|
1062
|
+
message
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
t.traverseFast(index.ast.program, (node) => {
|
|
1066
|
+
if (t.isCallExpression(node)) {
|
|
1067
|
+
const callee = node.callee;
|
|
1068
|
+
const name = t.isIdentifier(callee) ? callee.name : t.isMemberExpression(callee) && t.isIdentifier(callee.property) ? callee.property.name : null;
|
|
1069
|
+
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`);
|
|
1070
|
+
} else if (t.isJSXOpeningElement(node) && t.isJSXIdentifier(node.name) && (node.name.name === "video" || node.name.name === "audio")) warn(node, `<${node.name.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`);
|
|
1071
|
+
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");
|
|
1072
|
+
});
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
// src/source/lint/rules/graphic.ts
|
|
1077
|
+
/** `<Graphic>` kinds and their literal props, and `icon:` sticker names, checked before anything renders. */
|
|
1078
|
+
const graphicProps = {
|
|
1079
|
+
name: "graphic-props",
|
|
1080
|
+
check: (index) => {
|
|
1081
|
+
const out = [];
|
|
1082
|
+
for (const site of index.elements.values()) {
|
|
1083
|
+
if (site.kind === "sticker") {
|
|
1084
|
+
const src = site.props.get("src");
|
|
1085
|
+
if (typeof src?.literal === "string" && src.literal.startsWith("icon:") && !isIcon(src.literal.slice(5))) out.push({
|
|
1086
|
+
rule: "graphic-props",
|
|
1087
|
+
severity: "warning",
|
|
1088
|
+
message: `"${site.id}": no Lucide icon "${src.literal.slice(5)}"; the sticker shows a placeholder`,
|
|
1089
|
+
range: src.range,
|
|
1090
|
+
id: site.id,
|
|
1091
|
+
prop: "src"
|
|
1092
|
+
});
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (site.kind !== "graphic") continue;
|
|
1096
|
+
const kind = site.props.get("kind");
|
|
1097
|
+
if (typeof kind?.literal !== "string") continue;
|
|
1098
|
+
if (!GRAPHIC_KIND_NAMES.includes(kind.literal)) {
|
|
1099
|
+
const near = GRAPHIC_KIND_NAMES.filter((k) => k.includes(kind.literal) || kind.literal.includes(k.split("-")[0]));
|
|
1100
|
+
out.push({
|
|
1101
|
+
rule: "graphic-props",
|
|
1102
|
+
severity: "error",
|
|
1103
|
+
message: `"${site.id}": no graphic kind "${kind.literal}"${near.length ? `; did you mean ${near.map((k) => `"${k}"`).join(", ")}?` : ` (see the graphics reference)`}`,
|
|
1104
|
+
range: kind.range,
|
|
1105
|
+
id: site.id,
|
|
1106
|
+
prop: "kind"
|
|
1107
|
+
});
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const shape = GRAPHIC_KINDS[kind.literal].shape;
|
|
1111
|
+
for (const [name, prop] of site.props) {
|
|
1112
|
+
if (name === "kind" || GRAPHIC_BASE_PROPS.includes(name)) continue;
|
|
1113
|
+
if (!Object.hasOwn(shape, name)) {
|
|
1114
|
+
out.push({
|
|
1115
|
+
rule: "graphic-props",
|
|
1116
|
+
severity: "error",
|
|
1117
|
+
message: `"${site.id}": ${kind.literal} has no prop "${name}" (${Object.keys(shape).join(", ")})`,
|
|
1118
|
+
range: prop.range,
|
|
1119
|
+
id: site.id,
|
|
1120
|
+
prop: name
|
|
1121
|
+
});
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (prop.literal === void 0 || prop.isExpression && prop.binding !== void 0) continue;
|
|
1125
|
+
const result = shape[name].safeParse(prop.literal);
|
|
1126
|
+
if (!result.success) out.push({
|
|
1127
|
+
rule: "graphic-props",
|
|
1128
|
+
severity: "error",
|
|
1129
|
+
message: `"${site.id}.${name}": ${result.error.issues[0]?.message ?? "invalid"}`,
|
|
1130
|
+
range: prop.range,
|
|
1131
|
+
id: site.id,
|
|
1132
|
+
prop: name
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
// src/source/lint/rules/not-rendered.ts
|
|
1140
|
+
/** Values that ask for nothing, so writing them is never reported. */
|
|
1141
|
+
const OFF = [
|
|
1142
|
+
false,
|
|
1143
|
+
0,
|
|
1144
|
+
"none",
|
|
1145
|
+
"all"
|
|
1146
|
+
];
|
|
1147
|
+
/** `prop` or `prop.field`: the written literal, or undefined when it is absent or not a literal. */
|
|
1148
|
+
function literalAt(site, path) {
|
|
1149
|
+
const [prop, ...fields] = path.split(".");
|
|
1150
|
+
const p = site.props.get(prop);
|
|
1151
|
+
if (!p) return null;
|
|
1152
|
+
let value = p.literal;
|
|
1153
|
+
for (const f of fields) value = value !== null && typeof value === "object" && !Array.isArray(value) ? value[f] : void 0;
|
|
1154
|
+
return {
|
|
1155
|
+
value,
|
|
1156
|
+
range: p.range,
|
|
1157
|
+
prop
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
/** Props, values and elements the schemas accept but the player and renderer do not draw or play; see `jsx/rendering.ts`. */
|
|
1161
|
+
const notRendered = {
|
|
1162
|
+
name: "not-rendered",
|
|
1163
|
+
check: (index) => {
|
|
1164
|
+
const out = [];
|
|
1165
|
+
const warn = (site, what, reason, range = site.range, prop) => out.push({
|
|
1166
|
+
rule: "not-rendered",
|
|
1167
|
+
severity: "warning",
|
|
1168
|
+
message: `"${site.id}" <${site.name}>${what ? ` ${what}` : ""} ${reason}`,
|
|
1169
|
+
range,
|
|
1170
|
+
id: site.id,
|
|
1171
|
+
...prop ? { prop } : {}
|
|
1172
|
+
});
|
|
1173
|
+
const masks = /* @__PURE__ */ new Map();
|
|
1174
|
+
for (const site of index.elements.values()) {
|
|
1175
|
+
for (const v of RENDERED_VALUES) {
|
|
1176
|
+
if (v.kind !== site.kind) continue;
|
|
1177
|
+
const at = literalAt(site, v.prop);
|
|
1178
|
+
if (typeof at?.value !== "string" || v.values.includes(at.value)) continue;
|
|
1179
|
+
if (NOT_RENDERED.some((n) => n.kind === site.kind && n.prop === v.prop && n.value === at.value)) continue;
|
|
1180
|
+
warn(site, `${v.prop} "${at.value}"`, v.reason, at.range, at.prop);
|
|
1181
|
+
}
|
|
1182
|
+
for (const n of NOT_RENDERED) {
|
|
1183
|
+
if (n.kind !== "*" && n.kind !== site.kind) continue;
|
|
1184
|
+
if (n.parent && index.elements.get(site.parent ?? "")?.kind !== n.parent) continue;
|
|
1185
|
+
if (!n.prop) {
|
|
1186
|
+
warn(site, "", n.reason);
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const at = literalAt(site, n.prop);
|
|
1190
|
+
if (!at || n.prop.includes(".") && at.value === void 0) continue;
|
|
1191
|
+
if (n.value !== void 0 ? at.value !== n.value : OFF.includes(at.value)) continue;
|
|
1192
|
+
warn(site, n.value !== void 0 ? `${n.prop} "${n.value}"` : n.prop, n.reason, at.range, at.prop);
|
|
1193
|
+
}
|
|
1194
|
+
if (site.kind === "mask" && site.parent) {
|
|
1195
|
+
const parent = index.elements.get(site.parent);
|
|
1196
|
+
const count = (masks.get(site.parent) ?? 0) + 1;
|
|
1197
|
+
masks.set(site.parent, count);
|
|
1198
|
+
if (parent && !MASKED_KINDS.includes(parent.kind)) warn(site, `on ${parent.kind} "${parent.id}"`, MASK_NOT_RENDERED.kind);
|
|
1199
|
+
else if (count > 1) warn(site, "", MASK_NOT_RENDERED.second);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
// src/source/lint/rules/shots.ts
|
|
1206
|
+
/** Closer than this to a shot change, on the wrong side of it, a cut shows a few frames of the other shot. */
|
|
1207
|
+
const NEAR_S = .3;
|
|
1208
|
+
const NO_MARKERS = /* @__PURE__ */ new Map();
|
|
1209
|
+
const r2 = (t) => Math.round(t * 100) / 100;
|
|
1210
|
+
/** The written `in`/`out` as source seconds: a number or a word/shot/silence reference; undefined when absent or unresolvable. */
|
|
1211
|
+
function sourceTime(site, prop, asset) {
|
|
1212
|
+
const p = site.props.get(prop);
|
|
1213
|
+
if (p === void 0) return void 0;
|
|
1214
|
+
const t = resolveTimeRef(p.literal, asset, NO_MARKERS, {
|
|
1215
|
+
id: site.id,
|
|
1216
|
+
prop
|
|
1217
|
+
});
|
|
1218
|
+
return typeof t === "number" ? t : void 0;
|
|
1219
|
+
}
|
|
1220
|
+
/** Clips that draw a picture, with their asset's shot list. */
|
|
1221
|
+
function* cutClips(index, manifest, needShots = true) {
|
|
1222
|
+
for (const site of index.elements.values()) {
|
|
1223
|
+
if (site.kind !== "clip") continue;
|
|
1224
|
+
const src = site.props.get("src")?.literal;
|
|
1225
|
+
if (typeof src !== "string") continue;
|
|
1226
|
+
const asset = lookupAsset(manifest, src);
|
|
1227
|
+
if (asset && (!needShots || asset.shots?.length)) yield {
|
|
1228
|
+
site,
|
|
1229
|
+
asset,
|
|
1230
|
+
shots: asset.shots ?? []
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
/** 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. */
|
|
1235
|
+
const cutNearShot = {
|
|
1236
|
+
name: "cut-near-shot",
|
|
1237
|
+
check: (index, options) => {
|
|
1238
|
+
if (!options.manifest) return [];
|
|
1239
|
+
const out = [];
|
|
1240
|
+
for (const { site, asset, shots } of cutClips(index, options.manifest)) for (const prop of ["in", "out"]) {
|
|
1241
|
+
const p = site.props.get(prop);
|
|
1242
|
+
const t = sourceTime(site, prop, asset);
|
|
1243
|
+
if (!p || t === void 0) continue;
|
|
1244
|
+
for (const shot of shots) {
|
|
1245
|
+
const gap = prop === "in" ? shot.start - t : t - shot.start;
|
|
1246
|
+
if (gap <= 0 || gap >= NEAR_S || shot.i === 0) continue;
|
|
1247
|
+
const shown = prop === "in" ? `shot:${shot.i - 1} shows first` : `shot:${shot.i} shows last`;
|
|
1248
|
+
const use = prop === "in" ? `in="shot:${shot.i}"` : `out="shot:${shot.i - 1}.end"`;
|
|
1249
|
+
out.push({
|
|
1250
|
+
rule: "cut-near-shot",
|
|
1251
|
+
severity: "warning",
|
|
1252
|
+
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}`,
|
|
1253
|
+
range: p.range,
|
|
1254
|
+
id: site.id,
|
|
1255
|
+
prop
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return out;
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
/** One crop over a source range that contains a shot change: the framing the crop was set for ends mid-clip. */
|
|
1263
|
+
const shotChangeInsideCrop = {
|
|
1264
|
+
name: "shot-change-inside-crop",
|
|
1265
|
+
check: (index, options) => {
|
|
1266
|
+
if (!options.manifest) return [];
|
|
1267
|
+
const out = [];
|
|
1268
|
+
for (const { site, asset, shots } of cutClips(index, options.manifest)) {
|
|
1269
|
+
const transform = site.props.get("transform");
|
|
1270
|
+
const literal = transform?.literal;
|
|
1271
|
+
if (!transform || literal === null || typeof literal !== "object" || Array.isArray(literal) || !("crop" in literal)) continue;
|
|
1272
|
+
const start = sourceTime(site, "in", asset) ?? 0;
|
|
1273
|
+
const end = sourceTime(site, "out", asset) ?? asset.duration;
|
|
1274
|
+
const inside = shots.filter((s) => s.start > start + NEAR_S && s.start < end - NEAR_S);
|
|
1275
|
+
if (inside.length === 0) continue;
|
|
1276
|
+
const names = inside.map((s) => `shot:${s.i} (${r2(s.start)})`).join(", ");
|
|
1277
|
+
out.push({
|
|
1278
|
+
rule: "shot-change-inside-crop",
|
|
1279
|
+
severity: "warning",
|
|
1280
|
+
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`,
|
|
1281
|
+
range: transform.range,
|
|
1282
|
+
id: site.id,
|
|
1283
|
+
prop: "transform"
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
/** 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. */
|
|
1290
|
+
const repeatedSource = {
|
|
1291
|
+
name: "repeated-source",
|
|
1292
|
+
check: (index, options) => {
|
|
1293
|
+
if (!options.manifest) return [];
|
|
1294
|
+
const out = [];
|
|
1295
|
+
const byTrack = /* @__PURE__ */ new Map();
|
|
1296
|
+
for (const { site, asset } of cutClips(index, options.manifest, false)) {
|
|
1297
|
+
const start = sourceTime(site, "in", asset) ?? 0;
|
|
1298
|
+
const end = sourceTime(site, "out", asset) ?? asset.duration;
|
|
1299
|
+
const list = byTrack.get(site.parent ?? "") ?? [];
|
|
1300
|
+
list.push({
|
|
1301
|
+
site,
|
|
1302
|
+
src: asset.id,
|
|
1303
|
+
start,
|
|
1304
|
+
end
|
|
1305
|
+
});
|
|
1306
|
+
byTrack.set(site.parent ?? "", list);
|
|
1307
|
+
}
|
|
1308
|
+
for (const clips of byTrack.values()) for (let i = 1; i < clips.length; i++) {
|
|
1309
|
+
const b = clips[i];
|
|
1310
|
+
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);
|
|
1311
|
+
if (!a) continue;
|
|
1312
|
+
const from = Math.max(a.start, b.start);
|
|
1313
|
+
const to = Math.min(a.end, b.end);
|
|
1314
|
+
out.push({
|
|
1315
|
+
rule: "repeated-source",
|
|
1316
|
+
severity: "warning",
|
|
1317
|
+
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.`,
|
|
1318
|
+
range: b.site.range,
|
|
1319
|
+
id: b.site.id
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
return out;
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
// src/source/lint/rules/theme.ts
|
|
1326
|
+
/** Keys whose string values are colours. */
|
|
1327
|
+
const COLOR_KEYS = /* @__PURE__ */ new Set([
|
|
1328
|
+
"color",
|
|
1329
|
+
"fill",
|
|
1330
|
+
"background",
|
|
1331
|
+
"tint",
|
|
1332
|
+
"highlight",
|
|
1333
|
+
"upcoming",
|
|
1334
|
+
"frontColor",
|
|
1335
|
+
"sideColor"
|
|
1336
|
+
]);
|
|
1337
|
+
/** Subtrees where a literal colour is expected even with a theme: shadows are usually black, whatever the palette. */
|
|
1338
|
+
const SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
1339
|
+
"shadow",
|
|
1340
|
+
"shadows",
|
|
1341
|
+
"theme",
|
|
1342
|
+
"text",
|
|
1343
|
+
"content",
|
|
1344
|
+
"src"
|
|
1345
|
+
]);
|
|
1346
|
+
const THEMED_KINDS = /* @__PURE__ */ new Set([
|
|
1347
|
+
...STYLED_KINDS,
|
|
1348
|
+
"composition",
|
|
1349
|
+
"marker"
|
|
1350
|
+
]);
|
|
1351
|
+
const NOT_COLORS = /* @__PURE__ */ new Set([
|
|
1352
|
+
"none",
|
|
1353
|
+
"transparent",
|
|
1354
|
+
"currentColor"
|
|
1355
|
+
]);
|
|
1356
|
+
/** `$tokens` the theme does not define, and `style` names it has no preset for. */
|
|
1357
|
+
const themeRefs = {
|
|
1358
|
+
name: "theme-refs",
|
|
1359
|
+
check: (index) => {
|
|
1360
|
+
const theme = fileTheme(index);
|
|
1361
|
+
if (!theme) return [];
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const site of index.elements.values()) {
|
|
1364
|
+
for (const [name, prop] of site.props) {
|
|
1365
|
+
if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
|
|
1366
|
+
walk(prop.literal, name, (value, path) => {
|
|
1367
|
+
const m = typeof value === "string" ? TOKEN_PATTERN.exec(value) : null;
|
|
1368
|
+
if (m && themeToken(theme, m[1]) === void 0) out.push({
|
|
1369
|
+
rule: "theme-refs",
|
|
1370
|
+
severity: "error",
|
|
1371
|
+
message: `"${site.id}.${path}": ${value} is not a theme token (${Object.keys(theme.tokens).map((k) => `$${k}`).join(", ")})`,
|
|
1372
|
+
range: prop.range,
|
|
1373
|
+
id: site.id,
|
|
1374
|
+
prop: name
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
const style = site.props.get("style");
|
|
1379
|
+
if (STYLED_KINDS.includes(site.kind) && typeof style?.literal === "string" && !theme.styles[site.kind]?.[style.literal]) {
|
|
1380
|
+
const known = Object.keys(theme.styles[site.kind] ?? {});
|
|
1381
|
+
out.push({
|
|
1382
|
+
rule: "theme-refs",
|
|
1383
|
+
severity: "error",
|
|
1384
|
+
message: `"${site.id}": no ${site.kind} style "${style.literal}" in the theme${known.length ? ` (${known.join(", ")})` : ""}`,
|
|
1385
|
+
range: style.range,
|
|
1386
|
+
id: site.id,
|
|
1387
|
+
prop: "style"
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return out;
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
/** With a theme declared, literal colours and fonts on graphics drift from it; name a token instead. */
|
|
1395
|
+
const themeLiterals = {
|
|
1396
|
+
name: "theme-literals",
|
|
1397
|
+
check: (index) => {
|
|
1398
|
+
const theme = fileTheme(index);
|
|
1399
|
+
if (!theme?.declared) return [];
|
|
1400
|
+
const out = [];
|
|
1401
|
+
const byValue = /* @__PURE__ */ new Map();
|
|
1402
|
+
for (const [k, v] of Object.entries(theme.palette)) if (!byValue.has(v.toLowerCase())) byValue.set(v.toLowerCase(), k);
|
|
1403
|
+
for (const site of index.elements.values()) {
|
|
1404
|
+
if (!THEMED_KINDS.has(site.kind)) continue;
|
|
1405
|
+
for (const [name, prop] of site.props) {
|
|
1406
|
+
if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
|
|
1407
|
+
walk(prop.literal, name, (value, path, key) => {
|
|
1408
|
+
if (typeof value !== "string" || value.startsWith("$")) return;
|
|
1409
|
+
if (key === "font") out.push({
|
|
1410
|
+
rule: "theme-literals",
|
|
1411
|
+
severity: "warning",
|
|
1412
|
+
message: `"${site.id}.${path}" names font "${value}"; use $heading, $body or $mono so the theme sets it`,
|
|
1413
|
+
range: prop.range,
|
|
1414
|
+
id: site.id,
|
|
1415
|
+
prop: name
|
|
1416
|
+
});
|
|
1417
|
+
else if (COLOR_KEYS.has(key) && !NOT_COLORS.has(value)) {
|
|
1418
|
+
const token = byValue.get(value.toLowerCase());
|
|
1419
|
+
out.push({
|
|
1420
|
+
rule: "theme-literals",
|
|
1421
|
+
severity: "warning",
|
|
1422
|
+
message: `"${site.id}.${path}" is the literal colour ${value}; use ${token ? `$${token}` : "a palette token"} so a theme change reaches it`,
|
|
1423
|
+
range: prop.range,
|
|
1424
|
+
id: site.id,
|
|
1425
|
+
prop: name
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
/** Fonts no renderer ships: the export sets them in Inter unless the fonts are passed to it. */
|
|
1435
|
+
const fontAvailable = {
|
|
1436
|
+
name: "font-available",
|
|
1437
|
+
check: (index) => {
|
|
1438
|
+
const theme = fileTheme(index) ?? resolveTheme(void 0);
|
|
1439
|
+
const out = [];
|
|
1440
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1441
|
+
const check = (family, site, prop, name, path) => {
|
|
1442
|
+
if (isBundledFont(family) || seen.has(`${site.id}.${path}`)) return;
|
|
1443
|
+
seen.add(`${site.id}.${path}`);
|
|
1444
|
+
out.push({
|
|
1445
|
+
rule: "font-available",
|
|
1446
|
+
severity: "warning",
|
|
1447
|
+
message: `"${site.id}.${path}" uses font "${family}", which is not bundled; the export sets it in Inter unless the font is passed to it`,
|
|
1448
|
+
range: [prop.range[0], prop.range[1]],
|
|
1449
|
+
id: site.id,
|
|
1450
|
+
prop: name
|
|
1451
|
+
});
|
|
1452
|
+
};
|
|
1453
|
+
for (const site of index.elements.values()) for (const [name, prop] of site.props) {
|
|
1454
|
+
if (SKIP_KEYS.has(name) || prop.literal === void 0) continue;
|
|
1455
|
+
walk(prop.literal, name, (value, path, key) => {
|
|
1456
|
+
if (key !== "font" || typeof value !== "string") return;
|
|
1457
|
+
const m = TOKEN_PATTERN.exec(value);
|
|
1458
|
+
const family = m ? themeToken(theme, m[1]) : value;
|
|
1459
|
+
if (typeof family === "string") check(family, site, prop, name, path);
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
const comp = index.compositionId ? index.elements.get(index.compositionId) : void 0;
|
|
1463
|
+
const themeProp = comp?.props.get("theme");
|
|
1464
|
+
if (comp && themeProp && theme.declared) for (const [role, family] of Object.entries(theme.fonts)) check(family, comp, themeProp, "theme", `theme.fonts.${role}`);
|
|
1465
|
+
return out;
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
/** The file's theme when the composition's `theme` is a literal (or absent); null when it is an expression. */
|
|
1469
|
+
function fileTheme(index) {
|
|
1470
|
+
const prop = (index.compositionId ? index.elements.get(index.compositionId) : void 0)?.props.get("theme");
|
|
1471
|
+
if (!prop) return resolveTheme(void 0);
|
|
1472
|
+
if (prop.literal === void 0 || typeof prop.literal !== "object" || prop.literal === null) return null;
|
|
1473
|
+
return resolveTheme(prop.literal);
|
|
1474
|
+
}
|
|
1475
|
+
function walk(value, path, visit, key = path) {
|
|
1476
|
+
if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}.${i}`, visit, key));
|
|
1477
|
+
else if (typeof value === "object" && value !== null) {
|
|
1478
|
+
for (const [k, v] of Object.entries(value)) if (!SKIP_KEYS.has(k)) walk(v, `${path}.${k}`, visit, k);
|
|
1479
|
+
} else visit(value, path, key);
|
|
1480
|
+
}
|
|
1481
|
+
// src/source/lint/rules/index.ts
|
|
1482
|
+
const TIMING_PROPS = [
|
|
1483
|
+
"in",
|
|
1484
|
+
"out",
|
|
1485
|
+
"duration",
|
|
1486
|
+
"start",
|
|
1487
|
+
"offset",
|
|
1488
|
+
"anchor",
|
|
1489
|
+
"at"
|
|
1490
|
+
];
|
|
1491
|
+
const literalIds = {
|
|
1492
|
+
name: "literal-ids",
|
|
1493
|
+
check: (index) => index.anonymous.map((a) => ({
|
|
1494
|
+
rule: "literal-ids",
|
|
1495
|
+
severity: "error",
|
|
1496
|
+
message: `<${a.kind}>: ${a.reason}; every element needs a literal id so the editor can address it`,
|
|
1497
|
+
range: a.range
|
|
1498
|
+
}))
|
|
1499
|
+
};
|
|
1500
|
+
const uniqueIds = {
|
|
1501
|
+
name: "unique-ids",
|
|
1502
|
+
check: (index) => {
|
|
1503
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1504
|
+
const out = [];
|
|
1505
|
+
for (const { id, range } of index.idSites) {
|
|
1506
|
+
const count = (seen.get(id) ?? 0) + 1;
|
|
1507
|
+
seen.set(id, count);
|
|
1508
|
+
if (count === 2) out.push({
|
|
1509
|
+
rule: "unique-ids",
|
|
1510
|
+
severity: "error",
|
|
1511
|
+
message: `id "${id}" is used more than once`,
|
|
1512
|
+
range,
|
|
1513
|
+
id
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
return out;
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
const noStartOnSequence = {
|
|
1520
|
+
name: "no-start-on-sequence",
|
|
1521
|
+
check: (index) => {
|
|
1522
|
+
const out = [];
|
|
1523
|
+
for (const site of index.elements.values()) {
|
|
1524
|
+
const start = site.props.get("start");
|
|
1525
|
+
if (!start || !site.parent) continue;
|
|
1526
|
+
const parent = index.elements.get(site.parent);
|
|
1527
|
+
if (parent?.kind !== "track") continue;
|
|
1528
|
+
if (parent.props.get("layout")?.literal === "free") continue;
|
|
1529
|
+
if (site.props.has("anchor")) continue;
|
|
1530
|
+
out.push({
|
|
1531
|
+
rule: "no-start-on-sequence",
|
|
1532
|
+
severity: "error",
|
|
1533
|
+
message: `"${site.id}" has start on a sequence track; order decides start there, or use layout="free"`,
|
|
1534
|
+
range: start.range,
|
|
1535
|
+
id: site.id,
|
|
1536
|
+
prop: "start"
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
return out;
|
|
1540
|
+
}
|
|
1541
|
+
};
|
|
1542
|
+
/** The hooks a helper that makes elements may call: they read the player's clock, nothing else. */
|
|
1543
|
+
const LAYER_HOOKS = /* @__PURE__ */ new Set(["useT", "useLayer"]);
|
|
1544
|
+
const rules = [
|
|
1545
|
+
literalIds,
|
|
1546
|
+
uniqueIds,
|
|
1547
|
+
noStartOnSequence,
|
|
1548
|
+
{
|
|
1549
|
+
name: "pure-helpers",
|
|
1550
|
+
check: (index) => [...index.helpers.values()].filter((h) => h.producesElements && h.hooks.some((n) => !LAYER_HOOKS.has(n))).map((h) => ({
|
|
1551
|
+
rule: "pure-helpers",
|
|
1552
|
+
severity: "error",
|
|
1553
|
+
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>`,
|
|
1554
|
+
range: h.range
|
|
1555
|
+
}))
|
|
1556
|
+
},
|
|
1557
|
+
strayHtml,
|
|
1558
|
+
freeCodeClock,
|
|
1559
|
+
{
|
|
1560
|
+
name: "expression-locked",
|
|
1561
|
+
check: (index) => {
|
|
1562
|
+
const out = [];
|
|
1563
|
+
for (const site of index.elements.values()) for (const prop of TIMING_PROPS) {
|
|
1564
|
+
const p = site.props.get(prop);
|
|
1565
|
+
if (p?.isExpression && p.binding === void 0) out.push({
|
|
1566
|
+
rule: "expression-locked",
|
|
1567
|
+
severity: "warning",
|
|
1568
|
+
message: `"${site.id}.${prop}" is an expression; the timeline will show it locked`,
|
|
1569
|
+
range: p.range,
|
|
1570
|
+
id: site.id,
|
|
1571
|
+
prop
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
return out;
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
name: "one-captions",
|
|
1579
|
+
check: (index) => {
|
|
1580
|
+
return [...index.elements.values()].filter((s) => s.kind === "captions").slice(1).map((s) => ({
|
|
1581
|
+
rule: "one-captions",
|
|
1582
|
+
severity: "error",
|
|
1583
|
+
message: "a composition has one <Captions>",
|
|
1584
|
+
range: s.range,
|
|
1585
|
+
id: s.id
|
|
1586
|
+
}));
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
name: "ambiguous-duration",
|
|
1591
|
+
check: (index) => {
|
|
1592
|
+
const out = [];
|
|
1593
|
+
for (const site of index.elements.values()) {
|
|
1594
|
+
if (![
|
|
1595
|
+
"clip",
|
|
1596
|
+
"audio",
|
|
1597
|
+
"nested"
|
|
1598
|
+
].includes(site.kind)) continue;
|
|
1599
|
+
const d = site.props.get("duration");
|
|
1600
|
+
if (d && site.props.has("out")) out.push({
|
|
1601
|
+
rule: "ambiguous-duration",
|
|
1602
|
+
severity: "error",
|
|
1603
|
+
message: `"${site.id}" gives both out and duration`,
|
|
1604
|
+
range: d.range,
|
|
1605
|
+
id: site.id,
|
|
1606
|
+
prop: "duration"
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
name: "file-shape",
|
|
1614
|
+
check: (index) => {
|
|
1615
|
+
const out = [];
|
|
1616
|
+
if (!index.hasDefaultExport) out.push({
|
|
1617
|
+
rule: "file-shape",
|
|
1618
|
+
severity: "error",
|
|
1619
|
+
message: "the file must default-export the video component",
|
|
1620
|
+
range: [0, 0]
|
|
1621
|
+
});
|
|
1622
|
+
if (index.hasSchema && !index.defaults) out.push({
|
|
1623
|
+
rule: "file-shape",
|
|
1624
|
+
severity: "error",
|
|
1625
|
+
message: "`export const schema` needs an `export const defaults = {…}` object literal",
|
|
1626
|
+
range: [0, 0]
|
|
1627
|
+
});
|
|
1628
|
+
if (!index.compositionId) out.push({
|
|
1629
|
+
rule: "file-shape",
|
|
1630
|
+
severity: "error",
|
|
1631
|
+
message: "no <Composition> found",
|
|
1632
|
+
range: [0, 0]
|
|
1633
|
+
});
|
|
1634
|
+
return out;
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
{
|
|
1638
|
+
name: "missing-asset",
|
|
1639
|
+
check: (index, options) => {
|
|
1640
|
+
if (!options.manifest) return [];
|
|
1641
|
+
const out = [];
|
|
1642
|
+
for (const site of index.elements.values()) {
|
|
1643
|
+
const src = site.props.get("src");
|
|
1644
|
+
if (typeof src?.literal !== "string" || !src.literal.startsWith("asset:")) continue;
|
|
1645
|
+
if (!options.manifest.assets[src.literal.slice(6)]) out.push({
|
|
1646
|
+
rule: "missing-asset",
|
|
1647
|
+
severity: "error",
|
|
1648
|
+
message: `${src.literal} is not in the manifest`,
|
|
1649
|
+
range: src.range,
|
|
1650
|
+
id: site.id,
|
|
1651
|
+
prop: "src"
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
return out;
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
name: "sequence-gap",
|
|
1659
|
+
check: (index) => {
|
|
1660
|
+
const main = firstSequenceTrack(index);
|
|
1661
|
+
if (!main) return [];
|
|
1662
|
+
const out = [];
|
|
1663
|
+
for (const site of index.elements.values()) {
|
|
1664
|
+
if (site.kind !== "gap" || site.parent !== main.id) continue;
|
|
1665
|
+
out.push({
|
|
1666
|
+
rule: "sequence-gap",
|
|
1667
|
+
severity: "warning",
|
|
1668
|
+
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`,
|
|
1669
|
+
range: site.range,
|
|
1670
|
+
id: site.id
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
return out;
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
name: "cut-inside-word",
|
|
1678
|
+
check: (index, options) => {
|
|
1679
|
+
if (!options.manifest) return [];
|
|
1680
|
+
const out = [];
|
|
1681
|
+
for (const site of index.elements.values()) {
|
|
1682
|
+
if (site.kind !== "clip" && site.kind !== "audio") continue;
|
|
1683
|
+
const src = site.props.get("src")?.literal;
|
|
1684
|
+
if (typeof src !== "string") continue;
|
|
1685
|
+
const words = lookupAsset(options.manifest, src)?.transcript?.words;
|
|
1686
|
+
if (!words || words.length === 0) continue;
|
|
1687
|
+
for (const prop of ["in", "out"]) {
|
|
1688
|
+
const p = site.props.get(prop);
|
|
1689
|
+
if (typeof p?.literal !== "number") continue;
|
|
1690
|
+
const t = p.literal;
|
|
1691
|
+
const word = words.find((w) => t > w.start && t < w.end);
|
|
1692
|
+
if (!word) continue;
|
|
1693
|
+
const ref = prop === "in" ? `word:${word.i}` : `word:${word.i}.end`;
|
|
1694
|
+
out.push({
|
|
1695
|
+
rule: "cut-inside-word",
|
|
1696
|
+
severity: "warning",
|
|
1697
|
+
message: `"${site.id}.${prop}" = ${t} cuts inside "${word.text}" (${word.start}–${word.end}); use ${ref} or a time outside the word`,
|
|
1698
|
+
range: p.range,
|
|
1699
|
+
id: site.id,
|
|
1700
|
+
prop
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return out;
|
|
1705
|
+
}
|
|
1706
|
+
},
|
|
1707
|
+
cutNearShot,
|
|
1708
|
+
shotChangeInsideCrop,
|
|
1709
|
+
repeatedSource,
|
|
1710
|
+
themeRefs,
|
|
1711
|
+
themeLiterals,
|
|
1712
|
+
fontAvailable,
|
|
1713
|
+
graphicProps,
|
|
1714
|
+
notRendered
|
|
1715
|
+
];
|
|
1716
|
+
/** The composition's first track in document order that lays out as a sequence and is not hidden: the one under everything else. */
|
|
1717
|
+
function firstSequenceTrack(index) {
|
|
1718
|
+
let first = null;
|
|
1719
|
+
for (const site of index.elements.values()) {
|
|
1720
|
+
if (site.kind !== "track" || site.parent !== index.compositionId) continue;
|
|
1721
|
+
if (site.props.get("layout")?.literal === "free" || site.props.get("hidden")?.literal === true) continue;
|
|
1722
|
+
if (!first || site.siblingIndex < first.siblingIndex) first = site;
|
|
1723
|
+
}
|
|
1724
|
+
return first;
|
|
1725
|
+
}
|
|
1726
|
+
// src/source/lint/index.ts
|
|
1727
|
+
/** Static checks on a video file: the conventions that keep it editable by the UI. */
|
|
1728
|
+
function lint(text, options = {}) {
|
|
1729
|
+
const index = buildSourceIndex(text);
|
|
1730
|
+
const enabled = options.rules ? rules.filter((r) => options.rules.includes(r.name)) : rules;
|
|
1731
|
+
const out = [];
|
|
1732
|
+
for (const rule of enabled) out.push(...rule.check(index, options));
|
|
1733
|
+
return out.sort((a, b) => a.range[0] - b.range[0]);
|
|
1734
|
+
}
|
|
1735
|
+
// src/source/load.ts
|
|
1736
|
+
/** The modules a cut file may import. `@cut/kit` is the kit's old name, still accepted from older files. */
|
|
1737
|
+
const ALLOWED_IMPORTS = {
|
|
1738
|
+
react: React,
|
|
1739
|
+
"react/jsx-runtime": jsxRuntime,
|
|
1740
|
+
zod,
|
|
1741
|
+
"@cutcraft/kit": src_exports,
|
|
1742
|
+
"@cut/kit": src_exports
|
|
1743
|
+
};
|
|
1744
|
+
function loadModule(source, options = {}) {
|
|
1745
|
+
const { code } = transform(source, {
|
|
1746
|
+
transforms: [
|
|
1747
|
+
"typescript",
|
|
1748
|
+
"jsx",
|
|
1749
|
+
"imports"
|
|
1750
|
+
],
|
|
1751
|
+
keepUnusedImports: true,
|
|
1752
|
+
jsxRuntime: "automatic",
|
|
1753
|
+
production: true
|
|
1754
|
+
});
|
|
1755
|
+
const module = { exports: {} };
|
|
1756
|
+
const offered = {
|
|
1757
|
+
...ALLOWED_IMPORTS,
|
|
1758
|
+
...options.imports
|
|
1759
|
+
};
|
|
1760
|
+
const require = (name) => {
|
|
1761
|
+
if (!(name in offered)) throw new Error(`The file imports “${name}”, which this host does not offer (it offers ${Object.keys(offered).join(", ")}).`);
|
|
1762
|
+
return offered[name];
|
|
1763
|
+
};
|
|
1764
|
+
new Function("require", "exports", "module", code)(require, module.exports, module);
|
|
1765
|
+
if (typeof module.exports.default !== "function") throw new Error("The file has no default export (the video).");
|
|
1766
|
+
return module.exports;
|
|
1767
|
+
}
|
|
1768
|
+
export { ALLOWED_IMPORTS, applyEdits, bake, buildSourceIndex, elementName, fold, foldToSource, indentOf, lineRange, lint, literalOf, loadModule, parseSource, printElement, printLiteral, printProp, rangeOf, rules };
|