@hyperframes/lint 0.8.3 → 0.8.5

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
@@ -301,15 +301,6 @@ function buildLintContext(html, options = {}) {
301
301
  // src/rules/core.ts
302
302
  import postcss from "postcss";
303
303
  import selectorParser from "postcss-selector-parser";
304
- function escapeRegExp(value) {
305
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
306
- }
307
- function selectorTargetsCompositionId(selector, compositionId) {
308
- const escaped = escapeRegExp(compositionId);
309
- return new RegExp(
310
- String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`
311
- ).test(selector);
312
- }
313
304
  function repeatedDescendantId(selector) {
314
305
  let repeated = null;
315
306
  const requiredPseudoIds = (pseudo) => {
@@ -705,36 +696,6 @@ var coreRules = [
705
696
  }
706
697
  return findings;
707
698
  },
708
- // composition_self_attribute_selector
709
- ({ styles, rootCompositionId, rootTag }) => {
710
- const findings = [];
711
- if (!rootCompositionId) return findings;
712
- const seenSelectors = /* @__PURE__ */ new Set();
713
- const rootId = readAttr(rootTag?.raw || "", "id");
714
- for (const style of styles) {
715
- let root;
716
- try {
717
- root = postcss.parse(style.content);
718
- } catch {
719
- continue;
720
- }
721
- root.walkRules((rule) => {
722
- for (const selector of rule.selectors) {
723
- if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
724
- if (seenSelectors.has(selector)) continue;
725
- seenSelectors.add(selector);
726
- findings.push({
727
- code: "composition_self_attribute_selector",
728
- severity: "warning",
729
- message: "Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
730
- selector,
731
- fixHint: rootId ? `Use #${rootId} for clearer authoring intent and instance-isolated styling.` : "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling."
732
- });
733
- }
734
- });
735
- }
736
- return findings;
737
- },
738
699
  // studio_missing_editable_id
739
700
  ({ tags, rootTag }) => {
740
701
  const findings = [];
@@ -810,60 +771,13 @@ var coreRules = [
810
771
  }
811
772
  }
812
773
  return findings;
813
- },
814
- // pointer_events_none
815
- // fallow-ignore-next-line complexity
816
- ({ tags, styles }) => {
817
- const findings = [];
818
- const reported = /* @__PURE__ */ new Set();
819
- for (const tag of tags) {
820
- if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
821
- const inlineStyle = readAttr(tag.raw, "style") ?? "";
822
- if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
823
- const id = readAttr(tag.raw, "id");
824
- const key = id ?? tag.raw;
825
- if (reported.has(key)) continue;
826
- reported.add(key);
827
- findings.push({
828
- code: "pointer_events_none",
829
- severity: "info",
830
- message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
831
- elementId: id || void 0,
832
- fixHint: "If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
833
- snippet: truncateSnippet(tag.raw)
834
- });
835
- }
836
- for (const style of styles) {
837
- let root;
838
- try {
839
- root = postcss.parse(style.content);
840
- } catch {
841
- continue;
842
- }
843
- root.walkDecls("pointer-events", (decl) => {
844
- if (decl.value.trim().toLowerCase() !== "none") return;
845
- const rule = decl.parent;
846
- if (!rule || rule.type !== "rule") return;
847
- const selector = rule.selector;
848
- if (reported.has(selector)) return;
849
- reported.add(selector);
850
- findings.push({
851
- code: "pointer_events_none",
852
- severity: "info",
853
- message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
854
- selector,
855
- fixHint: "If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content."
856
- });
857
- });
858
- }
859
- return findings;
860
774
  }
861
775
  ];
862
776
 
863
777
  // src/rules/media.ts
864
778
  import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
865
779
  function tweensVolumeInSameCall(script, id) {
866
- const selector = new RegExp(`#${escapeRegExp2(id)}(?![\\w-])`, "g");
780
+ const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
867
781
  for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
868
782
  let depth = 0;
869
783
  const limit = Math.min(script.length, hit.index + 2e3);
@@ -879,11 +793,11 @@ function tweensVolumeInSameCall(script, id) {
879
793
  }
880
794
  return false;
881
795
  }
