@hyperframes/core 0.1.14 → 0.2.0-alpha.1
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/compiler/htmlBundler.d.ts.map +1 -1
- package/dist/compiler/htmlBundler.js +9 -0
- package/dist/compiler/htmlBundler.js.map +1 -1
- package/dist/compiler/rewriteSubCompPaths.d.ts +35 -0
- package/dist/compiler/rewriteSubCompPaths.d.ts.map +1 -0
- package/dist/compiler/rewriteSubCompPaths.js +85 -0
- package/dist/compiler/rewriteSubCompPaths.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lint/context.d.ts +16 -0
- package/dist/lint/context.d.ts.map +1 -0
- package/dist/lint/context.js +24 -0
- package/dist/lint/context.js.map +1 -0
- package/dist/lint/hyperframeLinter.d.ts +1 -1
- package/dist/lint/hyperframeLinter.d.ts.map +1 -1
- package/dist/lint/hyperframeLinter.js +34 -995
- package/dist/lint/hyperframeLinter.js.map +1 -1
- package/dist/lint/rules/adapters.d.ts +3 -0
- package/dist/lint/rules/adapters.d.ts.map +1 -0
- package/dist/lint/rules/adapters.js +43 -0
- package/dist/lint/rules/adapters.js.map +1 -0
- package/dist/lint/rules/captions.d.ts +3 -0
- package/dist/lint/rules/captions.d.ts.map +1 -0
- package/dist/lint/rules/captions.js +68 -0
- package/dist/lint/rules/captions.js.map +1 -0
- package/dist/lint/rules/composition.d.ts +3 -0
- package/dist/lint/rules/composition.d.ts.map +1 -0
- package/dist/lint/rules/composition.js +103 -0
- package/dist/lint/rules/composition.js.map +1 -0
- package/dist/lint/rules/core.d.ts +3 -0
- package/dist/lint/rules/core.d.ts.map +1 -0
- package/dist/lint/rules/core.js +155 -0
- package/dist/lint/rules/core.js.map +1 -0
- package/dist/lint/rules/gsap.d.ts +3 -0
- package/dist/lint/rules/gsap.d.ts.map +1 -0
- package/dist/lint/rules/gsap.js +347 -0
- package/dist/lint/rules/gsap.js.map +1 -0
- package/dist/lint/rules/media.d.ts +3 -0
- package/dist/lint/rules/media.d.ts.map +1 -0
- package/dist/lint/rules/media.js +217 -0
- package/dist/lint/rules/media.js.map +1 -0
- package/dist/lint/types.d.ts +1 -0
- package/dist/lint/types.d.ts.map +1 -1
- package/dist/lint/utils.d.ts +30 -0
- package/dist/lint/utils.d.ts.map +1 -0
- package/dist/lint/utils.js +104 -0
- package/dist/lint/utils.js.map +1 -0
- package/dist/studio-api/routes/files.d.ts.map +1 -1
- package/dist/studio-api/routes/files.js +220 -20
- package/dist/studio-api/routes/files.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,717 +1,41 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
1
|
+
import { buildLintContext } from "./context";
|
|
2
|
+
import { readAttr, truncateSnippet } from "./utils";
|
|
3
|
+
import { coreRules } from "./rules/core";
|
|
4
|
+
import { mediaRules } from "./rules/media";
|
|
5
|
+
import { gsapRules } from "./rules/gsap";
|
|
6
|
+
import { captionRules } from "./rules/captions";
|
|
7
|
+
import { compositionRules } from "./rules/composition";
|
|
8
|
+
import { adapterRules } from "./rules/adapters";
|
|
9
|
+
const ALL_RULES = [
|
|
10
|
+
...coreRules,
|
|
11
|
+
...mediaRules,
|
|
12
|
+
...gsapRules,
|
|
13
|
+
...captionRules,
|
|
14
|
+
...compositionRules,
|
|
15
|
+
...adapterRules,
|
|
16
|
+
];
|
|
11
17
|
export function lintHyperframeHtml(html, options = {}) {
|
|
12
|
-
|
|
13
|
-
// <template id="..."> tags that the runtime extracts at load time.
|
|
14
|
-
let source = html || "";
|
|
15
|
-
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
|
16
|
-
if (templateMatch?.[1])
|
|
17
|
-
source = templateMatch[1];
|
|
18
|
-
const filePath = options.filePath;
|
|
18
|
+
const ctx = buildLintContext(html, options);
|
|
19
19
|
const findings = [];
|
|
20
20
|
const seen = new Set();
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
seen.add(dedupeKey);
|
|
33
|
-
findings.push(filePath ? { ...finding, file: filePath } : finding);
|
|
34
|
-
};
|
|
35
|
-
const tags = extractOpenTags(source);
|
|
36
|
-
const styles = extractBlocks(source, STYLE_BLOCK_PATTERN);
|
|
37
|
-
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
|
|
38
|
-
const compositionIds = collectCompositionIds(tags);
|
|
39
|
-
const rootTag = findRootTag(source);
|
|
40
|
-
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
|
41
|
-
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
|
|
42
|
-
pushFinding({
|
|
43
|
-
code: "root_missing_composition_id",
|
|
44
|
-
severity: "error",
|
|
45
|
-
message: "Root composition is missing `data-composition-id`.",
|
|
46
|
-
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
|
47
|
-
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
|
|
48
|
-
snippet: truncateSnippet(rootTag?.raw || ""),
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
|
|
52
|
-
pushFinding({
|
|
53
|
-
code: "root_missing_dimensions",
|
|
54
|
-
severity: "error",
|
|
55
|
-
message: "Root composition is missing `data-width` or `data-height`.",
|
|
56
|
-
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
|
57
|
-
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
|
|
58
|
-
snippet: truncateSnippet(rootTag?.raw || ""),
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
|
|
62
|
-
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
|
|
63
|
-
pushFinding({
|
|
64
|
-
code: "missing_timeline_registry",
|
|
65
|
-
severity: "error",
|
|
66
|
-
message: "Missing `window.__timelines` registration.",
|
|
67
|
-
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
// Timeline assignment without initialization guard — causes silent failure
|
|
71
|
-
// when the runtime script hasn't loaded yet (window.__timelines is undefined).
|
|
72
|
-
if (TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
|
73
|
-
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)) {
|
|
74
|
-
pushFinding({
|
|
75
|
-
code: "timeline_registry_missing_init",
|
|
76
|
-
severity: "error",
|
|
77
|
-
message: "`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
|
|
78
|
-
fixHint: "Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
// Check for timeline ID mismatches: data-composition-id vs window.__timelines["X"] keys.
|
|
82
|
-
{
|
|
83
|
-
const htmlCompIds = new Set();
|
|
84
|
-
const timelineRegKeys = new Set();
|
|
85
|
-
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
|
86
|
-
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
|
|
87
|
-
let m;
|
|
88
|
-
while ((m = compIdRe.exec(source)) !== null) {
|
|
89
|
-
if (m[1])
|
|
90
|
-
htmlCompIds.add(m[1]);
|
|
91
|
-
}
|
|
92
|
-
while ((m = tlKeyRe.exec(source)) !== null) {
|
|
93
|
-
if (m[1])
|
|
94
|
-
timelineRegKeys.add(m[1]);
|
|
95
|
-
}
|
|
96
|
-
for (const key of timelineRegKeys) {
|
|
97
|
-
if (!htmlCompIds.has(key)) {
|
|
98
|
-
pushFinding({
|
|
99
|
-
code: "timeline_id_mismatch",
|
|
100
|
-
severity: "error",
|
|
101
|
-
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
|
102
|
-
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (INVALID_SCRIPT_CLOSE_PATTERN.test(source)) {
|
|
108
|
-
pushFinding({
|
|
109
|
-
code: "invalid_inline_script_syntax",
|
|
110
|
-
severity: "error",
|
|
111
|
-
message: "Detected malformed inline `<script>` closing syntax.",
|
|
112
|
-
fixHint: "Close inline scripts with a valid `</script>` tag.",
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
for (const script of scripts) {
|
|
116
|
-
const attrs = script.attrs || "";
|
|
117
|
-
if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["']application\/json["']/.test(attrs)) {
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
const syntaxError = getInlineScriptSyntaxError(script.content);
|
|
121
|
-
if (!syntaxError) {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
pushFinding({
|
|
125
|
-
code: "invalid_inline_script_syntax",
|
|
126
|
-
severity: "error",
|
|
127
|
-
message: `Inline script has invalid syntax: ${syntaxError}`,
|
|
128
|
-
fixHint: "Fix the inline script syntax before render verification.",
|
|
129
|
-
snippet: truncateSnippet(script.content),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
for (const tag of tags) {
|
|
133
|
-
const src = readAttr(tag.raw, "data-composition-src");
|
|
134
|
-
if (!src) {
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const compId = readAttr(tag.raw, "data-composition-id");
|
|
138
|
-
if (compId) {
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
pushFinding({
|
|
142
|
-
code: "host_missing_composition_id",
|
|
143
|
-
severity: "error",
|
|
144
|
-
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
|
|
145
|
-
elementId: readAttr(tag.raw, "id") || undefined,
|
|
146
|
-
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
|
|
147
|
-
snippet: truncateSnippet(tag.raw),
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
const scopedCssCompositionIds = new Set();
|
|
151
|
-
for (const style of styles) {
|
|
152
|
-
for (const compId of extractCompositionIdsFromCss(style.content)) {
|
|
153
|
-
scopedCssCompositionIds.add(compId);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (const compId of scopedCssCompositionIds) {
|
|
157
|
-
if (compositionIds.has(compId)) {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
pushFinding({
|
|
161
|
-
code: "scoped_css_missing_wrapper",
|
|
162
|
-
severity: "warning",
|
|
163
|
-
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
|
|
164
|
-
selector: `[data-composition-id="${compId}"]`,
|
|
165
|
-
fixHint: "Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
const mediaById = new Map();
|
|
169
|
-
const mediaFingerprintCounts = new Map();
|
|
170
|
-
for (const tag of tags) {
|
|
171
|
-
if (!isMediaTag(tag.name)) {
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const elementId = readAttr(tag.raw, "id");
|
|
175
|
-
if (elementId) {
|
|
176
|
-
const existing = mediaById.get(elementId) || [];
|
|
177
|
-
existing.push(tag);
|
|
178
|
-
mediaById.set(elementId, existing);
|
|
179
|
-
}
|
|
180
|
-
const fingerprint = [
|
|
181
|
-
tag.name,
|
|
182
|
-
readAttr(tag.raw, "src") || "",
|
|
183
|
-
readAttr(tag.raw, "data-start") || "",
|
|
184
|
-
readAttr(tag.raw, "data-duration") || "",
|
|
185
|
-
].join("|");
|
|
186
|
-
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
|
|
187
|
-
}
|
|
188
|
-
for (const [elementId, mediaTags] of mediaById) {
|
|
189
|
-
if (mediaTags.length < 2) {
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
pushFinding({
|
|
193
|
-
code: "duplicate_media_id",
|
|
194
|
-
severity: "error",
|
|
195
|
-
message: `Media id "${elementId}" is defined multiple times.`,
|
|
196
|
-
elementId,
|
|
197
|
-
fixHint: "Give each media element a unique id so preview and producer discover the same media graph.",
|
|
198
|
-
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
for (const [fingerprint, count] of mediaFingerprintCounts) {
|
|
202
|
-
if (count < 2) {
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
|
|
206
|
-
pushFinding({
|
|
207
|
-
code: "duplicate_media_discovery_risk",
|
|
208
|
-
severity: "warning",
|
|
209
|
-
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
|
|
210
|
-
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
|
|
211
|
-
snippet: truncateSnippet(`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`),
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
const clipIds = new Map();
|
|
215
|
-
const clipClasses = new Map();
|
|
216
|
-
for (const tag of tags) {
|
|
217
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
218
|
-
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
219
|
-
if (!classes.includes("clip"))
|
|
220
|
-
continue;
|
|
221
|
-
const id = readAttr(tag.raw, "id");
|
|
222
|
-
const info = { tag: tag.name, id: id || "", classes: classAttr };
|
|
223
|
-
if (id)
|
|
224
|
-
clipIds.set(`#${id}`, info);
|
|
225
|
-
for (const cls of classes) {
|
|
226
|
-
if (cls !== "clip")
|
|
227
|
-
clipClasses.set(`.${cls}`, info);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
const classUsage = countClassUsage(tags);
|
|
231
|
-
for (const script of scripts) {
|
|
232
|
-
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
|
233
|
-
const gsapWindows = extractGsapWindows(script.content);
|
|
234
|
-
for (let i = 0; i < gsapWindows.length; i++) {
|
|
235
|
-
const left = gsapWindows[i];
|
|
236
|
-
if (!left)
|
|
237
|
-
continue;
|
|
238
|
-
if (left.end <= left.position) {
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
for (let j = i + 1; j < gsapWindows.length; j++) {
|
|
242
|
-
const right = gsapWindows[j];
|
|
243
|
-
if (!right)
|
|
244
|
-
continue;
|
|
245
|
-
if (right.end <= right.position) {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
if (left.targetSelector !== right.targetSelector) {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
const overlapStart = Math.max(left.position, right.position);
|
|
252
|
-
const overlapEnd = Math.min(left.end, right.end);
|
|
253
|
-
if (overlapEnd <= overlapStart) {
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
if (left.overwriteAuto || right.overwriteAuto) {
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
const sharedProperties = left.properties.filter((prop) => right.properties.includes(prop));
|
|
260
|
-
if (sharedProperties.length === 0) {
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
pushFinding({
|
|
264
|
-
code: "overlapping_gsap_tweens",
|
|
265
|
-
severity: "warning",
|
|
266
|
-
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
|
|
267
|
-
selector: left.targetSelector,
|
|
268
|
-
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
|
|
269
|
-
snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// Check if any GSAP selector targets a clip element
|
|
274
|
-
for (const win of gsapWindows) {
|
|
275
|
-
const sel = win.targetSelector;
|
|
276
|
-
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
|
277
|
-
if (!clipInfo)
|
|
278
|
-
continue;
|
|
279
|
-
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
|
280
|
-
pushFinding({
|
|
281
|
-
code: "gsap_animates_clip_element",
|
|
282
|
-
severity: "error",
|
|
283
|
-
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
|
|
284
|
-
selector: sel,
|
|
285
|
-
elementId: clipInfo.id || undefined,
|
|
286
|
-
fixHint: "Wrap content in a child <div> and target that with GSAP.",
|
|
287
|
-
snippet: truncateSnippet(win.raw),
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
for (const win of gsapWindows) {
|
|
294
|
-
if (!isSuspiciousGlobalSelector(win.targetSelector)) {
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
const className = getSingleClassSelector(win.targetSelector);
|
|
298
|
-
if (className && (classUsage.get(className) || 0) < 2) {
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
pushFinding({
|
|
302
|
-
code: "unscoped_gsap_selector",
|
|
303
|
-
severity: "warning",
|
|
304
|
-
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
|
305
|
-
selector: win.targetSelector,
|
|
306
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
|
307
|
-
snippet: truncateSnippet(win.raw),
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
// ── Composition pitfall checks ──────────────────────────────────────────
|
|
312
|
-
// #2: Video without muted attribute (audio should come from separate <audio>)
|
|
313
|
-
for (const tag of tags) {
|
|
314
|
-
if (tag.name !== "video")
|
|
315
|
-
continue;
|
|
316
|
-
const hasMuted = /\bmuted\b/i.test(tag.raw);
|
|
317
|
-
if (!hasMuted && readAttr(tag.raw, "data-start")) {
|
|
318
|
-
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
319
|
-
pushFinding({
|
|
320
|
-
code: "video_missing_muted",
|
|
321
|
-
severity: "error",
|
|
322
|
-
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. The framework expects video to be muted with a separate <audio> element for sound.`,
|
|
323
|
-
elementId,
|
|
324
|
-
fixHint: "Add the `muted` attribute to the <video> tag and use a separate <audio> element with the same src for audio playback.",
|
|
325
|
-
snippet: truncateSnippet(tag.raw),
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// #3: Video nested inside a timed element (data-start on ancestor)
|
|
330
|
-
// Approximation: check if a <video data-start> appears inside another element with data-start
|
|
331
|
-
// by scanning for video tags whose raw position is between another timed element's open/close
|
|
332
|
-
const timedTagPositions = [];
|
|
333
|
-
for (const tag of tags) {
|
|
334
|
-
if (tag.name === "video" || tag.name === "audio")
|
|
335
|
-
continue;
|
|
336
|
-
if (readAttr(tag.raw, "data-start")) {
|
|
337
|
-
timedTagPositions.push({
|
|
338
|
-
name: tag.name,
|
|
339
|
-
start: tag.index,
|
|
340
|
-
id: readAttr(tag.raw, "id") || undefined,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
for (const tag of tags) {
|
|
345
|
-
if (tag.name !== "video")
|
|
346
|
-
continue;
|
|
347
|
-
if (!readAttr(tag.raw, "data-start"))
|
|
348
|
-
continue;
|
|
349
|
-
// Check if any timed non-media element appears before this video in the source
|
|
350
|
-
// and could be an ancestor (heuristic — not a full DOM parse)
|
|
351
|
-
for (const parent of timedTagPositions) {
|
|
352
|
-
if (parent.start < tag.index) {
|
|
353
|
-
// Check if there's a closing tag for the parent between parent.start and tag.index
|
|
354
|
-
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
|
|
355
|
-
const between = source.substring(parent.start, tag.index);
|
|
356
|
-
if (!parentClosePattern.test(between)) {
|
|
357
|
-
pushFinding({
|
|
358
|
-
code: "video_nested_in_timed_element",
|
|
359
|
-
severity: "error",
|
|
360
|
-
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 — video will be FROZEN in renders.`,
|
|
361
|
-
elementId: readAttr(tag.raw, "id") || undefined,
|
|
362
|
-
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).",
|
|
363
|
-
snippet: truncateSnippet(tag.raw),
|
|
364
|
-
});
|
|
365
|
-
break; // Only report once per video
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// #3.5: Self-closing <audio .../> or <video .../> — CRITICAL
|
|
371
|
-
// In HTML5, <audio> and <video> are NOT void elements. The browser silently
|
|
372
|
-
// ignores the "/>", leaving the tag open. All subsequent sibling elements
|
|
373
|
-
// become invisible fallback content inside the media tag, making entire
|
|
374
|
-
// compositions disappear. This is the #1 cause of "black preview" bugs.
|
|
375
|
-
{
|
|
376
|
-
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
|
|
377
|
-
let scMatch;
|
|
378
|
-
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
|
|
379
|
-
const tagName = scMatch[1] || "audio";
|
|
380
|
-
const elementId = readAttr(scMatch[0], "id") || undefined;
|
|
381
|
-
pushFinding({
|
|
382
|
-
code: "self_closing_media_tag",
|
|
383
|
-
severity: "error",
|
|
384
|
-
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.`,
|
|
385
|
-
elementId,
|
|
386
|
-
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
|
|
387
|
-
snippet: truncateSnippet(scMatch[0]),
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// #3.6: Placeholder/fake media URLs — CRITICAL
|
|
392
|
-
// Placeholder URLs (placehold.co, placeholder.com, example.com) will 404 at render time.
|
|
393
|
-
{
|
|
394
|
-
const PLACEHOLDER_DOMAINS = /\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
|
|
395
|
-
for (const tag of tags) {
|
|
396
|
-
if (!isMediaTag(tag.name))
|
|
397
|
-
continue;
|
|
398
|
-
const src = readAttr(tag.raw, "src");
|
|
399
|
-
if (!src)
|
|
400
|
-
continue;
|
|
401
|
-
if (PLACEHOLDER_DOMAINS.test(src)) {
|
|
402
|
-
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
403
|
-
pushFinding({
|
|
404
|
-
code: "placeholder_media_url",
|
|
405
|
-
severity: "error",
|
|
406
|
-
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
|
|
407
|
-
elementId,
|
|
408
|
-
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
|
|
409
|
-
snippet: truncateSnippet(tag.raw),
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
// #3.7: Inline base64 audio/video — PROHIBITED
|
|
415
|
-
// Base64 audio/video bloats file size and breaks rendering. Use URLs or relative paths.
|
|
416
|
-
{
|
|
417
|
-
const base64MediaRe = /src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
|
|
418
|
-
let b64Match;
|
|
419
|
-
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
|
420
|
-
const sample = (b64Match[2] || "").slice(0, 200);
|
|
421
|
-
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
|
422
|
-
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
|
423
|
-
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
|
424
|
-
pushFinding({
|
|
425
|
-
code: "base64_media_prohibited",
|
|
426
|
-
severity: "error",
|
|
427
|
-
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
|
|
428
|
-
fixHint: "Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
|
|
429
|
-
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
// #3.8: Media element checks — missing id, missing src, preload="none"
|
|
434
|
-
// The runtime discovers media via querySelectorAll("video[data-start]") which
|
|
435
|
-
// works fine for preview. But the renderer uses querySelectorAll("video[id][src]")
|
|
436
|
-
// — without id, elements are silently skipped (no audio, frozen video).
|
|
437
|
-
for (const tag of tags) {
|
|
438
|
-
if (tag.name !== "video" && tag.name !== "audio")
|
|
439
|
-
continue;
|
|
440
|
-
const hasDataStart = readAttr(tag.raw, "data-start");
|
|
441
|
-
const hasId = readAttr(tag.raw, "id");
|
|
442
|
-
const hasSrc = readAttr(tag.raw, "src");
|
|
443
|
-
if (hasDataStart && !hasId) {
|
|
444
|
-
pushFinding({
|
|
445
|
-
code: "media_missing_id",
|
|
446
|
-
severity: "error",
|
|
447
|
-
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
|
|
448
|
-
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
|
|
449
|
-
snippet: truncateSnippet(tag.raw),
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
if (hasDataStart && hasId && !hasSrc) {
|
|
453
|
-
pushFinding({
|
|
454
|
-
code: "media_missing_src",
|
|
455
|
-
severity: "error",
|
|
456
|
-
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
|
|
457
|
-
elementId: hasId,
|
|
458
|
-
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
|
|
459
|
-
snippet: truncateSnippet(tag.raw),
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
if (readAttr(tag.raw, "preload") === "none") {
|
|
463
|
-
pushFinding({
|
|
464
|
-
code: "media_preload_none",
|
|
465
|
-
severity: "warning",
|
|
466
|
-
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.`,
|
|
467
|
-
elementId: hasId || undefined,
|
|
468
|
-
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
|
|
469
|
-
snippet: truncateSnippet(tag.raw),
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
|
|
474
|
-
// Skip: elements with data-composition-id (managed by runtime), elements with
|
|
475
|
-
// opacity:0 in style (will be animated in by GSAP), and composition host elements.
|
|
476
|
-
// Most HyperFrames compositions use GSAP to manage element visibility via opacity
|
|
477
|
-
// animations, so this check is only relevant for elements that truly need to be
|
|
478
|
-
// hidden before the timeline starts.
|
|
479
|
-
for (const tag of tags) {
|
|
480
|
-
if (tag.name === "audio" || tag.name === "script" || tag.name === "style")
|
|
481
|
-
continue;
|
|
482
|
-
if (!readAttr(tag.raw, "data-start"))
|
|
483
|
-
continue;
|
|
484
|
-
// Skip composition roots and hosts — the runtime manages their lifecycle
|
|
485
|
-
if (readAttr(tag.raw, "data-composition-id"))
|
|
486
|
-
continue;
|
|
487
|
-
if (readAttr(tag.raw, "data-composition-src"))
|
|
488
|
-
continue;
|
|
489
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
490
|
-
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
491
|
-
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
492
|
-
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
|
493
|
-
if (!hasClip && !hasHiddenStyle) {
|
|
494
|
-
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
495
|
-
pushFinding({
|
|
496
|
-
code: "timed_element_missing_visibility_hidden",
|
|
497
|
-
severity: "info",
|
|
498
|
-
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.`,
|
|
499
|
-
elementId,
|
|
500
|
-
fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
|
501
|
-
snippet: truncateSnippet(tag.raw),
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
// #5: Deprecated attribute names
|
|
506
|
-
for (const tag of tags) {
|
|
507
|
-
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
|
|
508
|
-
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
509
|
-
pushFinding({
|
|
510
|
-
code: "deprecated_data_layer",
|
|
511
|
-
severity: "warning",
|
|
512
|
-
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
|
|
513
|
-
elementId,
|
|
514
|
-
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
|
|
515
|
-
snippet: truncateSnippet(tag.raw),
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
|
|
519
|
-
const elementId = readAttr(tag.raw, "id") || undefined;
|
|
520
|
-
pushFinding({
|
|
521
|
-
code: "deprecated_data_end",
|
|
522
|
-
severity: "warning",
|
|
523
|
-
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
|
|
524
|
-
elementId,
|
|
525
|
-
fixHint: "Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
|
|
526
|
-
snippet: truncateSnippet(tag.raw),
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
// ── Template literal variables in querySelector (breaks cheerio bundler) ──
|
|
531
|
-
for (const script of scripts) {
|
|
532
|
-
const templateLiteralSelectorPattern = /(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
|
|
533
|
-
let tlMatch;
|
|
534
|
-
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
|
|
535
|
-
pushFinding({
|
|
536
|
-
code: "template_literal_selector",
|
|
537
|
-
severity: "error",
|
|
538
|
-
message: "querySelector uses a template literal variable (e.g. `${compId}`). " +
|
|
539
|
-
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
|
|
540
|
-
file: filePath,
|
|
541
|
-
fixHint: "Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
|
|
542
|
-
snippet: truncateSnippet(tlMatch[0]),
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
// ── Rule: gsap_css_transform_conflict ─────────────────────────────────────
|
|
547
|
-
// Detects elements whose CSS <style> block sets `transform: translate*` or
|
|
548
|
-
// `transform: scale*` that are also targeted by a GSAP tl.to/tl.from tween
|
|
549
|
-
// animating x, y, xPercent, yPercent, or scale. GSAP's transform properties
|
|
550
|
-
// overwrite the *entire* CSS transform, silently discarding translateX(-50%)
|
|
551
|
-
// centering and similar positioning tricks.
|
|
552
|
-
//
|
|
553
|
-
// tl.fromTo is exempt: when the author provides explicit from/to states they
|
|
554
|
-
// own both ends of the transform, so overwriting CSS is intentional.
|
|
555
|
-
//
|
|
556
|
-
// Known limitations:
|
|
557
|
-
// - Only scans <style> blocks. Inline style="transform:..." on elements is
|
|
558
|
-
// not detected. This is common in AI-generated compositions and may cause
|
|
559
|
-
// false negatives. A follow-up could scan tag `style` attributes.
|
|
560
|
-
// - CSS selector regex matches bare #id and .class only. Compound selectors
|
|
561
|
-
// (#root .title), grouped selectors (#a, #b), and attribute selectors are
|
|
562
|
-
// not matched. Compositions typically use flat IDs so risk is low, but
|
|
563
|
-
// future maintainers should not assume full CSS parsing.
|
|
564
|
-
{
|
|
565
|
-
// selector → transform value (bare #id / .class only — see limitation above)
|
|
566
|
-
const cssTranslateSelectors = new Map();
|
|
567
|
-
const cssScaleSelectors = new Map();
|
|
568
|
-
for (const style of styles) {
|
|
569
|
-
for (const [, selector, body] of style.content.matchAll(/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g)) {
|
|
570
|
-
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
|
|
571
|
-
if (!tMatch || !tMatch[1])
|
|
572
|
-
continue;
|
|
573
|
-
const transformVal = tMatch[1].trim();
|
|
574
|
-
if (/translate/i.test(transformVal)) {
|
|
575
|
-
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
|
|
576
|
-
}
|
|
577
|
-
if (/scale/i.test(transformVal)) {
|
|
578
|
-
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
if (cssTranslateSelectors.size > 0 || cssScaleSelectors.size > 0) {
|
|
583
|
-
for (const script of scripts) {
|
|
584
|
-
if (!/gsap\.timeline/.test(script.content))
|
|
585
|
-
continue;
|
|
586
|
-
const windows = extractGsapWindows(script.content);
|
|
587
|
-
const conflicts = new Map();
|
|
588
|
-
for (const win of windows) {
|
|
589
|
-
// fromTo: author explicitly sets both ends — overwriting CSS is intentional
|
|
590
|
-
if (win.method === "fromTo")
|
|
591
|
-
continue;
|
|
592
|
-
const sel = win.targetSelector;
|
|
593
|
-
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
|
594
|
-
const translateProps = win.properties.filter((p) => ["x", "y", "xPercent", "yPercent"].includes(p));
|
|
595
|
-
const scaleProps = win.properties.filter((p) => p === "scale");
|
|
596
|
-
const cssFromTranslate = translateProps.length > 0 ? cssTranslateSelectors.get(cssKey) : undefined;
|
|
597
|
-
const cssFromScale = scaleProps.length > 0 ? cssScaleSelectors.get(cssKey) : undefined;
|
|
598
|
-
if (!cssFromTranslate && !cssFromScale)
|
|
599
|
-
continue;
|
|
600
|
-
const existing = conflicts.get(sel) ?? {
|
|
601
|
-
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
|
|
602
|
-
props: new Set(),
|
|
603
|
-
raw: win.raw,
|
|
604
|
-
};
|
|
605
|
-
for (const p of [...translateProps, ...scaleProps])
|
|
606
|
-
existing.props.add(p);
|
|
607
|
-
conflicts.set(sel, existing);
|
|
608
|
-
}
|
|
609
|
-
for (const [sel, { cssTransform, props, raw }] of conflicts) {
|
|
610
|
-
const propList = [...props].join("/");
|
|
611
|
-
pushFinding({
|
|
612
|
-
code: "gsap_css_transform_conflict",
|
|
613
|
-
severity: "warning",
|
|
614
|
-
message: `"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
|
|
615
|
-
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
|
|
616
|
-
`translateX(-50%) centering or CSS scale value.`,
|
|
617
|
-
selector: sel,
|
|
618
|
-
fixHint: `Remove the transform from CSS and use tl.fromTo('${sel}', ` +
|
|
619
|
-
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
|
|
620
|
-
`the full transform state. tl.fromTo is exempt from this rule.`,
|
|
621
|
-
snippet: truncateSnippet(raw),
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
// ── Caption lint rules ──────────────────────────────────────────────────
|
|
628
|
-
// Rule: caption_exit_missing_hard_kill
|
|
629
|
-
// Exit tweens (tl.to with opacity: 0) can fail when karaoke word-level tweens
|
|
630
|
-
// conflict, leaving captions stuck on screen. A hard tl.set kill is needed.
|
|
631
|
-
for (const script of scripts) {
|
|
632
|
-
const content = script.content;
|
|
633
|
-
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
|
634
|
-
const hasHardKill = /\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(content);
|
|
635
|
-
const hasCaptionLoop = /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
|
|
636
|
-
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
|
637
|
-
pushFinding({
|
|
638
|
-
code: "caption_exit_missing_hard_kill",
|
|
639
|
-
severity: "warning",
|
|
640
|
-
message: "Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
|
641
|
-
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
|
642
|
-
fixHint: 'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
|
|
643
|
-
"exit tl.to animation as a deterministic kill.",
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
// Rule: caption_text_overflow_risk
|
|
648
|
-
// Captions with nowrap text and no max-width will clip off-screen.
|
|
649
|
-
for (const style of styles) {
|
|
650
|
-
const content = style.content;
|
|
651
|
-
const captionBlocks = content.matchAll(/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi);
|
|
652
|
-
for (const [, selector, body] of captionBlocks) {
|
|
653
|
-
if (!body)
|
|
21
|
+
for (const rule of ALL_RULES) {
|
|
22
|
+
for (const finding of rule(ctx)) {
|
|
23
|
+
const dedupeKey = [
|
|
24
|
+
finding.code,
|
|
25
|
+
finding.severity,
|
|
26
|
+
finding.selector || "",
|
|
27
|
+
finding.elementId || "",
|
|
28
|
+
finding.message,
|
|
29
|
+
].join("|");
|
|
30
|
+
if (seen.has(dedupeKey))
|
|
654
31
|
continue;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (hasNowrap && !hasMaxWidth) {
|
|
658
|
-
pushFinding({
|
|
659
|
-
code: "caption_text_overflow_risk",
|
|
660
|
-
severity: "warning",
|
|
661
|
-
selector: (selector ?? "").trim(),
|
|
662
|
-
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
|
663
|
-
fixHint: "Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
// Rule: caption_container_relative_position
|
|
669
|
-
// position: relative on caption containers causes overflow and stacking issues.
|
|
670
|
-
for (const style of styles) {
|
|
671
|
-
const content = style.content;
|
|
672
|
-
const captionBlocks = content.matchAll(/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi);
|
|
673
|
-
for (const [, selector, body] of captionBlocks) {
|
|
674
|
-
if (!body)
|
|
675
|
-
continue;
|
|
676
|
-
if (/position\s*:\s*relative/i.test(body)) {
|
|
677
|
-
pushFinding({
|
|
678
|
-
code: "caption_container_relative_position",
|
|
679
|
-
severity: "warning",
|
|
680
|
-
selector: (selector ?? "").trim(),
|
|
681
|
-
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
|
682
|
-
fixHint: "Use position: absolute for all caption elements.",
|
|
683
|
-
});
|
|
684
|
-
}
|
|
32
|
+
seen.add(dedupeKey);
|
|
33
|
+
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
|
|
685
34
|
}
|
|
686
35
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// document) and in runtime mode (loadExternalCompositions re-injects them).
|
|
691
|
-
// But when a composition is used in a custom pipeline that bypasses both, the
|
|
692
|
-
// scripts won't be available. Flag this as an info-level finding so developers
|
|
693
|
-
// know the dependency exists.
|
|
694
|
-
{
|
|
695
|
-
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
|
696
|
-
let match;
|
|
697
|
-
const seen = new Set();
|
|
698
|
-
while ((match = externalScriptRe.exec(source)) !== null) {
|
|
699
|
-
const src = match[1] ?? "";
|
|
700
|
-
if (seen.has(src))
|
|
701
|
-
continue;
|
|
702
|
-
seen.add(src);
|
|
703
|
-
pushFinding({
|
|
704
|
-
code: "external_script_dependency",
|
|
705
|
-
severity: "info",
|
|
706
|
-
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
|
707
|
-
fixHint: "No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
|
708
|
-
snippet: truncateSnippet(match[0] ?? ""),
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
713
|
-
const warningCount = findings.filter((finding) => finding.severity === "warning").length;
|
|
714
|
-
const infoCount = findings.filter((finding) => finding.severity === "info").length;
|
|
36
|
+
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
37
|
+
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
38
|
+
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
715
39
|
return {
|
|
716
40
|
ok: errorCount === 0,
|
|
717
41
|
errorCount,
|
|
@@ -720,287 +44,7 @@ export function lintHyperframeHtml(html, options = {}) {
|
|
|
720
44
|
findings,
|
|
721
45
|
};
|
|
722
46
|
}
|
|
723
|
-
function extractOpenTags(source) {
|
|
724
|
-
const tags = [];
|
|
725
|
-
let match;
|
|
726
|
-
while ((match = TAG_PATTERN.exec(source)) !== null) {
|
|
727
|
-
const raw = match[0];
|
|
728
|
-
if (raw.startsWith("</") || raw.startsWith("<!")) {
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
tags.push({
|
|
732
|
-
raw,
|
|
733
|
-
name: (match[1] || "").toLowerCase(),
|
|
734
|
-
attrs: match[2] || "",
|
|
735
|
-
index: match.index,
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
return tags;
|
|
739
|
-
}
|
|
740
|
-
function extractBlocks(source, pattern) {
|
|
741
|
-
const blocks = [];
|
|
742
|
-
let match;
|
|
743
|
-
while ((match = pattern.exec(source)) !== null) {
|
|
744
|
-
blocks.push({
|
|
745
|
-
attrs: match[1] || "",
|
|
746
|
-
content: match[2] || "",
|
|
747
|
-
raw: match[0],
|
|
748
|
-
index: match.index,
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
return blocks;
|
|
752
|
-
}
|
|
753
|
-
function findRootTag(source) {
|
|
754
|
-
const bodyMatch = source.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
755
|
-
const bodyContent = bodyMatch ? (bodyMatch[1] ?? source) : source;
|
|
756
|
-
const bodyTags = extractOpenTags(bodyContent);
|
|
757
|
-
for (const tag of bodyTags) {
|
|
758
|
-
if (["script", "style", "meta", "link", "title"].includes(tag.name)) {
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
return tag;
|
|
762
|
-
}
|
|
763
|
-
return null;
|
|
764
|
-
}
|
|
765
|
-
function readAttr(tagSource, attr) {
|
|
766
|
-
if (!tagSource) {
|
|
767
|
-
return null;
|
|
768
|
-
}
|
|
769
|
-
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
770
|
-
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
|
|
771
|
-
return match?.[1] || null;
|
|
772
|
-
}
|
|
773
|
-
function collectCompositionIds(tags) {
|
|
774
|
-
const ids = new Set();
|
|
775
|
-
for (const tag of tags) {
|
|
776
|
-
const compId = readAttr(tag.raw, "data-composition-id");
|
|
777
|
-
if (compId) {
|
|
778
|
-
ids.add(compId);
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
return ids;
|
|
782
|
-
}
|
|
783
|
-
function extractCompositionIdsFromCss(css) {
|
|
784
|
-
const ids = new Set();
|
|
785
|
-
let match;
|
|
786
|
-
while ((match = COMPOSITION_ID_IN_CSS_PATTERN.exec(css)) !== null) {
|
|
787
|
-
if (match[1]) {
|
|
788
|
-
ids.add(match[1]);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
return [...ids];
|
|
792
|
-
}
|
|
793
|
-
function getInlineScriptSyntaxError(source) {
|
|
794
|
-
if (!source.trim()) {
|
|
795
|
-
return null;
|
|
796
|
-
}
|
|
797
|
-
try {
|
|
798
|
-
// eslint-disable-next-line no-new-func
|
|
799
|
-
new Function(source);
|
|
800
|
-
return null;
|
|
801
|
-
}
|
|
802
|
-
catch (error) {
|
|
803
|
-
if (error instanceof Error) {
|
|
804
|
-
return error.message;
|
|
805
|
-
}
|
|
806
|
-
return String(error);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
function isMediaTag(tagName) {
|
|
810
|
-
return tagName === "video" || tagName === "audio" || tagName === "img";
|
|
811
|
-
}
|
|
812
|
-
function countClassUsage(tags) {
|
|
813
|
-
const counts = new Map();
|
|
814
|
-
for (const tag of tags) {
|
|
815
|
-
const classAttr = readAttr(tag.raw, "class");
|
|
816
|
-
if (!classAttr) {
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
|
820
|
-
counts.set(className, (counts.get(className) || 0) + 1);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
return counts;
|
|
824
|
-
}
|
|
825
|
-
function readRegisteredTimelineCompositionId(script) {
|
|
826
|
-
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
827
|
-
return match?.[1] || null;
|
|
828
|
-
}
|
|
829
|
-
function extractGsapWindows(script) {
|
|
830
|
-
if (!/gsap\.timeline/.test(script)) {
|
|
831
|
-
return [];
|
|
832
|
-
}
|
|
833
|
-
const parsed = parseGsapScript(script);
|
|
834
|
-
if (parsed.animations.length === 0) {
|
|
835
|
-
return [];
|
|
836
|
-
}
|
|
837
|
-
const windows = [];
|
|
838
|
-
const timelineVar = parsed.timelineVar;
|
|
839
|
-
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
|
|
840
|
-
let match;
|
|
841
|
-
let index = 0;
|
|
842
|
-
while ((match = methodPattern.exec(script)) !== null && index < parsed.animations.length) {
|
|
843
|
-
const raw = match[0];
|
|
844
|
-
const meta = parseGsapWindowMeta(match[1] ?? "", match[2] ?? "");
|
|
845
|
-
const animation = parsed.animations[index];
|
|
846
|
-
index += 1;
|
|
847
|
-
if (!animation) {
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
windows.push({
|
|
851
|
-
targetSelector: animation.targetSelector,
|
|
852
|
-
position: animation.position,
|
|
853
|
-
end: animation.position + meta.effectiveDuration,
|
|
854
|
-
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
|
|
855
|
-
overwriteAuto: meta.overwriteAuto,
|
|
856
|
-
method: match[1] ?? "to",
|
|
857
|
-
raw,
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
return windows;
|
|
861
|
-
}
|
|
862
|
-
function parseGsapWindowMeta(method, argsStr) {
|
|
863
|
-
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
|
|
864
|
-
if (!selectorMatch) {
|
|
865
|
-
return { effectiveDuration: 0, properties: [], overwriteAuto: false };
|
|
866
|
-
}
|
|
867
|
-
const afterSelector = argsStr.slice(selectorMatch[0].length);
|
|
868
|
-
let properties = {};
|
|
869
|
-
let fromProperties = {};
|
|
870
|
-
if (method === "fromTo") {
|
|
871
|
-
const firstBrace = afterSelector.indexOf("{");
|
|
872
|
-
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
|
|
873
|
-
if (firstBrace !== -1 && firstEnd !== -1) {
|
|
874
|
-
fromProperties = parseLooseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
|
|
875
|
-
const secondPart = afterSelector.slice(firstEnd + 1);
|
|
876
|
-
const secondBrace = secondPart.indexOf("{");
|
|
877
|
-
const secondEnd = findMatchingBrace(secondPart, secondBrace);
|
|
878
|
-
if (secondBrace !== -1 && secondEnd !== -1) {
|
|
879
|
-
properties = parseLooseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
const braceStart = afterSelector.indexOf("{");
|
|
885
|
-
const braceEnd = findMatchingBrace(afterSelector, braceStart);
|
|
886
|
-
if (braceStart !== -1 && braceEnd !== -1) {
|
|
887
|
-
properties = parseLooseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
const duration = numberValue(properties.duration) || 0;
|
|
891
|
-
const repeat = numberValue(properties.repeat) || 0;
|
|
892
|
-
const yoyo = stringValue(properties.yoyo) === "true";
|
|
893
|
-
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
|
894
|
-
const effectiveDuration = duration * cycleCount * (yoyo ? 1 : 1);
|
|
895
|
-
const overwriteAuto = stringValue(properties.overwrite) === "auto";
|
|
896
|
-
const propertyNames = new Set();
|
|
897
|
-
for (const key of Object.keys(fromProperties)) {
|
|
898
|
-
if (!META_GSAP_KEYS.has(key)) {
|
|
899
|
-
propertyNames.add(key);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
for (const key of Object.keys(properties)) {
|
|
903
|
-
if (!META_GSAP_KEYS.has(key)) {
|
|
904
|
-
propertyNames.add(key);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
return {
|
|
908
|
-
effectiveDuration: method === "set" ? 0 : effectiveDuration,
|
|
909
|
-
properties: [...propertyNames],
|
|
910
|
-
overwriteAuto,
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
function parseLooseObjectLiteral(source) {
|
|
914
|
-
const result = {};
|
|
915
|
-
const cleaned = source.replace(/^\{|\}$/g, "").trim();
|
|
916
|
-
if (!cleaned) {
|
|
917
|
-
return result;
|
|
918
|
-
}
|
|
919
|
-
const propertyPattern = /(\w+)\s*:\s*("[^"]*"|'[^']*'|true|false|-?[\d.]+|[a-zA-Z_][\w.]*)/g;
|
|
920
|
-
let match;
|
|
921
|
-
while ((match = propertyPattern.exec(cleaned)) !== null) {
|
|
922
|
-
const key = match[1];
|
|
923
|
-
const rawValue = match[2];
|
|
924
|
-
if (!key || rawValue == null) {
|
|
925
|
-
continue;
|
|
926
|
-
}
|
|
927
|
-
if ((rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
|
928
|
-
(rawValue.startsWith("'") && rawValue.endsWith("'"))) {
|
|
929
|
-
result[key] = rawValue.slice(1, -1);
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
|
-
const numeric = Number(rawValue);
|
|
933
|
-
result[key] = Number.isFinite(numeric) ? numeric : rawValue;
|
|
934
|
-
}
|
|
935
|
-
return result;
|
|
936
|
-
}
|
|
937
|
-
function findMatchingBrace(source, startIndex) {
|
|
938
|
-
if (startIndex < 0) {
|
|
939
|
-
return -1;
|
|
940
|
-
}
|
|
941
|
-
let depth = 0;
|
|
942
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
943
|
-
if (source[i] === "{") {
|
|
944
|
-
depth += 1;
|
|
945
|
-
}
|
|
946
|
-
else if (source[i] === "}") {
|
|
947
|
-
depth -= 1;
|
|
948
|
-
if (depth === 0) {
|
|
949
|
-
return i;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
return -1;
|
|
954
|
-
}
|
|
955
|
-
function numberValue(value) {
|
|
956
|
-
if (typeof value === "number") {
|
|
957
|
-
return value;
|
|
958
|
-
}
|
|
959
|
-
if (typeof value === "string" && value.trim()) {
|
|
960
|
-
const numeric = Number(value);
|
|
961
|
-
return Number.isFinite(numeric) ? numeric : null;
|
|
962
|
-
}
|
|
963
|
-
return null;
|
|
964
|
-
}
|
|
965
|
-
function stringValue(value) {
|
|
966
|
-
if (typeof value === "string") {
|
|
967
|
-
return value;
|
|
968
|
-
}
|
|
969
|
-
if (typeof value === "number") {
|
|
970
|
-
return String(value);
|
|
971
|
-
}
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
function isSuspiciousGlobalSelector(selector) {
|
|
975
|
-
if (!selector) {
|
|
976
|
-
return false;
|
|
977
|
-
}
|
|
978
|
-
if (selector.includes("[data-composition-id=")) {
|
|
979
|
-
return false;
|
|
980
|
-
}
|
|
981
|
-
if (selector.startsWith("#")) {
|
|
982
|
-
return false;
|
|
983
|
-
}
|
|
984
|
-
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
|
985
|
-
}
|
|
986
|
-
function getSingleClassSelector(selector) {
|
|
987
|
-
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
|
988
|
-
return match?.groups?.name || null;
|
|
989
|
-
}
|
|
990
|
-
function truncateSnippet(value, maxLength = 220) {
|
|
991
|
-
const normalized = value.replace(/\s+/g, " ").trim();
|
|
992
|
-
if (!normalized) {
|
|
993
|
-
return undefined;
|
|
994
|
-
}
|
|
995
|
-
if (normalized.length <= maxLength) {
|
|
996
|
-
return normalized;
|
|
997
|
-
}
|
|
998
|
-
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
999
|
-
}
|
|
1000
47
|
// ── Async media URL accessibility checker ─────────────────────────────────
|
|
1001
|
-
/**
|
|
1002
|
-
* Extract all remote media URLs from HTML source.
|
|
1003
|
-
*/
|
|
1004
48
|
function extractMediaUrls(html) {
|
|
1005
49
|
const results = [];
|
|
1006
50
|
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
|
|
@@ -1016,7 +60,7 @@ function extractMediaUrls(html) {
|
|
|
1016
60
|
url: src,
|
|
1017
61
|
tagName,
|
|
1018
62
|
elementId: readAttr(raw, "id") || undefined,
|
|
1019
|
-
snippet: raw
|
|
63
|
+
snippet: truncateSnippet(raw) ?? "",
|
|
1020
64
|
});
|
|
1021
65
|
}
|
|
1022
66
|
}
|
|
@@ -1036,7 +80,6 @@ export async function lintMediaUrls(html, options = {}) {
|
|
|
1036
80
|
return [];
|
|
1037
81
|
const timeout = options.timeoutMs ?? 8000;
|
|
1038
82
|
const findings = [];
|
|
1039
|
-
// Dedupe by URL
|
|
1040
83
|
const seen = new Set();
|
|
1041
84
|
const unique = urls.filter((u) => {
|
|
1042
85
|
if (seen.has(u.url))
|
|
@@ -1044,7 +87,6 @@ export async function lintMediaUrls(html, options = {}) {
|
|
|
1044
87
|
seen.add(u.url);
|
|
1045
88
|
return true;
|
|
1046
89
|
});
|
|
1047
|
-
// Check all URLs in parallel
|
|
1048
90
|
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
|
|
1049
91
|
try {
|
|
1050
92
|
const controller = new AbortController();
|
|
@@ -1081,9 +123,6 @@ export async function lintMediaUrls(html, options = {}) {
|
|
|
1081
123
|
await Promise.all(checks);
|
|
1082
124
|
return findings;
|
|
1083
125
|
}
|
|
1084
|
-
/**
|
|
1085
|
-
* Extract all external script URLs from the HTML.
|
|
1086
|
-
*/
|
|
1087
126
|
function extractScriptUrls(html) {
|
|
1088
127
|
const results = [];
|
|
1089
128
|
const scriptRe = /<script\b[^>]*>/gi;
|
|
@@ -1096,7 +135,7 @@ function extractScriptUrls(html) {
|
|
|
1096
135
|
if (/^https?:\/\//i.test(src)) {
|
|
1097
136
|
results.push({
|
|
1098
137
|
url: src,
|
|
1099
|
-
snippet: raw
|
|
138
|
+
snippet: truncateSnippet(raw) ?? "",
|
|
1100
139
|
});
|
|
1101
140
|
}
|
|
1102
141
|
}
|