@hyperframes/lint 0.8.110 → 0.8.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -10,7 +10,7 @@ var TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = /window\.__timelines\s*=\s*\{\s*(
10
10
  var TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
11
11
  var WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines(?:\[\s*(?:["']([^"']+)["']|[A-Za-z_$][\w$.]*)\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
12
12
  var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
13
- var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
13
+ var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*(?:\?\?|\|\||&&)?=/g;
14
14
  var TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN = /window\.__timelines\s*=\s*\{/i;
15
15
  var TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
16
16
  function parseHtmlStructure(source) {
@@ -114,9 +114,7 @@ function readAttr(tagSource, attr) {
114
114
  return match?.[1] || null;
115
115
  }
116
116
  function hasAttrName(tagSource, attr) {
117
- const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
- const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
119
- return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
117
+ return readDecodedAttr(tagSource, attr) !== null;
120
118
  }
121
119
  function isAudibleVideoTag(tagSource) {
122
120
  if (hasAttrName(tagSource, "muted")) return false;
@@ -796,6 +794,34 @@ function repeatedDescendantId(selector) {
796
794
  }
797
795
  return repeated;
798
796
  }
797
+ function addUnescapedDigitIds(selector, ids) {
798
+ try {
799
+ selectorParser((root) => {
800
+ root.walkIds((node) => {
801
+ if (/^#\d/.test(node.toString().trim())) ids.add(node.value);
802
+ });
803
+ }).processSync(selector);
804
+ } catch {
805
+ }
806
+ }
807
+ var SELECTOR_CALL_PATTERN = /\.(?:querySelector(?:All)?|closest|matches|to|from|fromTo|set)\s*\(\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
808
+ function digitIdsTargetedBySelectors(styles, scripts) {
809
+ const ids = /* @__PURE__ */ new Set();
810
+ for (const style of styles) {
811
+ try {
812
+ postcss.parse(style.content).walkRules((rule) => {
813
+ for (const selector of rule.selectors) addUnescapedDigitIds(selector, ids);
814
+ });
815
+ } catch {
816
+ }
817
+ }
818
+ for (const script of scripts) {
819
+ for (const match of stripJsComments(script.content).matchAll(SELECTOR_CALL_PATTERN)) {
820
+ addUnescapedDigitIds(match[2] ?? "", ids);
821
+ }
822
+ }
823
+ return ids;
824
+ }
799
825
  function resolvedRuleSelectors(rule) {
800
826
  let ancestor = rule.parent;
801
827
  while (ancestor && ancestor.type !== "rule") ancestor = ancestor.parent;
@@ -814,16 +840,20 @@ function resolvedRuleSelectors(rule) {
814
840
  })
815
841
  );
816
842
  }
843
+ function rightmostCompoundNodes(selectorNode) {
844
+ const subject = [];
845
+ selectorNode.each((node) => {
846
+ if (node.type === "combinator") subject.length = 0;
847
+ else subject.push(node);
848
+ });
849
+ return subject;
850
+ }
817
851
  function selectorAliasesRuntimeHiddenStyle(selector) {
818
852
  let unsafe = false;
819
853
  try {
820
854
  selectorParser((root) => {
821
855
  root.each((selectorNode) => {
822
- const subject = [];
823
- selectorNode.each((node) => {
824
- if (node.type === "combinator") subject.length = 0;
825
- else subject.push(node);
826
- });
856
+ const subject = rightmostCompoundNodes(selectorNode);
827
857
  const hostScoped = subject.some(
828
858
  (node) => node.type === "attribute" && ["data-composition-src", "data-composition-file"].includes(
829
859
  node.attribute.toLowerCase()
@@ -846,6 +876,57 @@ function selectorAliasesRuntimeHiddenStyle(selector) {
846
876
  }
847
877
  return unsafe;
848
878
  }
879
+ var POSITION_PROPERTIES = /* @__PURE__ */ new Set(["left", "top", "right", "bottom", "inset"]);
880
+ function weakIdOf(node) {
881
+ if (node.type === "attribute" && node.attribute.toLowerCase() === "id") {
882
+ return node.operator === "=" && node.value ? node.value : null;
883
+ }
884
+ if (node.type !== "pseudo" || node.value.toLowerCase() !== ":where") return null;
885
+ const inner = node.nodes.flatMap((option) => option.nodes).find((n) => n.type === "id");
886
+ return inner ? inner.value : null;
887
+ }
888
+ function reducedSpecificityId(selector) {
889
+ let matched = null;
890
+ try {
891
+ selectorParser((root) => {
892
+ root.each((selectorNode) => {
893
+ const subject = rightmostCompoundNodes(selectorNode);
894
+ if (subject.some((node) => node.type === "id")) return;
895
+ for (const node of subject) matched = weakIdOf(node) ?? matched;
896
+ });
897
+ }).processSync(selector);
898
+ } catch {
899
+ return null;
900
+ }
901
+ return matched;
902
+ }
903
+ function cssIdSelector(id) {
904
+ return `#${id.replace(
905
+ /[^a-zA-Z0-9_-]|^-?\d/g,
906
+ (match) => Array.from(match, (char) => `\\${char.codePointAt(0)?.toString(16)} `).join("")
907
+ )}`;
908
+ }
909
+ function reducedSpecificityIdFindings(rule, reported) {
910
+ const positionProps = rule.nodes.flatMap(
911
+ (node) => node.type === "decl" && !node.important && POSITION_PROPERTIES.has(node.prop.toLowerCase()) ? [node.prop] : []
912
+ );
913
+ if (positionProps.length === 0) return [];
914
+ const findings = [];
915
+ for (const selector of resolvedRuleSelectors(rule)) {
916
+ const id = reported.has(selector) ? null : reducedSpecificityId(selector);
917
+ if (id === null) continue;
918
+ reported.add(selector);
919
+ findings.push({
920
+ code: "id_override_reduced_specificity",
921
+ severity: "warning",
922
+ message: `Selector "${selector}" sets ${positionProps.join("/")} through [id=...] or :where(#id), which has class-level or zero specificity, so a compound class rule (e.g. ".parent .row") on the same element can silently win over this override.`,
923
+ selector,
924
+ fixHint: `Use \`${cssIdSelector(id)}\` instead; id specificity beats any selector built only from classes.`,
925
+ snippet: truncateSnippet(rule.toString())
926
+ });
927
+ }
928
+ return findings;
929
+ }
849
930
  function ruleForcesOpacityZero(rule) {
850
931
  let forcesOpacityZero = false;
851
932
  rule.walkDecls(/^opacity$/i, (declaration) => {
@@ -853,6 +934,34 @@ function ruleForcesOpacityZero(rule) {
853
934
  });
854
935
  return forcesOpacityZero;
855
936
  }
937
+ var CSS_TRANSITION_PROPERTY_PATTERN = /^(?:-webkit-)?transition(?:-[a-z][a-z-]*)?$/i;
938
+ var TRANSITION_NONE_PROPERTIES = /* @__PURE__ */ new Set(["transition", "transition-property"]);
939
+ var TRANSITION_TIME_PROPERTIES = /* @__PURE__ */ new Set([
940
+ "transition",
941
+ "transition-duration",
942
+ "transition-delay"
943
+ ]);
944
+ function hasNonZeroTime(value) {
945
+ const times = value.match(/[+-]?(?:\d*\.)?\d+m?s\b/g) ?? [];
946
+ return value.includes("var(") || times.some((time) => Number.parseFloat(time) !== 0);
947
+ }
948
+ function isSeekUnsafeTransition(declaration) {
949
+ const property = declaration.prop.trim().toLowerCase();
950
+ if (!CSS_TRANSITION_PROPERTY_PATTERN.test(property)) return false;
951
+ const name = property.replace(/^-webkit-/, "");
952
+ const value = declaration.value.trim().toLowerCase();
953
+ if (value === "none" && TRANSITION_NONE_PROPERTIES.has(name)) return false;
954
+ return !TRANSITION_TIME_PROPERTIES.has(name) || hasNonZeroTime(value);
955
+ }
956
+ function cssTransitionFinding(declaration, details) {
957
+ return {
958
+ code: "css_transition_used",
959
+ severity: "warning",
960
+ message: `CSS declaration \`${declaration.prop}: ${declaration.value}\` runs on the browser clock and cannot be seeked deterministically across render workers.`,
961
+ fixHint: "Keep the class or attribute swap for state; put the visual change on the paused GSAP timeline.",
962
+ ...details
963
+ };
964
+ }
856
965
  function isStudioTimelineElement(tag) {
857
966
  if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
858
967
  return false;
@@ -971,15 +1080,18 @@ function describeRootDimensionsDrift(source, dataWidth, dataHeight) {
971
1080
  }
972
1081
  var coreRules = [
973
1082
  // id_requires_css_escape
974
- ({ tags }) => {
1083
+ ({ tags, styles, scripts }) => {
975
1084
  const findings = [];
1085
+ let targeted;
976
1086
  for (const tag of tags) {
977
1087
  const id = readAttr(tag.raw, "id");
978
1088
  if (!id || !/^\d/.test(id)) continue;
1089
+ targeted ??= digitIdsTargetedBySelectors(styles, scripts);
1090
+ const used = targeted.has(id);
979
1091
  findings.push({
980
1092
  code: "id_requires_css_escape",
981
- severity: "warning",
982
- message: `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
1093
+ severity: used ? "error" : "warning",
1094
+ message: used ? `id="${id}" starts with a digit, and the selector \`#${id}\` used in this composition is invalid: querySelector and GSAP throw a SyntaxError, and CSS drops the rule.` : `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
983
1095
  elementId: id,
984
1096
  fixHint: "Rename the id to start with a letter (recommended), or build selectors with `#${CSS.escape(id)}` at runtime.",
985
1097
  snippet: truncateSnippet(tag.raw)
@@ -1123,11 +1235,55 @@ var coreRules = [
1123
1235
  }
1124
1236
  return findings;
1125
1237
  },
1238
+ // css_transition_used
1239
+ ({ styles, tags }) => {
1240
+ const findings = [];
1241
+ for (const style of styles) {
1242
+ if (!/transition/i.test(style.content)) continue;
1243
+ let root;
1244
+ try {
1245
+ root = postcss.parse(style.content);
1246
+ } catch {
1247
+ continue;
1248
+ }
1249
+ root.walkDecls((declaration) => {
1250
+ if (!isSeekUnsafeTransition(declaration)) return;
1251
+ const selector = declaration.parent?.type === "rule" ? declaration.parent.selector : void 0;
1252
+ findings.push(
1253
+ cssTransitionFinding(declaration, {
1254
+ selector,
1255
+ snippet: truncateSnippet(declaration.toString())
1256
+ })
1257
+ );
1258
+ });
1259
+ }
1260
+ for (const tag of tags) {
1261
+ const inlineStyle = readDecodedAttr(tag.raw, "style");
1262
+ if (!inlineStyle || !/transition/i.test(inlineStyle)) continue;
1263
+ let root;
1264
+ try {
1265
+ root = postcss.parse(inlineStyle);
1266
+ } catch {
1267
+ continue;
1268
+ }
1269
+ root.walkDecls((declaration) => {
1270
+ if (!isSeekUnsafeTransition(declaration)) return;
1271
+ findings.push(
1272
+ cssTransitionFinding(declaration, {
1273
+ elementId: readDecodedAttr(tag.raw, "id") || void 0,
1274
+ snippet: truncateSnippet(tag.raw)
1275
+ })
1276
+ );
1277
+ });
1278
+ }
1279
+ return findings;
1280
+ },
1126
1281
  // CSS selector safety
1127
1282
  ({ styles, locate }) => {
1128
1283
  const findings = [];
1129
1284
  const reportedRepeatedIds = /* @__PURE__ */ new Set();
1130
1285
  const reportedHiddenStyleSelectors = /* @__PURE__ */ new Set();
1286
+ const reportedReducedIdSelectors = /* @__PURE__ */ new Set();
1131
1287
  for (const style of styles) {
1132
1288
  let root;
1133
1289
  try {
@@ -1146,6 +1302,7 @@ var coreRules = [
1146
1302
  }
1147
1303
  root.walkRules((rule) => {
1148
1304
  const forcesOpacityZero = ruleForcesOpacityZero(rule);
1305
+ findings.push(...reducedSpecificityIdFindings(rule, reportedReducedIdSelectors));
1149
1306
  for (const selector of resolvedRuleSelectors(rule)) {
1150
1307
  const repeatedId = repeatedDescendantId(selector);
1151
1308
  if (repeatedId && !reportedRepeatedIds.has(repeatedId)) {
@@ -2383,6 +2540,7 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
2383
2540
  ]);
2384
2541
  var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
2385
2542
  var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
2543
+ var COMPUTED_TIMELINE_REGISTRATION_PATTERN = /window\.__timelines\s*\[(?!\s*["'])(?:[^\r\n[\]]|\[[^\r\n\]]*\])+\]\s*(?:\?\?|\|\||&&)?=/;
2386
2544
  function readTagTiming(rawTag) {
2387
2545
  return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
2388
2546
  }
@@ -2691,6 +2849,26 @@ function negativeZIndexFinding(match, selector) {
2691
2849
  snippet: truncateSnippet(match[0])
2692
2850
  };
2693
2851
  }
2852
+ function hasComputedTimelineRegistration(scripts) {
2853
+ return scripts.some(
2854
+ (script) => COMPUTED_TIMELINE_REGISTRATION_PATTERN.test(stripJsCode(script.content))
2855
+ );
2856
+ }
2857
+ function mountsSource(tag) {
2858
+ return Boolean(
2859
+ readAttr(tag.raw, "data-composition-src") || readAttr(tag.raw, "data-composition-file")
2860
+ );
2861
+ }
2862
+ function missingTimelineFinding(tag, compositionId, isRoot) {
2863
+ return {
2864
+ code: "missing_data_no_timeline",
2865
+ severity: "warning",
2866
+ message: isRoot ? "This composition has no `window.__timelines` registration but is missing `data-no-timeline`. The producer polls for timeline registration for up to 45 seconds before timing out, adding 45 s to every render." : `Composition host "${compositionId}" has neither a matching \`window.__timelines\` registration nor \`data-no-timeline\`. The producer waits up to 45 seconds for every \`data-composition-id\` before rendering.`,
2867
+ elementId: readAttr(tag.raw, "id") || void 0,
2868
+ fixHint: isRoot ? 'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.' : "If this is a static section, use a plain `id` instead of `data-composition-id`, or add `data-no-timeline`. Otherwise, register its timeline or mount it with `data-composition-src`.",
2869
+ snippet: truncateSnippet(tag.raw)
2870
+ };
2871
+ }
2694
2872
  var compositionRules = [
2695
2873
  // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
2696
2874
  ({ tags }) => {
@@ -2959,27 +3137,25 @@ var compositionRules = [
2959
3137
  return findings;
2960
3138
  },
2961
3139
  // missing_data_no_timeline
2962
- // The producer polls window.__timelines[id] with a 45-second timeout waiting
2963
- // for GSAP timeline registration. Compositions that never call
2964
- // window.__timelines[id] = tl stall for 45 s every render. Adding
2965
- // data-no-timeline to the root element tells the producer to skip the poll.
2966
- ({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
2967
- if (options.isSubComposition) return [];
3140
+ // The producer polls window.__timelines[id] for every composition id in the
3141
+ // rendered document. A timeline-free root or bare nested composition host
3142
+ // therefore spends the full 45-second readiness budget unless it explicitly
3143
+ // opts out with data-no-timeline.
3144
+ ({ rootTag, rootCompositionId, tags, scripts, rawSource, options }) => {
2968
3145
  if (!rootCompositionId || !rootTag) return [];
2969
- const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
2970
- if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
2971
3146
  if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
2972
- const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
2973
- if (registersTimeline) return [];
2974
- return [
2975
- {
2976
- code: "missing_data_no_timeline",
2977
- severity: "warning",
2978
- message: "This composition has no `window.__timelines` registration but is missing `data-no-timeline`. The producer polls for timeline registration for up to 45 seconds before timing out, adding 45 s to every render.",
2979
- fixHint: 'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
2980
- snippet: truncateSnippet(rootTag.raw)
2981
- }
2982
- ];
3147
+ if (hasComputedTimelineRegistration(scripts)) return [];
3148
+ const registeredIds = new Set(
3149
+ scripts.flatMap((script) => extractTimelineRegistryKeys(stripJsComments(script.content)))
3150
+ );
3151
+ return tags.flatMap((tag) => {
3152
+ const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
3153
+ if (!compositionId || registeredIds.has(compositionId)) return [];
3154
+ const isRoot = tag.index === rootTag.index;
3155
+ if (isRoot ? options.isSubComposition : isInsideInertTemplate(tag, tags)) return [];
3156
+ if (readDecodedAttr(tag.raw, "data-no-timeline") !== null || mountsSource(tag)) return [];
3157
+ return [missingTimelineFinding(tag, compositionId, isRoot)];
3158
+ });
2983
3159
  },
2984
3160
  // negative_z_index
2985
3161
  // An element at a negative z-index is silently absent from both `snapshot`
@@ -4282,7 +4458,7 @@ ${right.raw}`)
4282
4458
  severity: "warning",
4283
4459
  message: `${fromToWindows.length} tl.fromTo() calls target "${selector}" with no stable baseline. The last-authored fromTo "from" values become the element's resting state for any seek before the first tween actually runs, because GSAP applies fromTo from-values at authoring time (immediateRender), not at tween position.`,
4284
4460
  selector,
4285
- fixHint: `Add \`immediateRender: false\` to the destination vars of each future fromTo, or set a safe resting state with an earlier \`tl.set("${selector}", { ... }, 0)\`. Pre-first-tween seeks must not inherit whichever fromTo call happened to author last.`,
4461
+ fixHint: `Add \`immediateRender: false\` to every fromTo except the earliest-positioned one (at ${firstFromToPosition}s), so pre-first-tween seeks keep its "from" values. A hiding \`tl.set(..., 0)\` baseline trips gsap_timeline_set_initial_hide, and a \`gsap.set\` authored before the fromTo calls is overwritten by them.`,
4286
4462
  snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n"))
4287
4463
  });
4288
4464
  }
@@ -4415,7 +4591,7 @@ ${right.raw}`)
4415
4591
  const cssFromScale = scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : void 0;
4416
4592
  if (!cssFromTranslate && !cssFromScale) continue;
4417
4593
  const existing = conflicts.get(sel) ?? {
4418
- cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
4594
+ cssTransform: [...new Set([cssFromTranslate, cssFromScale].filter(Boolean))].join(" "),
4419
4595
  props: /* @__PURE__ */ new Set(),
4420
4596
  raw: call.raw
4421
4597
  };