882
- function escapeRegExp2(value) {
796
+ function escapeRegExp(value) {
883
797
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
884
798
  }
885
799
  function hasAttrName(tagSource, attr) {
886
- const escaped = escapeRegExp2(attr);
800
+ const escaped = escapeRegExp(attr);
887
801
  const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
888
802
  return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
889
803
  }
@@ -897,13 +811,13 @@ function selectorTargetsManagedMedia(selector, mediaIndex) {
897
811
  if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
898
812
  if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
899
813
  for (const mediaId of mediaIndex.ids) {
900
- const escapedId = escapeRegExp2(mediaId);
814
+ const escapedId = escapeRegExp(mediaId);
901
815
  if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
902
816
  return true;
903
817
  }
904
818
  }
905
819
  for (const className of mediaIndex.classes) {
906
- if (new RegExp(`\\.${escapeRegExp2(className)}(?![\\w-])`).test(normalized)) {
820
+ if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
907
821
  return true;
908
822
  }
909
823
  }
@@ -1006,7 +920,7 @@ function findImperativeMediaControlFindings(ctx) {
1006
920
  }
1007
921
  }
1008
922
  for (const [variableName, elementId] of mediaVars) {
1009
- const escapedVar = escapeRegExp2(variableName);
923
+ const escapedVar = escapeRegExp(variableName);
1010
924
  const variablePatterns = [
1011
925
  { pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
1012
926
  { pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
@@ -1402,8 +1316,29 @@ var mediaRules = [
1402
1316
  // imperative_media_control
1403
1317
  findImperativeMediaControlFindings,
1404
1318
  // audio_volume_double_automation
1405
- findVolumeDoubleAutomationFindings
1319
+ findVolumeDoubleAutomationFindings,
1320
+ // audio_volume_tween_overrides_gain
1321
+ findVolumeTweenOverridesGainFindings
1406
1322
  ];
1323
+ function findVolumeTweenOverridesGainFindings(ctx) {
1324
+ const boosted = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") })).filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1).filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation")).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
1325
+ if (boosted.length === 0) return [];
1326
+ const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
1327
+ const findings = [];
1328
+ for (const { tag, id, volume } of boosted) {
1329
+ if (!tweensVolumeInSameCall(script, id)) continue;
1330
+ const db = volume > 0 ? `${(20 * Math.log10(volume)).toFixed(1)} dB` : "silence";
1331
+ findings.push({
1332
+ code: "audio_volume_tween_overrides_gain",
1333
+ severity: "warning",
1334
+ message: `#${id} has data-volume="${volume}" (${db}) and a GSAP tween on \`volume\`. Tween values are absolute \u2014 they REPLACE this gain rather than scale it \u2014 so wherever the tween names a value the clip plays at that value, not at ${db}.`,
1335
+ elementId: id,
1336
+ fixHint: "Write the tween's targets in the same absolute gain (e.g. `volume: 1.95`, not `volume: 1`), or reset data-volume to 1 and let the tween carry the level on its own.",
1337
+ snippet: truncateSnippet(tag.raw)
1338
+ });
1339
+ }
1340
+ return findings;
1341
+ }
1407
1342
  function findVolumeDoubleAutomationFindings(ctx) {
1408
1343
  const automated = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, automation: readDecodedAttr(tag.raw, "data-automation") })).filter((entry) => entry.automation && /"target"\s*:\s*"volume"/.test(entry.automation)).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
1409
1344
  if (automated.length === 0) return [];
@@ -1439,17 +1374,6 @@ function targetHasNoStableIdentity(selector, identity) {
1439
1374
  if (identity) return false;
1440
1375
  return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
1441
1376
  }
1442
- function countClassUsage(tags) {
1443
- const counts = /* @__PURE__ */ new Map();
1444
- for (const tag of tags) {
1445
- const classAttr = readAttr(tag.raw, "class");
1446
- if (!classAttr) continue;
1447
- for (const className of classAttr.split(/\s+/).filter(Boolean)) {
1448
- counts.set(className, (counts.get(className) || 0) + 1);
1449
- }
1450
- }
1451
- return counts;
1452
- }
1453
1377
  function readRegisteredTimelineCompositionId(script) {
1454
1378
  const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
1455
1379
  return match?.[1] || match?.[2] || null;
@@ -1665,16 +1589,6 @@ function findMatchingSceneBoundary(time, boundaries) {
1665
1589
  }
1666
1590
  return null;
1667
1591
  }
