@hyperframes/lint 0.8.66 → 0.8.68

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.d.ts CHANGED
@@ -4,6 +4,9 @@ type HyperframeLintFinding = {
4
4
  severity: HyperframeLintSeverity;
5
5
  message: string;
6
6
  file?: string;
7
+ /** One-based coordinates in the original source; absent when no unique location exists. */
8
+ line?: number;
9
+ column?: number;
7
10
  selector?: string;
8
11
  elementId?: string;
9
12
  fixHint?: string;
@@ -36,6 +39,7 @@ type HyperframeLinterOptions = {
36
39
  externalStyles?: Array<{
37
40
  href: string;
38
41
  content: string;
42
+ file?: string;
39
43
  }>;
40
44
  /**
41
45
  * Set to `true` when linting compositions destined for distributed / Lambda
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
+ // src/sourceLocations.ts
2
+ import { Parser as Parser2 } from "htmlparser2";
3
+
1
4
  // src/utils.ts
2
5
  import { Parser } from "htmlparser2";
6
+ import { parse } from "acorn";
3
7
  var COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
4
8
  var TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
5
9
  var TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
@@ -53,6 +57,7 @@ function parseHtmlStructure(source) {
53
57
  blocks[name].push({
54
58
  attrs: block.attrs,
55
59
  content: source.slice(block.contentStart, parser.startIndex),
60
+ contentStart: block.contentStart,
56
61
  raw: source.slice(block.index, parser.endIndex + 1),
57
62
  index: block.index
58
63
  });
@@ -202,11 +207,17 @@ function readTimelineRegistryTopLevelKeys(source) {
202
207
  function getInlineScriptSyntaxError(source) {
203
208
  if (!source.trim()) return null;
204
209
  try {
205
- new Function(source);
210
+ parse(source, {
211
+ ecmaVersion: "latest",
212
+ sourceType: "script",
213
+ allowReturnOutsideFunction: true
214
+ });
206
215
  return null;
207
216
  } catch (error) {
208
- if (error instanceof Error) return error.message;
209
- return String(error);
217
+ return {
218
+ message: error instanceof Error ? error.message : String(error),
219
+ offset: error instanceof SyntaxError && "pos" in error && typeof error.pos === "number" ? error.pos : void 0
220
+ };
210
221
  }
211
222
  }
212
223
  function stripStringLiterals(source) {
@@ -525,7 +536,7 @@ function stripCssComments(source) {
525
536
  }
526
537
  return out;
527
538
  }
528
- function stripHtmlCommentsOnce(source) {
539
+ function stripHtmlCommentsOnce(source, removed) {
529
540
  let out = "";
530
541
  let i = 0;
531
542
  for (; ; ) {
@@ -534,14 +545,17 @@ function stripHtmlCommentsOnce(source) {
534
545
  const end = source.indexOf("-->", start + 4);
535
546
  if (end < 0) return out + source.slice(i);
536
547
  out += source.slice(i, start);
548
+ removed?.(start, end + 3);
537
549
  i = end + 3;
538
550
  }
539
551
  }
540
- function stripHtmlComments(source) {
552
+ function stripHtmlComments(source, pass) {
541
553
  let out = source;
542
554
  for (let prev = ""; prev !== out; ) {
543
555
  prev = out;
544
- out = stripHtmlCommentsOnce(out);
556
+ const ranges = [];
557
+ out = stripHtmlCommentsOnce(out, pass ? (start, end) => ranges.push([start, end]) : void 0);
558
+ if (ranges.length) pass?.(ranges);
545
559
  }
546
560
  return out;
547
561
  }
@@ -557,19 +571,103 @@ function hasCaptionStyles(styles) {
557
571
  return styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
558
572
  }
559
573
  function truncateSnippet(value, maxLength = 220) {
560
- const normalized = value.replace(/\s+/g, " ").trim();
561
- if (!normalized) return void 0;
562
- if (normalized.length <= maxLength) return normalized;
563
- return `${normalized.slice(0, maxLength - 3)}...`;
574
+ const normalized2 = value.replace(/\s+/g, " ").trim();
575
+ if (!normalized2) return void 0;
576
+ if (normalized2.length <= maxLength) return normalized2;
577
+ return `${normalized2.slice(0, maxLength - 3)}...`;
564
578
  }
565
579
  function mediaSrcTagRe(tagAlternation) {
566
580
  return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']([^"']+)["'][^>]*>`, "gi");
567
581
  }
568
582
 
583
+ // src/sourceLocations.ts
584
+ function normalized(source) {
585
+ let text = "";
586
+ const offsets = [];
587
+ for (let i = 0; i < source.length; i++) {
588
+ const char = source[i];
589
+ if (/\s/.test(char)) {
590
+ if (text.endsWith(" ")) continue;
591
+ text += " ";
592
+ } else text += char;
593
+ offsets.push(i);
594
+ }
595
+ return { text, offsets };
596
+ }
597
+ function createSourceLocator(source) {
598
+ const tags = parseHtmlStructure(source).tags;
599
+ const searchable = source.split("");
600
+ const parser = new Parser2({
601
+ oncomment() {
602
+ searchable.fill("\0", parser.startIndex, parser.endIndex + 1);
603
+ }
604
+ });
605
+ parser.write(source);
606
+ parser.end();
607
+ const normalizedSource = normalized(searchable.join(""));
608
+ const at = (offset) => {
609
+ const before = source.slice(0, offset);
610
+ return {
611
+ line: before.split(/\r\n|\r|\n/).length,
612
+ column: offset - Math.max(before.lastIndexOf("\n"), before.lastIndexOf("\r"))
613
+ };
614
+ };
615
+ return (finding) => {
616
+ if (finding.line !== void 0) return { line: finding.line, column: finding.column };
617
+ if (finding.snippet) {
618
+ const offset = snippetOffset(normalizedSource, finding.snippet);
619
+ return offset === void 0 ? {} : at(offset);
620
+ }
621
+ const id = finding.elementId ?? /^#([\w-]+)$/.exec(finding.selector ?? "")?.[1];
622
+ if (!id) return {};
623
+ const matches = tags.filter((tag) => readDecodedAttr(tag.raw, "id") === id);
624
+ return matches.length === 1 ? at(matches[0].index) : {};
625
+ };
626
+ }
627
+ function snippetOffset(source, snippet) {
628
+ const needle = snippet.replace(/\s+/g, " ").trim().replace(/\.\.\.$/, "");
629
+ if (!needle) return void 0;
630
+ const index = source.text.indexOf(needle);
631
+ if (index < 0 || source.text.indexOf(needle, index + 1) >= 0) return void 0;
632
+ return source.offsets[index];
633
+ }
634
+
635
+ // src/sourceCoordinates.ts
636
+ function mappedHtmlSource(original) {
637
+ let offsets;
638
+ const source = stripHtmlComments(original, (ranges) => {
639
+ const previous = offsets;
640
+ const length = (previous?.length ?? original.length + 1) - ranges.reduce((n, [a, b]) => n + b - a, 0);
641
+ offsets = new Uint32Array(length);
642
+ let target = 0;
643
+ let start = 0;
644
+ const spans = [
645
+ ...ranges,
646
+ [previous ? previous.length : original.length + 1, 0]
647
+ ];
648
+ for (const [a, b] of spans) {
649
+ for (let i = start; i < a; i++) offsets[target++] = previous ? previous[i] : i;
650
+ start = b;
651
+ }
652
+ });
653
+ return { source, originalOffset: (index) => offsets ? offsets[index] : index };
654
+ }
655
+ function sourcePosition(source, offset, file) {
656
+ if (!Number.isInteger(offset) || offset < 0 || offset > source.length) return {};
657
+ const before = source.slice(0, offset);
658
+ return {
659
+ ...file ? { file } : {},
660
+ line: before.split(/\r\n|\r|\n/).length,
661
+ column: offset - Math.max(before.lastIndexOf("\n"), before.lastIndexOf("\r"))
662
+ };
663
+ }
664
+
569
665
  // src/context.ts
570
666
  function buildLintContext(html, options = {}) {
571
667
  const rawSource = html || "";
572
- let source = stripHtmlComments(rawSource);
668
+ const mapped = mappedHtmlSource(rawSource);
669
+ let source = mapped.source;
670
+ let sourceStart = 0;
573
671
  const initialStructure = parseHtmlStructure(source);
574
672
  const templateTags = initialStructure.tags.filter(
575
673
  (tag) => tag.name === "template" && tag.closeIndex != null
@@ -582,7 +680,8 @@ function buildLintContext(html, options = {}) {
582
680
  const template = templateTags[0];
583
681
  let structure = initialStructure;
584
682
  if (template && !findRootTag(sourceWithoutTemplates)) {
585
- source = source.slice(template.index + template.raw.length, template.closeIndex);
683
+ sourceStart = template.index + template.raw.length;
684
+ source = source.slice(sourceStart, template.closeIndex);
586
685
  structure = parseHtmlStructure(source);
587
686
  }
588
687
  const tags = structure.tags;
@@ -592,14 +691,23 @@ function buildLintContext(html, options = {}) {
592
691
  attrs: `href="${style.href}"`,
593
692
  content: style.content,
594
693
  raw: style.content,
595
- index: -1
694
+ index: -1,
695
+ file: style.file ?? style.href
596
696
  }))
597
697
  ];
598
698
  const scripts = structure.scripts;
599
699
  const compositionIds = collectCompositionIds(tags);
600
700
  const rootTag = findRootTag(source, tags);
601
701
  const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id");
702
+ const locate = (block, offset) => {
703
+ if (offset === void 0 || offset < 0 || offset > block.content.length) return {};
704
+ if (block.index === -1) return sourcePosition(block.content, offset, block.file);
705
+ if (block.contentStart === void 0) return {};
706
+ const originalOffset = mapped.originalOffset(sourceStart + block.contentStart + offset);
707
+ return originalOffset === void 0 ? {} : sourcePosition(rawSource, originalOffset, options.filePath);
708
+ };
602
709
  return {
710
+ locate,
603
711
  source,
604
712
  rawSource,
605
713
  tags,
@@ -619,7 +727,7 @@ import {
619
727
  HTML_BODY_CSS_WIDTH_FIRST_RE,
620
728
  HTML_BODY_CSS_HEIGHT_FIRST_RE,
621
729
  VIEWPORT_META_SIZE_RE
622
- } from "@hyperframes/parsers";
730
+ } from "@hyperframes/parsers/composition";
623
731
  function repeatedDescendantId(selector) {
624
732
  let repeated = null;
625
733
  const requiredPseudoIds = (pseudo) => {
@@ -997,7 +1105,7 @@ var coreRules = [
997
1105
  return findings;
998
1106
  },
999
1107
  // CSS selector safety
1000
- ({ styles }) => {
1108
+ ({ styles, locate }) => {
1001
1109
  const findings = [];
1002
1110
  const reportedRepeatedIds = /* @__PURE__ */ new Set();
1003
1111
  const reportedHiddenStyleSelectors = /* @__PURE__ */ new Set();
@@ -1007,6 +1115,10 @@ var coreRules = [
1007
1115
  root = postcss.parse(style.content);
1008
1116
  } catch (error) {
1009
1117
  findings.push({
1118
+ ...locate(
1119
+ style,
1120
+ error instanceof postcss.CssSyntaxError ? cssErrorOffset(style.content, error) : void 0
1121
+ ),
1010
1122
  code: "css_parse_error",
1011
1123
  severity: "error",
1012
1124
  message: `CSS parse error: ${error instanceof Error ? error.message : "unknown"}`
@@ -1072,7 +1184,7 @@ var coreRules = [
1072
1184
  ];
1073
1185
  },
1074
1186
  // invalid_inline_script_syntax (JS parse error)
1075
- ({ scripts }) => {
1187
+ ({ scripts, locate }) => {
1076
1188
  const findings = [];
1077
1189
  for (const script of scripts) {
1078
1190
  const attrs = script.attrs || "";
@@ -1083,9 +1195,10 @@ var coreRules = [
1083
1195
  const syntaxError = getInlineScriptSyntaxError(script.content);
1084
1196
  if (!syntaxError) continue;
1085
1197
  findings.push({
1198
+ ...locate(script, syntaxError.offset),
1086
1199
  code: "invalid_inline_script_syntax",
1087
1200
  severity: "error",
1088
- message: `Inline script has invalid syntax: ${syntaxError}`,
1201
+ message: `Inline script has invalid syntax: ${syntaxError.message}`,
1089
1202
  fixHint: "Fix the inline script syntax before render verification.",
1090
1203
  snippet: truncateSnippet(script.content)
1091
1204
  });
@@ -1151,7 +1264,7 @@ var coreRules = [
1151
1264
  return findings;
1152
1265
  },
1153
1266
  // non_deterministic_code
1154
- ({ scripts }) => {
1267
+ ({ scripts, locate }) => {
1155
1268
  const findings = [];
1156
1269
  const patterns = [
1157
1270
  {
@@ -1202,8 +1315,10 @@ var coreRules = [
1202
1315
  const withoutComments = stripJsComments(script.content);
1203
1316
  const executable = stripStringLiterals(withoutComments);
1204
1317
  for (const { pattern, label, hint, scansStrings } of patterns) {
1205
- if (pattern.test(scansStrings ? withoutComments : executable)) {
1318
+ const match = pattern.exec(scansStrings ? withoutComments : executable);
1319
+ if (match) {
1206
1320
  findings.push({
1321
+ ...locate(script, match.index),
1207
1322
  code: "non_deterministic_code",
1208
1323
  severity: "error",
1209
1324
  message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
@@ -1216,10 +1331,24 @@ var coreRules = [
1216
1331
  return findings;
1217
1332
  }
1218
1333
  ];
1334
+ function cssErrorOffset(source, error) {
1335
+ if (error.input && "offset" in error.input && typeof error.input.offset === "number")
1336
+ return error.input.offset;
1337
+ const { line, column } = error;
1338
+ if (line === void 0 || column === void 0) return void 0;
1339
+ let offset = 0;
1340
+ let currentLine = 1;
1341
+ for (const match of source.matchAll(/\n/g)) {
1342
+ if (currentLine >= line) break;
1343
+ offset = match.index + 1;
1344
+ currentLine++;
1345
+ }
1346
+ return currentLine === line ? offset + column - 1 : void 0;
1347
+ }
1219
1348
 
1220
1349
  // src/rules/media.ts
1221
1350
  import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
1222
- import { extractMediaSrcMutations } from "@hyperframes/parsers";
1351
+ import { extractMediaSrcMutations } from "@hyperframes/parsers/composition";
1223
1352
  import { parseHTML } from "linkedom";
1224
1353
  function tweensVolumeInSameCall(script, id) {
1225
1354
  const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
@@ -1366,18 +1495,18 @@ function classNamesFromAttr(classAttr) {
1366
1495
  return classAttr.split(/\s+/).filter(Boolean);
1367
1496
  }
1368
1497
  function selectorTargetsManagedMedia(selector, mediaIndex) {
1369
- const normalized = selector.trim();
1370
- if (!normalized) return false;
1371
- if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
1372
- if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
1498
+ const normalized2 = selector.trim();
1499
+ if (!normalized2) return false;
1500
+ if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized2)) return true;
1501
+ if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized2)) return true;
1373
1502
  for (const mediaId of mediaIndex.ids) {
1374
1503
  const escapedId = escapeRegExp(mediaId);
1375
- if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
1504
+ if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized2) || normalized2.includes(`[id="${mediaId}"]`) || normalized2.includes(`[id='${mediaId}']`)) {
1376
1505
  return true;
1377
1506
  }
1378
1507
  }
1379
1508
  for (const className of mediaIndex.classes) {
1380
- if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
1509
+ if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized2)) {
1381
1510
  return true;
1382
1511
  }
1383
1512
  }
@@ -2078,6 +2207,45 @@ function findAudioGroupCarveAttrFindings(ctx) {
2078
2207
  return findings;
2079
2208
  }
2080
2209
 
2210
+ // src/scriptPositions.ts
2211
+ import { parse as parse2 } from "acorn";
2212
+ import { simple } from "acorn-walk";
2213
+ var callIndexes = /* @__PURE__ */ new WeakMap();
2214
+ function callsFor(script) {
2215
+ const cached = callIndexes.get(script);
2216
+ if (cached) return cached;
2217
+ const calls = [];
2218
+ try {
2219
+ const ast = parse2(script.content, {
2220
+ ecmaVersion: "latest",
2221
+ sourceType: "script",
2222
+ allowReturnOutsideFunction: true
2223
+ });
2224
+ simple(ast, {
2225
+ CallExpression: (node) => {
2226
+ calls.push(node);
2227
+ }
2228
+ });
2229
+ } catch {
2230
+ }
2231
+ calls.sort((a, b) => a.start - b.start);
2232
+ callIndexes.set(script, calls);
2233
+ return calls;
2234
+ }
2235
+ function gsapCallOffset(script, method) {
2236
+ return callsFor(script).find(
2237
+ ({ callee }) => callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "gsap" && callee.property.type === "Identifier" && callee.property.name === method
2238
+ )?.start;
2239
+ }
2240
+ function containingCallOffset(script, tokenOffset) {
2241
+ let start;
2242
+ for (const call of callsFor(script)) {
2243
+ if (call.start > tokenOffset) break;
2244
+ if (call.end > tokenOffset) start = call.start;
2245
+ }
2246
+ return start;
2247
+ }
2248
+
2081
2249
  // src/rules/composition.ts
2082
2250
  import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
2083
2251
  import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
@@ -2116,8 +2284,8 @@ function readTagTiming(rawTag) {
2116
2284
  }
2117
2285
  function countPhysicalLines(source) {
2118
2286
  if (source.length === 0) return 0;
2119
- const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2120
- const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
2287
+ const normalized2 = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2288
+ const withoutFinalNewline = normalized2.endsWith("\n") ? normalized2.slice(0, -1) : normalized2;
2121
2289
  return withoutFinalNewline.split("\n").length;
2122
2290
  }
2123
2291
  function countStructuralLines(source) {
@@ -2130,8 +2298,8 @@ function isCaptionCue(tag) {
2130
2298
  }
2131
2299
  function isRegistrySourceFile(filePath) {
2132
2300
  if (!filePath) return false;
2133
- const normalized = filePath.replace(/\\/g, "/");
2134
- return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
2301
+ const normalized2 = filePath.replace(/\\/g, "/");
2302
+ return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized2);
2135
2303
  }
2136
2304
  function isRegistryInstalledFile(rawSource) {
2137
2305
  return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
@@ -3081,8 +3249,8 @@ function zeroValue(value) {
3081
3249
  return Number(value.trim()) === 0;
3082
3250
  }
3083
3251
  function isGsapColorProperty(property) {
3084
- const normalized = property.toLowerCase();
3085
- return !normalized.startsWith("--") && (normalized.endsWith("color") || normalized === "fill" || normalized === "stroke");
3252
+ const normalized2 = property.toLowerCase();
3253
+ return !normalized2.startsWith("--") && (normalized2.endsWith("color") || normalized2 === "fill" || normalized2 === "stroke");
3086
3254
  }
3087
3255
  function collectStaticCssVariableDefinitions(tags, styles) {
3088
3256
  const definitions = /* @__PURE__ */ new Set();
@@ -3209,10 +3377,10 @@ function styleHasHiddenInitialState(style) {
3209
3377
  function styleHasOpaqueBackground(style) {
3210
3378
  const background = readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
3211
3379
  if (!background) return false;
3212
- const normalized = background.toLowerCase().replace(/\s+/g, "");
3213
- if (normalized === "transparent" || normalized === "none") return false;
3214
- if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
3215
- if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
3380
+ const normalized2 = background.toLowerCase().replace(/\s+/g, "");
3381
+ if (normalized2 === "transparent" || normalized2 === "none") return false;
3382
+ if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized2)) return false;
3383
+ if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized2)) return false;
3216
3384
  return true;
3217
3385
  }
3218
3386
  function styleLooksFullFrameOverlay(style) {
@@ -3287,8 +3455,8 @@ function targetedSelectorTokens(selector) {
3287
3455
  const compounds = group.trim().split(/[\s>+~]+/).filter(Boolean);
3288
3456
  const last = compounds[compounds.length - 1];
3289
3457
  if (!last) continue;
3290
- const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
3291
- if (simple) for (const token of simple) tokens.add(token);
3458
+ const simple2 = last.match(/[#.][A-Za-z0-9_-]+/g);
3459
+ if (simple2) for (const token of simple2) tokens.add(token);
3292
3460
  }
3293
3461
  return tokens;
3294
3462
  }
@@ -3327,7 +3495,7 @@ function scanScriptsForRegexMatches(scripts, pattern, options) {
3327
3495
  content.length,
3328
3496
  match.index + match[0].length + options.contextAfter
3329
3497
  );
3330
- hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
3498
+ hits.push({ match, snippet: content.slice(contextStart, contextEnd), script });
3331
3499
  }
3332
3500
  }
3333
3501
  return hits;
@@ -3594,9 +3762,9 @@ function elementLevelTokens(tokens, tagsByToken) {
3594
3762
  return expanded;
3595
3763
  }
3596
3764
  function isMultiComponentDasharray(value) {
3597
- const normalized = value.replace(/!important\s*$/i, "").trim();
3598
- if (!normalized || /^none$/i.test(normalized)) return false;
3599
- return normalized.split(/[\s,]+/).filter(Boolean).length >= 2;
3765
+ const normalized2 = value.replace(/!important\s*$/i, "").trim();
3766
+ if (!normalized2 || /^none$/i.test(normalized2)) return false;
3767
+ return normalized2.split(/[\s,]+/).filter(Boolean).length >= 2;
3600
3768
  }
3601
3769
  function gsapDasharrayValueLooksMultiComponent(valueSource) {
3602
3770
  const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2];
@@ -4033,19 +4201,20 @@ ${right.raw}`)
4033
4201
  return findings;
4034
4202
  },
4035
4203
  // gsap_infinite_repeat
4036
- ({ scripts, rootTag }) => {
4204
+ ({ scripts, rootTag, locate }) => {
4037
4205
  const findings = [];
4038
4206
  const declaredDuration = Number.parseFloat(
4039
4207
  rootTag ? readAttr(rootTag.raw, "data-duration") ?? "" : ""
4040
4208
  );
4041
4209
  const hasFiniteCompositionWindow = Number.isFinite(declaredDuration) && declaredDuration > 0;
4042
4210
  const pattern = /repeat\s*:\s*-1(?!\d)/g;
4043
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4211
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4044
4212
  stripComments: true,
4045
4213
  contextBefore: 60,
4046
4214
  contextAfter: 60
4047
4215
  })) {
4048
4216
  findings.push({
4217
+ ...locate(script, containingCallOffset(script, match.index)),
4049
4218
  code: "gsap_infinite_repeat",
4050
4219
  severity: hasFiniteCompositionWindow ? "warning" : "error",
4051
4220
  message: hasFiniteCompositionWindow ? `GSAP tween uses \`repeat: -1\` (infinite), but the composition declares a finite ${declaredDuration}s window. HyperFrames clips deterministic seeking and export to that explicit duration.` : "GSAP tween uses `repeat: -1` (infinite) without a finite composition `data-duration`. The timeline can report an unbounded duration and make render planning fail.",
@@ -4056,15 +4225,16 @@ ${right.raw}`)
4056
4225
  return findings;
4057
4226
  },
4058
4227
  // gsap_repeat_ceil_overshoot
4059
- ({ scripts }) => {
4228
+ ({ scripts, locate }) => {
4060
4229
  const findings = [];
4061
4230
  const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
4062
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4231
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4063
4232
  stripComments: false,
4064
4233
  contextBefore: 40,
4065
4234
  contextAfter: 40
4066
4235
  })) {
4067
4236
  findings.push({
4237
+ ...locate(script, containingCallOffset(script, match.index)),
4068
4238
  code: "gsap_repeat_ceil_overshoot",
4069
4239
  severity: "warning",
4070
4240
  message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. For example, Math.ceil(10.5 / 2) - 1 = 5 repeats \u2192 6 cycles \xD7 2s = 12s, exceeding 10.5s.",
@@ -4075,15 +4245,16 @@ ${right.raw}`)
4075
4245
  return findings;
4076
4246
  },
4077
4247
  // gsap_repeat_floor_unclamped
4078
- ({ scripts }) => {
4248
+ ({ scripts, locate }) => {
4079
4249
  const findings = [];
4080
4250
  const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
4081
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4251
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4082
4252
  stripComments: false,
4083
4253
  contextBefore: 40,
4084
4254
  contextAfter: 40
4085
4255
  })) {
4086
4256
  findings.push({
4257
+ ...locate(script, containingCallOffset(script, match.index)),
4087
4258
  code: "gsap_repeat_floor_unclamped",
4088
4259
  severity: "warning",
4089
4260
  message: "GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, which GSAP interprets as an infinite repeat.",
@@ -4094,7 +4265,7 @@ ${right.raw}`)
4094
4265
  return findings;
4095
4266
  },
4096
4267
  // gsap_timeline_not_registered
4097
- ({ scripts, rawSource, options }) => {
4268
+ ({ scripts, rawSource, options, locate }) => {
4098
4269
  const findings = [];
4099
4270
  const canInheritFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
4100
4271
  for (const script of scripts) {
@@ -4103,6 +4274,7 @@ ${right.raw}`)
4103
4274
  const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
4104
4275
  if (hasRegistration || canInheritFromHost) continue;
4105
4276
  findings.push({
4277
+ ...locate(script, gsapCallOffset(script, "timeline")),
4106
4278
  code: "gsap_timeline_not_registered",
4107
4279
  severity: "error",
4108
4280
  message: "GSAP timeline is created but never registered in window.__timelines. The runtime discovers timelines from this registry \u2014 without registration, animations will not play during preview or render.",
@@ -5145,6 +5317,7 @@ var textureRules = [
5145
5317
  ];
5146
5318
 
5147
5319
  // src/rules/fonts.ts
5320
+ import postcss3 from "postcss";
5148
5321
  import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
5149
5322
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
5150
5323
  "serif",
@@ -5291,7 +5464,7 @@ var fontRules = [
5291
5464
  ];
5292
5465
  },
5293
5466
  // font_family_without_font_face
5294
- ({ styles, source, rawSource, options }) => {
5467
+ ({ styles, source, rawSource, options, locate }) => {
5295
5468
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
5296
5469
  const findings = [];
5297
5470
  const declared = extractFontFaceFamilies(styles);
@@ -5302,6 +5475,7 @@ var fontRules = [
5302
5475
  );
5303
5476
  if (undeclared.length === 0) return findings;
5304
5477
  findings.push({
5478
+ ...uniqueFontLocation(styles, undeclared, locate),
5305
5479
  code: "font_family_without_font_face",
5306
5480
  severity: "error",
5307
5481
  message: `Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. These are not in the auto-resolved font list, so the renderer cannot supply them automatically. Text will fall back to a generic font, producing incorrect typography in the video.`,
@@ -5310,6 +5484,26 @@ var fontRules = [
5310
5484
  return findings;
5311
5485
  }
5312
5486
  ];
5487
+ function uniqueFontLocation(styles, families, locate) {
5488
+ const locations = [];
5489
+ for (const style of styles) {
5490
+ try {
5491
+ postcss3.parse(style.content).walkDecls(/^font-family$/i, (decl) => {
5492
+ let parent = decl.parent;
5493
+ while (parent) {
5494
+ if (parent.type === "atrule" && parent.name.toLowerCase() === "font-face") return;
5495
+ parent = parent.parent;
5496
+ }
5497
+ if (!decl.value.split(",").some((part) => families.includes(normalizeUsedFontName(part) ?? "")))
5498
+ return;
5499
+ locations.push(locate(style, decl.source?.start?.offset));
5500
+ });
5501
+ } catch {
5502
+ return {};
5503
+ }
5504
+ }
5505
+ return locations.length === 1 ? locations[0] : {};
5506
+ }
5313
5507
 
5314
5508
  // src/rules/slideshow.ts
5315
5509
  import {
@@ -5582,13 +5776,27 @@ function collectFindings(produced, seen, filePath, into) {
5582
5776
  const dedupeKey = dedupeKeyFor(finding);
5583
5777
  if (seen.has(dedupeKey)) continue;
5584
5778
  seen.add(dedupeKey);
5585
- into.push(filePath ? { ...finding, file: filePath } : finding);
5779
+ into.push(filePath ? { ...finding, file: finding.file ?? filePath } : finding);
5586
5780
  }
5587
5781
  }
5588
5782
  async function lintHyperframeHtml(html, options = {}) {
5589
5783
  const startedAt = performance.now();
5590
5784
  const ctx = buildLintContext(html, options);
5591
- const { findings, timings } = await runRules(ctx, options.filePath);
5785
+ const { findings: rawFindings, timings } = await runRules(ctx, options.filePath);
5786
+ const locate = createSourceLocator(html);
5787
+ const findings = rawFindings.map((finding) => {
5788
+ if (finding.line !== void 0 || finding.code === "invalid_inline_script_syntax" || finding.code === "non_deterministic_code" || [
5789
+ "gsap_infinite_repeat",
5790
+ "gsap_repeat_ceil_overshoot",
5791
+ "gsap_repeat_floor_unclamped"
5792
+ ].includes(finding.code))
5793
+ return finding;
5794
+ if (finding.snippet && options.externalStyles?.some(
5795
+ (style) => style.content.replace(/\s+/g, " ").includes(finding.snippet.replace(/\.\.\.$/, ""))
5796
+ ))
5797
+ return finding;
5798
+ return { ...finding, ...locate(finding) };
5799
+ });
5592
5800
  const errorCount = findings.filter((f) => f.severity === "error").length;
5593
5801
  const warningCount = findings.filter((f) => f.severity === "warning").length;
5594
5802
  const infoCount = findings.filter((f) => f.severity === "info").length;
@@ -5966,8 +6174,12 @@ function collectLocalStylesheets(projectDir, document, compSrcPath) {
5966
6174
  function collectExternalStyles(projectDir, html, compSrcPath) {
5967
6175
  const styles = [];
5968
6176
  const { document } = parseHTML3(html);
5969
- for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
5970
- styles.push({ href, content });
6177
+ for (const { href, content, rootRelativePath } of collectLocalStylesheets(
6178
+ projectDir,
6179
+ document,
6180
+ compSrcPath
6181
+ )) {
6182
+ styles.push({ href, content, file: join2(projectDir, rootRelativePath) });
5971
6183
  }
5972
6184
  return styles;
5973
6185
  }