@hyperframes/lint 0.7.58 → 0.7.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.js +174 -25
- package/dist/browser.js.map +1 -1
- package/dist/index.js +178 -27
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ function findHtmlTag(tags) {
|
|
|
57
57
|
function findRootTag(source, parsedTags) {
|
|
58
58
|
const tags = parsedTags ?? parseHtmlStructure(source).tags;
|
|
59
59
|
const bodyTag = tags.find((tag) => tag.name === "body");
|
|
60
|
-
if (bodyTag && (
|
|
60
|
+
if (bodyTag && (readDecodedAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height"))) {
|
|
61
61
|
return bodyTag;
|
|
62
62
|
}
|
|
63
63
|
const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
|
|
@@ -67,7 +67,7 @@ function findRootTag(source, parsedTags) {
|
|
|
67
67
|
for (const tag of bodyTags) {
|
|
68
68
|
if (tag.index < skipBefore) continue;
|
|
69
69
|
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
|
|
70
|
-
if (tag.name === "svg" && !
|
|
70
|
+
if (tag.name === "svg" && !readDecodedAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height")) {
|
|
71
71
|
skipBefore = tag.endIndex ?? Infinity;
|
|
72
72
|
continue;
|
|
73
73
|
}
|
|
@@ -81,6 +81,20 @@ function readAttr(tagSource, attr) {
|
|
|
81
81
|
const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
|
|
82
82
|
return match?.[1] || null;
|
|
83
83
|
}
|
|
84
|
+
function readDecodedAttr(tagSource, attr) {
|
|
85
|
+
if (!tagSource) return null;
|
|
86
|
+
let value = null;
|
|
87
|
+
const parser = new Parser(
|
|
88
|
+
{
|
|
89
|
+
onattribute(name, decodedValue) {
|
|
90
|
+
if (value === null && name.toLowerCase() === attr.toLowerCase()) value = decodedValue;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{ decodeEntities: true, lowerCaseAttributeNames: false, lowerCaseTags: true }
|
|
94
|
+
);
|
|
95
|
+
parser.end(tagSource);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
84
98
|
function readJsonAttr(tagSource, attr) {
|
|
85
99
|
if (!tagSource) return null;
|
|
86
100
|
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -93,7 +107,7 @@ function readJsonAttr(tagSource, attr) {
|
|
|
93
107
|
function collectCompositionIds(tags) {
|
|
94
108
|
const ids = /* @__PURE__ */ new Set();
|
|
95
109
|
for (const tag of tags) {
|
|
96
|
-
const compId =
|
|
110
|
+
const compId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
97
111
|
if (compId) ids.add(compId);
|
|
98
112
|
}
|
|
99
113
|
return ids;
|
|
@@ -270,7 +284,7 @@ function buildLintContext(html, options = {}) {
|
|
|
270
284
|
const scripts = structure.scripts;
|
|
271
285
|
const compositionIds = collectCompositionIds(tags);
|
|
272
286
|
const rootTag = findRootTag(source, tags);
|
|
273
|
-
const rootCompositionId =
|
|
287
|
+
const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id");
|
|
274
288
|
return {
|
|
275
289
|
source,
|
|
276
290
|
rawSource,
|
|
@@ -306,7 +320,7 @@ function isStudioTimelineElement(tag) {
|
|
|
306
320
|
function describeStudioElement(tag) {
|
|
307
321
|
const parts = [`<${tag.name}`];
|
|
308
322
|
const className = readAttr(tag.raw, "class");
|
|
309
|
-
const compositionId =
|
|
323
|
+
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
310
324
|
const dataStart = readAttr(tag.raw, "data-start");
|
|
311
325
|
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
|
|
312
326
|
if (className) {
|
|
@@ -428,7 +442,7 @@ var coreRules = [
|
|
|
428
442
|
// root_missing_composition_id + root_missing_dimensions
|
|
429
443
|
({ rootTag }) => {
|
|
430
444
|
const findings = [];
|
|
431
|
-
if (!rootTag || !
|
|
445
|
+
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
|
|
432
446
|
findings.push({
|
|
433
447
|
code: "root_missing_composition_id",
|
|
434
448
|
severity: "error",
|
|
@@ -504,15 +518,10 @@ var coreRules = [
|
|
|
504
518
|
return findings;
|
|
505
519
|
},
|
|
506
520
|
// timeline_id_mismatch
|
|
507
|
-
({ source }) => {
|
|
521
|
+
({ source, compositionIds }) => {
|
|
508
522
|
const findings = [];
|
|
509
|
-
const htmlCompIds =
|
|
523
|
+
const htmlCompIds = new Set(compositionIds);
|
|
510
524
|
const timelineRegKeys = /* @__PURE__ */ new Set();
|
|
511
|
-
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
|
512
|
-
let m;
|
|
513
|
-
while ((m = compIdRe.exec(source)) !== null) {
|
|
514
|
-
if (m[1]) htmlCompIds.add(m[1]);
|
|
515
|
-
}
|
|
516
525
|
for (const key of extractTimelineRegistryKeys(source)) {
|
|
517
526
|
timelineRegKeys.add(key);
|
|
518
527
|
}
|
|
@@ -567,7 +576,7 @@ var coreRules = [
|
|
|
567
576
|
for (const tag of tags) {
|
|
568
577
|
const src = readAttr(tag.raw, "data-composition-src");
|
|
569
578
|
if (!src) continue;
|
|
570
|
-
if (
|
|
579
|
+
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
571
580
|
findings.push({
|
|
572
581
|
code: "host_missing_composition_id",
|
|
573
582
|
severity: "error",
|
|
@@ -642,7 +651,7 @@ var coreRules = [
|
|
|
642
651
|
code: "studio_missing_editable_id",
|
|
643
652
|
severity: "warning",
|
|
644
653
|
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
|
|
645
|
-
selector:
|
|
654
|
+
selector: readDecodedAttr(tag.raw, "data-composition-id") ? `[data-composition-id="${readDecodedAttr(tag.raw, "data-composition-id")}"]` : void 0,
|
|
646
655
|
fixHint: 'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
|
|
647
656
|
snippet: truncateSnippet(tag.raw)
|
|
648
657
|
});
|
|
@@ -1001,7 +1010,7 @@ var mediaRules = [
|
|
|
1001
1010
|
for (const tag of tags) {
|
|
1002
1011
|
if (tag.name === "video" || tag.name === "audio") continue;
|
|
1003
1012
|
if (voidElements.has(tag.name)) continue;
|
|
1004
|
-
if (
|
|
1013
|
+
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
1005
1014
|
if (readAttr(tag.raw, "data-start")) {
|
|
1006
1015
|
timedTagPositions.push({
|
|
1007
1016
|
name: tag.name,
|
|
@@ -1247,6 +1256,10 @@ async function loadParseGsapScript() {
|
|
|
1247
1256
|
}
|
|
1248
1257
|
var SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
|
1249
1258
|
var UNRESOLVED_TARGET = "__unresolved__";
|
|
1259
|
+
function targetHasNoStableIdentity(selector, identity) {
|
|
1260
|
+
if (identity) return false;
|
|
1261
|
+
return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
|
|
1262
|
+
}
|
|
1250
1263
|
function countClassUsage(tags) {
|
|
1251
1264
|
const counts = /* @__PURE__ */ new Map();
|
|
1252
1265
|
for (const tag of tags) {
|
|
@@ -1304,6 +1317,7 @@ async function extractGsapWindows(script) {
|
|
|
1304
1317
|
const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
|
|
1305
1318
|
windows.push({
|
|
1306
1319
|
targetSelector: animation.targetSelector,
|
|
1320
|
+
targetIdentity: animation.targetIdentity,
|
|
1307
1321
|
position: animation.position,
|
|
1308
1322
|
end: animation.position + effectiveDuration,
|
|
1309
1323
|
properties: Object.keys(animation.properties),
|
|
@@ -1395,7 +1409,7 @@ function findTagEnd(source, tag) {
|
|
|
1395
1409
|
}
|
|
1396
1410
|
function collectCompositionRanges(source, tags) {
|
|
1397
1411
|
return tags.map((tag) => {
|
|
1398
|
-
const id =
|
|
1412
|
+
const id = readDecodedAttr(tag.raw, "data-composition-id");
|
|
1399
1413
|
if (!id) return null;
|
|
1400
1414
|
return {
|
|
1401
1415
|
id,
|
|
@@ -1628,12 +1642,14 @@ var gsapRules = [
|
|
|
1628
1642
|
const left = gsapWindows[i];
|
|
1629
1643
|
if (!left) continue;
|
|
1630
1644
|
if (left.end <= left.position) continue;
|
|
1631
|
-
if (left.targetSelector
|
|
1645
|
+
if (targetHasNoStableIdentity(left.targetSelector, left.targetIdentity)) continue;
|
|
1632
1646
|
for (let j = i + 1; j < gsapWindows.length; j++) {
|
|
1633
1647
|
const right = gsapWindows[j];
|
|
1634
1648
|
if (!right) continue;
|
|
1635
1649
|
if (right.end <= right.position) continue;
|
|
1636
|
-
|
|
1650
|
+
const leftIdentity = left.targetIdentity ?? left.targetSelector;
|
|
1651
|
+
const rightIdentity = right.targetIdentity ?? right.targetSelector;
|
|
1652
|
+
if (leftIdentity !== rightIdentity) continue;
|
|
1637
1653
|
const overlapStart = Math.max(left.position, right.position);
|
|
1638
1654
|
const overlapEnd = Math.min(left.end, right.end);
|
|
1639
1655
|
if (overlapEnd <= overlapStart) continue;
|
|
@@ -2404,6 +2420,28 @@ import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
|
|
|
2404
2420
|
var MAX_COMPOSITION_LINES = 300;
|
|
2405
2421
|
var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
|
|
2406
2422
|
var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
|
|
2423
|
+
var CAPTION_CUE_TOKEN = /^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i;
|
|
2424
|
+
var HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25;
|
|
2425
|
+
var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
|
|
2426
|
+
"audio",
|
|
2427
|
+
"body",
|
|
2428
|
+
"br",
|
|
2429
|
+
"defs",
|
|
2430
|
+
"head",
|
|
2431
|
+
"hr",
|
|
2432
|
+
"html",
|
|
2433
|
+
"link",
|
|
2434
|
+
"meta",
|
|
2435
|
+
"script",
|
|
2436
|
+
"source",
|
|
2437
|
+
"style",
|
|
2438
|
+
"template",
|
|
2439
|
+
"title",
|
|
2440
|
+
"use",
|
|
2441
|
+
"video"
|
|
2442
|
+
]);
|
|
2443
|
+
var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
|
2444
|
+
var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
|
2407
2445
|
var OVERLAP_EPSILON_SECONDS = 1e-6;
|
|
2408
2446
|
function countPhysicalLines(source) {
|
|
2409
2447
|
if (source.length === 0) return 0;
|
|
@@ -2414,6 +2452,11 @@ function countPhysicalLines(source) {
|
|
|
2414
2452
|
function countStructuralLines(source) {
|
|
2415
2453
|
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
|
|
2416
2454
|
}
|
|
2455
|
+
function isCaptionCue(tag) {
|
|
2456
|
+
const classTokens = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
2457
|
+
const id = readAttr(tag.raw, "id");
|
|
2458
|
+
return classTokens.some((token) => CAPTION_CUE_TOKEN.test(token)) || Boolean(id && CAPTION_CUE_TOKEN.test(id));
|
|
2459
|
+
}
|
|
2417
2460
|
function isRegistrySourceFile(filePath) {
|
|
2418
2461
|
if (!filePath) return false;
|
|
2419
2462
|
const normalized = filePath.replace(/\\/g, "/");
|
|
@@ -2424,7 +2467,7 @@ function isRegistryInstalledFile(rawSource) {
|
|
|
2424
2467
|
}
|
|
2425
2468
|
function isCompositionRootOrMount(rawTag) {
|
|
2426
2469
|
return Boolean(
|
|
2427
|
-
|
|
2470
|
+
readDecodedAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src")
|
|
2428
2471
|
);
|
|
2429
2472
|
}
|
|
2430
2473
|
function extractCssUrlReferences(css) {
|
|
@@ -2457,6 +2500,33 @@ function leftmostCompoundClasses(selector) {
|
|
|
2457
2500
|
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
2458
2501
|
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
|
|
2459
2502
|
}
|
|
2503
|
+
function leftmostCompoundId(selector) {
|
|
2504
|
+
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
2505
|
+
return leftmost.match(/#([\w-]+)/)?.[1] ?? null;
|
|
2506
|
+
}
|
|
2507
|
+
function collectHeavyOverlayHooks(styles) {
|
|
2508
|
+
const classes = /* @__PURE__ */ new Set();
|
|
2509
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2510
|
+
for (const style of styles) {
|
|
2511
|
+
const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2512
|
+
const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
|
|
2513
|
+
let m;
|
|
2514
|
+
while ((m = ruleWithBody.exec(noComments)) !== null) {
|
|
2515
|
+
const header = (m[1] ?? "").trim();
|
|
2516
|
+
const body = m[2] ?? "";
|
|
2517
|
+
if (!header || header.startsWith("@")) continue;
|
|
2518
|
+
if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue;
|
|
2519
|
+
for (const sel of header.split(",")) {
|
|
2520
|
+
const trimmed = sel.trim();
|
|
2521
|
+
if (!trimmed) continue;
|
|
2522
|
+
for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls);
|
|
2523
|
+
const idToken = leftmostCompoundId(trimmed);
|
|
2524
|
+
if (idToken) ids.add(idToken);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return { classes, ids };
|
|
2529
|
+
}
|
|
2460
2530
|
function rootClassStyledSelectors(styles, rootClasses) {
|
|
2461
2531
|
const offenders = [];
|
|
2462
2532
|
for (const style of styles) {
|
|
@@ -2500,7 +2570,36 @@ function declaredIdsForBindingCheck(tags) {
|
|
|
2500
2570
|
if (declared.size === 0 && !findHtmlTag(tags)) return null;
|
|
2501
2571
|
return declared;
|
|
2502
2572
|
}
|
|
2573
|
+
function isInsideInertTemplate(tag, tags) {
|
|
2574
|
+
return tags.some(
|
|
2575
|
+
(candidate) => candidate.name === "template" && candidate.closeIndex != null && tag.index > candidate.index && tag.index < candidate.closeIndex
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2503
2578
|
var compositionRules = [
|
|
2579
|
+
// duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
|
|
2580
|
+
({ tags }) => {
|
|
2581
|
+
const tagsByCompositionId = /* @__PURE__ */ new Map();
|
|
2582
|
+
for (const tag of tags) {
|
|
2583
|
+
if (isInsideInertTemplate(tag, tags)) continue;
|
|
2584
|
+
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
2585
|
+
if (!compositionId || compositionId.trim().length === 0) continue;
|
|
2586
|
+
const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
|
|
2587
|
+
matchingTags.push(tag.raw);
|
|
2588
|
+
tagsByCompositionId.set(compositionId, matchingTags);
|
|
2589
|
+
}
|
|
2590
|
+
const findings = [];
|
|
2591
|
+
for (const [compositionId, matchingTags] of tagsByCompositionId) {
|
|
2592
|
+
if (matchingTags.length < 2) continue;
|
|
2593
|
+
findings.push({
|
|
2594
|
+
code: "duplicate_composition_id",
|
|
2595
|
+
severity: "error",
|
|
2596
|
+
message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`,
|
|
2597
|
+
fixHint: "Keep data-composition-id on exactly one element, the composition root. Remove it from metadata or duplicate hosts, especially a <meta> tag carrying the same data-composition-id as the root <div>, which causes a silent duplicate-id collision.",
|
|
2598
|
+
snippet: truncateSnippet(matchingTags[0] ?? "")
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
return findings;
|
|
2602
|
+
},
|
|
2504
2603
|
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
|
2505
2604
|
// href, inline-style url(), and <style> url() asset references on
|
|
2506
2605
|
// compositions. Sub-compositions live under compositions/ but are served
|
|
@@ -2575,6 +2674,7 @@ var compositionRules = [
|
|
|
2575
2674
|
const trackCounts = /* @__PURE__ */ new Map();
|
|
2576
2675
|
for (const tag of tags) {
|
|
2577
2676
|
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
|
|
2677
|
+
if (isCaptionCue(tag)) continue;
|
|
2578
2678
|
if (isCompositionRootOrMount(tag.raw)) continue;
|
|
2579
2679
|
if (!readAttr(tag.raw, "data-start")) continue;
|
|
2580
2680
|
const track = readAttr(tag.raw, "data-track-index");
|
|
@@ -2601,7 +2701,7 @@ var compositionRules = [
|
|
|
2601
2701
|
for (const tag of tags) {
|
|
2602
2702
|
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
|
2603
2703
|
if (!readAttr(tag.raw, "data-start")) continue;
|
|
2604
|
-
if (
|
|
2704
|
+
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
2605
2705
|
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
2606
2706
|
const classAttr = readAttr(tag.raw, "class") || "";
|
|
2607
2707
|
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
@@ -2701,7 +2801,7 @@ var compositionRules = [
|
|
|
2701
2801
|
const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
|
|
2702
2802
|
for (const tag of tags) {
|
|
2703
2803
|
if (skipTags.has(tag.name)) continue;
|
|
2704
|
-
if (
|
|
2804
|
+
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
2705
2805
|
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
2706
2806
|
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
|
2707
2807
|
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
|
|
@@ -2767,7 +2867,7 @@ var compositionRules = [
|
|
|
2767
2867
|
const findings = [];
|
|
2768
2868
|
if (options.isSubComposition) return findings;
|
|
2769
2869
|
if (!rootTag) return findings;
|
|
2770
|
-
const compId =
|
|
2870
|
+
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
|
2771
2871
|
if (!compId) return findings;
|
|
2772
2872
|
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
|
2773
2873
|
if (!hasStart) {
|
|
@@ -3125,7 +3225,7 @@ var compositionRules = [
|
|
|
3125
3225
|
({ rootTag, scripts, styles, tags, options }) => {
|
|
3126
3226
|
if (options.isSubComposition) return [];
|
|
3127
3227
|
if (!rootTag) return [];
|
|
3128
|
-
if (
|
|
3228
|
+
if (readDecodedAttr(rootTag.raw, "data-composition-id") === null) return [];
|
|
3129
3229
|
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
|
|
3130
3230
|
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
|
|
3131
3231
|
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
|
|
@@ -3177,6 +3277,55 @@ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
3177
3277
|
];
|
|
3178
3278
|
}
|
|
3179
3279
|
return [];
|
|
3280
|
+
},
|
|
3281
|
+
// composition_heavy_overlay_count_high
|
|
3282
|
+
// Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
|
|
3283
|
+
// with ~40 heavy overlay DOM elements — `filter:blur`, oversized
|
|
3284
|
+
// `radial-gradient`, and `clip-path` animations — captures solid-black for
|
|
3285
|
+
// the first ~half of the render, recovering near the end. Reproduces
|
|
3286
|
+
// identically via drawElement AND forced --no-browser-gpu screenshot
|
|
3287
|
+
// capture AND `snapshot`, so the offender is the capture layer itself, not
|
|
3288
|
+
// encoder/mux. Independent of duration (padding the timeline grows the bad
|
|
3289
|
+
// zone proportionally, doesn't shift it). Reporter's workaround was to
|
|
3290
|
+
// split into per-transition mini compositions + FFmpeg concat.
|
|
3291
|
+
//
|
|
3292
|
+
// Presence alone matters: opacity:0 and visibility:hidden overlays still
|
|
3293
|
+
// contribute to the capture-layer regression, so they're counted-in. The
|
|
3294
|
+
// only escape hatch is `display: none` — an element removed from the render
|
|
3295
|
+
// tree can't feed the compositor. Warn at 25, well below the observed
|
|
3296
|
+
// 40-element repro, to give authors lead time before hitting the bug.
|
|
3297
|
+
// fallow-ignore-next-line complexity
|
|
3298
|
+
({ tags, styles, rawSource, options }) => {
|
|
3299
|
+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
3300
|
+
const { classes: heavyClassTokens, ids: heavyIds } = collectHeavyOverlayHooks(styles);
|
|
3301
|
+
let heavyCount = 0;
|
|
3302
|
+
for (const tag of tags) {
|
|
3303
|
+
if (HEAVY_OVERLAY_EXEMPT_TAGS.has(tag.name)) continue;
|
|
3304
|
+
if (isCompositionRootOrMount(tag.raw)) continue;
|
|
3305
|
+
const styleAttr = readJsonAttr(tag.raw, "style") ?? "";
|
|
3306
|
+
if (styleAttr && INLINE_STYLE_DISPLAY_NONE_PATTERN.test(styleAttr)) continue;
|
|
3307
|
+
let heavy = false;
|
|
3308
|
+
if (styleAttr && HEAVY_OVERLAY_CSS_PATTERN.test(styleAttr)) heavy = true;
|
|
3309
|
+
if (!heavy && (heavyClassTokens.size > 0 || heavyIds.size > 0)) {
|
|
3310
|
+
const classList = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
3311
|
+
if (classList.some((cls) => heavyClassTokens.has(cls))) heavy = true;
|
|
3312
|
+
if (!heavy) {
|
|
3313
|
+
const idValue = readAttr(tag.raw, "id");
|
|
3314
|
+
if (idValue && heavyIds.has(idValue)) heavy = true;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
if (heavy) heavyCount += 1;
|
|
3318
|
+
}
|
|
3319
|
+
if (heavyCount < HEAVY_OVERLAY_ELEMENT_COUNT_WARN) return [];
|
|
3320
|
+
const splitTarget = options.isSubComposition ? "Split this sub-composition further into per-transition mini-compositions" : "Split coherent scenes / transitions into separate .html files under compositions/";
|
|
3321
|
+
return [
|
|
3322
|
+
{
|
|
3323
|
+
code: "composition_heavy_overlay_count_high",
|
|
3324
|
+
severity: "warning",
|
|
3325
|
+
message: `This composition has ${heavyCount} elements carrying "heavy overlay" CSS (filter:blur, radial-gradient, or clip-path). Field signal: a composition with ~40 such elements \u2014 including opacity:0 / visibility:hidden ones \u2014 captures solid-black for the first ~half of the render, recovering near the end. Reproduces identically via drawElement, forced screenshot capture, and snapshot, so the capture layer itself is the offender (not encoder/mux). Independent of duration. Presence alone matters; only display:none elements are excluded here.`,
|
|
3326
|
+
fixHint: `${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each capture only sees a small subset of heavy overlays at once. Even hidden overlays (opacity:0 / visibility:hidden) contribute \u2014 either remove truly unused ones from the source or scope them into their own per-transition sub-composition. If an overlay is genuinely inert for the whole clip, use display:none so it never enters the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`
|
|
3327
|
+
}
|
|
3328
|
+
];
|
|
3180
3329
|
}
|
|
3181
3330
|
];
|
|
3182
3331
|
|
|
@@ -3606,7 +3755,7 @@ function parseTiming(raw) {
|
|
|
3606
3755
|
}
|
|
3607
3756
|
function collectCompositionIdScenes(ctx, seen, out) {
|
|
3608
3757
|
for (const tag of ctx.tags) {
|
|
3609
|
-
const compositionId =
|
|
3758
|
+
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
|
3610
3759
|
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
|
|
3611
3760
|
continue;
|
|
3612
3761
|
const timing = parseTiming(tag.raw);
|
|
@@ -3906,8 +4055,10 @@ async function lintProject(projectDir, entryFile) {
|
|
|
3906
4055
|
const out = [];
|
|
3907
4056
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
3908
4057
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
3909
|
-
if (entry.isDirectory())
|
|
3910
|
-
|
|
4058
|
+
if (entry.isDirectory()) {
|
|
4059
|
+
if (!rel && entry.name === "components") continue;
|
|
4060
|
+
out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
|
|
4061
|
+
} else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
|
|
3911
4062
|
out.push(relPath);
|
|
3912
4063
|
}
|
|
3913
4064
|
}
|