1668
- function isSuspiciousGlobalSelector(selector) {
1669
- if (!selector) return false;
1670
- if (selector.includes("[data-composition-id=")) return false;
1671
- if (selector.startsWith("#")) return false;
1672
- return selector.startsWith(".") || /^[a-z]/i.test(selector);
1673
- }
1674
- function getSingleClassSelector(selector) {
1675
- const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
1676
- return match?.groups?.name || null;
1677
- }
1678
1592
  function readStyleProperty(style, property) {
1679
1593
  const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1680
1594
  const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
@@ -1918,7 +1832,7 @@ function isInsideGsapTweenVars(source, index, timelineVars) {
1918
1832
  else if (ch === "{") {
1919
1833
  if (depth === 0) {
1920
1834
  const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
1921
- const receivers = ["gsap", ...timelineVars].map(escapeRegExp3).join("|");
1835
+ const receivers = ["gsap", ...timelineVars].map(escapeRegExp2).join("|");
1922
1836
  return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
1923
1837
  before
1924
1838
  );
@@ -1954,7 +1868,7 @@ function parseFunctionValueSource(code) {
1954
1868
  const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
1955
1869
  return { firstParam, body: src.slice(match[0].length) };
1956
1870
  }
1957
- function escapeRegExp3(value) {
1871
+ function escapeRegExp2(value) {
1958
1872
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1959
1873
  }
1960
1874
  var NUMBER_METHODS = /* @__PURE__ */ new Set([
@@ -1968,7 +1882,7 @@ var NUMBER_METHODS = /* @__PURE__ */ new Set([
1968
1882
  function firstParamMemberAccessHazard(fn) {
1969
1883
  if (!fn.firstParam) return null;
1970
1884
  const pattern = new RegExp(
1971
- `\\b${escapeRegExp3(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1885
+ `\\b${escapeRegExp2(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1972
1886
  "g"
1973
1887
  );
1974
1888
  let match;
@@ -2012,7 +1926,7 @@ function collectMeasuringFunctionNames(bodies) {
2012
1926
  for (const [name, body] of bodies) {
2013
1927
  if (measuring.has(name)) continue;
2014
1928
  for (const measured of measuring) {
2015
- if (new RegExp(`\\b${escapeRegExp3(measured)}\\s*\\(`).test(body)) {
1929
+ if (new RegExp(`\\b${escapeRegExp2(measured)}\\s*\\(`).test(body)) {
2016
1930
  measuring.add(name);
2017
1931
  grew = true;
2018
1932
  break;
@@ -2026,7 +1940,7 @@ function collectMeasuringFunctionNames(bodies) {
2026
1940
  function expressionReachesMeasurement(expression, measuring) {
2027
1941
  if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
2028
1942
  for (const name of measuring) {
2029
- if (new RegExp(`\\b${escapeRegExp3(name)}\\b`).test(expression)) return true;
1943
+ if (new RegExp(`\\b${escapeRegExp2(name)}\\b`).test(expression)) return true;
2030
1944
  }
2031
1945
  return false;
2032
1946
  }
@@ -2049,7 +1963,7 @@ function resolveScriptElementTokens(source, tags) {
2049
1963
  const template = match[2] ?? "";
2050
1964
  const staticParts = template.split(/\$\{[^}]*\}/);
2051
1965
  if (staticParts.every((part) => part === "")) continue;
2052
- const idPattern = new RegExp(`^${staticParts.map(escapeRegExp3).join(".*")}$`);
1966
+ const idPattern = new RegExp(`^${staticParts.map(escapeRegExp2).join(".*")}$`);
2053
1967
  for (const id of documentIds) {
2054
1968
  if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
2055
1969
  }
@@ -2141,7 +2055,7 @@ function collectCssOpacityZeroSelectors(styles, tags) {
2141
2055
  return selectors;
2142
2056
  }
2143
2057
  var gsapRules = [
2144
- // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
2058
+ // overlapping_gsap_tweens + gsap_animates_clip_element
2145
2059
  // fallow-ignore-next-line complexity
2146
2060
  async ({ source, tags, scripts, styles, rootCompositionId }) => {
2147
2061
  const findings = [];
@@ -2161,7 +2075,6 @@ var gsapRules = [
2161
2075
  if (cls !== "clip") clipClasses.set(`.${cls}`, info);
2162
2076
  }
2163
2077
  }
2164
- const classUsage = countClassUsage(tags);
2165
2078
  const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
2166
2079
  const styleRules = collectSimpleStyleRules(styles);
2167
2080
  const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
@@ -2284,20 +2197,6 @@ ${right.raw}`)
2284
2197
  snippet: truncateSnippet(win.raw)
2285
2198
  });
2286
2199
  }
2287
- if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
2288
- for (const win of gsapWindows) {
2289
- if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
2290
- const className = getSingleClassSelector(win.targetSelector);
2291
- if (className && (classUsage.get(className) || 0) < 2) continue;
2292
- findings.push({
2293
- code: "unscoped_gsap_selector",
2294
- severity: "error",
2295
- message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
2296
- selector: win.targetSelector,
2297
- fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
2298
- snippet: truncateSnippet(win.raw)
2299
- });
2300
- }
2301
2200
  }
2302
2201
  return findings;
2303
2202
  },
@@ -2554,39 +2453,6 @@ ${right.raw}`)
2554
2453
  }
2555
2454
  return findings;
2556
2455
  },
2557
- // scene_layer_missing_visibility_kill
2558
- ({ scripts, tags }) => {
2559
- const findings = [];
2560
- const sceneElements = tags.filter((t) => {
2561
- const id = readAttr(t.raw, "id") || "";
2562
- return /^scene\d+$/i.test(id);
2563
- });
2564
- if (sceneElements.length < 2) return findings;
2565
- for (const script of scripts) {
2566
- const content = stripJsComments(script.content);
2567
- for (const tag of sceneElements) {
2568
- const id = readAttr(tag.raw, "id") || "";
2569
- const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
2570
- const hasExit = exitPattern.test(content);
2571
- if (!hasExit) continue;
2572
- const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
2573
- const hasKill = killPattern.test(content);
2574
- if (!hasKill) {
2575
- const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
2576
- const isClip = classes.includes("clip");
2577
- const fixHint = isClip ? `"#${id}" is a clip element \u2014 the framework already manages its visibility. Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill (\`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)\`) onto that wrapper instead.` : `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;
2578
- findings.push({
2579
- code: "scene_layer_missing_visibility_kill",
2580
- severity: "error",
2581
- elementId: id,
2582
- message: `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.`,
2583
- fixHint
2584
- });
2585
- }
2586
- }
2587
- }
2588
- return findings;
2589
- },
2590
2456
  // gsap_timeline_not_registered
2591
2457
  ({ scripts, rawSource, options }) => {
2592
2458
  const findings = [];
@@ -2949,7 +2815,7 @@ ${other.raw}`)
2949
2815
  const timelineVars = collectTimelineVarNames(source);
2950
2816
  for (const timelineVar of timelineVars) {
2951
2817
  const callPattern = new RegExp(
2952
- `\\b${escapeRegExp3(timelineVar)}\\.(?:add|call)\\s*\\(`,
2818
+ `\\b${escapeRegExp2(timelineVar)}\\.(?:add|call)\\s*\\(`,
2953
2819
  "g"
2954
2820
  );
2955
2821
  let match2;
@@ -2962,7 +2828,7 @@ ${other.raw}`)
2962
2828
  if (callbackExpressionHazard(firstArg)) report(site, site);
2963
2829
  }
2964
2830
  const eventCallbackPattern = new RegExp(
2965
- `\\b${escapeRegExp3(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2831
+ `\\b${escapeRegExp2(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2966
2832
  "g"
2967
2833
  );
2968
2834
  while ((match2 = eventCallbackPattern.exec(source)) !== null) {
@@ -3204,31 +3070,6 @@ ${other.raw}`)
3204
3070
  ];
3205
3071
 
3206
3072
  // src/rules/captions.ts
3207
- function extractArrayLiteral(src, varMatch) {
3208
- const openIdx = varMatch.index + varMatch[0].length - 1;
3209
- let depth = 0;
3210
- let inStr = false;
3211
- let strChar = "";
3212
- for (let i = openIdx; i < src.length; i++) {
3213
- const c = src[i];
3214
- if (inStr) {
3215
- if (c === "\\") {
3216
- i++;
3217
- continue;
3218
- }
3219
- if (c === strChar) inStr = false;
3220
- } else if (c === '"' || c === "'") {
3221
- inStr = true;
3222
- strChar = c;
3223
- } else if (c === "[") {
3224
- depth++;
3225
- } else if (c === "]") {
3226
- depth--;
3227
- if (depth === 0) return src.slice(openIdx, i + 1);
3228
- }
3229
- }
3230
- return null;
3231
- }
3232
3073
  var captionRules = [
3233
3074
  // caption_exit_missing_hard_kill
3234
3075
  ({ scripts, styles, options, rootCompositionId }) => {
@@ -3296,22 +3137,6 @@ var captionRules = [
3296
3137
  fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
3297
3138
  });
3298
3139
  }
3299
- if (hasInlineTranscript) {
3300
- const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
3301
- const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
3302
- if (transcriptJson) {
3303
- try {
3304
- JSON.parse(transcriptJson);
3305
- } catch {
3306
- findings.push({
3307
- code: "caption_transcript_parse_error",
3308
- severity: "error",
3309
- message: "Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail to parse it. Common cause: unquoted property keys with apostrophes in text.",
3310
- fixHint: `Use JSON-quoted keys: { "text": "don't", "start": 0, "end": 1 } instead of { text: "don't", start: 0, end: 1 }.`
3311
- });
3312
- }
3313
- }
3314
- }
3315
3140
  return findings;
3316
3141
  },
3317
3142
  // caption_container_relative_position
@@ -3696,33 +3521,6 @@ var compositionRules = [
3696
3521
  }
3697
3522
  return findings;
3698
3523
  },
3699
- // timed_element_missing_visibility_hidden
3700
- // fallow-ignore-next-line complexity
3701
- ({ tags }) => {
3702
- const findings = [];
3703
- for (const tag of tags) {
3704
- if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
3705
- if (!readAttr(tag.raw, "data-start")) continue;
3706
- if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
3707
- if (readAttr(tag.raw, "data-composition-src")) continue;
3708
- const classAttr = readAttr(tag.raw, "class") || "";
3709
- const styleAttr = readAttr(tag.raw, "style") || "";
3710
- const hasClip = classAttr.split(/\s+/).includes("clip");
3711
- const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
3712
- if (!hasClip && !hasHiddenStyle) {
3713
- const elementId = readAttr(tag.raw, "id") || void 0;
3714
- findings.push({
3715
- code: "timed_element_missing_visibility_hidden",
3716
- severity: "info",
3717
- 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.`,
3718
- elementId,
3719
- fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
3720
- snippet: truncateSnippet(tag.raw)
3721
- });
3722
- }
3723
- }
3724
- return findings;
3725
- },
3726
3524
  // deprecated_data_layer + deprecated_data_end
3727
3525
  // fallow-ignore-next-line complexity
3728
3526
  ({ tags }) => {
@@ -3803,7 +3601,7 @@ var compositionRules = [
3803
3601
  // fallow-ignore-next-line complexity
3804
3602
  ({ tags }) => {
3805
3603
  const findings = [];
3806
- const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
3604
+ const skipTags = /* @__PURE__ */ new Set(["audio", "img", "video", "script", "style", "template"]);
3807
3605
  for (const tag of tags) {
3808
3606
  if (skipTags.has(tag.name)) continue;
3809
3607
  if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
@@ -3817,10 +3615,17 @@ var compositionRules = [
3817
3615
  const elementId = readAttr(tag.raw, "id") || void 0;
3818
3616
  findings.push({
3819
3617
  code: "timed_element_missing_clip_class",
3820
- severity: "error",
3821
- message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
3618
+ // Not an error: the runtime drives timed visibility off the `data-start`
3619
+ // ATTRIBUTE, not this class `syncTimedElementVisibility` walks
3620
+ // `querySelectorAll("[data-start]")` and toggles `style.visibility`
3621
+ // regardless of class (pinned by the runtime's own init test, which
3622
+ // uses a bare `<div data-start data-duration>` with no `class="clip"`).
3623
+ // The class is an authoring convention the tooling reads, so a missing
3624
+ // one is worth flagging but does not break the render.
3625
+ severity: "warning",
3626
+ message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The runtime still hides it outside its time range, but Studio and the GSAP clip-ownership rules use .clip to recognise a clip, so leaving it off makes the element harder to edit and to lint.`,
3822
3627
  elementId,
3823
- fixHint: 'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
3628
+ fixHint: 'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
3824
3629
  snippet: truncateSnippet(tag.raw)
3825
3630
  });
3826
3631
  }
@@ -4679,36 +4484,22 @@ function collectGoogleFontFamilies(source, styles) {
4679
4484
  return families;
4680
4485
  }
4681
4486
  var fontRules = [
4682
- // google_fonts_import
4683
- ({ styles, source, rawSource, options }) => {
4684
- if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
4685
- const findings = [];
4686
- const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
4687
- const googleFontsInImport = styles.some(
4688
- (s) => /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content)
4689
- );
4690
- if (googleFontsInLink || googleFontsInImport) {
4691
- findings.push({
4692
- code: "google_fonts_import",
4693
- severity: "warning",
4694
- message: "Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts during compile/render, but raw external font requests add latency and can fail before canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
4695
- fixHint: "For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }."
4696
- });
4697
- }
4698
- return findings;
4699
- },
4700
- // system_font_will_alias — inform when a font will be silently substituted
4487
+ // system_font_will_alias — only for distributed / Lambda renders, where
4488
+ // system-font capture is disabled and the alias substitution does NOT happen,
4489
+ // so the font silently falls back to whatever the OS provides. Under a local
4490
+ // render the substitution is the renderer working as designed, not a defect,
4491
+ // so there is nothing for the author to act on.
4701
4492
  ({ styles, options }) => {
4493
+ if (!options.distributed) return [];
4702
4494
  const declared = extractFontFaceFamilies(styles);
4703
4495
  const used = extractUsedFontFamilies(styles);
4704
4496
  const aliased = collectAliasedFonts(used, declared);
4705
4497
  if (aliased.length === 0) return [];
4706
- const severity = options.distributed ? "warning" : "info";
4707
4498
  return [
4708
4499
  {
4709
4500
  code: "system_font_will_alias",
4710
- severity,
4711
- message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` + (options.distributed ? "In distributed/Lambda rendering system-font capture is disabled \u2014 these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead." : "The renderer maps these to bundled fonts for cross-platform consistency. Use the target font name directly for consistent preview and render results.")
4501
+ severity: "warning",
4502
+ message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. In distributed/Lambda rendering system-font capture is disabled \u2014 these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead.`
4712
4503
  }
4713
4504
  ];
4714
4505
  },
@@ -4804,35 +4595,63 @@ var slideshowRules = [
4804
4595
  ];
4805
4596
 
4806
4597
  // src/hyperframeLinter.ts
4807
- var ALL_RULES = [
4808
- ...coreRules,
4809
- ...mediaRules,
4810
- ...gsapRules,
4811
- ...captionRules,
4812
- ...compositionRules,
4813
- ...adapterRules,
4814
- ...textureRules,
4815
- ...fontRules,
4816
- ...slideshowRules
4598
+ var RULE_GROUPS = [
4599
+ { group: "core", rules: coreRules },
4600
+ { group: "media", rules: mediaRules },
4601
+ { group: "gsap", rules: gsapRules },
4602
+ { group: "captions", rules: captionRules },
4603
+ { group: "composition", rules: compositionRules },
4604
+ { group: "adapters", rules: adapterRules },
4605
+ { group: "textures", rules: textureRules },
4606
+ { group: "fonts", rules: fontRules },
4607
+ { group: "slideshow", rules: slideshowRules }
4817
4608
  ];
4818
- async function lintHyperframeHtml(html, options = {}) {
4819
- const ctx = buildLintContext(html, options);
4609
+ var LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
4610
+ var LINT_RULE_GROUP_COUNTS = Object.fromEntries(
4611
+ RULE_GROUPS.map(({ group, rules }) => [group, rules.length])
4612
+ );
4613
+ function dedupeKeyFor(finding) {
4614
+ return [
4615
+ finding.code,
4616
+ finding.severity,
4617
+ finding.selector || "",
4618
+ finding.elementId || "",
4619
+ finding.message
4620
+ ].join("|");
4621
+ }
4622
+ async function runRules(ctx, filePath) {
4820
4623
  const findings = [];
4821
4624
  const seen = /* @__PURE__ */ new Set();
4822
- for (const rule of ALL_RULES) {
4823
- for (const finding of await Promise.resolve(rule(ctx))) {
4824
- const dedupeKey = [
4825
- finding.code,
4826
- finding.severity,
4827
- finding.selector || "",
4828
- finding.elementId || "",
4829
- finding.message
4830
- ].join("|");
4831
- if (seen.has(dedupeKey)) continue;
4832
- seen.add(dedupeKey);
4833
- findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
4625
+ const groupMs = {};
4626
+ let slowestRule = "";
4627
+ let slowestRuleMs = 0;
4628
+ for (const { group, rules } of RULE_GROUPS) {
4629
+ for (let index = 0; index < rules.length; index++) {
4630
+ const ruleStartedAt = performance.now();
4631
+ const produced = await Promise.resolve(rules[index](ctx));
4632
+ const ruleMs = performance.now() - ruleStartedAt;
4633
+ groupMs[group] = (groupMs[group] ?? 0) + ruleMs;
4634
+ if (ruleMs > slowestRuleMs) {
4635
+ slowestRuleMs = ruleMs;
4636
+ slowestRule = `${group}#${index}`;
4637
+ }
4638
+ collectFindings(produced, seen, filePath, findings);
4834
4639
  }
4835
4640
  }
4641
+ return { findings, timings: { groupMs, slowestRule, slowestRuleMs } };
4642
+ }
4643
+ function collectFindings(produced, seen, filePath, into) {
4644
+ for (const finding of produced) {
4645
+ const dedupeKey = dedupeKeyFor(finding);
4646
+ if (seen.has(dedupeKey)) continue;
4647
+ seen.add(dedupeKey);
4648
+ into.push(filePath ? { ...finding, file: filePath } : finding);
4649
+ }
4650
+ }
4651
+ async function lintHyperframeHtml(html, options = {}) {
4652
+ const startedAt = performance.now();
4653
+ const ctx = buildLintContext(html, options);
4654
+ const { findings, timings } = await runRules(ctx, options.filePath);
4836
4655
  const errorCount = findings.filter((f) => f.severity === "error").length;
4837
4656
  const warningCount = findings.filter((f) => f.severity === "warning").length;
4838
4657
  const infoCount = findings.filter((f) => f.severity === "info").length;
@@ -4841,7 +4660,8 @@ async function lintHyperframeHtml(html, options = {}) {
4841
4660
  errorCount,
4842
4661
  warningCount,
4843
4662
  infoCount,
4844
- findings
4663
+ findings,
4664
+ timings: { totalMs: performance.now() - startedAt, ...timings }
4845
4665
  };
4846
4666
  }
4847
4667
  function extractMediaUrls(html) {
@@ -4914,6 +4734,7 @@ function shouldBlockRender(strictErrors, strictAll, totalErrors, totalWarnings)
4914
4734
  }
4915
4735
 
4916
4736
  // src/project.ts
4737
+ import { createHash } from "crypto";
4917
4738
  import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
4918
4739
  import { dirname, extname, join as join2, relative, resolve } from "path";
4919
4740
  import { rewriteAssetPath as rewriteAssetPath2 } from "@hyperframes/parsers/asset-paths";
@@ -5044,6 +4865,9 @@ function querySelectorAllIncludingTemplates(root, selector) {
5044
4865
  }
5045
4866
  return matches;
5046
4867
  }
4868
+ function contentDigest(html) {
4869
+ return createHash("sha256").update(html).digest("hex").slice(0, 16);
4870
+ }
5047
4871
  var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
5048
4872
  var MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
5049
4873
  function isLocalStylesheetHref(href) {
@@ -5124,7 +4948,7 @@ async function lintProject(projectDir, entryFile) {
5124
4948
  filePath: indexPath,
5125
4949
  externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath)
5126
4950
  });
5127
- results.push({ file: rootFile, result: rootResult });
4951
+ results.push({ file: rootFile, result: rootResult, contentHash: contentDigest(rootHtml) });
5128
4952
  totalErrors += rootResult.errorCount;
5129
4953
  totalWarnings += rootResult.warningCount;
5130
4954
  totalInfos += rootResult.infoCount;
@@ -5156,7 +4980,11 @@ async function lintProject(projectDir, entryFile) {
5156
4980
  isSubComposition: true,
5157
4981
  externalStyles: collectExternalStyles(projectDir, html, compSrcPath)
5158
4982
  });
5159
- results.push({ file: `compositions/${file}`, result });
4983
+ results.push({
4984
+ file: `compositions/${file}`,
4985
+ result,
4986
+ contentHash: contentDigest(html)
4987
+ });
5160
4988
  totalErrors += result.errorCount;
5161
4989
  totalWarnings += result.warningCount;
5162
4990
  totalInfos += result.infoCount;
@@ -5433,6 +5261,8 @@ function isSnippetFragment(html) {
5433
5261
  return /\bdata-hf-snippet\b/.test(firstTag[0]);
5434
5262
  }
5435
5263
  export {
5264
+ LINT_RULE_COUNT,
5265
+ LINT_RULE_GROUP_COUNTS,
5436
5266
  lintHyperframeHtml,
5437
5267
  lintMediaUrls,
5438
5268
  lintProject,