@hyperframes/lint 0.8.4 → 0.8.6

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) => {
@@ -401,54 +392,8 @@ function describeStudioElement(tag) {
401
392
  parts.push(">");
402
393
  return parts.join("");
403
394
  }
404
- var HEAD_BLOCKS_TO_IGNORE_PATTERN = /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
405
- var HTML_TAG_PATTERN = /<[^>]+>/g;
406
- var HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
407
- var AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
408
- var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
409
- var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
410
- var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
411
- var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
412
395
  var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
413
396
  var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
414
- function findCodeFenceLeak(headWithoutValidBlocks) {
415
- return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
416
- }
417
- function findOrphanCssLeak(headContent) {
418
- const residualText = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ").replace(HTML_TAG_PATTERN, " ");
419
- return ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? null;
420
- }
421
- function findStrayCloseLeak(headWithoutValidBlocks) {
422
- return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
423
- }
424
- function findLeakedTextInHeadContent(headContent) {
425
- const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
426
- return findCodeFenceLeak(withoutValidBlocks) ?? findOrphanCssLeak(headContent) ?? findStrayCloseLeak(withoutValidBlocks);
427
- }
428
- function findLeakedTextInHead(rawSource) {
429
- const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
430
- for (const match of headMatches) {
431
- const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
432
- if (leakedText) return leakedText;
433
- }
434
- return null;
435
- }
436
- function findLeakedTextBetweenHeadAndBody(rawSource) {
437
- const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
438
- for (const match of boundaryMatches) {
439
- const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
440
- if (leakedText) return leakedText;
441
- }
442
- return null;
443
- }
444
- function findLeakedTextBeforeCompositionRoot(source, rootTag) {
445
- if (!rootTag || rootTag.name === "body") return null;
446
- const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
447
- const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
448
- const prefixEnd = rootTag.index;
449
- if (prefixEnd <= prefixStart) return null;
450
- return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
451
- }
452
397
  function findProtectedVisibleMarkupRanges(source) {
453
398
  const ranges = [];
454
399
  for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
@@ -532,20 +477,6 @@ var coreRules = [
532
477
  }
533
478
  return findings;
534
479
  },
535
- // head_leaked_text
536
- ({ source, rootTag }) => {
537
- const snippet = findLeakedTextInHead(source) ?? findLeakedTextBetweenHeadAndBody(source) ?? findLeakedTextBeforeCompositionRoot(source, rootTag);
538
- if (!snippet) return [];
539
- return [
540
- {
541
- code: "head_leaked_text",
542
- severity: "error",
543
- message: "Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
544
- fixHint: "Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
545
- snippet: truncateSnippet(snippet)
546
- }
547
- ];
548
- },
549
480
  // visible_markup_comment
550
481
  ({ source }) => {
551
482
  const snippet = findVisibleMarkupCommentLeak(source);
@@ -705,36 +636,6 @@ var coreRules = [
705
636
  }
706
637
  return findings;
707
638
  },
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
639
  // studio_missing_editable_id
739
640
  ({ tags, rootTag }) => {
740
641
  const findings = [];
@@ -810,60 +711,13 @@ var coreRules = [
810
711
  }
811
712
  }
812
713
  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
714
  }
861
715
  ];
862
716
 
863
717
  // src/rules/media.ts
864
718
  import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
865
719
  function tweensVolumeInSameCall(script, id) {
866
- const selector = new RegExp(`#${escapeRegExp2(id)}(?![\\w-])`, "g");
720
+ const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
867
721
  for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
868
722
  let depth = 0;
869
723
  const limit = Math.min(script.length, hit.index + 2e3);
@@ -879,11 +733,11 @@ function tweensVolumeInSameCall(script, id) {
879
733
  }
880
734
  return false;
881
735
  }
