@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/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 && (readAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height"))) {
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" && !readAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height")) {
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 = readAttr(tag.raw, "data-composition-id");
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 = readAttr(rootTag?.raw || "", "data-composition-id");
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 = readAttr(tag.raw, "data-composition-id");
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 || !readAttr(rootTag.raw, "data-composition-id")) {
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 = /* @__PURE__ */ new Set();
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 (readAttr(tag.raw, "data-composition-id")) continue;
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: readAttr(tag.raw, "data-composition-id") ? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]` : void 0,
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 (readAttr(tag.raw, "data-composition-id")) continue;
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 = readAttr(tag.raw, "data-composition-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 === UNRESOLVED_TARGET) continue;
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
- if (left.targetSelector !== right.targetSelector) continue;
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
- readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src")
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 (readAttr(tag.raw, "data-composition-id")) continue;
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 (readAttr(tag.raw, "data-composition-id")) continue;
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 = readAttr(rootTag.raw, "data-composition-id");
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 (readAttr(rootTag.raw, "data-composition-id") === null) return [];
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 = readAttr(tag.raw, "data-composition-id");
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()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
3910
- else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
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
  }