@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.
Files changed (53) hide show
  1. package/dist/compiler/htmlBundler.d.ts.map +1 -1
  2. package/dist/compiler/htmlBundler.js +9 -0
  3. package/dist/compiler/htmlBundler.js.map +1 -1
  4. package/dist/compiler/rewriteSubCompPaths.d.ts +35 -0
  5. package/dist/compiler/rewriteSubCompPaths.d.ts.map +1 -0
  6. package/dist/compiler/rewriteSubCompPaths.js +85 -0
  7. package/dist/compiler/rewriteSubCompPaths.js.map +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/lint/context.d.ts +16 -0
  13. package/dist/lint/context.d.ts.map +1 -0
  14. package/dist/lint/context.js +24 -0
  15. package/dist/lint/context.js.map +1 -0
  16. package/dist/lint/hyperframeLinter.d.ts +1 -1
  17. package/dist/lint/hyperframeLinter.d.ts.map +1 -1
  18. package/dist/lint/hyperframeLinter.js +34 -995
  19. package/dist/lint/hyperframeLinter.js.map +1 -1
  20. package/dist/lint/rules/adapters.d.ts +3 -0
  21. package/dist/lint/rules/adapters.d.ts.map +1 -0
  22. package/dist/lint/rules/adapters.js +43 -0
  23. package/dist/lint/rules/adapters.js.map +1 -0
  24. package/dist/lint/rules/captions.d.ts +3 -0
  25. package/dist/lint/rules/captions.d.ts.map +1 -0
  26. package/dist/lint/rules/captions.js +68 -0
  27. package/dist/lint/rules/captions.js.map +1 -0
  28. package/dist/lint/rules/composition.d.ts +3 -0
  29. package/dist/lint/rules/composition.d.ts.map +1 -0
  30. package/dist/lint/rules/composition.js +103 -0
  31. package/dist/lint/rules/composition.js.map +1 -0
  32. package/dist/lint/rules/core.d.ts +3 -0
  33. package/dist/lint/rules/core.d.ts.map +1 -0
  34. package/dist/lint/rules/core.js +155 -0
  35. package/dist/lint/rules/core.js.map +1 -0
  36. package/dist/lint/rules/gsap.d.ts +3 -0
  37. package/dist/lint/rules/gsap.d.ts.map +1 -0
  38. package/dist/lint/rules/gsap.js +347 -0
  39. package/dist/lint/rules/gsap.js.map +1 -0
  40. package/dist/lint/rules/media.d.ts +3 -0
  41. package/dist/lint/rules/media.d.ts.map +1 -0
  42. package/dist/lint/rules/media.js +217 -0
  43. package/dist/lint/rules/media.js.map +1 -0
  44. package/dist/lint/types.d.ts +1 -0
  45. package/dist/lint/types.d.ts.map +1 -1
  46. package/dist/lint/utils.d.ts +30 -0
  47. package/dist/lint/utils.d.ts.map +1 -0
  48. package/dist/lint/utils.js +104 -0
  49. package/dist/lint/utils.js.map +1 -0
  50. package/dist/studio-api/routes/files.d.ts.map +1 -1
  51. package/dist/studio-api/routes/files.js +220 -20
  52. package/dist/studio-api/routes/files.js.map +1 -1
  53. package/package.json +1 -1
@@ -1,717 +1,41 @@
1
- import { parseGsapScript } from "../parsers/gsapParser";
2
- const TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
3
- const STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
4
- const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
5
- const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
6
- const TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
7
- const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
8
- const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
9
- const WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
10
- const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
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
- // Strip <template> wrapper if present — composition files are often wrapped in
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 pushFinding = (finding) => {
22
- const dedupeKey = [
23
- finding.code,
24
- finding.severity,
25
- finding.selector || "",
26
- finding.elementId || "",
27
- finding.message,
28
- ].join("|");
29
- if (seen.has(dedupeKey)) {
30
- return;
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
- const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
656
- const hasMaxWidth = /max-width/i.test(body);
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
- // ── External CDN script dependency check ────────────────────────────────
688
- // Compositions that load CDN libraries via <script src="https://..."> work
689
- // correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
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.length > 120 ? raw.slice(0, 117) + "..." : 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.length > 120 ? raw.slice(0, 117) + "..." : raw,
138
+ snippet: truncateSnippet(raw) ?? "",
1100
139
  });
1101
140
  }
1102
141
  }