882
- function escapeRegExp2(value) {
736
+ function escapeRegExp(value) {
883
737
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
884
738
  }
885
739
  function hasAttrName(tagSource, attr) {
886
- const escaped = escapeRegExp2(attr);
740
+ const escaped = escapeRegExp(attr);
887
741
  const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
888
742
  return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
889
743
  }
@@ -897,13 +751,13 @@ function selectorTargetsManagedMedia(selector, mediaIndex) {
897
751
  if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
898
752
  if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
899
753
  for (const mediaId of mediaIndex.ids) {
900
- const escapedId = escapeRegExp2(mediaId);
754
+ const escapedId = escapeRegExp(mediaId);
901
755
  if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
902
756
  return true;
903
757
  }
904
758
  }
905
759
  for (const className of mediaIndex.classes) {
906
- if (new RegExp(`\\.${escapeRegExp2(className)}(?![\\w-])`).test(normalized)) {
760
+ if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
907
761
  return true;
908
762
  }
909
763
  }
@@ -1006,7 +860,7 @@ function findImperativeMediaControlFindings(ctx) {
1006
860
  }
1007
861
  }
1008
862
  for (const [variableName, elementId] of mediaVars) {
1009
- const escapedVar = escapeRegExp2(variableName);
863
+ const escapedVar = escapeRegExp(variableName);
1010
864
  const variablePatterns = [
1011
865
  { pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
1012
866
  { pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
@@ -1460,17 +1314,6 @@ function targetHasNoStableIdentity(selector, identity) {
1460
1314
  if (identity) return false;
1461
1315
  return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
1462
1316
  }
1463
- function countClassUsage(tags) {
1464
- const counts = /* @__PURE__ */ new Map();
1465
- for (const tag of tags) {
1466
- const classAttr = readAttr(tag.raw, "class");
1467
- if (!classAttr) continue;
1468
- for (const className of classAttr.split(/\s+/).filter(Boolean)) {
1469
- counts.set(className, (counts.get(className) || 0) + 1);
1470
- }
1471
- }
1472
- return counts;
1473
- }
1474
1317
  function readRegisteredTimelineCompositionId(script) {
1475
1318
  const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
1476
1319
  return match?.[1] || match?.[2] || null;
@@ -1686,16 +1529,6 @@ function findMatchingSceneBoundary(time, boundaries) {
1686
1529
  }
1687
1530
  return null;
1688
1531
  }
1689
- function isSuspiciousGlobalSelector(selector) {
1690
- if (!selector) return false;
1691
- if (selector.includes("[data-composition-id=")) return false;
1692
- if (selector.startsWith("#")) return false;
1693
- return selector.startsWith(".") || /^[a-z]/i.test(selector);
1694
- }
1695
- function getSingleClassSelector(selector) {
1696
- const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
1697
- return match?.groups?.name || null;
1698
- }
1699
1532
  function readStyleProperty(style, property) {
1700
1533
  const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1701
1534
  const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
@@ -1939,7 +1772,7 @@ function isInsideGsapTweenVars(source, index, timelineVars) {
1939
1772
  else if (ch === "{") {
1940
1773
  if (depth === 0) {
1941
1774
  const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
1942
- const receivers = ["gsap", ...timelineVars].map(escapeRegExp3).join("|");
1775
+ const receivers = ["gsap", ...timelineVars].map(escapeRegExp2).join("|");
1943
1776
  return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
1944
1777
  before
1945
1778
  );
@@ -1975,7 +1808,7 @@ function parseFunctionValueSource(code) {
1975
1808
  const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
1976
1809
  return { firstParam, body: src.slice(match[0].length) };
1977
1810
  }
1978
- function escapeRegExp3(value) {
1811
+ function escapeRegExp2(value) {
1979
1812
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1980
1813
  }
1981
1814
  var NUMBER_METHODS = /* @__PURE__ */ new Set([
@@ -1989,7 +1822,7 @@ var NUMBER_METHODS = /* @__PURE__ */ new Set([
1989
1822
  function firstParamMemberAccessHazard(fn) {
1990
1823
  if (!fn.firstParam) return null;
1991
1824
  const pattern = new RegExp(
1992
- `\\b${escapeRegExp3(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1825
+ `\\b${escapeRegExp2(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1993
1826
  "g"
1994
1827
  );
1995
1828
  let match;
@@ -2033,7 +1866,7 @@ function collectMeasuringFunctionNames(bodies) {
2033
1866
  for (const [name, body] of bodies) {
2034
1867
  if (measuring.has(name)) continue;
2035
1868
  for (const measured of measuring) {
2036
- if (new RegExp(`\\b${escapeRegExp3(measured)}\\s*\\(`).test(body)) {
1869
+ if (new RegExp(`\\b${escapeRegExp2(measured)}\\s*\\(`).test(body)) {
2037
1870
  measuring.add(name);
2038
1871
  grew = true;
2039
1872
  break;
@@ -2047,7 +1880,7 @@ function collectMeasuringFunctionNames(bodies) {
2047
1880
  function expressionReachesMeasurement(expression, measuring) {
2048
1881
  if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
2049
1882
  for (const name of measuring) {
2050
- if (new RegExp(`\\b${escapeRegExp3(name)}\\b`).test(expression)) return true;
1883
+ if (new RegExp(`\\b${escapeRegExp2(name)}\\b`).test(expression)) return true;
2051
1884
  }
2052
1885
  return false;
2053
1886
  }
@@ -2070,7 +1903,7 @@ function resolveScriptElementTokens(source, tags) {
2070
1903
  const template = match[2] ?? "";
2071
1904
  const staticParts = template.split(/\$\{[^}]*\}/);
2072
1905
  if (staticParts.every((part) => part === "")) continue;
2073
- const idPattern = new RegExp(`^${staticParts.map(escapeRegExp3).join(".*")}$`);
1906
+ const idPattern = new RegExp(`^${staticParts.map(escapeRegExp2).join(".*")}$`);
2074
1907
  for (const id of documentIds) {
2075
1908
  if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
2076
1909
  }
@@ -2162,7 +1995,7 @@ function collectCssOpacityZeroSelectors(styles, tags) {
2162
1995
  return selectors;
2163
1996
  }
2164
1997
  var gsapRules = [
2165
- // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
1998
+ // overlapping_gsap_tweens + gsap_animates_clip_element
2166
1999
  // fallow-ignore-next-line complexity
2167
2000
  async ({ source, tags, scripts, styles, rootCompositionId }) => {
2168
2001
  const findings = [];
@@ -2182,7 +2015,6 @@ var gsapRules = [
2182
2015
  if (cls !== "clip") clipClasses.set(`.${cls}`, info);
2183
2016
  }
2184
2017
  }
2185
- const classUsage = countClassUsage(tags);
2186
2018
  const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
2187
2019
  const styleRules = collectSimpleStyleRules(styles);
2188
2020
  const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
@@ -2305,20 +2137,6 @@ ${right.raw}`)
2305
2137
  snippet: truncateSnippet(win.raw)
2306
2138
  });
2307
2139
  }
2308
- if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
2309
- for (const win of gsapWindows) {
2310
- if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
2311
- const className = getSingleClassSelector(win.targetSelector);
2312
- if (className && (classUsage.get(className) || 0) < 2) continue;
2313
- findings.push({
2314
- code: "unscoped_gsap_selector",
2315
- severity: "error",
2316
- message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
2317
- selector: win.targetSelector,
2318
- fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
2319
- snippet: truncateSnippet(win.raw)
2320
- });
2321
- }
2322
2140
  }
2323
2141
  return findings;
2324
2142
  },
@@ -2575,39 +2393,6 @@ ${right.raw}`)
2575
2393
  }
2576
2394
  return findings;
2577
2395
  },
2578
- // scene_layer_missing_visibility_kill
2579
- ({ scripts, tags }) => {
2580
- const findings = [];
2581
- const sceneElements = tags.filter((t) => {
2582
- const id = readAttr(t.raw, "id") || "";
2583
- return /^scene\d+$/i.test(id);
2584
- });
2585
- if (sceneElements.length < 2) return findings;
2586
- for (const script of scripts) {
2587
- const content = stripJsComments(script.content);
2588
- for (const tag of sceneElements) {
2589
- const id = readAttr(tag.raw, "id") || "";
2590
- const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
2591
- const hasExit = exitPattern.test(content);
2592
- if (!hasExit) continue;
2593
- const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
2594
- const hasKill = killPattern.test(content);
2595
- if (!hasKill) {
2596
- const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
2597
- const isClip = classes.includes("clip");
2598
- 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.`;
2599
- findings.push({
2600
- code: "scene_layer_missing_visibility_kill",
2601
- severity: "error",
2602
- elementId: id,
2603
- 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.`,
2604
- fixHint
2605
- });
2606
- }
2607
- }
2608
- }
2609
- return findings;
2610
- },
2611
2396
  // gsap_timeline_not_registered
2612
2397
  ({ scripts, rawSource, options }) => {
2613
2398
  const findings = [];
@@ -2970,7 +2755,7 @@ ${other.raw}`)
2970
2755
  const timelineVars = collectTimelineVarNames(source);
2971
2756
  for (const timelineVar of timelineVars) {
2972
2757
  const callPattern = new RegExp(
2973
- `\\b${escapeRegExp3(timelineVar)}\\.(?:add|call)\\s*\\(`,
2758
+ `\\b${escapeRegExp2(timelineVar)}\\.(?:add|call)\\s*\\(`,
2974
2759
  "g"
2975
2760
  );
2976
2761
  let match2;
@@ -2983,7 +2768,7 @@ ${other.raw}`)
2983
2768
  if (callbackExpressionHazard(firstArg)) report(site, site);
2984
2769
  }
2985
2770
  const eventCallbackPattern = new RegExp(
2986
- `\\b${escapeRegExp3(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2771
+ `\\b${escapeRegExp2(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2987
2772
  "g"
2988
2773
  );
2989
2774
  while ((match2 = eventCallbackPattern.exec(source)) !== null) {
@@ -3225,31 +3010,6 @@ ${other.raw}`)
3225
3010
  ];
3226
3011
 
3227
3012
  // src/rules/captions.ts
3228
- function extractArrayLiteral(src, varMatch) {
3229
- const openIdx = varMatch.index + varMatch[0].length - 1;
3230
- let depth = 0;
3231
- let inStr = false;
3232
- let strChar = "";
3233
- for (let i = openIdx; i < src.length; i++) {
3234
- const c = src[i];
3235
- if (inStr) {
3236
- if (c === "\\") {
3237
- i++;
3238
- continue;
3239
- }
3240
- if (c === strChar) inStr = false;
3241
- } else if (c === '"' || c === "'") {
3242
- inStr = true;
3243
- strChar = c;
3244
- } else if (c === "[") {
3245
- depth++;
3246
- } else if (c === "]") {
3247
- depth--;
3248
- if (depth === 0) return src.slice(openIdx, i + 1);
3249
- }
3250
- }
3251
- return null;
3252
- }
3253
3013
  var captionRules = [
3254
3014
  // caption_exit_missing_hard_kill
3255
3015
  ({ scripts, styles, options, rootCompositionId }) => {
@@ -3317,22 +3077,6 @@ var captionRules = [
3317
3077
  fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
3318
3078
  });
3319
3079
  }
3320
- if (hasInlineTranscript) {
3321
- const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
3322
- const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
3323
- if (transcriptJson) {
3324
- try {
3325
- JSON.parse(transcriptJson);
3326
- } catch {
3327
- findings.push({
3328
- code: "caption_transcript_parse_error",
3329
- severity: "error",
3330
- 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.",
3331
- fixHint: `Use JSON-quoted keys: { "text": "don't", "start": 0, "end": 1 } instead of { text: "don't", start: 0, end: 1 }.`
3332
- });
3333
- }
3334
- }
3335
- }
3336
3080
  return findings;
3337
3081
  },
3338
3082
  // caption_container_relative_position
@@ -3717,33 +3461,6 @@ var compositionRules = [
3717
3461
  }
3718
3462
  return findings;
3719
3463
  },
3720
- // timed_element_missing_visibility_hidden
3721
- // fallow-ignore-next-line complexity
3722
- ({ tags }) => {
3723
- const findings = [];
3724
- for (const tag of tags) {
3725
- if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
3726
- if (!readAttr(tag.raw, "data-start")) continue;
3727
- if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
3728
- if (readAttr(tag.raw, "data-composition-src")) continue;
3729
- const classAttr = readAttr(tag.raw, "class") || "";
3730
- const styleAttr = readAttr(tag.raw, "style") || "";
3731
- const hasClip = classAttr.split(/\s+/).includes("clip");
3732
- const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
3733
- if (!hasClip && !hasHiddenStyle) {
3734
- const elementId = readAttr(tag.raw, "id") || void 0;
3735
- findings.push({
3736
- code: "timed_element_missing_visibility_hidden",
3737
- severity: "info",
3738
- 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.`,
3739
- elementId,
3740
- fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
3741
- snippet: truncateSnippet(tag.raw)
3742
- });
3743
- }
3744
- }
3745
- return findings;
3746
- },
3747
3464
  // deprecated_data_layer + deprecated_data_end
3748
3465
  // fallow-ignore-next-line complexity
3749
3466
  ({ tags }) => {
@@ -3824,7 +3541,7 @@ var compositionRules = [
3824
3541
  // fallow-ignore-next-line complexity
3825
3542
  ({ tags }) => {
3826
3543
  const findings = [];
3827
- const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
3544
+ const skipTags = /* @__PURE__ */ new Set(["audio", "img", "video", "script", "style", "template"]);
3828
3545
  for (const tag of tags) {
3829
3546
  if (skipTags.has(tag.name)) continue;
3830
3547
  if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
@@ -3838,10 +3555,17 @@ var compositionRules = [
3838
3555
  const elementId = readAttr(tag.raw, "id") || void 0;
3839
3556
  findings.push({
3840
3557
  code: "timed_element_missing_clip_class",
3841
- severity: "error",
3842
- 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.`,
3558
+ // Not an error: the runtime drives timed visibility off the `data-start`
3559
+ // ATTRIBUTE, not this class `syncTimedElementVisibility` walks
3560
+ // `querySelectorAll("[data-start]")` and toggles `style.visibility`
3561
+ // regardless of class (pinned by the runtime's own init test, which
3562
+ // uses a bare `<div data-start data-duration>` with no `class="clip"`).
3563
+ // The class is an authoring convention the tooling reads, so a missing
3564
+ // one is worth flagging but does not break the render.
3565
+ severity: "warning",
3566
+ 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.`,
3843
3567
  elementId,
3844
- fixHint: 'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
3568
+ fixHint: 'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
3845
3569
  snippet: truncateSnippet(tag.raw)
3846
3570
  });
3847
3571
  }
@@ -4700,36 +4424,22 @@ function collectGoogleFontFamilies(source, styles) {
4700
4424
  return families;
4701
4425
  }
4702
4426
  var fontRules = [
4703
- // google_fonts_import
4704
- ({ styles, source, rawSource, options }) => {
4705
- if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
4706
- const findings = [];
4707
- const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
4708
- const googleFontsInImport = styles.some(
4709
- (s) => /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content)
4710
- );
4711
- if (googleFontsInLink || googleFontsInImport) {
4712
- findings.push({
4713
- code: "google_fonts_import",
4714
- severity: "warning",
4715
- 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.",
4716
- 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'); }."
4717
- });
4718
- }
4719
- return findings;
4720
- },
4721
- // system_font_will_alias — inform when a font will be silently substituted
4427
+ // system_font_will_alias — only for distributed / Lambda renders, where
4428
+ // system-font capture is disabled and the alias substitution does NOT happen,
4429
+ // so the font silently falls back to whatever the OS provides. Under a local
4430
+ // render the substitution is the renderer working as designed, not a defect,
4431
+ // so there is nothing for the author to act on.
4722
4432
  ({ styles, options }) => {
4433
+ if (!options.distributed) return [];
4723
4434
  const declared = extractFontFaceFamilies(styles);
4724
4435
  const used = extractUsedFontFamilies(styles);
4725
4436
  const aliased = collectAliasedFonts(used, declared);
4726
4437
  if (aliased.length === 0) return [];
4727
- const severity = options.distributed ? "warning" : "info";
4728
4438
  return [
4729
4439
  {
4730
4440
  code: "system_font_will_alias",
4731
- severity,
4732
- 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.")
4441
+ severity: "warning",
4442
+ 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.`
4733
4443
  }
4734
4444
  ];
4735
4445
  },
@@ -4825,35 +4535,63 @@ var slideshowRules = [
4825
4535
  ];
4826
4536
 
4827
4537
  // src/hyperframeLinter.ts
4828
- var ALL_RULES = [
4829
- ...coreRules,
4830
- ...mediaRules,
4831
- ...gsapRules,
4832
- ...captionRules,
4833
- ...compositionRules,
4834
- ...adapterRules,
4835
- ...textureRules,
4836
- ...fontRules,
4837
- ...slideshowRules
4538
+ var RULE_GROUPS = [
4539
+ { group: "core", rules: coreRules },
4540
+ { group: "media", rules: mediaRules },
4541
+ { group: "gsap", rules: gsapRules },
4542
+ { group: "captions", rules: captionRules },
4543
+ { group: "composition", rules: compositionRules },
4544
+ { group: "adapters", rules: adapterRules },
4545
+ { group: "textures", rules: textureRules },
4546
+ { group: "fonts", rules: fontRules },
4547
+ { group: "slideshow", rules: slideshowRules }
4838
4548
  ];
4839
- async function lintHyperframeHtml(html, options = {}) {
4840
- const ctx = buildLintContext(html, options);
4549
+ var LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
4550
+ var LINT_RULE_GROUP_COUNTS = Object.fromEntries(
4551
+ RULE_GROUPS.map(({ group, rules }) => [group, rules.length])
4552
+ );
4553
+ function dedupeKeyFor(finding) {
4554
+ return [
4555
+ finding.code,
4556
+ finding.severity,
4557
+ finding.selector || "",
4558
+ finding.elementId || "",
4559
+ finding.message
4560
+ ].join("|");
4561
+ }
4562
+ async function runRules(ctx, filePath) {
4841
4563
  const findings = [];
4842
4564
  const seen = /* @__PURE__ */ new Set();
4843
- for (const rule of ALL_RULES) {
4844
- for (const finding of await Promise.resolve(rule(ctx))) {
4845
- const dedupeKey = [
4846
- finding.code,
4847
- finding.severity,
4848
- finding.selector || "",
4849
- finding.elementId || "",
4850
- finding.message
4851
- ].join("|");
4852
- if (seen.has(dedupeKey)) continue;
4853
- seen.add(dedupeKey);
4854
- findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
4565
+ const groupMs = {};
4566
+ let slowestRule = "";
4567
+ let slowestRuleMs = 0;
4568
+ for (const { group, rules } of RULE_GROUPS) {
4569
+ for (let index = 0; index < rules.length; index++) {
4570
+ const ruleStartedAt = performance.now();
4571
+ const produced = await Promise.resolve(rules[index](ctx));
4572
+ const ruleMs = performance.now() - ruleStartedAt;
4573
+ groupMs[group] = (groupMs[group] ?? 0) + ruleMs;
4574
+ if (ruleMs > slowestRuleMs) {
4575
+ slowestRuleMs = ruleMs;
4576
+ slowestRule = `${group}#${index}`;
4577
+ }
4578
+ collectFindings(produced, seen, filePath, findings);
4855
4579
  }
4856
4580
  }
4581
+ return { findings, timings: { groupMs, slowestRule, slowestRuleMs } };
4582
+ }
4583
+ function collectFindings(produced, seen, filePath, into) {
4584
+ for (const finding of produced) {
4585
+ const dedupeKey = dedupeKeyFor(finding);
4586
+ if (seen.has(dedupeKey)) continue;
4587
+ seen.add(dedupeKey);
4588
+ into.push(filePath ? { ...finding, file: filePath } : finding);
4589
+ }
4590
+ }
4591
+ async function lintHyperframeHtml(html, options = {}) {
4592
+ const startedAt = performance.now();
4593
+ const ctx = buildLintContext(html, options);
4594
+ const { findings, timings } = await runRules(ctx, options.filePath);
4857
4595
  const errorCount = findings.filter((f) => f.severity === "error").length;
4858
4596
  const warningCount = findings.filter((f) => f.severity === "warning").length;
4859
4597
  const infoCount = findings.filter((f) => f.severity === "info").length;
@@ -4862,7 +4600,8 @@ async function lintHyperframeHtml(html, options = {}) {
4862
4600
  errorCount,
4863
4601
  warningCount,
4864
4602
  infoCount,
4865
- findings
4603
+ findings,
4604
+ timings: { totalMs: performance.now() - startedAt, ...timings }
4866
4605
  };
4867
4606
  }
4868
4607
  function extractMediaUrls(html) {
@@ -4935,6 +4674,7 @@ function shouldBlockRender(strictErrors, strictAll, totalErrors, totalWarnings)
4935
4674
  }
4936
4675
 
4937
4676
  // src/project.ts
4677
+ import { createHash } from "crypto";
4938
4678
  import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
4939
4679
  import { dirname, extname, join as join2, relative, resolve } from "path";
4940
4680
  import { rewriteAssetPath as rewriteAssetPath2 } from "@hyperframes/parsers/asset-paths";
@@ -5065,6 +4805,9 @@ function querySelectorAllIncludingTemplates(root, selector) {
5065
4805
  }
5066
4806
  return matches;
5067
4807
  }
4808
+ function contentDigest(html) {
4809
+ return createHash("sha256").update(html).digest("hex").slice(0, 16);
4810
+ }
5068
4811
  var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
5069
4812
  var MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
5070
4813
  function isLocalStylesheetHref(href) {
@@ -5145,7 +4888,7 @@ async function lintProject(projectDir, entryFile) {
5145
4888
  filePath: indexPath,
5146
4889
  externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath)
5147
4890
  });
5148
- results.push({ file: rootFile, result: rootResult });
4891
+ results.push({ file: rootFile, result: rootResult, contentHash: contentDigest(rootHtml) });
5149
4892
  totalErrors += rootResult.errorCount;
5150
4893
  totalWarnings += rootResult.warningCount;
5151
4894
  totalInfos += rootResult.infoCount;
@@ -5177,7 +4920,11 @@ async function lintProject(projectDir, entryFile) {
5177
4920
  isSubComposition: true,
5178
4921
  externalStyles: collectExternalStyles(projectDir, html, compSrcPath)
5179
4922
  });
5180
- results.push({ file: `compositions/${file}`, result });
4923
+ results.push({
4924
+ file: `compositions/${file}`,
4925
+ result,
4926
+ contentHash: contentDigest(html)
4927
+ });
5181
4928
  totalErrors += result.errorCount;
5182
4929
  totalWarnings += result.warningCount;
5183
4930
  totalInfos += result.infoCount;
@@ -5454,6 +5201,8 @@ function isSnippetFragment(html) {
5454
5201
  return /\bdata-hf-snippet\b/.test(firstTag[0]);
5455
5202
  }
5456
5203
  export {
5204
+ LINT_RULE_COUNT,
5205
+ LINT_RULE_GROUP_COUNTS,
5457
5206
  lintHyperframeHtml,
5458
5207
  lintMediaUrls,
5459
5208
  lintProject,