@hyperframes/lint 0.8.111 → 0.8.113

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
@@ -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;
@@ -799,6 +797,34 @@ function repeatedDescendantId(selector) {
799
797
  }
800
798
  return repeated;
801
799
  }
800
+ function addUnescapedDigitIds(selector, ids) {
801
+ try {
802
+ selectorParser((root) => {
803
+ root.walkIds((node) => {
804
+ if (/^#\d/.test(node.toString().trim())) ids.add(node.value);
805
+ });
806
+ }).processSync(selector);
807
+ } catch {
808
+ }
809
+ }
810
+ var SELECTOR_CALL_PATTERN = /(?:\.(?:querySelector(?:All)?|closest|matches|to|from|fromTo|set|toArray)\s*\(|(?<![\w$-])(?:trigger|endTrigger|pin|scrollTrigger|scroller|pinnedContainer|pinSpacer)["']?\s*:)\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
811
+ function digitIdsTargetedBySelectors(styles, scripts) {
812
+ const ids = /* @__PURE__ */ new Set();
813
+ for (const style of styles) {
814
+ try {
815
+ postcss.parse(style.content).walkRules((rule) => {
816
+ for (const selector of rule.selectors) addUnescapedDigitIds(selector, ids);
817
+ });
818
+ } catch {
819
+ }
820
+ }
821
+ for (const script of scripts) {
822
+ for (const match of stripJsComments(script.content).matchAll(SELECTOR_CALL_PATTERN)) {
823
+ addUnescapedDigitIds(match[2] ?? "", ids);
824
+ }
825
+ }
826
+ return ids;
827
+ }
802
828
  function resolvedRuleSelectors(rule) {
803
829
  let ancestor = rule.parent;
804
830
  while (ancestor && ancestor.type !== "rule") ancestor = ancestor.parent;
@@ -817,16 +843,20 @@ function resolvedRuleSelectors(rule) {
817
843
  })
818
844
  );
819
845
  }
846
+ function rightmostCompoundNodes(selectorNode) {
847
+ const subject = [];
848
+ selectorNode.each((node) => {
849
+ if (node.type === "combinator") subject.length = 0;
850
+ else subject.push(node);
851
+ });
852
+ return subject;
853
+ }
820
854
  function selectorAliasesRuntimeHiddenStyle(selector) {
821
855
  let unsafe = false;
822
856
  try {
823
857
  selectorParser((root) => {
824
858
  root.each((selectorNode) => {
825
- const subject = [];
826
- selectorNode.each((node) => {
827
- if (node.type === "combinator") subject.length = 0;
828
- else subject.push(node);
829
- });
859
+ const subject = rightmostCompoundNodes(selectorNode);
830
860
  const hostScoped = subject.some(
831
861
  (node) => node.type === "attribute" && ["data-composition-src", "data-composition-file"].includes(
832
862
  node.attribute.toLowerCase()
@@ -849,6 +879,57 @@ function selectorAliasesRuntimeHiddenStyle(selector) {
849
879
  }
850
880
  return unsafe;
851
881
  }
882
+ var POSITION_PROPERTIES = /* @__PURE__ */ new Set(["left", "top", "right", "bottom", "inset"]);
883
+ function weakIdOf(node) {
884
+ if (node.type === "attribute" && node.attribute.toLowerCase() === "id") {
885
+ return node.operator === "=" && node.value ? node.value : null;
886
+ }
887
+ if (node.type !== "pseudo" || node.value.toLowerCase() !== ":where") return null;
888
+ const inner = node.nodes.flatMap((option) => option.nodes).find((n) => n.type === "id");
889
+ return inner ? inner.value : null;
890
+ }
891
+ function reducedSpecificityId(selector) {
892
+ let matched = null;
893
+ try {
894
+ selectorParser((root) => {
895
+ root.each((selectorNode) => {
896
+ const subject = rightmostCompoundNodes(selectorNode);
897
+ if (subject.some((node) => node.type === "id")) return;
898
+ for (const node of subject) matched = weakIdOf(node) ?? matched;
899
+ });
900
+ }).processSync(selector);
901
+ } catch {
902
+ return null;
903
+ }
904
+ return matched;
905
+ }
906
+ function cssIdSelector(id) {
907
+ return `#${id.replace(
908
+ /[^a-zA-Z0-9_-]|^-?\d/g,
909
+ (match) => Array.from(match, (char) => `\\${char.codePointAt(0)?.toString(16)} `).join("")
910
+ )}`;
911
+ }
912
+ function reducedSpecificityIdFindings(rule, reported) {
913
+ const positionProps = rule.nodes.flatMap(
914
+ (node) => node.type === "decl" && !node.important && POSITION_PROPERTIES.has(node.prop.toLowerCase()) ? [node.prop] : []
915
+ );
916
+ if (positionProps.length === 0) return [];
917
+ const findings = [];
918
+ for (const selector of resolvedRuleSelectors(rule)) {
919
+ const id = reported.has(selector) ? null : reducedSpecificityId(selector);
920
+ if (id === null) continue;
921
+ reported.add(selector);
922
+ findings.push({
923
+ code: "id_override_reduced_specificity",
924
+ severity: "warning",
925
+ 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.`,
926
+ selector,
927
+ fixHint: `Use \`${cssIdSelector(id)}\` instead; id specificity beats any selector built only from classes.`,
928
+ snippet: truncateSnippet(rule.toString())
929
+ });
930
+ }
931
+ return findings;
932
+ }
852
933
  function ruleForcesOpacityZero(rule) {
853
934
  let forcesOpacityZero = false;
854
935
  rule.walkDecls(/^opacity$/i, (declaration) => {
@@ -856,6 +937,34 @@ function ruleForcesOpacityZero(rule) {
856
937
  });
857
938
  return forcesOpacityZero;
858
939
  }
940
+ var CSS_TRANSITION_PROPERTY_PATTERN = /^(?:-webkit-)?transition(?:-[a-z][a-z-]*)?$/i;
941
+ var TRANSITION_NONE_PROPERTIES = /* @__PURE__ */ new Set(["transition", "transition-property"]);
942
+ var TRANSITION_TIME_PROPERTIES = /* @__PURE__ */ new Set([
943
+ "transition",
944
+ "transition-duration",
945
+ "transition-delay"
946
+ ]);
947
+ function hasNonZeroTime(value) {
948
+ const times = value.match(/[+-]?(?:\d*\.)?\d+m?s\b/g) ?? [];
949
+ return value.includes("var(") || times.some((time) => Number.parseFloat(time) !== 0);
950
+ }
951
+ function isSeekUnsafeTransition(declaration) {
952
+ const property = declaration.prop.trim().toLowerCase();
953
+ if (!CSS_TRANSITION_PROPERTY_PATTERN.test(property)) return false;
954
+ const name = property.replace(/^-webkit-/, "");
955
+ const value = declaration.value.trim().toLowerCase();
956
+ if (value === "none" && TRANSITION_NONE_PROPERTIES.has(name)) return false;
957
+ return !TRANSITION_TIME_PROPERTIES.has(name) || hasNonZeroTime(value);
958
+ }
959
+ function cssTransitionFinding(declaration, details) {
960
+ return {
961
+ code: "css_transition_used",
962
+ severity: "warning",
963
+ message: `CSS declaration \`${declaration.prop}: ${declaration.value}\` runs on the browser clock and cannot be seeked deterministically across render workers.`,
964
+ fixHint: "Keep the class or attribute swap for state; put the visual change on the paused GSAP timeline.",
965
+ ...details
966
+ };
967
+ }
859
968
  function isStudioTimelineElement(tag) {
860
969
  if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
861
970
  return false;
@@ -974,15 +1083,18 @@ function describeRootDimensionsDrift(source, dataWidth, dataHeight) {
974
1083
  }
975
1084
  var coreRules = [
976
1085
  // id_requires_css_escape
977
- ({ tags }) => {
1086
+ ({ tags, styles, scripts }) => {
978
1087
  const findings = [];
1088
+ let targeted;
979
1089
  for (const tag of tags) {
980
1090
  const id = readAttr(tag.raw, "id");
981
1091
  if (!id || !/^\d/.test(id)) continue;
1092
+ targeted ??= digitIdsTargetedBySelectors(styles, scripts);
1093
+ const used = targeted.has(id);
982
1094
  findings.push({
983
1095
  code: "id_requires_css_escape",
984
- severity: "warning",
985
- message: `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
1096
+ severity: used ? "error" : "warning",
1097
+ 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().`,
986
1098
  elementId: id,
987
1099
  fixHint: "Rename the id to start with a letter (recommended), or build selectors with `#${CSS.escape(id)}` at runtime.",
988
1100
  snippet: truncateSnippet(tag.raw)
@@ -1126,11 +1238,55 @@ var coreRules = [
1126
1238
  }
1127
1239
  return findings;
1128
1240
  },
1241
+ // css_transition_used
1242
+ ({ styles, tags }) => {
1243
+ const findings = [];
1244
+ for (const style of styles) {
1245
+ if (!/transition/i.test(style.content)) continue;
1246
+ let root;
1247
+ try {
1248
+ root = postcss.parse(style.content);
1249
+ } catch {
1250
+ continue;
1251
+ }
1252
+ root.walkDecls((declaration) => {
1253
+ if (!isSeekUnsafeTransition(declaration)) return;
1254
+ const selector = declaration.parent?.type === "rule" ? declaration.parent.selector : void 0;
1255
+ findings.push(
1256
+ cssTransitionFinding(declaration, {
1257
+ selector,
1258
+ snippet: truncateSnippet(declaration.toString())
1259
+ })
1260
+ );
1261
+ });
1262
+ }
1263
+ for (const tag of tags) {
1264
+ const inlineStyle = readDecodedAttr(tag.raw, "style");
1265
+ if (!inlineStyle || !/transition/i.test(inlineStyle)) continue;
1266
+ let root;
1267
+ try {
1268
+ root = postcss.parse(inlineStyle);
1269
+ } catch {
1270
+ continue;
1271
+ }
1272
+ root.walkDecls((declaration) => {
1273
+ if (!isSeekUnsafeTransition(declaration)) return;
1274
+ findings.push(
1275
+ cssTransitionFinding(declaration, {
1276
+ elementId: readDecodedAttr(tag.raw, "id") || void 0,
1277
+ snippet: truncateSnippet(tag.raw)
1278
+ })
1279
+ );
1280
+ });
1281
+ }
1282
+ return findings;
1283
+ },
1129
1284
  // CSS selector safety
1130
1285
  ({ styles, locate }) => {
1131
1286
  const findings = [];
1132
1287
  const reportedRepeatedIds = /* @__PURE__ */ new Set();
1133
1288
  const reportedHiddenStyleSelectors = /* @__PURE__ */ new Set();
1289
+ const reportedReducedIdSelectors = /* @__PURE__ */ new Set();
1134
1290
  for (const style of styles) {
1135
1291
  let root;
1136
1292
  try {
@@ -1149,6 +1305,7 @@ var coreRules = [
1149
1305
  }
1150
1306
  root.walkRules((rule) => {
1151
1307
  const forcesOpacityZero = ruleForcesOpacityZero(rule);
1308
+ findings.push(...reducedSpecificityIdFindings(rule, reportedReducedIdSelectors));
1152
1309
  for (const selector of resolvedRuleSelectors(rule)) {
1153
1310
  const repeatedId = repeatedDescendantId(selector);
1154
1311
  if (repeatedId && !reportedRepeatedIds.has(repeatedId)) {
@@ -2386,6 +2543,7 @@ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
2386
2543
  ]);
2387
2544
  var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
2388
2545
  var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
2546
+ var COMPUTED_TIMELINE_REGISTRATION_PATTERN = /window\.__timelines\s*\[(?!\s*["'])(?:[^\r\n[\]]|\[[^\r\n\]]*\])+\]\s*(?:\?\?|\|\||&&)?=/;
2389
2547
  function readTagTiming(rawTag) {
2390
2548
  return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
2391
2549
  }
@@ -2694,6 +2852,26 @@ function negativeZIndexFinding(match, selector) {
2694
2852
  snippet: truncateSnippet(match[0])
2695
2853
  };
2696
2854
  }
2855
+ function hasComputedTimelineRegistration(scripts) {
2856
+ return scripts.some(
2857
+ (script) => COMPUTED_TIMELINE_REGISTRATION_PATTERN.test(stripJsCode(script.content))
2858
+ );
2859
+ }
2860
+ function mountsSource(tag) {
2861
+ return Boolean(
2862
+ readAttr(tag.raw, "data-composition-src") || readAttr(tag.raw, "data-composition-file")
2863
+ );
2864
+ }
2865
+ function missingTimelineFinding(tag, compositionId, isRoot) {
2866
+ return {
2867
+ code: "missing_data_no_timeline",
2868
+ severity: "warning",
2869
+ 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.`,
2870
+ elementId: readAttr(tag.raw, "id") || void 0,
2871
+ 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`.",
2872
+ snippet: truncateSnippet(tag.raw)
2873
+ };
2874
+ }
2697
2875
  var compositionRules = [
2698
2876
  // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
2699
2877
  ({ tags }) => {
@@ -2962,27 +3140,25 @@ var compositionRules = [
2962
3140
  return findings;
2963
3141
  },
2964
3142
  // missing_data_no_timeline
2965
- // The producer polls window.__timelines[id] with a 45-second timeout waiting
2966
- // for GSAP timeline registration. Compositions that never call
2967
- // window.__timelines[id] = tl stall for 45 s every render. Adding
2968
- // data-no-timeline to the root element tells the producer to skip the poll.
2969
- ({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
2970
- if (options.isSubComposition) return [];
3143
+ // The producer polls window.__timelines[id] for every composition id in the
3144
+ // rendered document. A timeline-free root or bare nested composition host
3145
+ // therefore spends the full 45-second readiness budget unless it explicitly
3146
+ // opts out with data-no-timeline.
3147
+ ({ rootTag, rootCompositionId, tags, scripts, rawSource, options }) => {
2971
3148
  if (!rootCompositionId || !rootTag) return [];
2972
- const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
2973
- if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
2974
3149
  if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
2975
- const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
2976
- if (registersTimeline) return [];
2977
- return [
2978
- {
2979
- code: "missing_data_no_timeline",
2980
- severity: "warning",
2981
- 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.",
2982
- fixHint: 'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
2983
- snippet: truncateSnippet(rootTag.raw)
2984
- }
2985
- ];
3150
+ if (hasComputedTimelineRegistration(scripts)) return [];
3151
+ const registeredIds = new Set(
3152
+ scripts.flatMap((script) => extractTimelineRegistryKeys(stripJsComments(script.content)))
3153
+ );
3154
+ return tags.flatMap((tag) => {
3155
+ const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
3156
+ if (!compositionId || registeredIds.has(compositionId)) return [];
3157
+ const isRoot = tag.index === rootTag.index;
3158
+ if (isRoot ? options.isSubComposition : isInsideInertTemplate(tag, tags)) return [];
3159
+ if (readDecodedAttr(tag.raw, "data-no-timeline") !== null || mountsSource(tag)) return [];
3160
+ return [missingTimelineFinding(tag, compositionId, isRoot)];
3161
+ });
2986
3162
  },
2987
3163
  // negative_z_index
2988
3164
  // An element at a negative z-index is silently absent from both `snapshot`
@@ -4285,7 +4461,7 @@ ${right.raw}`)
4285
4461
  severity: "warning",
4286
4462
  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.`,
4287
4463
  selector,
4288
- 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.`,
4464
+ 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.`,
4289
4465
  snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n"))
4290
4466
  });
4291
4467
  }
@@ -4418,7 +4594,7 @@ ${right.raw}`)
4418
4594
  const cssFromScale = scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : void 0;
4419
4595
  if (!cssFromTranslate && !cssFromScale) continue;
4420
4596
  const existing = conflicts.get(sel) ?? {
4421
- cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
4597
+ cssTransform: [...new Set([cssFromTranslate, cssFromScale].filter(Boolean))].join(" "),
4422
4598
  props: /* @__PURE__ */ new Set(),
4423
4599
  raw: call.raw
4424
4600
  };