@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/browser.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/browser.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,16 +571,100 @@ 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)}...`;
578
+ }
579
+
580
+ // src/sourceLocations.ts
581
+ function normalized(source) {
582
+ let text = "";
583
+ const offsets = [];
584
+ for (let i = 0; i < source.length; i++) {
585
+ const char = source[i];
586
+ if (/\s/.test(char)) {
587
+ if (text.endsWith(" ")) continue;
588
+ text += " ";
589
+ } else text += char;
590
+ offsets.push(i);
591
+ }
592
+ return { text, offsets };
593
+ }
594
+ function createSourceLocator(source) {
595
+ const tags = parseHtmlStructure(source).tags;
596
+ const searchable = source.split("");
597
+ const parser = new Parser2({
598
+ oncomment() {
599
+ searchable.fill("\0", parser.startIndex, parser.endIndex + 1);
600
+ }
601
+ });
602
+ parser.write(source);
603
+ parser.end();
604
+ const normalizedSource = normalized(searchable.join(""));
605
+ const at = (offset) => {
606
+ const before = source.slice(0, offset);
607
+ return {
608
+ line: before.split(/\r\n|\r|\n/).length,
609
+ column: offset - Math.max(before.lastIndexOf("\n"), before.lastIndexOf("\r"))
610
+ };
611
+ };
612
+ return (finding) => {
613
+ if (finding.line !== void 0) return { line: finding.line, column: finding.column };
614
+ if (finding.snippet) {
615
+ const offset = snippetOffset(normalizedSource, finding.snippet);
616
+ return offset === void 0 ? {} : at(offset);
617
+ }
618
+ const id = finding.elementId ?? /^#([\w-]+)$/.exec(finding.selector ?? "")?.[1];
619
+ if (!id) return {};
620
+ const matches = tags.filter((tag) => readDecodedAttr(tag.raw, "id") === id);
621
+ return matches.length === 1 ? at(matches[0].index) : {};
622
+ };
623
+ }
624
+ function snippetOffset(source, snippet) {
625
+ const needle = snippet.replace(/\s+/g, " ").trim().replace(/\.\.\.$/, "");
626
+ if (!needle) return void 0;
627
+ const index = source.text.indexOf(needle);
628
+ if (index < 0 || source.text.indexOf(needle, index + 1) >= 0) return void 0;
629
+ return source.offsets[index];
630
+ }
631
+
632
+ // src/sourceCoordinates.ts
633
+ function mappedHtmlSource(original) {
634
+ let offsets;
635
+ const source = stripHtmlComments(original, (ranges) => {
636
+ const previous = offsets;
637
+ const length = (previous?.length ?? original.length + 1) - ranges.reduce((n, [a, b]) => n + b - a, 0);
638
+ offsets = new Uint32Array(length);
639
+ let target = 0;
640
+ let start = 0;
641
+ const spans = [
642
+ ...ranges,
643
+ [previous ? previous.length : original.length + 1, 0]
644
+ ];
645
+ for (const [a, b] of spans) {
646
+ for (let i = start; i < a; i++) offsets[target++] = previous ? previous[i] : i;
647
+ start = b;
648
+ }
649
+ });
650
+ return { source, originalOffset: (index) => offsets ? offsets[index] : index };
651
+ }
652
+ function sourcePosition(source, offset, file) {
653
+ if (!Number.isInteger(offset) || offset < 0 || offset > source.length) return {};
654
+ const before = source.slice(0, offset);
655
+ return {
656
+ ...file ? { file } : {},
657
+ line: before.split(/\r\n|\r|\n/).length,
658
+ column: offset - Math.max(before.lastIndexOf("\n"), before.lastIndexOf("\r"))
659
+ };
564
660
  }
565
661
 
566
662
  // src/context.ts
567
663
  function buildLintContext(html, options = {}) {
568
664
  const rawSource = html || "";
569
- let source = stripHtmlComments(rawSource);
665
+ const mapped = mappedHtmlSource(rawSource);
666
+ let source = mapped.source;
667
+ let sourceStart = 0;
570
668
  const initialStructure = parseHtmlStructure(source);
571
669
  const templateTags = initialStructure.tags.filter(
572
670
  (tag) => tag.name === "template" && tag.closeIndex != null
@@ -579,7 +677,8 @@ function buildLintContext(html, options = {}) {
579
677
  const template = templateTags[0];
580
678
  let structure = initialStructure;
581
679
  if (template && !findRootTag(sourceWithoutTemplates)) {
582
- source = source.slice(template.index + template.raw.length, template.closeIndex);
680
+ sourceStart = template.index + template.raw.length;
681
+ source = source.slice(sourceStart, template.closeIndex);
583
682
  structure = parseHtmlStructure(source);
584
683
  }
585
684
  const tags = structure.tags;
@@ -589,14 +688,23 @@ function buildLintContext(html, options = {}) {
589
688
  attrs: `href="${style.href}"`,
590
689
  content: style.content,
591
690
  raw: style.content,
592
- index: -1
691
+ index: -1,
692
+ file: style.file ?? style.href
593
693
  }))
594
694
  ];
595
695
  const scripts = structure.scripts;
596
696
  const compositionIds = collectCompositionIds(tags);
597
697
  const rootTag = findRootTag(source, tags);
598
698
  const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id");
699
+ const locate = (block, offset) => {
700
+ if (offset === void 0 || offset < 0 || offset > block.content.length) return {};
701
+ if (block.index === -1) return sourcePosition(block.content, offset, block.file);
702
+ if (block.contentStart === void 0) return {};
703
+ const originalOffset = mapped.originalOffset(sourceStart + block.contentStart + offset);
704
+ return originalOffset === void 0 ? {} : sourcePosition(rawSource, originalOffset, options.filePath);
705
+ };
599
706
  return {
707
+ locate,
600
708
  source,
601
709
  rawSource,
602
710
  tags,
@@ -616,7 +724,7 @@ import {
616
724
  HTML_BODY_CSS_WIDTH_FIRST_RE,
617
725
  HTML_BODY_CSS_HEIGHT_FIRST_RE,
618
726
  VIEWPORT_META_SIZE_RE
619
- } from "@hyperframes/parsers";
727
+ } from "@hyperframes/parsers/composition";
620
728
  function repeatedDescendantId(selector) {
621
729
  let repeated = null;
622
730
  const requiredPseudoIds = (pseudo) => {
@@ -994,7 +1102,7 @@ var coreRules = [
994
1102
  return findings;
995
1103
  },
996
1104
  // CSS selector safety
997
- ({ styles }) => {
1105
+ ({ styles, locate }) => {
998
1106
  const findings = [];
999
1107
  const reportedRepeatedIds = /* @__PURE__ */ new Set();
1000
1108
  const reportedHiddenStyleSelectors = /* @__PURE__ */ new Set();
@@ -1004,6 +1112,10 @@ var coreRules = [
1004
1112
  root = postcss.parse(style.content);
1005
1113
  } catch (error) {
1006
1114
  findings.push({
1115
+ ...locate(
1116
+ style,
1117
+ error instanceof postcss.CssSyntaxError ? cssErrorOffset(style.content, error) : void 0
1118
+ ),
1007
1119
  code: "css_parse_error",
1008
1120
  severity: "error",
1009
1121
  message: `CSS parse error: ${error instanceof Error ? error.message : "unknown"}`
@@ -1069,7 +1181,7 @@ var coreRules = [
1069
1181
  ];
1070
1182
  },
1071
1183
  // invalid_inline_script_syntax (JS parse error)
1072
- ({ scripts }) => {
1184
+ ({ scripts, locate }) => {
1073
1185
  const findings = [];
1074
1186
  for (const script of scripts) {
1075
1187
  const attrs = script.attrs || "";
@@ -1080,9 +1192,10 @@ var coreRules = [
1080
1192
  const syntaxError = getInlineScriptSyntaxError(script.content);
1081
1193
  if (!syntaxError) continue;
1082
1194
  findings.push({
1195
+ ...locate(script, syntaxError.offset),
1083
1196
  code: "invalid_inline_script_syntax",
1084
1197
  severity: "error",
1085
- message: `Inline script has invalid syntax: ${syntaxError}`,
1198
+ message: `Inline script has invalid syntax: ${syntaxError.message}`,
1086
1199
  fixHint: "Fix the inline script syntax before render verification.",
1087
1200
  snippet: truncateSnippet(script.content)
1088
1201
  });
@@ -1148,7 +1261,7 @@ var coreRules = [
1148
1261
  return findings;
1149
1262
  },
1150
1263
  // non_deterministic_code
1151
- ({ scripts }) => {
1264
+ ({ scripts, locate }) => {
1152
1265
  const findings = [];
1153
1266
  const patterns = [
1154
1267
  {
@@ -1199,8 +1312,10 @@ var coreRules = [
1199
1312
  const withoutComments = stripJsComments(script.content);
1200
1313
  const executable = stripStringLiterals(withoutComments);
1201
1314
  for (const { pattern, label, hint, scansStrings } of patterns) {
1202
- if (pattern.test(scansStrings ? withoutComments : executable)) {
1315
+ const match = pattern.exec(scansStrings ? withoutComments : executable);
1316
+ if (match) {
1203
1317
  findings.push({
1318
+ ...locate(script, match.index),
1204
1319
  code: "non_deterministic_code",
1205
1320
  severity: "error",
1206
1321
  message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
@@ -1213,10 +1328,24 @@ var coreRules = [
1213
1328
  return findings;
1214
1329
  }
1215
1330
  ];
1331
+ function cssErrorOffset(source, error) {
1332
+ if (error.input && "offset" in error.input && typeof error.input.offset === "number")
1333
+ return error.input.offset;
1334
+ const { line, column } = error;
1335
+ if (line === void 0 || column === void 0) return void 0;
1336
+ let offset = 0;
1337
+ let currentLine = 1;
1338
+ for (const match of source.matchAll(/\n/g)) {
1339
+ if (currentLine >= line) break;
1340
+ offset = match.index + 1;
1341
+ currentLine++;
1342
+ }
1343
+ return currentLine === line ? offset + column - 1 : void 0;
1344
+ }
1216
1345
 
1217
1346
  // src/rules/media.ts
1218
1347
  import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
1219
- import { extractMediaSrcMutations } from "@hyperframes/parsers";
1348
+ import { extractMediaSrcMutations } from "@hyperframes/parsers/composition";
1220
1349
  import { parseHTML } from "linkedom";
1221
1350
  function tweensVolumeInSameCall(script, id) {
1222
1351
  const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
@@ -1363,18 +1492,18 @@ function classNamesFromAttr(classAttr) {
1363
1492
  return classAttr.split(/\s+/).filter(Boolean);
1364
1493
  }
1365
1494
  function selectorTargetsManagedMedia(selector, mediaIndex) {
1366
- const normalized = selector.trim();
1367
- if (!normalized) return false;
1368
- if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
1369
- if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
1495
+ const normalized2 = selector.trim();
1496
+ if (!normalized2) return false;
1497
+ if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized2)) return true;
1498
+ if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized2)) return true;
1370
1499
  for (const mediaId of mediaIndex.ids) {
1371
1500
  const escapedId = escapeRegExp(mediaId);
1372
- if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
1501
+ if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized2) || normalized2.includes(`[id="${mediaId}"]`) || normalized2.includes(`[id='${mediaId}']`)) {
1373
1502
  return true;
1374
1503
  }
1375
1504
  }
1376
1505
  for (const className of mediaIndex.classes) {
1377
- if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
1506
+ if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized2)) {
1378
1507
  return true;
1379
1508
  }
1380
1509
  }
@@ -2075,6 +2204,45 @@ function findAudioGroupCarveAttrFindings(ctx) {
2075
2204
  return findings;
2076
2205
  }
2077
2206
 
2207
+ // src/scriptPositions.ts
2208
+ import { parse as parse2 } from "acorn";
2209
+ import { simple } from "acorn-walk";
2210
+ var callIndexes = /* @__PURE__ */ new WeakMap();
2211
+ function callsFor(script) {
2212
+ const cached = callIndexes.get(script);
2213
+ if (cached) return cached;
2214
+ const calls = [];
2215
+ try {
2216
+ const ast = parse2(script.content, {
2217
+ ecmaVersion: "latest",
2218
+ sourceType: "script",
2219
+ allowReturnOutsideFunction: true
2220
+ });
2221
+ simple(ast, {
2222
+ CallExpression: (node) => {
2223
+ calls.push(node);
2224
+ }
2225
+ });
2226
+ } catch {
2227
+ }
2228
+ calls.sort((a, b) => a.start - b.start);
2229
+ callIndexes.set(script, calls);
2230
+ return calls;
2231
+ }
2232
+ function gsapCallOffset(script, method) {
2233
+ return callsFor(script).find(
2234
+ ({ callee }) => callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "gsap" && callee.property.type === "Identifier" && callee.property.name === method
2235
+ )?.start;
2236
+ }
2237
+ function containingCallOffset(script, tokenOffset) {
2238
+ let start;
2239
+ for (const call of callsFor(script)) {
2240
+ if (call.start > tokenOffset) break;
2241
+ if (call.end > tokenOffset) start = call.start;
2242
+ }
2243
+ return start;
2244
+ }
2245
+
2078
2246
  // src/rules/composition.ts
2079
2247
  import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
2080
2248
  import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
@@ -2113,8 +2281,8 @@ function readTagTiming(rawTag) {
2113
2281
  }
2114
2282
  function countPhysicalLines(source) {
2115
2283
  if (source.length === 0) return 0;
2116
- const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2117
- const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
2284
+ const normalized2 = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2285
+ const withoutFinalNewline = normalized2.endsWith("\n") ? normalized2.slice(0, -1) : normalized2;
2118
2286
  return withoutFinalNewline.split("\n").length;
2119
2287
  }
2120
2288
  function countStructuralLines(source) {
@@ -2127,8 +2295,8 @@ function isCaptionCue(tag) {
2127
2295
  }
2128
2296
  function isRegistrySourceFile(filePath) {
2129
2297
  if (!filePath) return false;
2130
- const normalized = filePath.replace(/\\/g, "/");
2131
- return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
2298
+ const normalized2 = filePath.replace(/\\/g, "/");
2299
+ return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized2);
2132
2300
  }
2133
2301
  function isRegistryInstalledFile(rawSource) {
2134
2302
  return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
@@ -3078,8 +3246,8 @@ function zeroValue(value) {
3078
3246
  return Number(value.trim()) === 0;
3079
3247
  }
3080
3248
  function isGsapColorProperty(property) {
3081
- const normalized = property.toLowerCase();
3082
- return !normalized.startsWith("--") && (normalized.endsWith("color") || normalized === "fill" || normalized === "stroke");
3249
+ const normalized2 = property.toLowerCase();
3250
+ return !normalized2.startsWith("--") && (normalized2.endsWith("color") || normalized2 === "fill" || normalized2 === "stroke");
3083
3251
  }
3084
3252
  function collectStaticCssVariableDefinitions(tags, styles) {
3085
3253
  const definitions = /* @__PURE__ */ new Set();
@@ -3206,10 +3374,10 @@ function styleHasHiddenInitialState(style) {
3206
3374
  function styleHasOpaqueBackground(style) {
3207
3375
  const background = readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
3208
3376
  if (!background) return false;
3209
- const normalized = background.toLowerCase().replace(/\s+/g, "");
3210
- if (normalized === "transparent" || normalized === "none") return false;
3211
- if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
3212
- if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
3377
+ const normalized2 = background.toLowerCase().replace(/\s+/g, "");
3378
+ if (normalized2 === "transparent" || normalized2 === "none") return false;
3379
+ if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized2)) return false;
3380
+ if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized2)) return false;
3213
3381
  return true;
3214
3382
  }
3215
3383
  function styleLooksFullFrameOverlay(style) {
@@ -3284,8 +3452,8 @@ function targetedSelectorTokens(selector) {
3284
3452
  const compounds = group.trim().split(/[\s>+~]+/).filter(Boolean);
3285
3453
  const last = compounds[compounds.length - 1];
3286
3454
  if (!last) continue;
3287
- const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
3288
- if (simple) for (const token of simple) tokens.add(token);
3455
+ const simple2 = last.match(/[#.][A-Za-z0-9_-]+/g);
3456
+ if (simple2) for (const token of simple2) tokens.add(token);
3289
3457
  }
3290
3458
  return tokens;
3291
3459
  }
@@ -3324,7 +3492,7 @@ function scanScriptsForRegexMatches(scripts, pattern, options) {
3324
3492
  content.length,
3325
3493
  match.index + match[0].length + options.contextAfter
3326
3494
  );
3327
- hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
3495
+ hits.push({ match, snippet: content.slice(contextStart, contextEnd), script });
3328
3496
  }
3329
3497
  }
3330
3498
  return hits;
@@ -3591,9 +3759,9 @@ function elementLevelTokens(tokens, tagsByToken) {
3591
3759
  return expanded;
3592
3760
  }
3593
3761
  function isMultiComponentDasharray(value) {
3594
- const normalized = value.replace(/!important\s*$/i, "").trim();
3595
- if (!normalized || /^none$/i.test(normalized)) return false;
3596
- return normalized.split(/[\s,]+/).filter(Boolean).length >= 2;
3762
+ const normalized2 = value.replace(/!important\s*$/i, "").trim();
3763
+ if (!normalized2 || /^none$/i.test(normalized2)) return false;
3764
+ return normalized2.split(/[\s,]+/).filter(Boolean).length >= 2;
3597
3765
  }
3598
3766
  function gsapDasharrayValueLooksMultiComponent(valueSource) {
3599
3767
  const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2];
@@ -4030,19 +4198,20 @@ ${right.raw}`)
4030
4198
  return findings;
