@hyperframes/lint 0.7.15 → 0.7.17
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/dist/browser.d.ts +55 -0
- package/dist/browser.js +3276 -0
- package/dist/browser.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
package/dist/browser.js
ADDED
|
@@ -0,0 +1,3276 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
var TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
|
3
|
+
var STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
|
|
4
|
+
var SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
5
|
+
var COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
|
|
6
|
+
var TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
|
|
7
|
+
var TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
|
|
8
|
+
var WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
|
|
9
|
+
var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
|
10
|
+
var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
|
|
11
|
+
function extractOpenTags(source) {
|
|
12
|
+
const tags = [];
|
|
13
|
+
let match;
|
|
14
|
+
const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags);
|
|
15
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
16
|
+
const raw = match[0];
|
|
17
|
+
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
|
|
18
|
+
tags.push({
|
|
19
|
+
raw,
|
|
20
|
+
name: (match[1] || "").toLowerCase(),
|
|
21
|
+
attrs: match[2] || "",
|
|
22
|
+
index: match.index
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return tags;
|
|
26
|
+
}
|
|
27
|
+
function extractBlocks(source, pattern) {
|
|
28
|
+
const blocks = [];
|
|
29
|
+
let match;
|
|
30
|
+
const p = new RegExp(pattern.source, pattern.flags);
|
|
31
|
+
while ((match = p.exec(source)) !== null) {
|
|
32
|
+
blocks.push({
|
|
33
|
+
attrs: match[1] || "",
|
|
34
|
+
content: match[2] || "",
|
|
35
|
+
raw: match[0],
|
|
36
|
+
index: match.index
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return blocks;
|
|
40
|
+
}
|
|
41
|
+
function findHtmlTag(source) {
|
|
42
|
+
const match = /<html\b([^<>]*)>/i.exec(source);
|
|
43
|
+
if (!match) return null;
|
|
44
|
+
return {
|
|
45
|
+
raw: match[0],
|
|
46
|
+
name: "html",
|
|
47
|
+
attrs: match[1] ?? "",
|
|
48
|
+
index: match.index
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function findRootTag(source) {
|
|
52
|
+
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
|
|
53
|
+
const bodyCloseMatch = /<\/body>/i.exec(source);
|
|
54
|
+
if (bodyOpenMatch && (readAttr(bodyOpenMatch[0], "data-composition-id") || readAttr(bodyOpenMatch[0], "data-width") || readAttr(bodyOpenMatch[0], "data-height"))) {
|
|
55
|
+
return {
|
|
56
|
+
raw: bodyOpenMatch[0],
|
|
57
|
+
name: "body",
|
|
58
|
+
attrs: bodyOpenMatch[1] ?? "",
|
|
59
|
+
index: bodyOpenMatch.index
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
|
63
|
+
const bodyEnd = bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart ? bodyCloseMatch.index : source.length;
|
|
64
|
+
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
|
|
65
|
+
const bodyTags = extractOpenTags(bodyContent);
|
|
66
|
+
for (const tag of bodyTags) {
|
|
67
|
+
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
|
|
68
|
+
return { ...tag, index: tag.index + bodyStart };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function readAttr(tagSource, attr) {
|
|
73
|
+
if (!tagSource) return null;
|
|
74
|
+
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
|
|
76
|
+
return match?.[1] || null;
|
|
77
|
+
}
|
|
78
|
+
function readJsonAttr(tagSource, attr) {
|
|
79
|
+
if (!tagSource) return null;
|
|
80
|
+
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
|
|
82
|
+
if (!match) return null;
|
|
83
|
+
return match[1] ?? match[2] ?? null;
|
|
84
|
+
}
|
|
85
|
+
function collectCompositionIds(tags) {
|
|
86
|
+
const ids = /* @__PURE__ */ new Set();
|
|
87
|
+
for (const tag of tags) {
|
|
88
|
+
const compId = readAttr(tag.raw, "data-composition-id");
|
|
89
|
+
if (compId) ids.add(compId);
|
|
90
|
+
}
|
|
91
|
+
return ids;
|
|
92
|
+
}
|
|
93
|
+
function extractCompositionIdsFromCss(css) {
|
|
94
|
+
const ids = /* @__PURE__ */ new Set();
|
|
95
|
+
let match;
|
|
96
|
+
const pattern = new RegExp(
|
|
97
|
+
COMPOSITION_ID_IN_CSS_PATTERN.source,
|
|
98
|
+
COMPOSITION_ID_IN_CSS_PATTERN.flags
|
|
99
|
+
);
|
|
100
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
101
|
+
if (match[1]) ids.add(match[1]);
|
|
102
|
+
}
|
|
103
|
+
return [...ids];
|
|
104
|
+
}
|
|
105
|
+
function extractTimelineRegistryKeys(source) {
|
|
106
|
+
const keys = /* @__PURE__ */ new Set();
|
|
107
|
+
let match;
|
|
108
|
+
const pattern = new RegExp(
|
|
109
|
+
TIMELINE_REGISTRY_KEY_PATTERN.source,
|
|
110
|
+
TIMELINE_REGISTRY_KEY_PATTERN.flags
|
|
111
|
+
);
|
|
112
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
113
|
+
const key = match[1] ?? match[2];
|
|
114
|
+
if (key) keys.add(key);
|
|
115
|
+
}
|
|
116
|
+
return [...keys];
|
|
117
|
+
}
|
|
118
|
+
function getInlineScriptSyntaxError(source) {
|
|
119
|
+
if (!source.trim()) return null;
|
|
120
|
+
try {
|
|
121
|
+
new Function(source);
|
|
122
|
+
return null;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof Error) return error.message;
|
|
125
|
+
return String(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function stripJsComments(source) {
|
|
129
|
+
let out = "";
|
|
130
|
+
let i = 0;
|
|
131
|
+
let quote = null;
|
|
132
|
+
let escaped = false;
|
|
133
|
+
while (i < source.length) {
|
|
134
|
+
const ch = source[i] ?? "";
|
|
135
|
+
const next = source[i + 1] ?? "";
|
|
136
|
+
if (quote) {
|
|
137
|
+
out += ch;
|
|
138
|
+
if (escaped) {
|
|
139
|
+
escaped = false;
|
|
140
|
+
} else if (ch === "\\") {
|
|
141
|
+
escaped = true;
|
|
142
|
+
} else if (ch === quote) {
|
|
143
|
+
quote = null;
|
|
144
|
+
}
|
|
145
|
+
i += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
149
|
+
quote = ch;
|
|
150
|
+
out += ch;
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === "/" && next === "/") {
|
|
155
|
+
out += " ";
|
|
156
|
+
i += 2;
|
|
157
|
+
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
|
|
158
|
+
out += " ";
|
|
159
|
+
i += 1;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === "/" && next === "*") {
|
|
164
|
+
out += " ";
|
|
165
|
+
i += 2;
|
|
166
|
+
while (i < source.length) {
|
|
167
|
+
const blockCh = source[i] ?? "";
|
|
168
|
+
const blockNext = source[i + 1] ?? "";
|
|
169
|
+
if (blockCh === "*" && blockNext === "/") {
|
|
170
|
+
out += " ";
|
|
171
|
+
i += 2;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
|
|
175
|
+
i += 1;
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
out += ch;
|
|
180
|
+
i += 1;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function stripHtmlCommentsOnce(source) {
|
|
185
|
+
let out = "";
|
|
186
|
+
let i = 0;
|
|
187
|
+
for (; ; ) {
|
|
188
|
+
const start = source.indexOf("<!--", i);
|
|
189
|
+
if (start < 0) return out + source.slice(i);
|
|
190
|
+
const end = source.indexOf("-->", start + 4);
|
|
191
|
+
if (end < 0) return out + source.slice(i);
|
|
192
|
+
out += source.slice(i, start);
|
|
193
|
+
i = end + 3;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function stripHtmlComments(source) {
|
|
197
|
+
let out = source;
|
|
198
|
+
for (let prev = ""; prev !== out; ) {
|
|
199
|
+
prev = out;
|
|
200
|
+
out = stripHtmlCommentsOnce(out);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function extractScriptTextsAndSrcs(scripts) {
|
|
205
|
+
const texts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
|
206
|
+
const srcs = scripts.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "").filter(Boolean);
|
|
207
|
+
return { texts, srcs };
|
|
208
|
+
}
|
|
209
|
+
function isMediaTag(tagName) {
|
|
210
|
+
return tagName === "video" || tagName === "audio" || tagName === "img";
|
|
211
|
+
}
|
|
212
|
+
function truncateSnippet(value, maxLength = 220) {
|
|
213
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
214
|
+
if (!normalized) return void 0;
|
|
215
|
+
if (normalized.length <= maxLength) return normalized;
|
|
216
|
+
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/context.ts
|
|
220
|
+
function buildLintContext(html, options = {}) {
|
|
221
|
+
const rawSource = html || "";
|
|
222
|
+
let source = stripHtmlComments(rawSource);
|
|
223
|
+
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
|
224
|
+
if (templateMatch?.[1]) source = templateMatch[1];
|
|
225
|
+
const tags = extractOpenTags(source);
|
|
226
|
+
const styles = [
|
|
227
|
+
...extractBlocks(source, STYLE_BLOCK_PATTERN),
|
|
228
|
+
...(options.externalStyles ?? []).map((style) => ({
|
|
229
|
+
attrs: `href="${style.href}"`,
|
|
230
|
+
content: style.content,
|
|
231
|
+
raw: style.content,
|
|
232
|
+
index: -1
|
|
233
|
+
}))
|
|
234
|
+
];
|
|
235
|
+
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
|
|
236
|
+
const compositionIds = collectCompositionIds(tags);
|
|
237
|
+
const rootTag = findRootTag(source);
|
|
238
|
+
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
|
239
|
+
return {
|
|
240
|
+
source,
|
|
241
|
+
rawSource,
|
|
242
|
+
tags,
|
|
243
|
+
styles,
|
|
244
|
+
scripts,
|
|
245
|
+
compositionIds,
|
|
246
|
+
rootTag,
|
|
247
|
+
rootCompositionId,
|
|
248
|
+
options
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/rules/core.ts
|
|
253
|
+
import postcss from "postcss";
|
|
254
|
+
function escapeRegExp(value) {
|
|
255
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
256
|
+
}
|
|
257
|
+
function selectorTargetsCompositionId(selector, compositionId) {
|
|
258
|
+
const escaped = escapeRegExp(compositionId);
|
|
259
|
+
return new RegExp(
|
|
260
|
+
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`
|
|
261
|
+
).test(selector);
|
|
262
|
+
}
|
|
263
|
+
function isStudioTimelineElement(tag) {
|
|
264
|
+
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return Boolean(
|
|
268
|
+
readAttr(tag.raw, "data-start") || readAttr(tag.raw, "data-track-index") || readAttr(tag.raw, "data-track") || readAttr(tag.raw, "data-composition-src") || readAttr(tag.raw, "data-composition-file")
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
function describeStudioElement(tag) {
|
|
272
|
+
const parts = [`<${tag.name}`];
|
|
273
|
+
const className = readAttr(tag.raw, "class");
|
|
274
|
+
const compositionId = readAttr(tag.raw, "data-composition-id");
|
|
275
|
+
const dataStart = readAttr(tag.raw, "data-start");
|
|
276
|
+
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
|
|
277
|
+
if (className) {
|
|
278
|
+
const primaryClass = className.split(/\s+/).map((value) => value.trim()).find((value) => value && value !== "clip");
|
|
279
|
+
if (primaryClass) parts.push(` class="${primaryClass}"`);
|
|
280
|
+
}
|
|
281
|
+
if (compositionId) parts.push(` data-composition-id="${compositionId}"`);
|
|
282
|
+
if (dataStart) parts.push(` data-start="${dataStart}"`);
|
|
283
|
+
if (dataTrack) parts.push(` data-track-index="${dataTrack}"`);
|
|
284
|
+
parts.push(">");
|
|
285
|
+
return parts.join("");
|
|
286
|
+
}
|
|
287
|
+
var HEAD_BLOCKS_TO_IGNORE_PATTERN = /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
|
|
288
|
+
var HTML_TAG_PATTERN = /<[^>]+>/g;
|
|
289
|
+
var HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
|
290
|
+
var AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
|
291
|
+
var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
|
292
|
+
var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
|
293
|
+
var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
|
294
|
+
var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
|
295
|
+
function findCodeFenceLeak(headWithoutValidBlocks) {
|
|
296
|
+
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
297
|
+
}
|
|
298
|
+
function findOrphanCssLeak(headContent) {
|
|
299
|
+
const residualText = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ").replace(HTML_TAG_PATTERN, " ");
|
|
300
|
+
return ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? null;
|
|
301
|
+
}
|
|
302
|
+
function findStrayCloseLeak(headWithoutValidBlocks) {
|
|
303
|
+
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
304
|
+
}
|
|
305
|
+
function findLeakedTextInHeadContent(headContent) {
|
|
306
|
+
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
|
|
307
|
+
return findCodeFenceLeak(withoutValidBlocks) ?? findOrphanCssLeak(headContent) ?? findStrayCloseLeak(withoutValidBlocks);
|
|
308
|
+
}
|
|
309
|
+
function findLeakedTextInHead(rawSource) {
|
|
310
|
+
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
|
|
311
|
+
for (const match of headMatches) {
|
|
312
|
+
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
313
|
+
if (leakedText) return leakedText;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
function findLeakedTextBetweenHeadAndBody(rawSource) {
|
|
318
|
+
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
|
319
|
+
for (const match of boundaryMatches) {
|
|
320
|
+
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
321
|
+
if (leakedText) return leakedText;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
function findLeakedTextBeforeCompositionRoot(source, rootTag) {
|
|
326
|
+
if (!rootTag || rootTag.name === "body") return null;
|
|
327
|
+
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
|
328
|
+
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
|
329
|
+
const prefixEnd = rootTag.index;
|
|
330
|
+
if (prefixEnd <= prefixStart) return null;
|
|
331
|
+
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
|
332
|
+
}
|
|
333
|
+
var coreRules = [
|
|
334
|
+
// root_missing_composition_id + root_missing_dimensions
|
|
335
|
+
({ rootTag }) => {
|
|
336
|
+
const findings = [];
|
|
337
|
+
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
|
|
338
|
+
findings.push({
|
|
339
|
+
code: "root_missing_composition_id",
|
|
340
|
+
severity: "error",
|
|
341
|
+
message: "Root composition is missing `data-composition-id`.",
|
|
342
|
+
elementId: rootTag ? readAttr(rootTag.raw, "id") || void 0 : void 0,
|
|
343
|
+
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
|
|
344
|
+
snippet: truncateSnippet(rootTag?.raw || "")
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
|
|
348
|
+
findings.push({
|
|
349
|
+
code: "root_missing_dimensions",
|
|
350
|
+
severity: "error",
|
|
351
|
+
message: "Root composition is missing `data-width` or `data-height`.",
|
|
352
|
+
elementId: rootTag ? readAttr(rootTag.raw, "id") || void 0 : void 0,
|
|
353
|
+
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
|
|
354
|
+
snippet: truncateSnippet(rootTag?.raw || "")
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return findings;
|
|
358
|
+
},
|
|
359
|
+
// head_leaked_text
|
|
360
|
+
({ source, rootTag }) => {
|
|
361
|
+
const snippet = findLeakedTextInHead(source) ?? findLeakedTextBetweenHeadAndBody(source) ?? findLeakedTextBeforeCompositionRoot(source, rootTag);
|
|
362
|
+
if (!snippet) return [];
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
code: "head_leaked_text",
|
|
366
|
+
severity: "error",
|
|
367
|
+
message: "Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
|
368
|
+
fixHint: "Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
|
369
|
+
snippet: truncateSnippet(snippet)
|
|
370
|
+
}
|
|
371
|
+
];
|
|
372
|
+
},
|
|
373
|
+
// missing_timeline_registry + timeline_registry_missing_init
|
|
374
|
+
({ source, rawSource, options }) => {
|
|
375
|
+
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const findings = [];
|
|
379
|
+
if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
|
|
380
|
+
findings.push({
|
|
381
|
+
code: "missing_timeline_registry",
|
|
382
|
+
severity: "error",
|
|
383
|
+
message: "Missing `window.__timelines` registration.",
|
|
384
|
+
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`."
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && !TIMELINE_REGISTRY_INIT_PATTERN.test(source)) {
|
|
388
|
+
findings.push({
|
|
389
|
+
code: "timeline_registry_missing_init",
|
|
390
|
+
severity: "error",
|
|
391
|
+
message: "`window.__timelines[\u2026] = \u2026` is used without initializing `window.__timelines` first.",
|
|
392
|
+
fixHint: "Add `window.__timelines = window.__timelines || {};` before any timeline assignment."
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return findings;
|
|
396
|
+
},
|
|
397
|
+
// timeline_id_mismatch
|
|
398
|
+
({ source }) => {
|
|
399
|
+
const findings = [];
|
|
400
|
+
const htmlCompIds = /* @__PURE__ */ new Set();
|
|
401
|
+
const timelineRegKeys = /* @__PURE__ */ new Set();
|
|
402
|
+
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
|
403
|
+
let m;
|
|
404
|
+
while ((m = compIdRe.exec(source)) !== null) {
|
|
405
|
+
if (m[1]) htmlCompIds.add(m[1]);
|
|
406
|
+
}
|
|
407
|
+
for (const key of extractTimelineRegistryKeys(source)) {
|
|
408
|
+
timelineRegKeys.add(key);
|
|
409
|
+
}
|
|
410
|
+
for (const key of timelineRegKeys) {
|
|
411
|
+
if (!htmlCompIds.has(key)) {
|
|
412
|
+
findings.push({
|
|
413
|
+
code: "timeline_id_mismatch",
|
|
414
|
+
severity: "error",
|
|
415
|
+
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
|
416
|
+
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return findings;
|
|
421
|
+
},
|
|
422
|
+
// invalid_inline_script_syntax (malformed close tag)
|
|
423
|
+
({ source }) => {
|
|
424
|
+
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
|
|
425
|
+
return [
|
|
426
|
+
{
|
|
427
|
+
code: "invalid_inline_script_syntax",
|
|
428
|
+
severity: "error",
|
|
429
|
+
message: "Detected malformed inline `<script>` closing syntax.",
|
|
430
|
+
fixHint: "Close inline scripts with a valid `<\/script>` tag."
|
|
431
|
+
}
|
|
432
|
+
];
|
|
433
|
+
},
|
|
434
|
+
// invalid_inline_script_syntax (JS parse error)
|
|
435
|
+
({ scripts }) => {
|
|
436
|
+
const findings = [];
|
|
437
|
+
for (const script of scripts) {
|
|
438
|
+
const attrs = script.attrs || "";
|
|
439
|
+
if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
|
|
440
|
+
attrs
|
|
441
|
+
))
|
|
442
|
+
continue;
|
|
443
|
+
const syntaxError = getInlineScriptSyntaxError(script.content);
|
|
444
|
+
if (!syntaxError) continue;
|
|
445
|
+
findings.push({
|
|
446
|
+
code: "invalid_inline_script_syntax",
|
|
447
|
+
severity: "error",
|
|
448
|
+
message: `Inline script has invalid syntax: ${syntaxError}`,
|
|
449
|
+
fixHint: "Fix the inline script syntax before render verification.",
|
|
450
|
+
snippet: truncateSnippet(script.content)
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return findings;
|
|
454
|
+
},
|
|
455
|
+
// host_missing_composition_id
|
|
456
|
+
({ tags }) => {
|
|
457
|
+
const findings = [];
|
|
458
|
+
for (const tag of tags) {
|
|
459
|
+
const src = readAttr(tag.raw, "data-composition-src");
|
|
460
|
+
if (!src) continue;
|
|
461
|
+
if (readAttr(tag.raw, "data-composition-id")) continue;
|
|
462
|
+
findings.push({
|
|
463
|
+
code: "host_missing_composition_id",
|
|
464
|
+
severity: "error",
|
|
465
|
+
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
|
|
466
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
467
|
+
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
|
|
468
|
+
snippet: truncateSnippet(tag.raw)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return findings;
|
|
472
|
+
},
|
|
473
|
+
// scoped_css_missing_wrapper
|
|
474
|
+
({ styles, compositionIds }) => {
|
|
475
|
+
const findings = [];
|
|
476
|
+
const scopedCssCompositionIds = /* @__PURE__ */ new Set();
|
|
477
|
+
for (const style of styles) {
|
|
478
|
+
for (const compId of extractCompositionIdsFromCss(style.content)) {
|
|
479
|
+
scopedCssCompositionIds.add(compId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const compId of scopedCssCompositionIds) {
|
|
483
|
+
if (compositionIds.has(compId)) continue;
|
|
484
|
+
findings.push({
|
|
485
|
+
code: "scoped_css_missing_wrapper",
|
|
486
|
+
severity: "warning",
|
|
487
|
+
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
|
|
488
|
+
selector: `[data-composition-id="${compId}"]`,
|
|
489
|
+
fixHint: "Preserve the matching composition wrapper or align the CSS scope to an existing wrapper."
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return findings;
|
|
493
|
+
},
|
|
494
|
+
// composition_self_attribute_selector
|
|
495
|
+
({ styles, rootCompositionId, rootTag }) => {
|
|
496
|
+
const findings = [];
|
|
497
|
+
if (!rootCompositionId) return findings;
|
|
498
|
+
const seenSelectors = /* @__PURE__ */ new Set();
|
|
499
|
+
const rootId = readAttr(rootTag?.raw || "", "id");
|
|
500
|
+
for (const style of styles) {
|
|
501
|
+
let root;
|
|
502
|
+
try {
|
|
503
|
+
root = postcss.parse(style.content);
|
|
504
|
+
} catch {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
root.walkRules((rule) => {
|
|
508
|
+
for (const selector of rule.selectors) {
|
|
509
|
+
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
|
|
510
|
+
if (seenSelectors.has(selector)) continue;
|
|
511
|
+
seenSelectors.add(selector);
|
|
512
|
+
findings.push({
|
|
513
|
+
code: "composition_self_attribute_selector",
|
|
514
|
+
severity: "warning",
|
|
515
|
+
message: "Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
|
|
516
|
+
selector,
|
|
517
|
+
fixHint: rootId ? `Use #${rootId} for clearer authoring intent and instance-isolated styling.` : "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling."
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return findings;
|
|
523
|
+
},
|
|
524
|
+
// studio_missing_editable_id
|
|
525
|
+
({ tags, rootTag }) => {
|
|
526
|
+
const findings = [];
|
|
527
|
+
for (const tag of tags) {
|
|
528
|
+
if (rootTag && tag.index === rootTag.index) continue;
|
|
529
|
+
if (!isStudioTimelineElement(tag)) continue;
|
|
530
|
+
if (readAttr(tag.raw, "id")) continue;
|
|
531
|
+
const descriptor = describeStudioElement(tag);
|
|
532
|
+
findings.push({
|
|
533
|
+
code: "studio_missing_editable_id",
|
|
534
|
+
severity: "warning",
|
|
535
|
+
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
|
|
536
|
+
selector: readAttr(tag.raw, "data-composition-id") ? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]` : void 0,
|
|
537
|
+
fixHint: 'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
|
|
538
|
+
snippet: truncateSnippet(tag.raw)
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return findings;
|
|
542
|
+
},
|
|
543
|
+
// non_deterministic_code
|
|
544
|
+
({ scripts }) => {
|
|
545
|
+
const findings = [];
|
|
546
|
+
const patterns = [
|
|
547
|
+
{
|
|
548
|
+
pattern: /Math\.random\s*\(/,
|
|
549
|
+
label: "Math.random()",
|
|
550
|
+
hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames."
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
pattern: /Date\.now\s*\(/,
|
|
554
|
+
label: "Date.now()",
|
|
555
|
+
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
pattern: /new\s+Date\s*\(/,
|
|
559
|
+
label: "new Date()",
|
|
560
|
+
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
pattern: /performance\.now\s*\(/,
|
|
564
|
+
label: "performance.now()",
|
|
565
|
+
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time."
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
pattern: /crypto\.getRandomValues\s*\(/,
|
|
569
|
+
label: "crypto.getRandomValues()",
|
|
570
|
+
hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders."
|
|
571
|
+
}
|
|
572
|
+
];
|
|
573
|
+
for (const script of scripts) {
|
|
574
|
+
const stripped = stripJsComments(script.content);
|
|
575
|
+
for (const { pattern, label, hint } of patterns) {
|
|
576
|
+
if (pattern.test(stripped)) {
|
|
577
|
+
findings.push({
|
|
578
|
+
code: "non_deterministic_code",
|
|
579
|
+
severity: "error",
|
|
580
|
+
message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
|
|
581
|
+
fixHint: hint,
|
|
582
|
+
snippet: truncateSnippet(script.content)
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return findings;
|
|
588
|
+
},
|
|
589
|
+
// pointer_events_none
|
|
590
|
+
// fallow-ignore-next-line complexity
|
|
591
|
+
({ tags, styles }) => {
|
|
592
|
+
const findings = [];
|
|
593
|
+
const reported = /* @__PURE__ */ new Set();
|
|
594
|
+
for (const tag of tags) {
|
|
595
|
+
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
|
|
596
|
+
const inlineStyle = readAttr(tag.raw, "style") ?? "";
|
|
597
|
+
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
|
|
598
|
+
const id = readAttr(tag.raw, "id");
|
|
599
|
+
const key = id ?? tag.raw;
|
|
600
|
+
if (reported.has(key)) continue;
|
|
601
|
+
reported.add(key);
|
|
602
|
+
findings.push({
|
|
603
|
+
code: "pointer_events_none",
|
|
604
|
+
severity: "info",
|
|
605
|
+
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
|
|
606
|
+
elementId: id || void 0,
|
|
607
|
+
fixHint: "If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
|
608
|
+
snippet: truncateSnippet(tag.raw)
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
for (const style of styles) {
|
|
612
|
+
let root;
|
|
613
|
+
try {
|
|
614
|
+
root = postcss.parse(style.content);
|
|
615
|
+
} catch {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
root.walkDecls("pointer-events", (decl) => {
|
|
619
|
+
if (decl.value.trim().toLowerCase() !== "none") return;
|
|
620
|
+
const rule = decl.parent;
|
|
621
|
+
if (!rule || rule.type !== "rule") return;
|
|
622
|
+
const selector = rule.selector;
|
|
623
|
+
if (reported.has(selector)) return;
|
|
624
|
+
reported.add(selector);
|
|
625
|
+
findings.push({
|
|
626
|
+
code: "pointer_events_none",
|
|
627
|
+
severity: "info",
|
|
628
|
+
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
|
|
629
|
+
selector,
|
|
630
|
+
fixHint: "If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content."
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return findings;
|
|
635
|
+
}
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
// src/rules/media.ts
|
|
639
|
+
function escapeRegExp2(value) {
|
|
640
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
641
|
+
}
|
|
642
|
+
function hasAttrName(tagSource, attr) {
|
|
643
|
+
const escaped = escapeRegExp2(attr);
|
|
644
|
+
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
|
|
645
|
+
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
|
|
646
|
+
}
|
|
647
|
+
function classNamesFromAttr(classAttr) {
|
|
648
|
+
if (!classAttr) return [];
|
|
649
|
+
return classAttr.split(/\s+/).filter(Boolean);
|
|
650
|
+
}
|
|
651
|
+
function selectorTargetsManagedMedia(selector, mediaIndex) {
|
|
652
|
+
const normalized = selector.trim();
|
|
653
|
+
if (!normalized) return false;
|
|
654
|
+
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
|
|
655
|
+
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
|
|
656
|
+
for (const mediaId of mediaIndex.ids) {
|
|
657
|
+
const escapedId = escapeRegExp2(mediaId);
|
|
658
|
+
if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const className of mediaIndex.classes) {
|
|
663
|
+
if (new RegExp(`\\.${escapeRegExp2(className)}(?![\\w-])`).test(normalized)) {
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
function findImperativeMediaControlFindings(ctx) {
|
|
670
|
+
const findings = [];
|
|
671
|
+
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
|
|
672
|
+
const mediaIndex = {
|
|
673
|
+
ids: new Set(
|
|
674
|
+
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id) => Boolean(id))
|
|
675
|
+
),
|
|
676
|
+
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
|
|
677
|
+
hasVideo: mediaTags.some((tag) => tag.name === "video"),
|
|
678
|
+
hasAudio: mediaTags.some((tag) => tag.name === "audio")
|
|
679
|
+
};
|
|
680
|
+
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
|
|
681
|
+
for (const script of ctx.scripts) {
|
|
682
|
+
const mediaVars = /* @__PURE__ */ new Map();
|
|
683
|
+
const assignmentPatterns = [
|
|
684
|
+
{
|
|
685
|
+
pattern: /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
|
|
686
|
+
variableIndex: 1,
|
|
687
|
+
targetIndex: 2
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
pattern: /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
|
|
691
|
+
variableIndex: 1,
|
|
692
|
+
targetIndex: 3
|
|
693
|
+
}
|
|
694
|
+
];
|
|
695
|
+
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
|
|
696
|
+
let match;
|
|
697
|
+
while ((match = pattern.exec(script.content)) !== null) {
|
|
698
|
+
const variableName = match[variableIndex];
|
|
699
|
+
const target = match[targetIndex];
|
|
700
|
+
if (!variableName || !target) continue;
|
|
701
|
+
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
|
|
702
|
+
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : void 0);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const directIdPatterns = [
|
|
707
|
+
{
|
|
708
|
+
pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
|
|
709
|
+
kind: "play()",
|
|
710
|
+
targetIndex: 1
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
|
|
714
|
+
kind: "pause()",
|
|
715
|
+
targetIndex: 1
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
|
|
719
|
+
kind: "currentTime",
|
|
720
|
+
targetIndex: 1
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
|
|
724
|
+
kind: "muted assignment",
|
|
725
|
+
targetIndex: 1
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
|
|
729
|
+
kind: "play()",
|
|
730
|
+
targetIndex: 2
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
|
|
734
|
+
kind: "pause()",
|
|
735
|
+
targetIndex: 2
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
|
|
739
|
+
kind: "currentTime",
|
|
740
|
+
targetIndex: 2
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
|
|
744
|
+
kind: "muted assignment",
|
|
745
|
+
targetIndex: 2
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
for (const { pattern, kind, targetIndex } of directIdPatterns) {
|
|
749
|
+
let match;
|
|
750
|
+
while ((match = pattern.exec(script.content)) !== null) {
|
|
751
|
+
const target = match[targetIndex];
|
|
752
|
+
if (!target) continue;
|
|
753
|
+
const elementId = mediaIndex.ids.has(target) ? target : selectorTargetsManagedMedia(target, mediaIndex) ? void 0 : null;
|
|
754
|
+
if (elementId === null) continue;
|
|
755
|
+
findings.push({
|
|
756
|
+
code: "imperative_media_control",
|
|
757
|
+
severity: "error",
|
|
758
|
+
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
|
759
|
+
elementId: elementId || void 0,
|
|
760
|
+
fixHint: "Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
|
761
|
+
snippet: truncateSnippet(match[0])
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
for (const [variableName, elementId] of mediaVars) {
|
|
766
|
+
const escapedVar = escapeRegExp2(variableName);
|
|
767
|
+
const variablePatterns = [
|
|
768
|
+
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
|
|
769
|
+
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
|
|
770
|
+
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
|
|
771
|
+
{
|
|
772
|
+
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
|
|
773
|
+
kind: "muted assignment"
|
|
774
|
+
}
|
|
775
|
+
];
|
|
776
|
+
for (const { pattern, kind } of variablePatterns) {
|
|
777
|
+
let match;
|
|
778
|
+
while ((match = pattern.exec(script.content)) !== null) {
|
|
779
|
+
findings.push({
|
|
780
|
+
code: "imperative_media_control",
|
|
781
|
+
severity: "error",
|
|
782
|
+
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
|
783
|
+
elementId,
|
|
784
|
+
fixHint: "Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
|
785
|
+
snippet: truncateSnippet(match[0])
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return findings;
|
|
792
|
+
}
|
|
793
|
+
var mediaRules = [
|
|
794
|
+
// duplicate_media_id + duplicate_media_discovery_risk
|
|
795
|
+
({ tags }) => {
|
|
796
|
+
const findings = [];
|
|
797
|
+
const mediaById = /* @__PURE__ */ new Map();
|
|
798
|
+
const mediaFingerprintCounts = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const tag of tags) {
|
|
800
|
+
if (!isMediaTag(tag.name)) continue;
|
|
801
|
+
const elementId = readAttr(tag.raw, "id");
|
|
802
|
+
if (elementId) {
|
|
803
|
+
const existing = mediaById.get(elementId) || [];
|
|
804
|
+
existing.push(tag);
|
|
805
|
+
mediaById.set(elementId, existing);
|
|
806
|
+
}
|
|
807
|
+
const fingerprint = [
|
|
808
|
+
tag.name,
|
|
809
|
+
readAttr(tag.raw, "src") || "",
|
|
810
|
+
readAttr(tag.raw, "data-start") || "",
|
|
811
|
+
readAttr(tag.raw, "data-duration") || ""
|
|
812
|
+
].join("|");
|
|
813
|
+
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
|
|
814
|
+
}
|
|
815
|
+
for (const [elementId, mediaTags] of mediaById) {
|
|
816
|
+
if (mediaTags.length < 2) continue;
|
|
817
|
+
findings.push({
|
|
818
|
+
code: "duplicate_media_id",
|
|
819
|
+
severity: "error",
|
|
820
|
+
message: `Media id "${elementId}" is defined multiple times.`,
|
|
821
|
+
elementId,
|
|
822
|
+
fixHint: "Give each media element a unique id so preview and producer discover the same media graph.",
|
|
823
|
+
snippet: truncateSnippet(mediaTags[0]?.raw || "")
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
for (const [fingerprint, count] of mediaFingerprintCounts) {
|
|
827
|
+
if (count < 2) continue;
|
|
828
|
+
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
|
|
829
|
+
findings.push({
|
|
830
|
+
code: "duplicate_media_discovery_risk",
|
|
831
|
+
severity: "warning",
|
|
832
|
+
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
|
|
833
|
+
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
|
|
834
|
+
snippet: truncateSnippet(
|
|
835
|
+
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`
|
|
836
|
+
)
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return findings;
|
|
840
|
+
},
|
|
841
|
+
// video_missing_muted
|
|
842
|
+
({ tags }) => {
|
|
843
|
+
const findings = [];
|
|
844
|
+
for (const tag of tags) {
|
|
845
|
+
if (tag.name !== "video") continue;
|
|
846
|
+
const hasMuted = hasAttrName(tag.raw, "muted");
|
|
847
|
+
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
|
|
848
|
+
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
|
|
849
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
850
|
+
findings.push({
|
|
851
|
+
code: "video_missing_muted",
|
|
852
|
+
severity: "error",
|
|
853
|
+
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
|
|
854
|
+
elementId,
|
|
855
|
+
fixHint: 'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
|
|
856
|
+
snippet: truncateSnippet(tag.raw)
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
if (hasMuted && hasDeclaredAudio) {
|
|
860
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
861
|
+
findings.push({
|
|
862
|
+
code: "video_muted_with_declared_audio",
|
|
863
|
+
severity: "error",
|
|
864
|
+
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
|
|
865
|
+
elementId,
|
|
866
|
+
fixHint: 'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
|
|
867
|
+
snippet: truncateSnippet(tag.raw)
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return findings;
|
|
872
|
+
},
|
|
873
|
+
// video_nested_in_timed_element
|
|
874
|
+
({ source, tags }) => {
|
|
875
|
+
const findings = [];
|
|
876
|
+
const voidElements = /* @__PURE__ */ new Set([
|
|
877
|
+
"area",
|
|
878
|
+
"base",
|
|
879
|
+
"br",
|
|
880
|
+
"col",
|
|
881
|
+
"embed",
|
|
882
|
+
"hr",
|
|
883
|
+
"img",
|
|
884
|
+
"input",
|
|
885
|
+
"link",
|
|
886
|
+
"meta",
|
|
887
|
+
"source",
|
|
888
|
+
"track",
|
|
889
|
+
"wbr"
|
|
890
|
+
]);
|
|
891
|
+
const timedTagPositions = [];
|
|
892
|
+
for (const tag of tags) {
|
|
893
|
+
if (tag.name === "video" || tag.name === "audio") continue;
|
|
894
|
+
if (voidElements.has(tag.name)) continue;
|
|
895
|
+
if (readAttr(tag.raw, "data-composition-id")) continue;
|
|
896
|
+
if (readAttr(tag.raw, "data-start")) {
|
|
897
|
+
timedTagPositions.push({
|
|
898
|
+
name: tag.name,
|
|
899
|
+
start: tag.index,
|
|
900
|
+
id: readAttr(tag.raw, "id") || void 0
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
for (const tag of tags) {
|
|
905
|
+
if (tag.name !== "video") continue;
|
|
906
|
+
if (!readAttr(tag.raw, "data-start")) continue;
|
|
907
|
+
for (const parent of timedTagPositions) {
|
|
908
|
+
if (parent.start < tag.index) {
|
|
909
|
+
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
|
|
910
|
+
const between = source.substring(parent.start, tag.index);
|
|
911
|
+
if (!parentClosePattern.test(between)) {
|
|
912
|
+
findings.push({
|
|
913
|
+
code: "video_nested_in_timed_element",
|
|
914
|
+
severity: "error",
|
|
915
|
+
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media \u2014 video will be FROZEN in renders.`,
|
|
916
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
917
|
+
fixHint: "Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
|
|
918
|
+
snippet: truncateSnippet(tag.raw)
|
|
919
|
+
});
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return findings;
|
|
926
|
+
},
|
|
927
|
+
// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
|
|
928
|
+
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
|
|
929
|
+
// them, so they render BLANK/black in preview and renders — and the other lint/validate
|
|
930
|
+
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
|
|
931
|
+
({ tags, options }) => {
|
|
932
|
+
const findings = [];
|
|
933
|
+
if (!options.isSubComposition) return findings;
|
|
934
|
+
for (const tag of tags) {
|
|
935
|
+
if (tag.name !== "video" && tag.name !== "audio") continue;
|
|
936
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
937
|
+
findings.push({
|
|
938
|
+
code: "media_in_subcomposition",
|
|
939
|
+
severity: "error",
|
|
940
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
|
|
941
|
+
elementId,
|
|
942
|
+
fixHint: "Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
|
|
943
|
+
snippet: truncateSnippet(tag.raw)
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return findings;
|
|
947
|
+
},
|
|
948
|
+
// self_closing_media_tag
|
|
949
|
+
({ source }) => {
|
|
950
|
+
const findings = [];
|
|
951
|
+
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
|
|
952
|
+
let scMatch;
|
|
953
|
+
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
|
|
954
|
+
const tagName = scMatch[1] || "audio";
|
|
955
|
+
const elementId = readAttr(scMatch[0], "id") || void 0;
|
|
956
|
+
findings.push({
|
|
957
|
+
code: "self_closing_media_tag",
|
|
958
|
+
severity: "error",
|
|
959
|
+
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
|
|
960
|
+
elementId,
|
|
961
|
+
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> \u2014 media elements MUST have explicit closing tags.`,
|
|
962
|
+
snippet: truncateSnippet(scMatch[0])
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
return findings;
|
|
966
|
+
},
|
|
967
|
+
// placeholder_media_url
|
|
968
|
+
({ tags }) => {
|
|
969
|
+
const findings = [];
|
|
970
|
+
const PLACEHOLDER_DOMAINS = /\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
|
|
971
|
+
for (const tag of tags) {
|
|
972
|
+
if (!isMediaTag(tag.name)) continue;
|
|
973
|
+
const src = readAttr(tag.raw, "src");
|
|
974
|
+
if (!src) continue;
|
|
975
|
+
if (PLACEHOLDER_DOMAINS.test(src)) {
|
|
976
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
977
|
+
findings.push({
|
|
978
|
+
code: "placeholder_media_url",
|
|
979
|
+
severity: "error",
|
|
980
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
|
|
981
|
+
elementId,
|
|
982
|
+
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
|
|
983
|
+
snippet: truncateSnippet(tag.raw)
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return findings;
|
|
988
|
+
},
|
|
989
|
+
// base64_media_prohibited
|
|
990
|
+
({ source }) => {
|
|
991
|
+
const findings = [];
|
|
992
|
+
const base64MediaRe = /src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
|
|
993
|
+
let b64Match;
|
|
994
|
+
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
|
995
|
+
const sample = (b64Match[2] || "").slice(0, 200);
|
|
996
|
+
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
|
997
|
+
const dataSize = Math.round((b64Match[2] || "").length * 3 / 4);
|
|
998
|
+
const isSuspicious = uniqueChars < 15 || dataSize > 1e3 && dataSize < 5e4;
|
|
999
|
+
findings.push({
|
|
1000
|
+
code: "base64_media_prohibited",
|
|
1001
|
+
severity: "error",
|
|
1002
|
+
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " \u2014 likely fabricated data" : ""}. Base64 media is prohibited \u2014 it bloats file size and breaks rendering.`,
|
|
1003
|
+
fixHint: "Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
|
|
1004
|
+
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "...")
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
return findings;
|
|
1008
|
+
},
|
|
1009
|
+
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
|
|
1010
|
+
({ tags }) => {
|
|
1011
|
+
const findings = [];
|
|
1012
|
+
for (const tag of tags) {
|
|
1013
|
+
if (tag.name !== "video" && tag.name !== "audio") continue;
|
|
1014
|
+
const hasDataStart = readAttr(tag.raw, "data-start");
|
|
1015
|
+
const hasId = readAttr(tag.raw, "id");
|
|
1016
|
+
const hasSrc = readAttr(tag.raw, "src");
|
|
1017
|
+
if (hasSrc && !hasDataStart) {
|
|
1018
|
+
findings.push({
|
|
1019
|
+
code: "media_missing_data_start",
|
|
1020
|
+
severity: "error",
|
|
1021
|
+
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
|
|
1022
|
+
elementId: hasId || void 0,
|
|
1023
|
+
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
|
|
1024
|
+
snippet: truncateSnippet(tag.raw)
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
if (hasDataStart && !hasId) {
|
|
1028
|
+
findings.push({
|
|
1029
|
+
code: "media_missing_id",
|
|
1030
|
+
severity: "error",
|
|
1031
|
+
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements \u2014 this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
|
|
1032
|
+
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
|
|
1033
|
+
snippet: truncateSnippet(tag.raw)
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
if (hasDataStart && hasId && !hasSrc) {
|
|
1037
|
+
findings.push({
|
|
1038
|
+
code: "media_missing_src",
|
|
1039
|
+
severity: "error",
|
|
1040
|
+
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
|
|
1041
|
+
elementId: hasId,
|
|
1042
|
+
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
|
|
1043
|
+
snippet: truncateSnippet(tag.raw)
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
if (readAttr(tag.raw, "preload") === "none") {
|
|
1047
|
+
findings.push({
|
|
1048
|
+
code: "media_preload_none",
|
|
1049
|
+
severity: "warning",
|
|
1050
|
+
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
|
|
1051
|
+
elementId: hasId || void 0,
|
|
1052
|
+
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
|
|
1053
|
+
snippet: truncateSnippet(tag.raw)
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return findings;
|
|
1058
|
+
},
|
|
1059
|
+
// video_audio_double_source — catches audible <video> paired with a separate
|
|
1060
|
+
// <audio> pointing to the same file, which causes double playback at runtime
|
|
1061
|
+
({ tags }) => {
|
|
1062
|
+
const findings = [];
|
|
1063
|
+
const videoSources = /* @__PURE__ */ new Map();
|
|
1064
|
+
const audioSources = /* @__PURE__ */ new Map();
|
|
1065
|
+
for (const tag of tags) {
|
|
1066
|
+
if (!readAttr(tag.raw, "data-start")) continue;
|
|
1067
|
+
const src = readAttr(tag.raw, "src");
|
|
1068
|
+
if (!src) continue;
|
|
1069
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
1070
|
+
if (tag.name === "video") {
|
|
1071
|
+
const isMuted = hasAttrName(tag.raw, "muted");
|
|
1072
|
+
if (!isMuted) {
|
|
1073
|
+
videoSources.set(src, { id: elementId, raw: tag.raw });
|
|
1074
|
+
}
|
|
1075
|
+
} else if (tag.name === "audio") {
|
|
1076
|
+
audioSources.set(src, { id: elementId, raw: tag.raw });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
for (const [src, audioInfo] of audioSources) {
|
|
1080
|
+
const videoInfo = videoSources.get(src);
|
|
1081
|
+
if (!videoInfo) continue;
|
|
1082
|
+
findings.push({
|
|
1083
|
+
code: "video_audio_double_source",
|
|
1084
|
+
severity: "error",
|
|
1085
|
+
message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio \u2014 the duplicate <audio> will cause double playback and echo.`,
|
|
1086
|
+
elementId: audioInfo.id,
|
|
1087
|
+
fixHint: "Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
|
|
1088
|
+
snippet: truncateSnippet(audioInfo.raw)
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
return findings;
|
|
1092
|
+
},
|
|
1093
|
+
// imperative_media_control
|
|
1094
|
+
findImperativeMediaControlFindings
|
|
1095
|
+
];
|
|
1096
|
+
|
|
1097
|
+
// src/rules/gsap.ts
|
|
1098
|
+
async function loadParseGsapScript() {
|
|
1099
|
+
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
|
|
1100
|
+
return mod.parseGsapScriptAcorn;
|
|
1101
|
+
}
|
|
1102
|
+
var SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
|
1103
|
+
function countClassUsage(tags) {
|
|
1104
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1105
|
+
for (const tag of tags) {
|
|
1106
|
+
const classAttr = readAttr(tag.raw, "class");
|
|
1107
|
+
if (!classAttr) continue;
|
|
1108
|
+
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
|
1109
|
+
counts.set(className, (counts.get(className) || 0) + 1);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return counts;
|
|
1113
|
+
}
|
|
1114
|
+
function readRegisteredTimelineCompositionId(script) {
|
|
1115
|
+
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
1116
|
+
return match?.[1] || match?.[2] || null;
|
|
1117
|
+
}
|
|
1118
|
+
function unwrapRaw(value) {
|
|
1119
|
+
if (typeof value === "number") return value;
|
|
1120
|
+
if (typeof value !== "string") return void 0;
|
|
1121
|
+
const code = value.startsWith("__raw:") ? value.slice(6) : value;
|
|
1122
|
+
return code.replace(/^\s*["']|["']\s*$/g, "");
|
|
1123
|
+
}
|
|
1124
|
+
function extrasNumber(value) {
|
|
1125
|
+
const unwrapped = unwrapRaw(value);
|
|
1126
|
+
const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
|
|
1127
|
+
return Number.isFinite(numeric) ? numeric : 0;
|
|
1128
|
+
}
|
|
1129
|
+
function synthesizeWindowRaw(timelineVar, anim) {
|
|
1130
|
+
const entries = Object.entries(anim.properties).map(([k, v]) => {
|
|
1131
|
+
if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`;
|
|
1132
|
+
return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
|
|
1133
|
+
});
|
|
1134
|
+
if (anim.duration !== void 0) entries.push(`duration: ${anim.duration}`);
|
|
1135
|
+
if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`);
|
|
1136
|
+
const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
|
|
1137
|
+
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
|
|
1138
|
+
}
|
|
1139
|
+
var gsapWindowsCache = /* @__PURE__ */ new Map();
|
|
1140
|
+
async function cachedExtractGsapWindows(scriptContent) {
|
|
1141
|
+
const cached = gsapWindowsCache.get(scriptContent);
|
|
1142
|
+
if (cached) return cached;
|
|
1143
|
+
const windows = await extractGsapWindows(scriptContent);
|
|
1144
|
+
gsapWindowsCache.set(scriptContent, windows);
|
|
1145
|
+
return windows;
|
|
1146
|
+
}
|
|
1147
|
+
async function extractGsapWindows(script) {
|
|
1148
|
+
if (!/gsap\.timeline/.test(script)) return [];
|
|
1149
|
+
const parseGsapScript = await loadParseGsapScript();
|
|
1150
|
+
const parsed = parseGsapScript(script);
|
|
1151
|
+
if (parsed.animations.length === 0) return [];
|
|
1152
|
+
const windows = [];
|
|
1153
|
+
for (const animation of parsed.animations) {
|
|
1154
|
+
if (typeof animation.position !== "number") continue;
|
|
1155
|
+
const repeat = extrasNumber(animation.extras?.repeat);
|
|
1156
|
+
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
|
1157
|
+
const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
|
|
1158
|
+
windows.push({
|
|
1159
|
+
targetSelector: animation.targetSelector,
|
|
1160
|
+
position: animation.position,
|
|
1161
|
+
end: animation.position + effectiveDuration,
|
|
1162
|
+
properties: Object.keys(animation.properties),
|
|
1163
|
+
propertyValues: animation.properties,
|
|
1164
|
+
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
|
|
1165
|
+
method: animation.method,
|
|
1166
|
+
raw: synthesizeWindowRaw(parsed.timelineVar, animation)
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
return windows;
|
|
1170
|
+
}
|
|
1171
|
+
function numberValue(value) {
|
|
1172
|
+
if (typeof value === "number") return value;
|
|
1173
|
+
if (typeof value === "string" && value.trim()) {
|
|
1174
|
+
const numeric = Number(value);
|
|
1175
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
function stringValue(value) {
|
|
1180
|
+
if (typeof value === "string") return value;
|
|
1181
|
+
if (typeof value === "number") return String(value);
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
function zeroValue(value) {
|
|
1185
|
+
if (typeof value === "number") return value === 0;
|
|
1186
|
+
if (typeof value !== "string") return false;
|
|
1187
|
+
return Number(value.trim()) === 0;
|
|
1188
|
+
}
|
|
1189
|
+
function isHiddenGsapState(values) {
|
|
1190
|
+
const visibility = stringValue(values.visibility)?.toLowerCase();
|
|
1191
|
+
const display = stringValue(values.display)?.toLowerCase();
|
|
1192
|
+
return zeroValue(values.opacity) || zeroValue(values.autoAlpha) || visibility === "hidden" || display === "none";
|
|
1193
|
+
}
|
|
1194
|
+
function oneValue(values, keys) {
|
|
1195
|
+
for (const key of keys) {
|
|
1196
|
+
const value = values[key];
|
|
1197
|
+
if (value !== void 0) return value;
|
|
1198
|
+
}
|
|
1199
|
+
return void 0;
|
|
1200
|
+
}
|
|
1201
|
+
function isVisibleGsapState(values) {
|
|
1202
|
+
const opacity = oneValue(values, ["opacity", "autoAlpha"]);
|
|
1203
|
+
if (typeof opacity === "number") return opacity > 0;
|
|
1204
|
+
if (typeof opacity === "string" && opacity.trim()) {
|
|
1205
|
+
const numeric = Number(opacity);
|
|
1206
|
+
if (Number.isFinite(numeric)) return numeric > 0;
|
|
1207
|
+
}
|
|
1208
|
+
const visibility = stringValue(values.visibility)?.toLowerCase();
|
|
1209
|
+
if (visibility === "visible" || visibility === "inherit") return true;
|
|
1210
|
+
const display = stringValue(values.display)?.toLowerCase();
|
|
1211
|
+
if (display && display !== "none") return true;
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
function makesOverlayVisible(win) {
|
|
1215
|
+
if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true;
|
|
1216
|
+
return isVisibleGsapState(win.propertyValues);
|
|
1217
|
+
}
|
|
1218
|
+
function isSceneBoundaryExit(win) {
|
|
1219
|
+
if (win.end <= win.position) return false;
|
|
1220
|
+
if (win.method !== "to" && win.method !== "fromTo") return false;
|
|
1221
|
+
return isHiddenGsapState(win.propertyValues);
|
|
1222
|
+
}
|
|
1223
|
+
function isHardKillSet(win, selector, boundary) {
|
|
1224
|
+
return win.method === "set" && win.targetSelector === selector && Math.abs(win.position - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues);
|
|
1225
|
+
}
|
|
1226
|
+
function hiddenStateLiteral(values) {
|
|
1227
|
+
if (zeroValue(values.autoAlpha)) return "{ autoAlpha: 0 }";
|
|
1228
|
+
if (zeroValue(values.opacity)) return "{ opacity: 0 }";
|
|
1229
|
+
if (stringValue(values.visibility)?.toLowerCase() === "hidden") return '{ visibility: "hidden" }';
|
|
1230
|
+
if (stringValue(values.display)?.toLowerCase() === "none") return '{ display: "none" }';
|
|
1231
|
+
return "{ opacity: 0 }";
|
|
1232
|
+
}
|
|
1233
|
+
function findTagEnd(source, tag) {
|
|
1234
|
+
const escapedTagName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1235
|
+
const pattern = new RegExp(`<\\/?${escapedTagName}\\b[^>]*>`, "gi");
|
|
1236
|
+
pattern.lastIndex = tag.index;
|
|
1237
|
+
let depth = 0;
|
|
1238
|
+
let match;
|
|
1239
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
1240
|
+
const raw = match[0];
|
|
1241
|
+
const isClosing = /^<\s*\//.test(raw);
|
|
1242
|
+
const isSelfClosing = /\/\s*>$/.test(raw);
|
|
1243
|
+
if (!isClosing && !isSelfClosing) depth += 1;
|
|
1244
|
+
if (isClosing) depth -= 1;
|
|
1245
|
+
if (depth === 0) return pattern.lastIndex;
|
|
1246
|
+
}
|
|
1247
|
+
return source.length;
|
|
1248
|
+
}
|
|
1249
|
+
function collectCompositionRanges(source, tags) {
|
|
1250
|
+
return tags.map((tag) => {
|
|
1251
|
+
const id = readAttr(tag.raw, "data-composition-id");
|
|
1252
|
+
if (!id) return null;
|
|
1253
|
+
return {
|
|
1254
|
+
id,
|
|
1255
|
+
start: tag.index,
|
|
1256
|
+
end: findTagEnd(source, tag)
|
|
1257
|
+
};
|
|
1258
|
+
}).filter((range) => range !== null);
|
|
1259
|
+
}
|
|
1260
|
+
function findContainingCompositionId(tag, ranges) {
|
|
1261
|
+
let match = null;
|
|
1262
|
+
for (const range of ranges) {
|
|
1263
|
+
if (tag.index < range.start || tag.index >= range.end) continue;
|
|
1264
|
+
if (!match || range.start >= match.start) match = range;
|
|
1265
|
+
}
|
|
1266
|
+
return match?.id || null;
|
|
1267
|
+
}
|
|
1268
|
+
function collectClipStartBoundariesByComposition(source, tags) {
|
|
1269
|
+
const ranges = collectCompositionRanges(source, tags);
|
|
1270
|
+
const boundaries = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const tag of tags) {
|
|
1272
|
+
const classAttr = readAttr(tag.raw, "class") || "";
|
|
1273
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
1274
|
+
if (!classes.includes("clip")) continue;
|
|
1275
|
+
const compositionId = findContainingCompositionId(tag, ranges);
|
|
1276
|
+
if (!compositionId) continue;
|
|
1277
|
+
const start = numberValue(readAttr(tag.raw, "data-start") ?? void 0);
|
|
1278
|
+
if (start == null || start <= 0) continue;
|
|
1279
|
+
const compositionBoundaries = boundaries.get(compositionId) ?? /* @__PURE__ */ new Set();
|
|
1280
|
+
compositionBoundaries.add(start);
|
|
1281
|
+
boundaries.set(compositionId, compositionBoundaries);
|
|
1282
|
+
}
|
|
1283
|
+
return new Map(
|
|
1284
|
+
[...boundaries.entries()].map(([compositionId, values]) => [
|
|
1285
|
+
compositionId,
|
|
1286
|
+
[...values].sort((a, b) => a - b)
|
|
1287
|
+
])
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
function findMatchingSceneBoundary(time, boundaries) {
|
|
1291
|
+
for (const boundary of boundaries) {
|
|
1292
|
+
if (Math.abs(time - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS) return boundary;
|
|
1293
|
+
}
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
function isSuspiciousGlobalSelector(selector) {
|
|
1297
|
+
if (!selector) return false;
|
|
1298
|
+
if (selector.includes("[data-composition-id=")) return false;
|
|
1299
|
+
if (selector.startsWith("#")) return false;
|
|
1300
|
+
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
|
1301
|
+
}
|
|
1302
|
+
function getSingleClassSelector(selector) {
|
|
1303
|
+
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
|
1304
|
+
return match?.groups?.name || null;
|
|
1305
|
+
}
|
|
1306
|
+
function readStyleProperty(style, property) {
|
|
1307
|
+
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1308
|
+
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
|
1309
|
+
return match?.[1]?.trim() || null;
|
|
1310
|
+
}
|
|
1311
|
+
function cssZero(value) {
|
|
1312
|
+
if (!value) return false;
|
|
1313
|
+
return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim());
|
|
1314
|
+
}
|
|
1315
|
+
function styleHasHiddenInitialState(style) {
|
|
1316
|
+
const opacity = readStyleProperty(style, "opacity");
|
|
1317
|
+
if (opacity && Number(opacity) === 0) return true;
|
|
1318
|
+
if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true;
|
|
1319
|
+
if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true;
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
function styleHasOpaqueBackground(style) {
|
|
1323
|
+
const background = readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
|
|
1324
|
+
if (!background) return false;
|
|
1325
|
+
const normalized = background.toLowerCase().replace(/\s+/g, "");
|
|
1326
|
+
if (normalized === "transparent" || normalized === "none") return false;
|
|
1327
|
+
if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
|
|
1328
|
+
if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
|
|
1329
|
+
return true;
|
|
1330
|
+
}
|
|
1331
|
+
function styleLooksFullFrameOverlay(style) {
|
|
1332
|
+
const position = readStyleProperty(style, "position")?.toLowerCase();
|
|
1333
|
+
if (position !== "fixed" && position !== "absolute") return false;
|
|
1334
|
+
const coversFrame = cssZero(readStyleProperty(style, "inset")) || cssZero(readStyleProperty(style, "top")) && cssZero(readStyleProperty(style, "right")) && cssZero(readStyleProperty(style, "bottom")) && cssZero(readStyleProperty(style, "left"));
|
|
1335
|
+
return coversFrame && styleHasOpaqueBackground(style);
|
|
1336
|
+
}
|
|
1337
|
+
function collectSimpleStyleRules(styles) {
|
|
1338
|
+
const rules = /* @__PURE__ */ new Map();
|
|
1339
|
+
for (const style of styles) {
|
|
1340
|
+
for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
|
|
1341
|
+
if (!selectorList || !body) continue;
|
|
1342
|
+
for (const selector of selectorList.split(",")) {
|
|
1343
|
+
const token = selector.trim();
|
|
1344
|
+
if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue;
|
|
1345
|
+
rules.set(token, `${rules.get(token) || ""};${body}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return rules;
|
|
1350
|
+
}
|
|
1351
|
+
function tagSimpleSelectors(tag) {
|
|
1352
|
+
const selectors = [];
|
|
1353
|
+
const id = readAttr(tag.raw, "id");
|
|
1354
|
+
if (id) selectors.push(`#${id}`);
|
|
1355
|
+
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
1356
|
+
for (const className of classes) selectors.push(`.${className}`);
|
|
1357
|
+
return selectors;
|
|
1358
|
+
}
|
|
1359
|
+
function combinedTagStyle(tag, styleRules) {
|
|
1360
|
+
const styles = [readAttr(tag.raw, "style") || ""];
|
|
1361
|
+
for (const selector of tagSimpleSelectors(tag)) {
|
|
1362
|
+
const ruleStyle = styleRules.get(selector);
|
|
1363
|
+
if (ruleStyle) styles.push(ruleStyle);
|
|
1364
|
+
}
|
|
1365
|
+
return styles.filter(Boolean).join(";");
|
|
1366
|
+
}
|
|
1367
|
+
function cssTransformToGsapProps(cssTransform) {
|
|
1368
|
+
const parts = [];
|
|
1369
|
+
const translateMatch = cssTransform.match(
|
|
1370
|
+
/translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/
|
|
1371
|
+
);
|
|
1372
|
+
if (translateMatch) {
|
|
1373
|
+
const [, xVal, xUnit, yVal, yUnit] = translateMatch;
|
|
1374
|
+
if (xUnit === "%") parts.push(`xPercent: ${xVal}`);
|
|
1375
|
+
else parts.push(`x: ${xVal}`);
|
|
1376
|
+
if (yUnit === "%") parts.push(`yPercent: ${yVal}`);
|
|
1377
|
+
else parts.push(`y: ${yVal}`);
|
|
1378
|
+
}
|
|
1379
|
+
const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/);
|
|
1380
|
+
if (txMatch) {
|
|
1381
|
+
const [, val, unit] = txMatch;
|
|
1382
|
+
parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`);
|
|
1383
|
+
}
|
|
1384
|
+
const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/);
|
|
1385
|
+
if (tyMatch) {
|
|
1386
|
+
const [, val, unit] = tyMatch;
|
|
1387
|
+
parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`);
|
|
1388
|
+
}
|
|
1389
|
+
const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/);
|
|
1390
|
+
if (scaleMatch) {
|
|
1391
|
+
parts.push(`scale: ${scaleMatch[1]}`);
|
|
1392
|
+
}
|
|
1393
|
+
return parts.length > 0 ? parts.join(", ") : null;
|
|
1394
|
+
}
|
|
1395
|
+
var CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
|
|
1396
|
+
var CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];
|
|
1397
|
+
function targetedSelectorTokens(selector) {
|
|
1398
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
1399
|
+
for (const group of selector.split(",")) {
|
|
1400
|
+
const compounds = group.trim().split(/[\s>+~]+/).filter(Boolean);
|
|
1401
|
+
const last = compounds[compounds.length - 1];
|
|
1402
|
+
if (!last) continue;
|
|
1403
|
+
const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
|
|
1404
|
+
if (simple) for (const token of simple) tokens.add(token);
|
|
1405
|
+
}
|
|
1406
|
+
return tokens;
|
|
1407
|
+
}
|
|
1408
|
+
function matchCssTransform(gsapSelector, cssMap) {
|
|
1409
|
+
if (cssMap.size === 0) return void 0;
|
|
1410
|
+
const direct = cssMap.get(gsapSelector);
|
|
1411
|
+
if (direct) return direct;
|
|
1412
|
+
const tokens = targetedSelectorTokens(gsapSelector);
|
|
1413
|
+
for (const [cssSelector, value] of cssMap) {
|
|
1414
|
+
if (tokens.has(cssSelector)) return value;
|
|
1415
|
+
}
|
|
1416
|
+
return void 0;
|
|
1417
|
+
}
|
|
1418
|
+
function extractStandaloneGsapTransformCalls(script) {
|
|
1419
|
+
const calls = [];
|
|
1420
|
+
const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
|
|
1421
|
+
let match;
|
|
1422
|
+
while ((match = pattern.exec(script)) !== null) {
|
|
1423
|
+
const method = match[1] ?? "set";
|
|
1424
|
+
const selector = match[3] ?? "";
|
|
1425
|
+
const propsBody = match[4] ?? "";
|
|
1426
|
+
const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
|
|
1427
|
+
calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
|
|
1428
|
+
}
|
|
1429
|
+
return calls;
|
|
1430
|
+
}
|
|
1431
|
+
var gsapRules = [
|
|
1432
|
+
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
|
|
1433
|
+
// fallow-ignore-next-line complexity
|
|
1434
|
+
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
|
1435
|
+
const findings = [];
|
|
1436
|
+
const clipIds = /* @__PURE__ */ new Map();
|
|
1437
|
+
const clipClasses = /* @__PURE__ */ new Map();
|
|
1438
|
+
for (const tag of tags) {
|
|
1439
|
+
const classAttr = readAttr(tag.raw, "class") || "";
|
|
1440
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
1441
|
+
if (!classes.includes("clip")) continue;
|
|
1442
|
+
const id = readAttr(tag.raw, "id");
|
|
1443
|
+
const info = {
|
|
1444
|
+
tag: tag.name,
|
|
1445
|
+
id: id || "",
|
|
1446
|
+
classes: classAttr
|
|
1447
|
+
};
|
|
1448
|
+
if (id) clipIds.set(`#${id}`, info);
|
|
1449
|
+
for (const cls of classes) {
|
|
1450
|
+
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const classUsage = countClassUsage(tags);
|
|
1454
|
+
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
|
1455
|
+
const styleRules = collectSimpleStyleRules(styles);
|
|
1456
|
+
const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
|
|
1457
|
+
for (const script of scripts) {
|
|
1458
|
+
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
|
1459
|
+
const gsapWindows = await cachedExtractGsapWindows(script.content);
|
|
1460
|
+
const clipStartBoundaries = clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
|
|
1461
|
+
for (let i = 0; i < gsapWindows.length; i++) {
|
|
1462
|
+
const left = gsapWindows[i];
|
|
1463
|
+
if (!left) continue;
|
|
1464
|
+
if (left.end <= left.position) continue;
|
|
1465
|
+
for (let j = i + 1; j < gsapWindows.length; j++) {
|
|
1466
|
+
const right = gsapWindows[j];
|
|
1467
|
+
if (!right) continue;
|
|
1468
|
+
if (right.end <= right.position) continue;
|
|
1469
|
+
if (left.targetSelector !== right.targetSelector) continue;
|
|
1470
|
+
const overlapStart = Math.max(left.position, right.position);
|
|
1471
|
+
const overlapEnd = Math.min(left.end, right.end);
|
|
1472
|
+
if (overlapEnd <= overlapStart) continue;
|
|
1473
|
+
if (left.overwriteAuto || right.overwriteAuto) continue;
|
|
1474
|
+
const sharedProperties = left.properties.filter(
|
|
1475
|
+
(prop) => right.properties.includes(prop)
|
|
1476
|
+
);
|
|
1477
|
+
if (sharedProperties.length === 0) continue;
|
|
1478
|
+
findings.push({
|
|
1479
|
+
code: "overlapping_gsap_tweens",
|
|
1480
|
+
severity: "warning",
|
|
1481
|
+
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
|
|
1482
|
+
selector: left.targetSelector,
|
|
1483
|
+
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
|
|
1484
|
+
snippet: truncateSnippet(`${left.raw}
|
|
1485
|
+
${right.raw}`)
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (clipStartBoundaries.length > 0) {
|
|
1490
|
+
for (const win of gsapWindows) {
|
|
1491
|
+
if (!isSceneBoundaryExit(win)) continue;
|
|
1492
|
+
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
|
|
1493
|
+
if (boundary == null) continue;
|
|
1494
|
+
const hasHardKill = gsapWindows.some(
|
|
1495
|
+
(candidate) => isHardKillSet(candidate, win.targetSelector, boundary)
|
|
1496
|
+
);
|
|
1497
|
+
if (hasHardKill) continue;
|
|
1498
|
+
findings.push({
|
|
1499
|
+
code: "gsap_exit_missing_hard_kill",
|
|
1500
|
+
severity: "error",
|
|
1501
|
+
message: `GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.`,
|
|
1502
|
+
selector: win.targetSelector,
|
|
1503
|
+
fixHint: `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` after the exit tween.`,
|
|
1504
|
+
snippet: truncateSnippet(win.raw)
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
for (const tag of tags) {
|
|
1509
|
+
const selectors = tagSimpleSelectors(tag);
|
|
1510
|
+
if (selectors.length === 0) continue;
|
|
1511
|
+
const overlayKey = readAttr(tag.raw, "id") || String(tag.index);
|
|
1512
|
+
if (reportedVisibleOverlayKeys.has(overlayKey)) continue;
|
|
1513
|
+
const authoredStyle = combinedTagStyle(tag, styleRules);
|
|
1514
|
+
if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue;
|
|
1515
|
+
if (styleHasHiddenInitialState(authoredStyle)) continue;
|
|
1516
|
+
const visibilityWindows = gsapWindows.filter((win) => {
|
|
1517
|
+
const tokens = targetedSelectorTokens(win.targetSelector);
|
|
1518
|
+
if (!selectors.some((selector2) => tokens.has(selector2))) return false;
|
|
1519
|
+
return win.properties.some(
|
|
1520
|
+
(prop) => ["opacity", "autoAlpha", "visibility", "display"].includes(prop)
|
|
1521
|
+
);
|
|
1522
|
+
}).sort((a, b) => a.position - b.position);
|
|
1523
|
+
const startsHiddenAtZero = visibilityWindows.some(
|
|
1524
|
+
(win) => win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues)
|
|
1525
|
+
);
|
|
1526
|
+
if (startsHiddenAtZero) continue;
|
|
1527
|
+
const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
|
|
1528
|
+
if (!firstVisible) continue;
|
|
1529
|
+
const selector = selectors.find(
|
|
1530
|
+
(candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)
|
|
1531
|
+
) || selectors[0] || tag.name;
|
|
1532
|
+
const laterHidden = visibilityWindows.some(
|
|
1533
|
+
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues)
|
|
1534
|
+
);
|
|
1535
|
+
if (firstVisible.method !== "from" && !laterHidden) continue;
|
|
1536
|
+
reportedVisibleOverlayKeys.add(overlayKey);
|
|
1537
|
+
findings.push({
|
|
1538
|
+
code: "gsap_fullscreen_overlay_starts_visible",
|
|
1539
|
+
severity: "error",
|
|
1540
|
+
message: `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
|
|
1541
|
+
selector,
|
|
1542
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
1543
|
+
fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add \`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
|
|
1544
|
+
snippet: truncateSnippet(firstVisible.raw)
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
for (const win of gsapWindows) {
|
|
1548
|
+
const sel = win.targetSelector;
|
|
1549
|
+
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
|
1550
|
+
if (!clipInfo) continue;
|
|
1551
|
+
const conflictingProps = win.properties.filter(
|
|
1552
|
+
(p) => p === "visibility" || p === "display"
|
|
1553
|
+
);
|
|
1554
|
+
if (conflictingProps.length === 0) continue;
|
|
1555
|
+
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
|
1556
|
+
findings.push({
|
|
1557
|
+
code: "gsap_animates_clip_element",
|
|
1558
|
+
severity: "error",
|
|
1559
|
+
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} \u2014 do not animate these properties on clip elements.`,
|
|
1560
|
+
selector: sel,
|
|
1561
|
+
elementId: clipInfo.id || void 0,
|
|
1562
|
+
fixHint: "Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
|
|
1563
|
+
snippet: truncateSnippet(win.raw)
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
|
|
1567
|
+
for (const win of gsapWindows) {
|
|
1568
|
+
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
|
|
1569
|
+
const className = getSingleClassSelector(win.targetSelector);
|
|
1570
|
+
if (className && (classUsage.get(className) || 0) < 2) continue;
|
|
1571
|
+
findings.push({
|
|
1572
|
+
code: "unscoped_gsap_selector",
|
|
1573
|
+
severity: "error",
|
|
1574
|
+
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
|
1575
|
+
selector: win.targetSelector,
|
|
1576
|
+
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
|
1577
|
+
snippet: truncateSnippet(win.raw)
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return findings;
|
|
1582
|
+
},
|
|
1583
|
+
// gsap_css_transform_conflict
|
|
1584
|
+
// fallow-ignore-next-line complexity
|
|
1585
|
+
async ({ styles, scripts, tags }) => {
|
|
1586
|
+
const findings = [];
|
|
1587
|
+
const cssTranslateSelectors = /* @__PURE__ */ new Map();
|
|
1588
|
+
const cssScaleSelectors = /* @__PURE__ */ new Map();
|
|
1589
|
+
for (const style of styles) {
|
|
1590
|
+
for (const [, selector, body] of style.content.matchAll(
|
|
1591
|
+
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
|
|
1592
|
+
)) {
|
|
1593
|
+
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
|
|
1594
|
+
if (!tMatch || !tMatch[1]) continue;
|
|
1595
|
+
const transformVal = tMatch[1].trim();
|
|
1596
|
+
if (/translate/i.test(transformVal))
|
|
1597
|
+
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
|
|
1598
|
+
if (/scale/i.test(transformVal))
|
|
1599
|
+
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
for (const tag of tags) {
|
|
1603
|
+
const inlineStyle = readAttr(tag.raw, "style");
|
|
1604
|
+
if (!inlineStyle) continue;
|
|
1605
|
+
const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
|
|
1606
|
+
if (!tMatch || !tMatch[1]) continue;
|
|
1607
|
+
const transformVal = tMatch[1].trim();
|
|
1608
|
+
const id = readAttr(tag.raw, "id");
|
|
1609
|
+
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
1610
|
+
const selectors = [];
|
|
1611
|
+
if (id) selectors.push(`#${id}`);
|
|
1612
|
+
for (const cls of classes) selectors.push(`.${cls}`);
|
|
1613
|
+
if (selectors.length === 0) continue;
|
|
1614
|
+
for (const sel of selectors) {
|
|
1615
|
+
if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))
|
|
1616
|
+
cssTranslateSelectors.set(sel, transformVal);
|
|
1617
|
+
if (/scale/i.test(transformVal) && !cssScaleSelectors.has(sel))
|
|
1618
|
+
cssScaleSelectors.set(sel, transformVal);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (cssTranslateSelectors.size === 0 && cssScaleSelectors.size === 0) return findings;
|
|
1622
|
+
for (const script of scripts) {
|
|
1623
|
+
if (!/gsap\.timeline/.test(script.content)) continue;
|
|
1624
|
+
const windows = await cachedExtractGsapWindows(script.content);
|
|
1625
|
+
const calls = [
|
|
1626
|
+
...windows.map((win) => ({
|
|
1627
|
+
method: win.method,
|
|
1628
|
+
selector: win.targetSelector,
|
|
1629
|
+
properties: win.properties,
|
|
1630
|
+
raw: win.raw
|
|
1631
|
+
})),
|
|
1632
|
+
...extractStandaloneGsapTransformCalls(stripJsComments(script.content))
|
|
1633
|
+
];
|
|
1634
|
+
const conflicts = /* @__PURE__ */ new Map();
|
|
1635
|
+
for (const call of calls) {
|
|
1636
|
+
if (call.method === "fromTo" || call.method === "from") continue;
|
|
1637
|
+
const sel = call.selector;
|
|
1638
|
+
const translateProps = call.properties.filter(
|
|
1639
|
+
(p) => CONFLICTING_TRANSLATE_PROPS.includes(p)
|
|
1640
|
+
);
|
|
1641
|
+
const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
|
|
1642
|
+
const cssFromTranslate = translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : void 0;
|
|
1643
|
+
const cssFromScale = scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : void 0;
|
|
1644
|
+
if (!cssFromTranslate && !cssFromScale) continue;
|
|
1645
|
+
const existing = conflicts.get(sel) ?? {
|
|
1646
|
+
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
|
|
1647
|
+
props: /* @__PURE__ */ new Set(),
|
|
1648
|
+
raw: call.raw
|
|
1649
|
+
};
|
|
1650
|
+
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
|
|
1651
|
+
conflicts.set(sel, existing);
|
|
1652
|
+
}
|
|
1653
|
+
for (const [sel, { cssTransform, props, raw }] of conflicts) {
|
|
1654
|
+
const propList = [...props].join("/");
|
|
1655
|
+
const gsapEquivalent = cssTransformToGsapProps(cssTransform);
|
|
1656
|
+
const fixHint = gsapEquivalent ? `Remove \`transform: ${cssTransform}\` from CSS and replace with GSAP properties: ${gsapEquivalent}. Example: tl.fromTo('${sel}', { ${gsapEquivalent} }, { ${gsapEquivalent}, ...yourAnimation }). tl.fromTo is exempt from this rule.` : `Remove the transform from CSS and use tl.fromTo('${sel}', { xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns the full transform state. tl.fromTo is exempt from this rule.`;
|
|
1657
|
+
findings.push({
|
|
1658
|
+
code: "gsap_css_transform_conflict",
|
|
1659
|
+
severity: "error",
|
|
1660
|
+
message: `"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ${propList}. GSAP will overwrite the full CSS transform, discarding any translateX(-50%) centering or CSS scale value.`,
|
|
1661
|
+
selector: sel,
|
|
1662
|
+
fixHint,
|
|
1663
|
+
snippet: truncateSnippet(raw)
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return findings;
|
|
1668
|
+
},
|
|
1669
|
+
// missing_gsap_script
|
|
1670
|
+
({ scripts, rawSource, options }) => {
|
|
1671
|
+
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
|
1672
|
+
const allScriptSrcs = scripts.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "").filter(Boolean);
|
|
1673
|
+
const canInheritGsapFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
|
|
1674
|
+
const usesGsap = allScriptTexts.some(
|
|
1675
|
+
(t) => /gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t)
|
|
1676
|
+
);
|
|
1677
|
+
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
|
|
1678
|
+
const hasInlineGsap = allScriptTexts.some(
|
|
1679
|
+
(t) => /\/\*\s*inlined:.*gsap/i.test(t) || /\b_gsScope\b/.test(t) || /\bGreenSock\b/.test(t) || /\bgsap\.(config|defaults|version)\b/.test(t) || t.length > 5e3 && /\bgsap\b/i.test(t)
|
|
1680
|
+
);
|
|
1681
|
+
if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return [];
|
|
1682
|
+
return [
|
|
1683
|
+
{
|
|
1684
|
+
code: "missing_gsap_script",
|
|
1685
|
+
severity: "error",
|
|
1686
|
+
message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
|
|
1687
|
+
fixHint: 'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"><\/script> before your animation script.'
|
|
1688
|
+
}
|
|
1689
|
+
];
|
|
1690
|
+
},
|
|
1691
|
+
// audio_reactive_single_tween_per_group
|
|
1692
|
+
// fallow-ignore-next-line complexity
|
|
1693
|
+
({ scripts, styles }) => {
|
|
1694
|
+
const findings = [];
|
|
1695
|
+
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
1696
|
+
if (!isCaptionFile) return findings;
|
|
1697
|
+
for (const script of scripts) {
|
|
1698
|
+
const content = script.content;
|
|
1699
|
+
const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
|
|
1700
|
+
if (!hasAudioData) continue;
|
|
1701
|
+
const hasCaptionLoop = /forEach/.test(content) && /caption|group|cg-/.test(content);
|
|
1702
|
+
if (!hasCaptionLoop) continue;
|
|
1703
|
+
const hasInnerSamplingLoop = /for\s*\(\s*var\s+\w+\s*=\s*group\.start/.test(content) || /for\s*\(\s*var\s+at\s*=/.test(content) || /while\s*\(\s*\w+\s*<\s*group\.end/.test(content);
|
|
1704
|
+
if (!hasInnerSamplingLoop) {
|
|
1705
|
+
const hasPeakTween = /peak(?:Bass|Treble|Energy)/.test(content) && /group\.start/.test(content);
|
|
1706
|
+
if (hasPeakTween) {
|
|
1707
|
+
findings.push({
|
|
1708
|
+
code: "audio_reactive_single_tween_per_group",
|
|
1709
|
+
severity: "warning",
|
|
1710
|
+
message: "Audio-reactive captions use a single tween per group based on peak values. This sets one static value at group.start \u2014 not perceptible as audio reactivity.",
|
|
1711
|
+
fixHint: "Sample audio data at 100-200ms intervals throughout each group's lifetime (for loop from group.start to group.end) and create a tween at each sample point for visible pulsing."
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return findings;
|
|
1717
|
+
},
|
|
1718
|
+
// gsap_infinite_repeat
|
|
1719
|
+
({ scripts }) => {
|
|
1720
|
+
const findings = [];
|
|
1721
|
+
for (const script of scripts) {
|
|
1722
|
+
const content = stripJsComments(script.content);
|
|
1723
|
+
const pattern = /repeat\s*:\s*-1(?!\d)/g;
|
|
1724
|
+
let match;
|
|
1725
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1726
|
+
const contextStart = Math.max(0, match.index - 60);
|
|
1727
|
+
const contextEnd = Math.min(content.length, match.index + match[0].length + 60);
|
|
1728
|
+
const snippet = content.slice(contextStart, contextEnd).trim();
|
|
1729
|
+
findings.push({
|
|
1730
|
+
code: "gsap_infinite_repeat",
|
|
1731
|
+
severity: "error",
|
|
1732
|
+
message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic capture engine which seeks to exact frame times. Use a finite repeat count calculated from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
|
|
1733
|
+
fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
|
|
1734
|
+
snippet: truncateSnippet(snippet)
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return findings;
|
|
1739
|
+
},
|
|
1740
|
+
// gsap_repeat_ceil_overshoot
|
|
1741
|
+
({ scripts }) => {
|
|
1742
|
+
const findings = [];
|
|
1743
|
+
for (const script of scripts) {
|
|
1744
|
+
const content = script.content;
|
|
1745
|
+
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
|
|
1746
|
+
let match;
|
|
1747
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1748
|
+
const contextStart = Math.max(0, match.index - 40);
|
|
1749
|
+
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
|
|
1750
|
+
const snippet = content.slice(contextStart, contextEnd).trim();
|
|
1751
|
+
findings.push({
|
|
1752
|
+
code: "gsap_repeat_ceil_overshoot",
|
|
1753
|
+
severity: "warning",
|
|
1754
|
+
message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. For example, Math.ceil(10.5 / 2) - 1 = 5 repeats \u2192 6 cycles \xD7 2s = 12s, exceeding 10.5s.",
|
|
1755
|
+
fixHint: "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: `repeat: Math.floor(totalDuration / cycleDuration) - 1`. Math.floor(10.5 / 2) - 1 = 4 repeats \u2192 5 cycles \xD7 2s = 10s \u2713",
|
|
1756
|
+
snippet: truncateSnippet(snippet)
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return findings;
|
|
1761
|
+
},
|
|
1762
|
+
// scene_layer_missing_visibility_kill
|
|
1763
|
+
({ scripts, tags }) => {
|
|
1764
|
+
const findings = [];
|
|
1765
|
+
const sceneElements = tags.filter((t) => {
|
|
1766
|
+
const id = readAttr(t.raw, "id") || "";
|
|
1767
|
+
return /^scene\d+$/i.test(id);
|
|
1768
|
+
});
|
|
1769
|
+
if (sceneElements.length < 2) return findings;
|
|
1770
|
+
for (const script of scripts) {
|
|
1771
|
+
const content = stripJsComments(script.content);
|
|
1772
|
+
for (const tag of sceneElements) {
|
|
1773
|
+
const id = readAttr(tag.raw, "id") || "";
|
|
1774
|
+
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
|
|
1775
|
+
const hasExit = exitPattern.test(content);
|
|
1776
|
+
if (!hasExit) continue;
|
|
1777
|
+
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
|
|
1778
|
+
const hasKill = killPattern.test(content);
|
|
1779
|
+
if (!hasKill) {
|
|
1780
|
+
findings.push({
|
|
1781
|
+
code: "scene_layer_missing_visibility_kill",
|
|
1782
|
+
severity: "error",
|
|
1783
|
+
elementId: id,
|
|
1784
|
+
message: `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.`,
|
|
1785
|
+
fixHint: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return findings;
|
|
1791
|
+
},
|
|
1792
|
+
// gsap_timeline_not_registered
|
|
1793
|
+
({ scripts, rawSource, options }) => {
|
|
1794
|
+
const findings = [];
|
|
1795
|
+
const canInheritFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
|
|
1796
|
+
for (const script of scripts) {
|
|
1797
|
+
const content = script.content;
|
|
1798
|
+
if (!/gsap\.timeline/.test(content)) continue;
|
|
1799
|
+
const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
|
|
1800
|
+
if (hasRegistration || canInheritFromHost) continue;
|
|
1801
|
+
findings.push({
|
|
1802
|
+
code: "gsap_timeline_not_registered",
|
|
1803
|
+
severity: "error",
|
|
1804
|
+
message: "GSAP timeline is created but never registered in window.__timelines. The runtime discovers timelines from this registry \u2014 without registration, animations will not play during preview or render.",
|
|
1805
|
+
fixHint: 'Add `window.__timelines = window.__timelines || {};` and `window.__timelines["root"] = tl;` after creating the timeline (use the composition\'s data-composition-id as the key).'
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
return findings;
|
|
1809
|
+
},
|
|
1810
|
+
// gsap_timeline_registered_before_async_build — registering window.__timelines[id]
|
|
1811
|
+
// BEFORE the timeline is built inside document.fonts.ready (or any async callback)
|
|
1812
|
+
// leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
|
|
1813
|
+
// treats "key present" as "ready" and nests the child ONCE, while still empty — so the
|
|
1814
|
+
// animation never renders when this composition is mounted as a sub-composition.
|
|
1815
|
+
// Register only AFTER the build completes (the documented async-setup contract).
|
|
1816
|
+
({ scripts }) => {
|
|
1817
|
+
const findings = [];
|
|
1818
|
+
for (const script of scripts) {
|
|
1819
|
+
const content = stripJsComments(script.content);
|
|
1820
|
+
const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
|
|
1821
|
+
if (regIdx < 0) continue;
|
|
1822
|
+
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
|
|
1823
|
+
if (fontsReadyIdx < 0) continue;
|
|
1824
|
+
if (regIdx >= fontsReadyIdx) continue;
|
|
1825
|
+
const tail = content.slice(fontsReadyIdx);
|
|
1826
|
+
if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
|
|
1827
|
+
findings.push({
|
|
1828
|
+
code: "gsap_timeline_registered_before_async_build",
|
|
1829
|
+
severity: "error",
|
|
1830
|
+
message: 'window.__timelines is assigned BEFORE the timeline is built inside document.fonts.ready. An empty timeline registered early gets nested empty when this composition is used as a sub-composition (the readiness gate treats "key present" as "ready" and never re-nests), so the animation renders blank.',
|
|
1831
|
+
fixHint: "Move the `window.__timelines[id] = tl;` assignment to the END of the document.fonts.ready callback, after the tweens are added. Optionally call window.__hfForceTimelineRebind() right after, to re-nest the populated timeline."
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
return findings;
|
|
1835
|
+
},
|
|
1836
|
+
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
|
|
1837
|
+
// fallow-ignore-next-line complexity
|
|
1838
|
+
async ({ styles, scripts, tags }) => {
|
|
1839
|
+
const findings = [];
|
|
1840
|
+
const cssOpacityZeroSelectors = /* @__PURE__ */ new Set();
|
|
1841
|
+
for (const style of styles) {
|
|
1842
|
+
for (const [, selector, body] of style.content.matchAll(
|
|
1843
|
+
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
|
|
1844
|
+
)) {
|
|
1845
|
+
if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) {
|
|
1846
|
+
cssOpacityZeroSelectors.add((selector ?? "").trim());
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
for (const tag of tags) {
|
|
1851
|
+
const inlineStyle = readAttr(tag.raw, "style");
|
|
1852
|
+
if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle)) continue;
|
|
1853
|
+
const id = readAttr(tag.raw, "id");
|
|
1854
|
+
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
1855
|
+
if (id) cssOpacityZeroSelectors.add(`#${id}`);
|
|
1856
|
+
for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
|
|
1857
|
+
}
|
|
1858
|
+
if (cssOpacityZeroSelectors.size === 0) return findings;
|
|
1859
|
+
for (const script of scripts) {
|
|
1860
|
+
if (!/gsap\.timeline/.test(script.content)) continue;
|
|
1861
|
+
const windows = await cachedExtractGsapWindows(script.content);
|
|
1862
|
+
for (const win of windows) {
|
|
1863
|
+
if (win.method !== "from") continue;
|
|
1864
|
+
if (!win.properties.includes("opacity")) continue;
|
|
1865
|
+
if (win.propertyValues["opacity"] !== 0) continue;
|
|
1866
|
+
const sel = win.targetSelector;
|
|
1867
|
+
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
|
1868
|
+
if (!cssOpacityZeroSelectors.has(cssKey)) continue;
|
|
1869
|
+
findings.push({
|
|
1870
|
+
code: "gsap_from_opacity_noop",
|
|
1871
|
+
severity: "error",
|
|
1872
|
+
message: `"${sel}" has CSS \`opacity: 0\` and a gsap.${win.method}() that also sets opacity to 0. gsap.from() animates FROM the specified value TO the current CSS value \u2014 since CSS is already 0, the element animates from 0\u21920 and never becomes visible.`,
|
|
1873
|
+
selector: sel,
|
|
1874
|
+
fixHint: `Remove \`opacity: 0\` from the CSS/inline style on "${sel}". Let gsap.from({opacity: 0}) handle the initial hidden state \u2014 it will animate FROM 0 TO the CSS value (1 by default).`,
|
|
1875
|
+
snippet: truncateSnippet(win.raw)
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return findings;
|
|
1880
|
+
},
|
|
1881
|
+
// gsap_group_selector_keyframes
|
|
1882
|
+
({ scripts }) => {
|
|
1883
|
+
const findings = [];
|
|
1884
|
+
for (const script of scripts) {
|
|
1885
|
+
const content = stripJsComments(script.content);
|
|
1886
|
+
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
|
|
1887
|
+
let match;
|
|
1888
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1889
|
+
const selector = match[1];
|
|
1890
|
+
const count = selector.split(",").length;
|
|
1891
|
+
const contextStart = Math.max(0, match.index - 20);
|
|
1892
|
+
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
|
|
1893
|
+
findings.push({
|
|
1894
|
+
code: "gsap_group_selector_keyframes",
|
|
1895
|
+
severity: "warning",
|
|
1896
|
+
message: `GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). Editing one element's keyframes in Studio will affect all ${count} elements. Split into individual tweens for per-element keyframe control.`,
|
|
1897
|
+
fixHint: `Replace the group selector with individual tl.to() calls per element, each with their own keyframes object.`,
|
|
1898
|
+
snippet: truncateSnippet(content.slice(contextStart, contextEnd))
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return findings;
|
|
1903
|
+
}
|
|
1904
|
+
];
|
|
1905
|
+
|
|
1906
|
+
// src/rules/captions.ts
|
|
1907
|
+
function extractArrayLiteral(src, varMatch) {
|
|
1908
|
+
const openIdx = varMatch.index + varMatch[0].length - 1;
|
|
1909
|
+
let depth = 0;
|
|
1910
|
+
let inStr = false;
|
|
1911
|
+
let strChar = "";
|
|
1912
|
+
for (let i = openIdx; i < src.length; i++) {
|
|
1913
|
+
const c = src[i];
|
|
1914
|
+
if (inStr) {
|
|
1915
|
+
if (c === "\\") {
|
|
1916
|
+
i++;
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (c === strChar) inStr = false;
|
|
1920
|
+
} else if (c === '"' || c === "'") {
|
|
1921
|
+
inStr = true;
|
|
1922
|
+
strChar = c;
|
|
1923
|
+
} else if (c === "[") {
|
|
1924
|
+
depth++;
|
|
1925
|
+
} else if (c === "]") {
|
|
1926
|
+
depth--;
|
|
1927
|
+
if (depth === 0) return src.slice(openIdx, i + 1);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
var captionRules = [
|
|
1933
|
+
// caption_exit_missing_hard_kill
|
|
1934
|
+
({ scripts, styles, options, rootCompositionId }) => {
|
|
1935
|
+
const findings = [];
|
|
1936
|
+
const isCaptionComposition = Boolean(options.filePath && /caption/i.test(options.filePath)) || rootCompositionId === "captions" || styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
|
|
1937
|
+
if (!isCaptionComposition) return findings;
|
|
1938
|
+
for (const script of scripts) {
|
|
1939
|
+
const content = script.content;
|
|
1940
|
+
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
|
1941
|
+
const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
|
|
1942
|
+
content
|
|
1943
|
+
);
|
|
1944
|
+
const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
|
|
1945
|
+
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
|
1946
|
+
findings.push({
|
|
1947
|
+
code: "caption_exit_missing_hard_kill",
|
|
1948
|
+
severity: "error",
|
|
1949
|
+
message: "Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
|
1950
|
+
fixHint: 'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every exit tl.to animation as a deterministic kill.'
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return findings;
|
|
1955
|
+
},
|
|
1956
|
+
// caption_text_overflow_risk
|
|
1957
|
+
({ styles }) => {
|
|
1958
|
+
const findings = [];
|
|
1959
|
+
for (const style of styles) {
|
|
1960
|
+
const captionBlocks = style.content.matchAll(
|
|
1961
|
+
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi
|
|
1962
|
+
);
|
|
1963
|
+
for (const [, selector, body] of captionBlocks) {
|
|
1964
|
+
if (!body) continue;
|
|
1965
|
+
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
|
|
1966
|
+
const hasMaxWidth = /max-width/i.test(body);
|
|
1967
|
+
if (hasNowrap && !hasMaxWidth) {
|
|
1968
|
+
findings.push({
|
|
1969
|
+
code: "caption_text_overflow_risk",
|
|
1970
|
+
severity: "warning",
|
|
1971
|
+
selector: (selector ?? "").trim(),
|
|
1972
|
+
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
|
1973
|
+
fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden."
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return findings;
|
|
1979
|
+
},
|
|
1980
|
+
// caption_transcript_not_inline
|
|
1981
|
+
// fallow-ignore-next-line complexity
|
|
1982
|
+
({ scripts, styles, options }) => {
|
|
1983
|
+
const findings = [];
|
|
1984
|
+
const isCaptionFile = options.filePath && /caption/i.test(options.filePath) || styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
1985
|
+
if (!isCaptionFile) return findings;
|
|
1986
|
+
const allScript = scripts.map((s) => s.content).join("\n");
|
|
1987
|
+
const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test(
|
|
1988
|
+
allScript
|
|
1989
|
+
);
|
|
1990
|
+
const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript);
|
|
1991
|
+
if (!hasInlineTranscript && hasFetchTranscript) {
|
|
1992
|
+
findings.push({
|
|
1993
|
+
code: "caption_transcript_not_inline",
|
|
1994
|
+
severity: "error",
|
|
1995
|
+
message: "Captions composition loads transcript via fetch(). The studio caption editor requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
|
|
1996
|
+
fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
if (hasInlineTranscript) {
|
|
2000
|
+
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
|
2001
|
+
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
|
2002
|
+
if (transcriptJson) {
|
|
2003
|
+
try {
|
|
2004
|
+
JSON.parse(transcriptJson);
|
|
2005
|
+
} catch {
|
|
2006
|
+
findings.push({
|
|
2007
|
+
code: "caption_transcript_parse_error",
|
|
2008
|
+
severity: "error",
|
|
2009
|
+
message: "Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail to parse it. Common cause: unquoted property keys with apostrophes in text.",
|
|
2010
|
+
fixHint: `Use JSON-quoted keys: { "text": "don't", "start": 0, "end": 1 } instead of { text: "don't", start: 0, end: 1 }.`
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
return findings;
|
|
2016
|
+
},
|
|
2017
|
+
// caption_container_relative_position
|
|
2018
|
+
({ styles }) => {
|
|
2019
|
+
const findings = [];
|
|
2020
|
+
for (const style of styles) {
|
|
2021
|
+
const captionBlocks = style.content.matchAll(
|
|
2022
|
+
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi
|
|
2023
|
+
);
|
|
2024
|
+
for (const [, selector, body] of captionBlocks) {
|
|
2025
|
+
if (!body) continue;
|
|
2026
|
+
if (/position\s*:\s*relative/i.test(body)) {
|
|
2027
|
+
findings.push({
|
|
2028
|
+
code: "caption_container_relative_position",
|
|
2029
|
+
severity: "error",
|
|
2030
|
+
selector: (selector ?? "").trim(),
|
|
2031
|
+
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
|
2032
|
+
fixHint: "Use position: absolute for all caption elements."
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return findings;
|
|
2038
|
+
},
|
|
2039
|
+
// caption_overflow_clips_scaled_words
|
|
2040
|
+
({ styles, scripts }) => {
|
|
2041
|
+
const findings = [];
|
|
2042
|
+
const hasScaledWords = scripts.some(
|
|
2043
|
+
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content)
|
|
2044
|
+
);
|
|
2045
|
+
if (!hasScaledWords) return findings;
|
|
2046
|
+
for (const style of styles) {
|
|
2047
|
+
const captionBlocks = style.content.matchAll(
|
|
2048
|
+
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi
|
|
2049
|
+
);
|
|
2050
|
+
for (const [, selector, body] of captionBlocks) {
|
|
2051
|
+
if (!body) continue;
|
|
2052
|
+
if (/overflow\s*:\s*hidden/i.test(body)) {
|
|
2053
|
+
findings.push({
|
|
2054
|
+
code: "caption_overflow_clips_scaled_words",
|
|
2055
|
+
severity: "error",
|
|
2056
|
+
selector: (selector ?? "").trim(),
|
|
2057
|
+
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
|
|
2058
|
+
fixHint: "Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead."
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return findings;
|
|
2064
|
+
},
|
|
2065
|
+
// caption_textshadow_on_group_container
|
|
2066
|
+
({ scripts, styles }) => {
|
|
2067
|
+
const findings = [];
|
|
2068
|
+
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
2069
|
+
if (!isCaptionFile) return findings;
|
|
2070
|
+
for (const script of scripts) {
|
|
2071
|
+
const groupShadowPattern = /\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
|
|
2072
|
+
const selectorShadowPattern = /\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
|
|
2073
|
+
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
|
|
2074
|
+
findings.push({
|
|
2075
|
+
code: "caption_textshadow_on_group_container",
|
|
2076
|
+
severity: "warning",
|
|
2077
|
+
message: "textShadow is tweened on a caption group container. When children have semi-transparent color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible rectangle behind the entire group.",
|
|
2078
|
+
fixHint: "Apply textShadow to individual active word elements instead of the group container. Use scale on the group for bass-reactive pulsing."
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return findings;
|
|
2083
|
+
},
|
|
2084
|
+
// caption_fittext_scale_mismatch
|
|
2085
|
+
// fallow-ignore-next-line complexity
|
|
2086
|
+
({ scripts }) => {
|
|
2087
|
+
const findings = [];
|
|
2088
|
+
for (const script of scripts) {
|
|
2089
|
+
const content = script.content;
|
|
2090
|
+
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
|
|
2091
|
+
if (!fitTextMatch) continue;
|
|
2092
|
+
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
|
|
2093
|
+
if (!maxWidth) continue;
|
|
2094
|
+
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
|
|
2095
|
+
const captionContext = /caption|word|cg-|karaoke/i.test(content);
|
|
2096
|
+
if (!captionContext || scaleMatches.length === 0) continue;
|
|
2097
|
+
let maxScale = 1;
|
|
2098
|
+
for (const m of scaleMatches) {
|
|
2099
|
+
const val = parseFloat(m[1] ?? "1");
|
|
2100
|
+
if (val > maxScale) maxScale = val;
|
|
2101
|
+
}
|
|
2102
|
+
const effectiveWidth = maxWidth * maxScale;
|
|
2103
|
+
if (effectiveWidth > 1760) {
|
|
2104
|
+
findings.push({
|
|
2105
|
+
code: "caption_fittext_scale_mismatch",
|
|
2106
|
+
severity: "warning",
|
|
2107
|
+
message: `fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
|
|
2108
|
+
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
return findings;
|
|
2113
|
+
}
|
|
2114
|
+
];
|
|
2115
|
+
|
|
2116
|
+
// src/rules/composition.ts
|
|
2117
|
+
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
|
|
2118
|
+
var MAX_COMPOSITION_LINES = 300;
|
|
2119
|
+
var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
|
|
2120
|
+
var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
|
|
2121
|
+
function countPhysicalLines(source) {
|
|
2122
|
+
if (source.length === 0) return 0;
|
|
2123
|
+
const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2124
|
+
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
|
|
2125
|
+
return withoutFinalNewline.split("\n").length;
|
|
2126
|
+
}
|
|
2127
|
+
function countStructuralLines(source) {
|
|
2128
|
+
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
|
|
2129
|
+
}
|
|
2130
|
+
function isRegistrySourceFile(filePath) {
|
|
2131
|
+
if (!filePath) return false;
|
|
2132
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
2133
|
+
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
|
|
2134
|
+
}
|
|
2135
|
+
function isRegistryInstalledFile(rawSource) {
|
|
2136
|
+
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
|
|
2137
|
+
}
|
|
2138
|
+
function isCompositionRootOrMount(rawTag) {
|
|
2139
|
+
return Boolean(
|
|
2140
|
+
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src")
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
function extractCssUrlReferences(css) {
|
|
2144
|
+
const out = [];
|
|
2145
|
+
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2146
|
+
const urlPattern = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
|
2147
|
+
let m;
|
|
2148
|
+
while ((m = urlPattern.exec(noComments)) !== null) {
|
|
2149
|
+
const raw = (m[2] ?? "").trim();
|
|
2150
|
+
if (raw) out.push(raw);
|
|
2151
|
+
}
|
|
2152
|
+
return out;
|
|
2153
|
+
}
|
|
2154
|
+
function extractCssSelectors(css) {
|
|
2155
|
+
const out = [];
|
|
2156
|
+
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2157
|
+
const ruleHeader = /([^{}]+)\{/g;
|
|
2158
|
+
let m;
|
|
2159
|
+
while ((m = ruleHeader.exec(noComments)) !== null) {
|
|
2160
|
+
const header = (m[1] ?? "").trim();
|
|
2161
|
+
if (!header || header.startsWith("@")) continue;
|
|
2162
|
+
for (const sel of header.split(",")) {
|
|
2163
|
+
const s = sel.trim();
|
|
2164
|
+
if (s) out.push(s);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return out;
|
|
2168
|
+
}
|
|
2169
|
+
function leftmostCompoundClasses(selector) {
|
|
2170
|
+
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
2171
|
+
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
|
|
2172
|
+
}
|
|
2173
|
+
function rootClassStyledSelectors(styles, rootClasses) {
|
|
2174
|
+
const offenders = [];
|
|
2175
|
+
for (const style of styles) {
|
|
2176
|
+
for (const selector of extractCssSelectors(style.content)) {
|
|
2177
|
+
const hitsRoot = leftmostCompoundClasses(selector).some((c) => rootClasses.includes(c));
|
|
2178
|
+
if (hitsRoot && !offenders.includes(selector)) offenders.push(selector);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
return offenders;
|
|
2182
|
+
}
|
|
2183
|
+
var compositionRules = [
|
|
2184
|
+
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
|
2185
|
+
// href, inline-style url(), and <style> url() asset references on
|
|
2186
|
+
// compositions. Sub-compositions live under compositions/ but are served
|
|
2187
|
+
// with the project root as their base URL, so any `../`-traversing path
|
|
2188
|
+
// climbs above the project root and 404s in Studio preview. Renders
|
|
2189
|
+
// tolerate it because the server-side bundler rewrites `../foo` against
|
|
2190
|
+
// each sub-composition's source path; the runtime now mirrors that fallback
|
|
2191
|
+
// (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
|
|
2192
|
+
// the authoring-time signal is still wrong — flag it at lint time so the
|
|
2193
|
+
// baked path is plain root-relative and matches what the bundler emits.
|
|
2194
|
+
//
|
|
2195
|
+
// Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
|
|
2196
|
+
// values, `[style]` inline url(), and `<style>` block url() references.
|
|
2197
|
+
// Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
|
|
2198
|
+
// hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
|
|
2199
|
+
// traversal is flagged. Subsumes the older `../capture/`-specific rule.
|
|
2200
|
+
// fallow-ignore-next-line complexity
|
|
2201
|
+
({ tags, styles, rawSource, options }) => {
|
|
2202
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
2203
|
+
const offenders = [];
|
|
2204
|
+
const collect = (value) => {
|
|
2205
|
+
if (!value) return;
|
|
2206
|
+
const trimmed = value.trim();
|
|
2207
|
+
if (!trimmed.startsWith("../") && trimmed !== "..") return;
|
|
2208
|
+
offenders.push(trimmed);
|
|
2209
|
+
};
|
|
2210
|
+
for (const tag of tags) {
|
|
2211
|
+
collect(readAttr(tag.raw, "src"));
|
|
2212
|
+
collect(readAttr(tag.raw, "href"));
|
|
2213
|
+
const styleAttr = readJsonAttr(tag.raw, "style");
|
|
2214
|
+
if (styleAttr) {
|
|
2215
|
+
for (const url of extractCssUrlReferences(styleAttr)) collect(url);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
for (const style of styles) {
|
|
2219
|
+
for (const url of extractCssUrlReferences(style.content)) collect(url);
|
|
2220
|
+
}
|
|
2221
|
+
if (offenders.length === 0) return [];
|
|
2222
|
+
const prefixCounts = /* @__PURE__ */ new Map();
|
|
2223
|
+
for (const path of offenders) {
|
|
2224
|
+
const prefix = path.match(/^(?:\.\.\/)+[^/]+\//)?.[0] ?? path;
|
|
2225
|
+
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
|
|
2226
|
+
}
|
|
2227
|
+
const prefixSummary = Array.from(prefixCounts.entries()).sort(([, a], [, b]) => b - a).map(([prefix, count]) => count > 1 ? `${prefix} (${count})` : prefix).join(", ");
|
|
2228
|
+
return [
|
|
2229
|
+
{
|
|
2230
|
+
code: "invalid_parent_traversal_in_asset_path",
|
|
2231
|
+
severity: "error",
|
|
2232
|
+
message: `Found ${offenders.length} asset path(s) traversing above the project root with "../" (${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
|
|
2233
|
+
fixHint: 'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") \u2014 compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.'
|
|
2234
|
+
}
|
|
2235
|
+
];
|
|
2236
|
+
},
|
|
2237
|
+
// composition_file_too_large
|
|
2238
|
+
({ rawSource, options }) => {
|
|
2239
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
2240
|
+
const lineCount = countStructuralLines(rawSource);
|
|
2241
|
+
if (lineCount <= MAX_COMPOSITION_LINES) return [];
|
|
2242
|
+
const splitTarget = options.isSubComposition ? "Split this sub-composition further into smaller .html files" : "Split coherent scenes or layers into separate .html files under compositions/";
|
|
2243
|
+
return [
|
|
2244
|
+
{
|
|
2245
|
+
code: "composition_file_too_large",
|
|
2246
|
+
severity: "warning",
|
|
2247
|
+
message: `This HTML composition file has ${lineCount} lines. Smaller sub-compositions are easier to read, iterate on, and diff.`,
|
|
2248
|
+
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`
|
|
2249
|
+
}
|
|
2250
|
+
];
|
|
2251
|
+
},
|
|
2252
|
+
// timeline_track_too_dense
|
|
2253
|
+
// fallow-ignore-next-line complexity
|
|
2254
|
+
({ tags, options }) => {
|
|
2255
|
+
const trackCounts = /* @__PURE__ */ new Map();
|
|
2256
|
+
for (const tag of tags) {
|
|
2257
|
+
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
|
|
2258
|
+
if (isCompositionRootOrMount(tag.raw)) continue;
|
|
2259
|
+
if (!readAttr(tag.raw, "data-start")) continue;
|
|
2260
|
+
const track = readAttr(tag.raw, "data-track-index");
|
|
2261
|
+
if (!track) continue;
|
|
2262
|
+
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
|
|
2263
|
+
}
|
|
2264
|
+
const findings = [];
|
|
2265
|
+
for (const [track, count] of trackCounts) {
|
|
2266
|
+
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
|
|
2267
|
+
const splitTarget = options.isSubComposition ? "Move coherent scene groups into smaller .html files" : "Move coherent scene groups into separate .html files under compositions/";
|
|
2268
|
+
findings.push({
|
|
2269
|
+
code: "timeline_track_too_dense",
|
|
2270
|
+
severity: "warning",
|
|
2271
|
+
message: `Track ${track} has ${count} timed elements in this HTML file. Smaller sub-compositions keep timelines easier to read, iterate on, and diff.`,
|
|
2272
|
+
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return findings;
|
|
2276
|
+
},
|
|
2277
|
+
// timed_element_missing_visibility_hidden
|
|
2278
|
+
// fallow-ignore-next-line complexity
|
|
2279
|
+
({ tags }) => {
|
|
2280
|
+
const findings = [];
|
|
2281
|
+
for (const tag of tags) {
|
|
2282
|
+
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
|
2283
|
+
if (!readAttr(tag.raw, "data-start")) continue;
|
|
2284
|
+
if (readAttr(tag.raw, "data-composition-id")) continue;
|
|
2285
|
+
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
2286
|
+
const classAttr = readAttr(tag.raw, "class") || "";
|
|
2287
|
+
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
2288
|
+
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
2289
|
+
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
|
2290
|
+
if (!hasClip && !hasHiddenStyle) {
|
|
2291
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
2292
|
+
findings.push({
|
|
2293
|
+
code: "timed_element_missing_visibility_hidden",
|
|
2294
|
+
severity: "info",
|
|
2295
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
|
|
2296
|
+
elementId,
|
|
2297
|
+
fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
|
2298
|
+
snippet: truncateSnippet(tag.raw)
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
return findings;
|
|
2303
|
+
},
|
|
2304
|
+
// deprecated_data_layer + deprecated_data_end
|
|
2305
|
+
// fallow-ignore-next-line complexity
|
|
2306
|
+
({ tags }) => {
|
|
2307
|
+
const findings = [];
|
|
2308
|
+
for (const tag of tags) {
|
|
2309
|
+
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
|
|
2310
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
2311
|
+
findings.push({
|
|
2312
|
+
code: "deprecated_data_layer",
|
|
2313
|
+
severity: "error",
|
|
2314
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
|
|
2315
|
+
elementId,
|
|
2316
|
+
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
|
|
2317
|
+
snippet: truncateSnippet(tag.raw)
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
|
|
2321
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
2322
|
+
findings.push({
|
|
2323
|
+
code: "deprecated_data_end",
|
|
2324
|
+
severity: "error",
|
|
2325
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
|
|
2326
|
+
elementId,
|
|
2327
|
+
fixHint: "Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
|
|
2328
|
+
snippet: truncateSnippet(tag.raw)
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
return findings;
|
|
2333
|
+
},
|
|
2334
|
+
// split_data_attribute_selector
|
|
2335
|
+
({ scripts, styles }) => {
|
|
2336
|
+
const findings = [];
|
|
2337
|
+
const splitDataAttrSelectorPattern = /\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
|
|
2338
|
+
const scan = (content) => {
|
|
2339
|
+
splitDataAttrSelectorPattern.lastIndex = 0;
|
|
2340
|
+
let match;
|
|
2341
|
+
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
|
|
2342
|
+
const compId = match[2] ?? "";
|
|
2343
|
+
const attrName = match[3] ?? "";
|
|
2344
|
+
const attrValue = match[5] ?? "";
|
|
2345
|
+
findings.push({
|
|
2346
|
+
code: "split_data_attribute_selector",
|
|
2347
|
+
severity: "error",
|
|
2348
|
+
message: `Selector "${match[0]}" combines two attributes inside one CSS attribute selector. Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.`,
|
|
2349
|
+
selector: match[0],
|
|
2350
|
+
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
|
|
2351
|
+
snippet: truncateSnippet(match[0])
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
for (const style of styles) scan(style.content);
|
|
2356
|
+
for (const script of scripts) scan(script.content);
|
|
2357
|
+
return findings;
|
|
2358
|
+
},
|
|
2359
|
+
// template_literal_selector
|
|
2360
|
+
({ scripts }) => {
|
|
2361
|
+
const findings = [];
|
|
2362
|
+
for (const script of scripts) {
|
|
2363
|
+
const templateLiteralSelectorPattern = /(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
|
|
2364
|
+
let tlMatch;
|
|
2365
|
+
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
|
|
2366
|
+
findings.push({
|
|
2367
|
+
code: "template_literal_selector",
|
|
2368
|
+
severity: "error",
|
|
2369
|
+
message: "querySelector uses a template literal variable (e.g. `${compId}`). The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
|
|
2370
|
+
fixHint: "Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
|
|
2371
|
+
snippet: truncateSnippet(tlMatch[0])
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return findings;
|
|
2376
|
+
},
|
|
2377
|
+
// timed_element_missing_clip_class
|
|
2378
|
+
// fallow-ignore-next-line complexity
|
|
2379
|
+
({ tags }) => {
|
|
2380
|
+
const findings = [];
|
|
2381
|
+
const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
|
|
2382
|
+
for (const tag of tags) {
|
|
2383
|
+
if (skipTags.has(tag.name)) continue;
|
|
2384
|
+
if (readAttr(tag.raw, "data-composition-id")) continue;
|
|
2385
|
+
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
2386
|
+
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
|
2387
|
+
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
|
|
2388
|
+
if (!hasStart && !hasDuration) continue;
|
|
2389
|
+
const classAttr = readAttr(tag.raw, "class") || "";
|
|
2390
|
+
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
2391
|
+
if (hasClip) continue;
|
|
2392
|
+
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
2393
|
+
findings.push({
|
|
2394
|
+
code: "timed_element_missing_clip_class",
|
|
2395
|
+
severity: "error",
|
|
2396
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
|
|
2397
|
+
elementId,
|
|
2398
|
+
fixHint: 'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
|
|
2399
|
+
snippet: truncateSnippet(tag.raw)
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
return findings;
|
|
2403
|
+
},
|
|
2404
|
+
// overlapping_clips_same_track
|
|
2405
|
+
// fallow-ignore-next-line complexity
|
|
2406
|
+
({ tags }) => {
|
|
2407
|
+
const findings = [];
|
|
2408
|
+
const trackMap = /* @__PURE__ */ new Map();
|
|
2409
|
+
for (const tag of tags) {
|
|
2410
|
+
const startStr = readAttr(tag.raw, "data-start");
|
|
2411
|
+
const durationStr = readAttr(tag.raw, "data-duration");
|
|
2412
|
+
const trackStr = readAttr(tag.raw, "data-track-index");
|
|
2413
|
+
if (!startStr || !durationStr || !trackStr) continue;
|
|
2414
|
+
const start = Number(startStr);
|
|
2415
|
+
const duration = Number(durationStr);
|
|
2416
|
+
const track = trackStr;
|
|
2417
|
+
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
|
|
2418
|
+
const clips = trackMap.get(track) || [];
|
|
2419
|
+
clips.push({
|
|
2420
|
+
start,
|
|
2421
|
+
end: start + duration,
|
|
2422
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2423
|
+
snippet: truncateSnippet(tag.raw) || ""
|
|
2424
|
+
});
|
|
2425
|
+
trackMap.set(track, clips);
|
|
2426
|
+
}
|
|
2427
|
+
for (const [track, clips] of trackMap) {
|
|
2428
|
+
clips.sort((a, b) => a.start - b.start);
|
|
2429
|
+
for (let i = 0; i < clips.length - 1; i++) {
|
|
2430
|
+
const current = clips[i];
|
|
2431
|
+
const next = clips[i + 1];
|
|
2432
|
+
if (!current || !next) continue;
|
|
2433
|
+
if (current.end > next.start) {
|
|
2434
|
+
findings.push({
|
|
2435
|
+
code: "overlapping_clips_same_track",
|
|
2436
|
+
severity: "error",
|
|
2437
|
+
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
|
|
2438
|
+
fixHint: "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index."
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
return findings;
|
|
2444
|
+
},
|
|
2445
|
+
// root_composition_missing_data_start
|
|
2446
|
+
({ rootTag, options }) => {
|
|
2447
|
+
const findings = [];
|
|
2448
|
+
if (options.isSubComposition) return findings;
|
|
2449
|
+
if (!rootTag) return findings;
|
|
2450
|
+
const compId = readAttr(rootTag.raw, "data-composition-id");
|
|
2451
|
+
if (!compId) return findings;
|
|
2452
|
+
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
|
2453
|
+
if (!hasStart) {
|
|
2454
|
+
findings.push({
|
|
2455
|
+
code: "root_composition_missing_data_start",
|
|
2456
|
+
severity: "error",
|
|
2457
|
+
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
|
2458
|
+
fixHint: 'Add data-start="0" to the root composition element.',
|
|
2459
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
return findings;
|
|
2463
|
+
},
|
|
2464
|
+
// standalone_composition_wrapped_in_template
|
|
2465
|
+
({ rawSource, options }) => {
|
|
2466
|
+
const findings = [];
|
|
2467
|
+
if (options.isSubComposition) return findings;
|
|
2468
|
+
const trimmed = rawSource.trimStart().toLowerCase();
|
|
2469
|
+
if (trimmed.startsWith("<template")) {
|
|
2470
|
+
findings.push({
|
|
2471
|
+
code: "standalone_composition_wrapped_in_template",
|
|
2472
|
+
severity: "error",
|
|
2473
|
+
message: "Root index.html is wrapped in a <template> tag. Only sub-compositions loaded via data-composition-src should use <template> wrappers. The runtime cannot play a standalone composition inside a template.",
|
|
2474
|
+
fixHint: "Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead."
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
return findings;
|
|
2478
|
+
},
|
|
2479
|
+
// root_composition_missing_html_wrapper
|
|
2480
|
+
({ rawSource, rootTag, options }) => {
|
|
2481
|
+
const findings = [];
|
|
2482
|
+
if (options.isSubComposition) return findings;
|
|
2483
|
+
const trimmed = rawSource.trimStart().toLowerCase();
|
|
2484
|
+
if (trimmed.startsWith("<template")) return findings;
|
|
2485
|
+
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
|
|
2486
|
+
const hasComposition = rawSource.includes("data-composition-id");
|
|
2487
|
+
if (hasComposition && !hasDoctype) {
|
|
2488
|
+
findings.push({
|
|
2489
|
+
code: "root_composition_missing_html_wrapper",
|
|
2490
|
+
severity: "error",
|
|
2491
|
+
message: "Composition starts with a bare element instead of a proper HTML document. An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> is a fragment \u2014 browsers quirks-mode it, the preview server cannot load it, and the bundler will fail to inject runtime scripts.",
|
|
2492
|
+
fixHint: 'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
|
|
2493
|
+
snippet: rootTag ? truncateSnippet(rootTag.raw) : void 0
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
return findings;
|
|
2497
|
+
},
|
|
2498
|
+
// requestanimationframe_in_composition
|
|
2499
|
+
({ scripts, rawSource, options }) => {
|
|
2500
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
2501
|
+
const findings = [];
|
|
2502
|
+
for (const script of scripts) {
|
|
2503
|
+
const stripped = stripJsComments(script.content);
|
|
2504
|
+
if (/requestAnimationFrame\s*\(/.test(stripped)) {
|
|
2505
|
+
findings.push({
|
|
2506
|
+
code: "requestanimationframe_in_composition",
|
|
2507
|
+
severity: "error",
|
|
2508
|
+
message: "`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
|
|
2509
|
+
fixHint: "Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
|
|
2510
|
+
snippet: truncateSnippet(script.content)
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
return findings;
|
|
2515
|
+
},
|
|
2516
|
+
// invalid_variable_values_json
|
|
2517
|
+
// Host elements (`[data-composition-src]`) carry per-instance values via
|
|
2518
|
+
// `data-variable-values`. The runtime swallows JSON errors silently and
|
|
2519
|
+
// falls back to declared defaults, which masks typos. This rule surfaces
|
|
2520
|
+
// the parse failure so authors notice before render time.
|
|
2521
|
+
// fallow-ignore-next-line complexity
|
|
2522
|
+
({ tags }) => {
|
|
2523
|
+
const findings = [];
|
|
2524
|
+
for (const tag of tags) {
|
|
2525
|
+
const raw = readJsonAttr(tag.raw, "data-variable-values");
|
|
2526
|
+
if (!raw) continue;
|
|
2527
|
+
let parsed;
|
|
2528
|
+
try {
|
|
2529
|
+
parsed = JSON.parse(raw);
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
const reason = err instanceof Error ? err.message : "unknown";
|
|
2532
|
+
findings.push({
|
|
2533
|
+
code: "invalid_variable_values_json",
|
|
2534
|
+
severity: "error",
|
|
2535
|
+
message: `data-variable-values is not valid JSON (${reason}).`,
|
|
2536
|
+
fixHint: `Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values='{"title":"Hello"}'.`,
|
|
2537
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2538
|
+
snippet: truncateSnippet(tag.raw)
|
|
2539
|
+
});
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2543
|
+
findings.push({
|
|
2544
|
+
code: "invalid_variable_values_json",
|
|
2545
|
+
severity: "error",
|
|
2546
|
+
message: 'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
|
|
2547
|
+
fixHint: "Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
|
|
2548
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2549
|
+
snippet: truncateSnippet(tag.raw)
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return findings;
|
|
2554
|
+
},
|
|
2555
|
+
// invalid_composition_variables_declaration
|
|
2556
|
+
// The runtime parses `data-composition-variables` and silently returns []
|
|
2557
|
+
// on any structural problem. Surface JSON / shape failures so authors
|
|
2558
|
+
// catch them at lint time rather than wondering why their `getVariables()`
|
|
2559
|
+
// defaults aren't applied.
|
|
2560
|
+
// fallow-ignore-next-line complexity
|
|
2561
|
+
({ source }) => {
|
|
2562
|
+
const htmlTag = findHtmlTag(source);
|
|
2563
|
+
if (!htmlTag) return [];
|
|
2564
|
+
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
|
|
2565
|
+
if (!raw) return [];
|
|
2566
|
+
let parsed;
|
|
2567
|
+
try {
|
|
2568
|
+
parsed = JSON.parse(raw);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
const reason = err instanceof Error ? err.message : "unknown";
|
|
2571
|
+
return [
|
|
2572
|
+
{
|
|
2573
|
+
code: "invalid_composition_variables_declaration",
|
|
2574
|
+
severity: "error",
|
|
2575
|
+
message: `data-composition-variables is not valid JSON (${reason}).`,
|
|
2576
|
+
fixHint: `Provide a JSON array of variable declarations: data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"Hello"}]'.`,
|
|
2577
|
+
snippet: truncateSnippet(htmlTag.raw)
|
|
2578
|
+
}
|
|
2579
|
+
];
|
|
2580
|
+
}
|
|
2581
|
+
if (!Array.isArray(parsed)) {
|
|
2582
|
+
return [
|
|
2583
|
+
{
|
|
2584
|
+
code: "invalid_composition_variables_declaration",
|
|
2585
|
+
severity: "error",
|
|
2586
|
+
message: "data-composition-variables must be a JSON array of variable declarations.",
|
|
2587
|
+
fixHint: `Wrap declarations in [] and give each an id, type, label, and default: '[{"id":"title","type":"string","label":"Title","default":"Hello"}]'.`,
|
|
2588
|
+
snippet: truncateSnippet(htmlTag.raw)
|
|
2589
|
+
}
|
|
2590
|
+
];
|
|
2591
|
+
}
|
|
2592
|
+
const findings = [];
|
|
2593
|
+
const knownTypes = new Set(COMPOSITION_VARIABLE_TYPES);
|
|
2594
|
+
for (let i = 0; i < parsed.length; i += 1) {
|
|
2595
|
+
const entry = parsed[i];
|
|
2596
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2597
|
+
findings.push({
|
|
2598
|
+
code: "invalid_composition_variables_declaration",
|
|
2599
|
+
severity: "error",
|
|
2600
|
+
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
|
|
2601
|
+
snippet: truncateSnippet(htmlTag.raw)
|
|
2602
|
+
});
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
const e = entry;
|
|
2606
|
+
const missing = [];
|
|
2607
|
+
if (typeof e.id !== "string") missing.push("id");
|
|
2608
|
+
if (typeof e.type !== "string" || !knownTypes.has(e.type)) missing.push("type");
|
|
2609
|
+
if (typeof e.label !== "string") missing.push("label");
|
|
2610
|
+
if (!("default" in e)) missing.push("default");
|
|
2611
|
+
if (missing.length > 0) {
|
|
2612
|
+
findings.push({
|
|
2613
|
+
code: "invalid_composition_variables_declaration",
|
|
2614
|
+
severity: "error",
|
|
2615
|
+
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
|
|
2616
|
+
snippet: truncateSnippet(htmlTag.raw)
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return findings;
|
|
2621
|
+
},
|
|
2622
|
+
// subcomposition_blanks_before_host
|
|
2623
|
+
// Warns when a full-bleed sub-composition slot ends before the host composition
|
|
2624
|
+
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
|
|
2625
|
+
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
|
|
2626
|
+
// stays silent on intentional short clips (an intro followed by other clips that
|
|
2627
|
+
// carry the timeline forward).
|
|
2628
|
+
// fallow-ignore-next-line complexity
|
|
2629
|
+
({ tags, rootTag }) => {
|
|
2630
|
+
if (!rootTag) return [];
|
|
2631
|
+
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
|
|
2632
|
+
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
|
2633
|
+
const EPSILON = 0.5;
|
|
2634
|
+
const START_TOLERANCE = 0.5;
|
|
2635
|
+
const round3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
2636
|
+
const timed = tags.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null).map((tag) => {
|
|
2637
|
+
const start = Number(readAttr(tag.raw, "data-start")) || 0;
|
|
2638
|
+
const dur = Number(readAttr(tag.raw, "data-duration"));
|
|
2639
|
+
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
|
|
2640
|
+
return { tag, start, end };
|
|
2641
|
+
});
|
|
2642
|
+
const tailCovered = (exceptIndex) => timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
|
|
2643
|
+
const findings = [];
|
|
2644
|
+
for (const t of timed) {
|
|
2645
|
+
if (readAttr(t.tag.raw, "data-composition-src") === null) continue;
|
|
2646
|
+
if (t.start > START_TOLERANCE) continue;
|
|
2647
|
+
if (!Number.isFinite(t.end)) continue;
|
|
2648
|
+
if (t.end >= rootDuration - EPSILON) continue;
|
|
2649
|
+
if (tailCovered(t.tag.index)) continue;
|
|
2650
|
+
const elementId = readAttr(t.tag.raw, "id") || void 0;
|
|
2651
|
+
const gap = round3(rootDuration - t.end);
|
|
2652
|
+
findings.push({
|
|
2653
|
+
code: "subcomposition_blanks_before_host",
|
|
2654
|
+
severity: "warning",
|
|
2655
|
+
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s \u2014 its slot will be blank for ~${gap}s.`,
|
|
2656
|
+
elementId,
|
|
2657
|
+
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
|
|
2658
|
+
snippet: truncateSnippet(t.tag.raw)
|
|
2659
|
+
});
|
|
2660
|
+
}
|
|
2661
|
+
return findings;
|
|
2662
|
+
},
|
|
2663
|
+
// subcomposition_root_styled_by_class
|
|
2664
|
+
// A sub-composition's <style> is scoped at render time to
|
|
2665
|
+
// `[data-composition-id="<id>"] <selector>` so scenes inlined into one document
|
|
2666
|
+
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
|
|
2667
|
+
// element's own class (e.g. `.frame { ... }` on the same element that carries
|
|
2668
|
+
// data-composition-id) therefore becomes a DESCENDANT selector that can never
|
|
2669
|
+
// match the root — the whole scene renders unstyled (tiny text top-left, images
|
|
2670
|
+
// at natural size). lint/validate/inspect evaluate the file in isolation (no
|
|
2671
|
+
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
|
|
2672
|
+
// break is invisible until the composited MP4 render. Style the root via `#root`
|
|
2673
|
+
// (the scoper special-cases the root id) and descendants via plain selectors,
|
|
2674
|
+
// like the registry blocks — the runtime already scopes each scene by id, so a
|
|
2675
|
+
// class namespace on the root is redundant.
|
|
2676
|
+
({ rootTag, rootCompositionId, styles, options }) => {
|
|
2677
|
+
if (!options.isSubComposition) return [];
|
|
2678
|
+
if (isRegistrySourceFile(options.filePath)) return [];
|
|
2679
|
+
if (!rootTag || !rootCompositionId) return [];
|
|
2680
|
+
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
2681
|
+
if (rootClasses.length === 0) return [];
|
|
2682
|
+
const offenders = rootClassStyledSelectors(styles, rootClasses);
|
|
2683
|
+
if (offenders.length === 0) return [];
|
|
2684
|
+
const example = offenders.slice(0, 3).join(", ");
|
|
2685
|
+
return [
|
|
2686
|
+
{
|
|
2687
|
+
code: "subcomposition_root_styled_by_class",
|
|
2688
|
+
severity: "error",
|
|
2689
|
+
message: `Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root \u2014 the scene renders unstyled (tiny text top-left, full-size images). lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
|
|
2690
|
+
selector: example,
|
|
2691
|
+
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) \u2014 the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
|
|
2692
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
2693
|
+
}
|
|
2694
|
+
];
|
|
2695
|
+
}
|
|
2696
|
+
];
|
|
2697
|
+
|
|
2698
|
+
// src/rules/adapters.ts
|
|
2699
|
+
var adapterRules = [
|
|
2700
|
+
// missing_lottie_script
|
|
2701
|
+
({ tags, scripts }) => {
|
|
2702
|
+
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
|
|
2703
|
+
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
|
|
2704
|
+
const usesLottieApi = texts.some(
|
|
2705
|
+
(t) => /lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t)
|
|
2706
|
+
);
|
|
2707
|
+
const hasLottieScript = srcs.some((src) => /lottie/i.test(src));
|
|
2708
|
+
if (!(hasLottieAttr || usesLottieApi) || hasLottieScript) return [];
|
|
2709
|
+
return [
|
|
2710
|
+
{
|
|
2711
|
+
code: "missing_lottie_script",
|
|
2712
|
+
severity: "error",
|
|
2713
|
+
message: "Composition uses Lottie but no Lottie script is loaded. The animation will not render.",
|
|
2714
|
+
fixHint: 'Add <script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"><\/script> before your Lottie code.'
|
|
2715
|
+
}
|
|
2716
|
+
];
|
|
2717
|
+
},
|
|
2718
|
+
// missing_three_script
|
|
2719
|
+
({ scripts }) => {
|
|
2720
|
+
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
|
|
2721
|
+
const usesThree = texts.some((t) => /\bTHREE\./.test(t));
|
|
2722
|
+
const hasThreeScript = srcs.some((src) => /three/i.test(src));
|
|
2723
|
+
const hasThreeImportMap = texts.some(
|
|
2724
|
+
(t) => /["']three["']/.test(t) && /importmap/.test(scripts.find((s) => s.content === t)?.attrs || "")
|
|
2725
|
+
);
|
|
2726
|
+
const hasThreeModuleImport = texts.some(
|
|
2727
|
+
(t) => /\bimport\b.*['"]three['"]/.test(t) || /\bfrom\s+['"]three['"]/.test(t)
|
|
2728
|
+
);
|
|
2729
|
+
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) return [];
|
|
2730
|
+
return [
|
|
2731
|
+
{
|
|
2732
|
+
code: "missing_three_script",
|
|
2733
|
+
severity: "error",
|
|
2734
|
+
message: "Composition uses Three.js but no Three.js script is loaded. The 3D scene will not render.",
|
|
2735
|
+
fixHint: 'Add <script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"><\/script> before your Three.js code.'
|
|
2736
|
+
}
|
|
2737
|
+
];
|
|
2738
|
+
}
|
|
2739
|
+
];
|
|
2740
|
+
|
|
2741
|
+
// src/rules/textures.ts
|
|
2742
|
+
import postcss2 from "postcss";
|
|
2743
|
+
var TEXTURE_BASE_CLASS = "hf-texture-text";
|
|
2744
|
+
var TEXTURE_CLASS_PREFIX = "hf-texture-";
|
|
2745
|
+
function classNames(tag) {
|
|
2746
|
+
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
|
|
2747
|
+
}
|
|
2748
|
+
function isTextureMaterialClass(className) {
|
|
2749
|
+
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
|
|
2750
|
+
}
|
|
2751
|
+
function hasInlineMaskImage(tag) {
|
|
2752
|
+
const style = readAttr(tag.raw, "style") ?? "";
|
|
2753
|
+
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
|
|
2754
|
+
}
|
|
2755
|
+
function hasInlineDropShadow(tag) {
|
|
2756
|
+
const style = readAttr(tag.raw, "style") ?? "";
|
|
2757
|
+
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
|
|
2758
|
+
}
|
|
2759
|
+
function classNamesInSelector(selector) {
|
|
2760
|
+
const classes = /* @__PURE__ */ new Set();
|
|
2761
|
+
const pattern = /\.([A-Za-z_][\w-]*)/g;
|
|
2762
|
+
let match;
|
|
2763
|
+
while ((match = pattern.exec(selector)) !== null) {
|
|
2764
|
+
const className = match[1];
|
|
2765
|
+
if (!className) continue;
|
|
2766
|
+
classes.add(className);
|
|
2767
|
+
}
|
|
2768
|
+
return [...classes];
|
|
2769
|
+
}
|
|
2770
|
+
function textureClassesInSelector(selector) {
|
|
2771
|
+
return classNamesInSelector(selector).filter(isTextureMaterialClass);
|
|
2772
|
+
}
|
|
2773
|
+
function simpleSelectorMatchesTag(selector, tag, tagClasses) {
|
|
2774
|
+
const trimmed = selector.trim();
|
|
2775
|
+
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
|
|
2776
|
+
if (!simpleSelectorPattern.test(trimmed)) return false;
|
|
2777
|
+
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
|
|
2778
|
+
if (typeMatch && typeMatch[1].toLowerCase() !== tag.name) return false;
|
|
2779
|
+
const selectorClasses = classNamesInSelector(trimmed);
|
|
2780
|
+
return selectorClasses.length > 0 && selectorClasses.every((className) => tagClasses.includes(className));
|
|
2781
|
+
}
|
|
2782
|
+
function collectTextureCss(styles) {
|
|
2783
|
+
const definedTextureClasses = /* @__PURE__ */ new Set();
|
|
2784
|
+
const dropShadowRules = [];
|
|
2785
|
+
const roots = [];
|
|
2786
|
+
for (const style of styles) {
|
|
2787
|
+
let root;
|
|
2788
|
+
try {
|
|
2789
|
+
root = postcss2.parse(style.content);
|
|
2790
|
+
} catch {
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
roots.push(root);
|
|
2794
|
+
root.walkRules((rule) => {
|
|
2795
|
+
const selectors = rule.selectors ?? [];
|
|
2796
|
+
let hasMaskImage = false;
|
|
2797
|
+
for (const node of rule.nodes ?? []) {
|
|
2798
|
+
if (node.type !== "decl") continue;
|
|
2799
|
+
const prop = node.prop.toLowerCase();
|
|
2800
|
+
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
|
|
2801
|
+
}
|
|
2802
|
+
if (hasMaskImage) {
|
|
2803
|
+
for (const selector of selectors) {
|
|
2804
|
+
for (const className of textureClassesInSelector(selector)) {
|
|
2805
|
+
definedTextureClasses.add(className);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
for (const root of roots) {
|
|
2812
|
+
root.walkRules((rule) => {
|
|
2813
|
+
const selectors = rule.selectors ?? [];
|
|
2814
|
+
let hasDropShadow = false;
|
|
2815
|
+
for (const node of rule.nodes ?? []) {
|
|
2816
|
+
if (node.type !== "decl") continue;
|
|
2817
|
+
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
|
|
2818
|
+
hasDropShadow = true;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
if (hasDropShadow) {
|
|
2822
|
+
for (const selector of selectors) {
|
|
2823
|
+
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
|
|
2824
|
+
const targetsDefinedTextureClass = textureClassesInSelector(selector).some(
|
|
2825
|
+
(className) => definedTextureClasses.has(className)
|
|
2826
|
+
);
|
|
2827
|
+
dropShadowRules.push({
|
|
2828
|
+
selector,
|
|
2829
|
+
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
return { definedTextureClasses, dropShadowRules };
|
|
2836
|
+
}
|
|
2837
|
+
var textureRules = [
|
|
2838
|
+
({ tags, styles }) => {
|
|
2839
|
+
const findings = [];
|
|
2840
|
+
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
|
|
2841
|
+
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
|
|
2842
|
+
if (!directlyTargetsTexture) continue;
|
|
2843
|
+
findings.push({
|
|
2844
|
+
code: "texture_drop_shadow_on_text",
|
|
2845
|
+
severity: "warning",
|
|
2846
|
+
message: "Drop shadow is applied directly to textured text.",
|
|
2847
|
+
selector,
|
|
2848
|
+
fixHint: "Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element."
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
for (const tag of tags) {
|
|
2852
|
+
if (tag.name === "style" || tag.name === "script") continue;
|
|
2853
|
+
const classes = classNames(tag);
|
|
2854
|
+
if (classes.length === 0) continue;
|
|
2855
|
+
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
|
|
2856
|
+
const textureClasses = classes.filter(isTextureMaterialClass);
|
|
2857
|
+
if (textureClasses.length > 0 && !hasBaseClass) {
|
|
2858
|
+
findings.push({
|
|
2859
|
+
code: "texture_class_missing_base",
|
|
2860
|
+
severity: "warning",
|
|
2861
|
+
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
|
|
2862
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2863
|
+
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
|
|
2864
|
+
snippet: truncateSnippet(tag.raw)
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
|
|
2868
|
+
findings.push({
|
|
2869
|
+
code: "texture_text_missing_mask",
|
|
2870
|
+
severity: "warning",
|
|
2871
|
+
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
|
|
2872
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2873
|
+
fixHint: "Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
|
|
2874
|
+
snippet: truncateSnippet(tag.raw)
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
for (const textureClass of textureClasses) {
|
|
2878
|
+
if (definedTextureClasses.has(textureClass)) continue;
|
|
2879
|
+
findings.push({
|
|
2880
|
+
code: "texture_class_unknown",
|
|
2881
|
+
severity: "error",
|
|
2882
|
+
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
|
|
2883
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2884
|
+
fixHint: "Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
|
|
2885
|
+
snippet: truncateSnippet(tag.raw)
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
if (hasBaseClass) {
|
|
2889
|
+
for (const rule of dropShadowRules) {
|
|
2890
|
+
if (rule.directlyTargetsTexture) continue;
|
|
2891
|
+
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
|
|
2892
|
+
findings.push({
|
|
2893
|
+
code: "texture_drop_shadow_on_text",
|
|
2894
|
+
severity: "warning",
|
|
2895
|
+
message: "Drop shadow is applied directly to textured text.",
|
|
2896
|
+
selector: rule.selector,
|
|
2897
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2898
|
+
fixHint: "Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
|
2899
|
+
snippet: truncateSnippet(tag.raw)
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (hasBaseClass && hasInlineDropShadow(tag)) {
|
|
2904
|
+
findings.push({
|
|
2905
|
+
code: "texture_drop_shadow_on_text",
|
|
2906
|
+
severity: "warning",
|
|
2907
|
+
message: "Drop shadow is applied directly to textured text.",
|
|
2908
|
+
elementId: readAttr(tag.raw, "id") || void 0,
|
|
2909
|
+
fixHint: "Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
|
2910
|
+
snippet: truncateSnippet(tag.raw)
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
return findings;
|
|
2915
|
+
}
|
|
2916
|
+
];
|
|
2917
|
+
|
|
2918
|
+
// src/rules/fonts.ts
|
|
2919
|
+
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
|
|
2920
|
+
var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
|
|
2921
|
+
"serif",
|
|
2922
|
+
"sans-serif",
|
|
2923
|
+
"monospace",
|
|
2924
|
+
"cursive",
|
|
2925
|
+
"fantasy",
|
|
2926
|
+
"system-ui",
|
|
2927
|
+
"ui-serif",
|
|
2928
|
+
"ui-sans-serif",
|
|
2929
|
+
"ui-monospace",
|
|
2930
|
+
"ui-rounded",
|
|
2931
|
+
"math",
|
|
2932
|
+
"emoji",
|
|
2933
|
+
"fangsong",
|
|
2934
|
+
"inherit",
|
|
2935
|
+
"initial",
|
|
2936
|
+
"unset",
|
|
2937
|
+
"revert"
|
|
2938
|
+
]);
|
|
2939
|
+
function stripCssComments(css) {
|
|
2940
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
2941
|
+
}
|
|
2942
|
+
function extractFontFaceFamilies(styles) {
|
|
2943
|
+
const families = /* @__PURE__ */ new Set();
|
|
2944
|
+
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
|
|
2945
|
+
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
|
|
2946
|
+
for (const style of styles) {
|
|
2947
|
+
const content = stripCssComments(style.content);
|
|
2948
|
+
let match;
|
|
2949
|
+
while ((match = fontFaceRe.exec(content)) !== null) {
|
|
2950
|
+
const familyMatch = match[0].match(familyRe);
|
|
2951
|
+
if (familyMatch?.[2]) {
|
|
2952
|
+
families.add(familyMatch[2].trim().toLowerCase());
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
return families;
|
|
2957
|
+
}
|
|
2958
|
+
function extractUsedFontFamilies(styles) {
|
|
2959
|
+
const used = [];
|
|
2960
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2961
|
+
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
|
|
2962
|
+
for (const style of styles) {
|
|
2963
|
+
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
|
|
2964
|
+
let match;
|
|
2965
|
+
while ((match = propRe.exec(withoutFontFace)) !== null) {
|
|
2966
|
+
const stack = match[1];
|
|
2967
|
+
for (const part of stack.split(",")) {
|
|
2968
|
+
const name = part.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase();
|
|
2969
|
+
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
|
|
2970
|
+
seen.add(name);
|
|
2971
|
+
used.push(name);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
return used;
|
|
2977
|
+
}
|
|
2978
|
+
function collectAliasedFonts(used, declared) {
|
|
2979
|
+
const aliased = [];
|
|
2980
|
+
for (const name of used) {
|
|
2981
|
+
if (declared.has(name)) continue;
|
|
2982
|
+
const displayName = resolveAliasDisplayName(name);
|
|
2983
|
+
if (!displayName) continue;
|
|
2984
|
+
if (displayName.toLowerCase() === name) continue;
|
|
2985
|
+
aliased.push(`'${name}' \u2192 ${displayName}`);
|
|
2986
|
+
}
|
|
2987
|
+
return aliased;
|
|
2988
|
+
}
|
|
2989
|
+
function normalizeFontFamily(name) {
|
|
2990
|
+
const decoded = name.replace(/\+/g, " ").trim();
|
|
2991
|
+
if (!decoded) return null;
|
|
2992
|
+
try {
|
|
2993
|
+
return decodeURIComponent(decoded).trim().toLowerCase() || null;
|
|
2994
|
+
} catch {
|
|
2995
|
+
return decoded.toLowerCase();
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
function extractGoogleFontFamiliesFromUrl(rawUrl) {
|
|
2999
|
+
const url = rawUrl.replace(/&/gi, "&");
|
|
3000
|
+
let parsed;
|
|
3001
|
+
try {
|
|
3002
|
+
parsed = new URL(url, "https://fonts.googleapis.com");
|
|
3003
|
+
} catch {
|
|
3004
|
+
return [];
|
|
3005
|
+
}
|
|
3006
|
+
if (parsed.hostname.toLowerCase() !== "fonts.googleapis.com") return [];
|
|
3007
|
+
const families = [];
|
|
3008
|
+
for (const value of parsed.searchParams.getAll("family")) {
|
|
3009
|
+
for (const familySpec of value.split("|")) {
|
|
3010
|
+
const family = normalizeFontFamily(familySpec.split(":")[0] || "");
|
|
3011
|
+
if (family) families.push(family);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
return families;
|
|
3015
|
+
}
|
|
3016
|
+
function collectGoogleFontFamilies(source, styles) {
|
|
3017
|
+
const families = /* @__PURE__ */ new Set();
|
|
3018
|
+
const addUrl = (url) => {
|
|
3019
|
+
for (const family of extractGoogleFontFamiliesFromUrl(url)) families.add(family);
|
|
3020
|
+
};
|
|
3021
|
+
const linkHrefRe = /<link\b[^>]*\bhref\s*=\s*(?:(["'])([^"']*fonts\.googleapis\.com[^"']*)\1|([^\s>]*fonts\.googleapis\.com[^\s>]*))[^>]*>/gi;
|
|
3022
|
+
for (const match of source.matchAll(linkHrefRe)) {
|
|
3023
|
+
const href = match[2] || match[3];
|
|
3024
|
+
if (href) addUrl(href);
|
|
3025
|
+
}
|
|
3026
|
+
const importUrlRe = /@import\s+(?:url\(\s*)?(["']?)([^"')\s]*fonts\.googleapis\.com[^"')\s]*)\1\s*\)?/gi;
|
|
3027
|
+
for (const style of styles) {
|
|
3028
|
+
for (const match of style.content.matchAll(importUrlRe)) {
|
|
3029
|
+
if (match[2]) addUrl(match[2]);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return families;
|
|
3033
|
+
}
|
|
3034
|
+
var fontRules = [
|
|
3035
|
+
// google_fonts_import
|
|
3036
|
+
({ styles, source, rawSource, options }) => {
|
|
3037
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
3038
|
+
const findings = [];
|
|
3039
|
+
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
|
|
3040
|
+
const googleFontsInImport = styles.some(
|
|
3041
|
+
(s) => /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content)
|
|
3042
|
+
);
|
|
3043
|
+
if (googleFontsInLink || googleFontsInImport) {
|
|
3044
|
+
findings.push({
|
|
3045
|
+
code: "google_fonts_import",
|
|
3046
|
+
severity: "warning",
|
|
3047
|
+
message: "Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts during compile/render, but raw external font requests add latency and can fail before canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
|
|
3048
|
+
fixHint: "For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }."
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
return findings;
|
|
3052
|
+
},
|
|
3053
|
+
// system_font_will_alias — inform when a font will be silently substituted
|
|
3054
|
+
({ styles, options }) => {
|
|
3055
|
+
const declared = extractFontFaceFamilies(styles);
|
|
3056
|
+
const used = extractUsedFontFamilies(styles);
|
|
3057
|
+
const aliased = collectAliasedFonts(used, declared);
|
|
3058
|
+
if (aliased.length === 0) return [];
|
|
3059
|
+
const severity = options.distributed ? "warning" : "info";
|
|
3060
|
+
return [
|
|
3061
|
+
{
|
|
3062
|
+
code: "system_font_will_alias",
|
|
3063
|
+
severity,
|
|
3064
|
+
message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` + (options.distributed ? "In distributed/Lambda rendering system-font capture is disabled \u2014 these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead." : "The renderer maps these to bundled fonts for cross-platform consistency. Use the target font name directly for consistent preview and render results.")
|
|
3065
|
+
}
|
|
3066
|
+
];
|
|
3067
|
+
},
|
|
3068
|
+
// font_family_without_font_face
|
|
3069
|
+
({ styles, source, rawSource, options }) => {
|
|
3070
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
3071
|
+
const findings = [];
|
|
3072
|
+
const declared = extractFontFaceFamilies(styles);
|
|
3073
|
+
const used = extractUsedFontFamilies(styles);
|
|
3074
|
+
const googleFonts = collectGoogleFontFamilies(source, styles);
|
|
3075
|
+
const undeclared = used.filter(
|
|
3076
|
+
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name)
|
|
3077
|
+
);
|
|
3078
|
+
if (undeclared.length === 0) return findings;
|
|
3079
|
+
findings.push({
|
|
3080
|
+
code: "font_family_without_font_face",
|
|
3081
|
+
severity: "error",
|
|
3082
|
+
message: `Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. These are not in the auto-resolved font list, so the renderer cannot supply them automatically. Text will fall back to a generic font, producing incorrect typography in the video.`,
|
|
3083
|
+
fixHint: "Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } for each font family, pointing to the captured .woff2 files."
|
|
3084
|
+
});
|
|
3085
|
+
return findings;
|
|
3086
|
+
}
|
|
3087
|
+
];
|
|
3088
|
+
|
|
3089
|
+
// src/rules/slideshow.ts
|
|
3090
|
+
import {
|
|
3091
|
+
parseSlideshowManifest,
|
|
3092
|
+
resolveSlideshow,
|
|
3093
|
+
isSceneLikeCompositionId
|
|
3094
|
+
} from "@hyperframes/parsers/slideshow";
|
|
3095
|
+
function parseTiming(raw) {
|
|
3096
|
+
const startStr = readAttr(raw, "data-start");
|
|
3097
|
+
if (startStr === null) return null;
|
|
3098
|
+
const start = Number(startStr);
|
|
3099
|
+
if (!Number.isFinite(start)) return null;
|
|
3100
|
+
const durationStr = readAttr(raw, "data-duration");
|
|
3101
|
+
if (durationStr !== null) {
|
|
3102
|
+
const duration = Number(durationStr);
|
|
3103
|
+
if (Number.isFinite(duration)) return { start, duration };
|
|
3104
|
+
}
|
|
3105
|
+
const endStr = readAttr(raw, "data-end") ?? readAttr(raw, "data-hf-authored-end");
|
|
3106
|
+
if (endStr !== null) {
|
|
3107
|
+
const end = Number(endStr);
|
|
3108
|
+
if (Number.isFinite(end) && end > start) return { start, duration: end - start };
|
|
3109
|
+
}
|
|
3110
|
+
return null;
|
|
3111
|
+
}
|
|
3112
|
+
function collectCompositionIdScenes(ctx, seen, out) {
|
|
3113
|
+
for (const tag of ctx.tags) {
|
|
3114
|
+
const compositionId = readAttr(tag.raw, "data-composition-id");
|
|
3115
|
+
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
|
|
3116
|
+
continue;
|
|
3117
|
+
const timing = parseTiming(tag.raw);
|
|
3118
|
+
if (!timing || timing.duration <= 0) continue;
|
|
3119
|
+
seen.add(compositionId);
|
|
3120
|
+
out.push({ id: compositionId, ...timing });
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
function extractScenesFromClips(ctx) {
|
|
3124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3125
|
+
const scenes = [];
|
|
3126
|
+
collectCompositionIdScenes(ctx, seen, scenes);
|
|
3127
|
+
return scenes;
|
|
3128
|
+
}
|
|
3129
|
+
var slideshowRules = [
|
|
3130
|
+
(ctx) => {
|
|
3131
|
+
const findings = [];
|
|
3132
|
+
let manifest;
|
|
3133
|
+
try {
|
|
3134
|
+
manifest = parseSlideshowManifest(ctx.source);
|
|
3135
|
+
} catch (e) {
|
|
3136
|
+
findings.push({
|
|
3137
|
+
code: "slideshow_invalid",
|
|
3138
|
+
severity: "error",
|
|
3139
|
+
message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
|
|
3140
|
+
fixHint: 'Ensure the <script type="application/hyperframes-slideshow+json"> block contains valid JSON matching the SlideshowManifest schema.'
|
|
3141
|
+
});
|
|
3142
|
+
return findings;
|
|
3143
|
+
}
|
|
3144
|
+
if (!manifest) return findings;
|
|
3145
|
+
const scenes = extractScenesFromClips(ctx);
|
|
3146
|
+
const { errors } = resolveSlideshow(manifest, scenes);
|
|
3147
|
+
for (const error of errors) {
|
|
3148
|
+
findings.push({
|
|
3149
|
+
code: "slideshow_unresolved_ref",
|
|
3150
|
+
severity: "error",
|
|
3151
|
+
message: `Slideshow manifest error: ${error}`,
|
|
3152
|
+
fixHint: "Ensure every sceneId in the slideshow island matches the data-composition-id of a scene element in the composition, or provide explicit startTime/endTime."
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
return findings;
|
|
3156
|
+
}
|
|
3157
|
+
];
|
|
3158
|
+
|
|
3159
|
+
// src/hyperframeLinter.ts
|
|
3160
|
+
var ALL_RULES = [
|
|
3161
|
+
...coreRules,
|
|
3162
|
+
...mediaRules,
|
|
3163
|
+
...gsapRules,
|
|
3164
|
+
...captionRules,
|
|
3165
|
+
...compositionRules,
|
|
3166
|
+
...adapterRules,
|
|
3167
|
+
...textureRules,
|
|
3168
|
+
...fontRules,
|
|
3169
|
+
...slideshowRules
|
|
3170
|
+
];
|
|
3171
|
+
async function lintHyperframeHtml(html, options = {}) {
|
|
3172
|
+
const ctx = buildLintContext(html, options);
|
|
3173
|
+
const findings = [];
|
|
3174
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3175
|
+
for (const rule of ALL_RULES) {
|
|
3176
|
+
for (const finding of await Promise.resolve(rule(ctx))) {
|
|
3177
|
+
const dedupeKey = [
|
|
3178
|
+
finding.code,
|
|
3179
|
+
finding.severity,
|
|
3180
|
+
finding.selector || "",
|
|
3181
|
+
finding.elementId || "",
|
|
3182
|
+
finding.message
|
|
3183
|
+
].join("|");
|
|
3184
|
+
if (seen.has(dedupeKey)) continue;
|
|
3185
|
+
seen.add(dedupeKey);
|
|
3186
|
+
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
3190
|
+
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
3191
|
+
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
3192
|
+
return {
|
|
3193
|
+
ok: errorCount === 0,
|
|
3194
|
+
errorCount,
|
|
3195
|
+
warningCount,
|
|
3196
|
+
infoCount,
|
|
3197
|
+
findings
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
function extractMediaUrls(html) {
|
|
3201
|
+
const results = [];
|
|
3202
|
+
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
|
|
3203
|
+
let match;
|
|
3204
|
+
while ((match = tagRe.exec(html)) !== null) {
|
|
3205
|
+
const tagName = (match[1] ?? "").toLowerCase();
|
|
3206
|
+
const raw = match[0];
|
|
3207
|
+
const src = readAttr(raw, "src");
|
|
3208
|
+
if (!src) continue;
|
|
3209
|
+
if (/^https?:\/\//i.test(src)) {
|
|
3210
|
+
results.push({
|
|
3211
|
+
url: src,
|
|
3212
|
+
tagName,
|
|
3213
|
+
elementId: readAttr(raw, "id") || void 0,
|
|
3214
|
+
snippet: truncateSnippet(raw) ?? ""
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return results;
|
|
3219
|
+
}
|
|
3220
|
+
async function lintMediaUrls(html, options = {}) {
|
|
3221
|
+
const urls = extractMediaUrls(html);
|
|
3222
|
+
if (urls.length === 0) return [];
|
|
3223
|
+
const timeout = options.timeoutMs ?? 8e3;
|
|
3224
|
+
const findings = [];
|
|
3225
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3226
|
+
const unique = urls.filter((u) => {
|
|
3227
|
+
if (seen.has(u.url)) return false;
|
|
3228
|
+
seen.add(u.url);
|
|
3229
|
+
return true;
|
|
3230
|
+
});
|
|
3231
|
+
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
|
|
3232
|
+
try {
|
|
3233
|
+
const controller = new AbortController();
|
|
3234
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
3235
|
+
const resp = await fetch(url, {
|
|
3236
|
+
method: "HEAD",
|
|
3237
|
+
signal: controller.signal,
|
|
3238
|
+
redirect: "follow"
|
|
3239
|
+
});
|
|
3240
|
+
clearTimeout(timer);
|
|
3241
|
+
if (!resp.ok) {
|
|
3242
|
+
findings.push({
|
|
3243
|
+
code: "inaccessible_media_url",
|
|
3244
|
+
severity: "error",
|
|
3245
|
+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
|
|
3246
|
+
elementId,
|
|
3247
|
+
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
|
3248
|
+
snippet
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
} catch (err) {
|
|
3252
|
+
const reason = err instanceof Error ? err.name : "unknown";
|
|
3253
|
+
findings.push({
|
|
3254
|
+
code: "inaccessible_media_url",
|
|
3255
|
+
severity: "error",
|
|
3256
|
+
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
|
|
3257
|
+
elementId,
|
|
3258
|
+
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
|
3259
|
+
snippet
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
});
|
|
3263
|
+
await Promise.all(checks);
|
|
3264
|
+
return findings;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// src/shouldBlockRender.ts
|
|
3268
|
+
function shouldBlockRender(strictErrors, strictAll, totalErrors, totalWarnings) {
|
|
3269
|
+
return strictErrors && totalErrors > 0 || strictAll && (totalErrors > 0 || totalWarnings > 0);
|
|
3270
|
+
}
|
|
3271
|
+
export {
|
|
3272
|
+
lintHyperframeHtml,
|
|
3273
|
+
lintMediaUrls,
|
|
3274
|
+
shouldBlockRender
|
|
3275
|
+
};
|
|
3276
|
+
//# sourceMappingURL=browser.js.map
|