@hyperframes/lint 0.7.58 → 0.7.59

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,7 @@ 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;
2407
2424
  var OVERLAP_EPSILON_SECONDS = 1e-6;
2408
2425
  function countPhysicalLines(source) {
2409
2426
  if (source.length === 0) return 0;
@@ -2414,6 +2431,11 @@ function countPhysicalLines(source) {
2414
2431
  function countStructuralLines(source) {
2415
2432
  return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
2416
2433
  }
2434
+ function isCaptionCue(tag) {
2435
+ const classTokens = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
2436
+ const id = readAttr(tag.raw, "id");
2437
+ return classTokens.some((token) => CAPTION_CUE_TOKEN.test(token)) || Boolean(id && CAPTION_CUE_TOKEN.test(id));
2438
+ }
2417
2439
  function isRegistrySourceFile(filePath) {
2418
2440
  if (!filePath) return false;
2419
2441
  const normalized = filePath.replace(/\\/g, "/");
@@ -2424,7 +2446,7 @@ function isRegistryInstalledFile(rawSource) {
2424
2446
  }
2425
2447
  function isCompositionRootOrMount(rawTag) {
2426
2448
  return Boolean(
2427
- readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src")
2449
+ readDecodedAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src")
2428
2450
  );
2429
2451
  }
2430
2452
  function extractCssUrlReferences(css) {
@@ -2500,7 +2522,36 @@ function declaredIdsForBindingCheck(tags) {
2500
2522
  if (declared.size === 0 && !findHtmlTag(tags)) return null;
2501
2523
  return declared;
2502
2524
  }
2525
+ function isInsideInertTemplate(tag, tags) {
2526
+ return tags.some(
2527
+ (candidate) => candidate.name === "template" && candidate.closeIndex != null && tag.index > candidate.index && tag.index < candidate.closeIndex
2528
+ );
2529
+ }
2503
2530
  var compositionRules = [
2531
+ // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
2532
+ ({ tags }) => {
2533
+ const tagsByCompositionId = /* @__PURE__ */ new Map();
2534
+ for (const tag of tags) {
2535
+ if (isInsideInertTemplate(tag, tags)) continue;
2536
+ const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
2537
+ if (!compositionId || compositionId.trim().length === 0) continue;
2538
+ const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
2539
+ matchingTags.push(tag.raw);
2540
+ tagsByCompositionId.set(compositionId, matchingTags);
2541
+ }
2542
+ const findings = [];
2543
+ for (const [compositionId, matchingTags] of tagsByCompositionId) {
2544
+ if (matchingTags.length < 2) continue;
2545
+ findings.push({
2546
+ code: "duplicate_composition_id",
2547
+ severity: "error",
2548
+ message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`,
2549
+ 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.",
2550
+ snippet: truncateSnippet(matchingTags[0] ?? "")
2551
+ });
2552
+ }
2553
+ return findings;
2554
+ },
2504
2555
  // invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
2505
2556
  // href, inline-style url(), and <style> url() asset references on
2506
2557
  // compositions. Sub-compositions live under compositions/ but are served
@@ -2575,6 +2626,7 @@ var compositionRules = [
2575
2626
  const trackCounts = /* @__PURE__ */ new Map();
2576
2627
  for (const tag of tags) {
2577
2628
  if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
2629
+ if (isCaptionCue(tag)) continue;
2578
2630
  if (isCompositionRootOrMount(tag.raw)) continue;
2579
2631
  if (!readAttr(tag.raw, "data-start")) continue;
2580
2632
  const track = readAttr(tag.raw, "data-track-index");
@@ -2601,7 +2653,7 @@ var compositionRules = [
2601
2653
  for (const tag of tags) {
2602
2654
  if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
2603
2655
  if (!readAttr(tag.raw, "data-start")) continue;
2604
- if (readAttr(tag.raw, "data-composition-id")) continue;
2656
+ if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
2605
2657
  if (readAttr(tag.raw, "data-composition-src")) continue;
2606
2658
  const classAttr = readAttr(tag.raw, "class") || "";
2607
2659
  const styleAttr = readAttr(tag.raw, "style") || "";
@@ -2701,7 +2753,7 @@ var compositionRules = [
2701
2753
  const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
2702
2754
  for (const tag of tags) {
2703
2755
  if (skipTags.has(tag.name)) continue;
2704
- if (readAttr(tag.raw, "data-composition-id")) continue;
2756
+ if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
2705
2757
  if (readAttr(tag.raw, "data-composition-src")) continue;
2706
2758
  const hasStart = readAttr(tag.raw, "data-start") !== null;
2707
2759
  const hasDuration = readAttr(tag.raw, "data-duration") !== null;
@@ -2767,7 +2819,7 @@ var compositionRules = [
2767
2819
  const findings = [];
2768
2820
  if (options.isSubComposition) return findings;
2769
2821
  if (!rootTag) return findings;
2770
- const compId = readAttr(rootTag.raw, "data-composition-id");
2822
+ const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
2771
2823
  if (!compId) return findings;
2772
2824
  const hasStart = readAttr(rootTag.raw, "data-start") !== null;
2773
2825
  if (!hasStart) {
@@ -3125,7 +3177,7 @@ var compositionRules = [
3125
3177
  ({ rootTag, scripts, styles, tags, options }) => {
3126
3178
  if (options.isSubComposition) return [];
3127
3179
  if (!rootTag) return [];
3128
- if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
3180
+ if (readDecodedAttr(rootTag.raw, "data-composition-id") === null) return [];
3129
3181
  if (readAttr(rootTag.raw, "data-duration") !== null) return [];
3130
3182
  const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
3131
3183
  const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
@@ -3606,7 +3658,7 @@ function parseTiming(raw) {
3606
3658
  }
3607
3659
  function collectCompositionIdScenes(ctx, seen, out) {
3608
3660
  for (const tag of ctx.tags) {
3609
- const compositionId = readAttr(tag.raw, "data-composition-id");
3661
+ const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
3610
3662
  if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
3611
3663
  continue;
3612
3664
  const timing = parseTiming(tag.raw);
@@ -3906,8 +3958,10 @@ async function lintProject(projectDir, entryFile) {
3906
3958
  const out = [];
3907
3959
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
3908
3960
  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("._")) {
3961
+ if (entry.isDirectory()) {
3962
+ if (!rel && entry.name === "components") continue;
3963
+ out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
3964
+ } else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
3911
3965
  out.push(relPath);
3912
3966
  }
3913
3967
  }