4031
4199
  },
4032
4200
  // gsap_infinite_repeat
4033
- ({ scripts, rootTag }) => {
4201
+ ({ scripts, rootTag, locate }) => {
4034
4202
  const findings = [];
4035
4203
  const declaredDuration = Number.parseFloat(
4036
4204
  rootTag ? readAttr(rootTag.raw, "data-duration") ?? "" : ""
4037
4205
  );
4038
4206
  const hasFiniteCompositionWindow = Number.isFinite(declaredDuration) && declaredDuration > 0;
4039
4207
  const pattern = /repeat\s*:\s*-1(?!\d)/g;
4040
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4208
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4041
4209
  stripComments: true,
4042
4210
  contextBefore: 60,
4043
4211
  contextAfter: 60
4044
4212
  })) {
4045
4213
  findings.push({
4214
+ ...locate(script, containingCallOffset(script, match.index)),
4046
4215
  code: "gsap_infinite_repeat",
4047
4216
  severity: hasFiniteCompositionWindow ? "warning" : "error",
4048
4217
  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.",
@@ -4053,15 +4222,16 @@ ${right.raw}`)
4053
4222
  return findings;
4054
4223
  },
4055
4224
  // gsap_repeat_ceil_overshoot
4056
- ({ scripts }) => {
4225
+ ({ scripts, locate }) => {
4057
4226
  const findings = [];
4058
4227
  const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
4059
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4228
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4060
4229
  stripComments: false,
4061
4230
  contextBefore: 40,
4062
4231
  contextAfter: 40
4063
4232
  })) {
4064
4233
  findings.push({
4234
+ ...locate(script, containingCallOffset(script, match.index)),
4065
4235
  code: "gsap_repeat_ceil_overshoot",
4066
4236
  severity: "warning",
4067
4237
  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.",
@@ -4072,15 +4242,16 @@ ${right.raw}`)
4072
4242
  return findings;
4073
4243
  },
4074
4244
  // gsap_repeat_floor_unclamped
4075
- ({ scripts }) => {
4245
+ ({ scripts, locate }) => {
4076
4246
  const findings = [];
4077
4247
  const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
4078
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4248
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4079
4249
  stripComments: false,
4080
4250
  contextBefore: 40,
4081
4251
  contextAfter: 40
4082
4252
  })) {
4083
4253
  findings.push({
4254
+ ...locate(script, containingCallOffset(script, match.index)),
4084
4255
  code: "gsap_repeat_floor_unclamped",
4085
4256
  severity: "warning",
4086
4257
  message: "GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, which GSAP interprets as an infinite repeat.",
@@ -4091,7 +4262,7 @@ ${right.raw}`)
4091
4262
  return findings;
4092
4263
  },
4093
4264
  // gsap_timeline_not_registered
4094
- ({ scripts, rawSource, options }) => {
4265
+ ({ scripts, rawSource, options, locate }) => {
4095
4266
  const findings = [];
4096
4267
  const canInheritFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
4097
4268
  for (const script of scripts) {
@@ -4100,6 +4271,7 @@ ${right.raw}`)
4100
4271
  const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
4101
4272
  if (hasRegistration || canInheritFromHost) continue;
4102
4273
  findings.push({
4274
+ ...locate(script, gsapCallOffset(script, "timeline")),
4103
4275
  code: "gsap_timeline_not_registered",
4104
4276
  severity: "error",
4105
4277
  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.",
@@ -5142,6 +5314,7 @@ var textureRules = [
5142
5314
  ];
5143
5315
 
5144
5316
  // src/rules/fonts.ts
5317
+ import postcss3 from "postcss";
5145
5318
  import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
5146
5319
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
5147
5320
  "serif",
@@ -5288,7 +5461,7 @@ var fontRules = [
5288
5461
  ];
5289
5462
  },
5290
5463
  // font_family_without_font_face
5291
- ({ styles, source, rawSource, options }) => {
5464
+ ({ styles, source, rawSource, options, locate }) => {
5292
5465
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
5293
5466
  const findings = [];
5294
5467
  const declared = extractFontFaceFamilies(styles);
@@ -5299,6 +5472,7 @@ var fontRules = [
5299
5472
  );
5300
5473
  if (undeclared.length === 0) return findings;
5301
5474
  findings.push({
5475
+ ...uniqueFontLocation(styles, undeclared, locate),
5302
5476
  code: "font_family_without_font_face",
5303
5477
  severity: "error",
5304
5478
  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.`,
@@ -5307,6 +5481,26 @@ var fontRules = [
5307
5481
  return findings;
5308
5482
  }
5309
5483
  ];
5484
+ function uniqueFontLocation(styles, families, locate) {
5485
+ const locations = [];
5486
+ for (const style of styles) {
5487
+ try {
5488
+ postcss3.parse(style.content).walkDecls(/^font-family$/i, (decl) => {
5489
+ let parent = decl.parent;
5490
+ while (parent) {
5491
+ if (parent.type === "atrule" && parent.name.toLowerCase() === "font-face") return;
5492
+ parent = parent.parent;
5493
+ }
5494
+ if (!decl.value.split(",").some((part) => families.includes(normalizeUsedFontName(part) ?? "")))
5495
+ return;
5496
+ locations.push(locate(style, decl.source?.start?.offset));
5497
+ });
5498
+ } catch {
5499
+ return {};
5500
+ }
5501
+ }
5502
+ return locations.length === 1 ? locations[0] : {};
5503
+ }
5310
5504
 
5311
5505
  // src/rules/slideshow.ts
5312
5506
  import {
@@ -5579,13 +5773,27 @@ function collectFindings(produced, seen, filePath, into) {
5579
5773
  const dedupeKey = dedupeKeyFor(finding);
5580
5774
  if (seen.has(dedupeKey)) continue;
5581
5775
  seen.add(dedupeKey);
5582
- into.push(filePath ? { ...finding, file: filePath } : finding);
5776
+ into.push(filePath ? { ...finding, file: finding.file ?? filePath } : finding);
5583
5777
  }
5584
5778
  }
5585
5779
  async function lintHyperframeHtml(html, options = {}) {
5586
5780
  const startedAt = performance.now();
5587
5781
  const ctx = buildLintContext(html, options);
5588
- const { findings, timings } = await runRules(ctx, options.filePath);
5782
+ const { findings: rawFindings, timings } = await runRules(ctx, options.filePath);
5783
+ const locate = createSourceLocator(html);
5784
+ const findings = rawFindings.map((finding) => {
5785
+ if (finding.line !== void 0 || finding.code === "invalid_inline_script_syntax" || finding.code === "non_deterministic_code" || [
5786
+ "gsap_infinite_repeat",
5787
+ "gsap_repeat_ceil_overshoot",
5788
+ "gsap_repeat_floor_unclamped"
5789
+ ].includes(finding.code))
5790
+ return finding;
5791
+ if (finding.snippet && options.externalStyles?.some(
5792
+ (style) => style.content.replace(/\s+/g, " ").includes(finding.snippet.replace(/\.\.\.$/, ""))
5793
+ ))
5794
+ return finding;
5795
+ return { ...finding, ...locate(finding) };
5796
+ });
5589
5797
  const errorCount = findings.filter((f) => f.severity === "error").length;
5590
5798
  const warningCount = findings.filter((f) => f.severity === "warning").length;
5591
5799
  const infoCount = findings.filter((f) => f.severity === "info").length;