@hyperframes/lint 0.8.67 → 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
@@ -39,6 +39,7 @@ type HyperframeLinterOptions = {
39
39
  externalStyles?: Array<{
40
40
  href: string;
41
41
  content: string;
42
+ file?: string;
42
43
  }>;
43
44
  /**
44
45
  * Set to `true` when linting compositions destined for distributed / Lambda
package/dist/browser.js CHANGED
@@ -3,6 +3,7 @@ import { Parser as Parser2 } from "htmlparser2";
3
3
 
4
4
  // src/utils.ts
5
5
  import { Parser } from "htmlparser2";
6
+ import { parse } from "acorn";
6
7
  var COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
7
8
  var TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
8
9
  var TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
@@ -56,6 +57,7 @@ function parseHtmlStructure(source) {
56
57
  blocks[name].push({
57
58
  attrs: block.attrs,
58
59
  content: source.slice(block.contentStart, parser.startIndex),
60
+ contentStart: block.contentStart,
59
61
  raw: source.slice(block.index, parser.endIndex + 1),
60
62
  index: block.index
61
63
  });
@@ -205,11 +207,17 @@ function readTimelineRegistryTopLevelKeys(source) {
205
207
  function getInlineScriptSyntaxError(source) {
206
208
  if (!source.trim()) return null;
207
209
  try {
208
- new Function(source);
210
+ parse(source, {
211
+ ecmaVersion: "latest",
212
+ sourceType: "script",
213
+ allowReturnOutsideFunction: true
214
+ });
209
215
  return null;
210
216
  } catch (error) {
211
- if (error instanceof Error) return error.message;
212
- 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
+ };
213
221
  }
214
222
  }
215
223
  function stripStringLiterals(source) {
@@ -528,7 +536,7 @@ function stripCssComments(source) {
528
536
  }
529
537
  return out;
530
538
  }
531
- function stripHtmlCommentsOnce(source) {
539
+ function stripHtmlCommentsOnce(source, removed) {
532
540
  let out = "";
533
541
  let i = 0;
534
542
  for (; ; ) {
@@ -537,14 +545,17 @@ function stripHtmlCommentsOnce(source) {
537
545
  const end = source.indexOf("-->", start + 4);
538
546
  if (end < 0) return out + source.slice(i);
539
547
  out += source.slice(i, start);
548
+ removed?.(start, end + 3);
540
549
  i = end + 3;
541
550
  }
542
551
  }
543
- function stripHtmlComments(source) {
552
+ function stripHtmlComments(source, pass) {
544
553
  let out = source;
545
554
  for (let prev = ""; prev !== out; ) {
546
555
  prev = out;
547
- 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);
548
559
  }
549
560
  return out;
550
561
  }
@@ -618,10 +629,42 @@ function snippetOffset(source, snippet) {
618
629
  return source.offsets[index];
619
630
  }
620
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
+ };
660
+ }
661
+
621
662
  // src/context.ts
622
663
  function buildLintContext(html, options = {}) {
623
664
  const rawSource = html || "";
624
- let source = stripHtmlComments(rawSource);
665
+ const mapped = mappedHtmlSource(rawSource);
666
+ let source = mapped.source;
667
+ let sourceStart = 0;
625
668
  const initialStructure = parseHtmlStructure(source);
626
669
  const templateTags = initialStructure.tags.filter(
627
670
  (tag) => tag.name === "template" && tag.closeIndex != null
@@ -634,7 +677,8 @@ function buildLintContext(html, options = {}) {
634
677
  const template = templateTags[0];
635
678
  let structure = initialStructure;
636
679
  if (template && !findRootTag(sourceWithoutTemplates)) {
637
- source = source.slice(template.index + template.raw.length, template.closeIndex);
680
+ sourceStart = template.index + template.raw.length;
681
+ source = source.slice(sourceStart, template.closeIndex);
638
682
  structure = parseHtmlStructure(source);
639
683
  }
640
684
  const tags = structure.tags;
@@ -644,14 +688,23 @@ function buildLintContext(html, options = {}) {
644
688
  attrs: `href="${style.href}"`,
645
689
  content: style.content,
646
690
  raw: style.content,
647
- index: -1
691
+ index: -1,
692
+ file: style.file ?? style.href
648
693
  }))
649
694
  ];
650
695
  const scripts = structure.scripts;
651
696
  const compositionIds = collectCompositionIds(tags);
652
697
  const rootTag = findRootTag(source, tags);
653
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
+ };
654
706
  return {
707
+ locate,
655
708
  source,
656
709
  rawSource,
657
710
  tags,
@@ -671,7 +724,7 @@ import {
671
724
  HTML_BODY_CSS_WIDTH_FIRST_RE,
672
725
  HTML_BODY_CSS_HEIGHT_FIRST_RE,
673
726
  VIEWPORT_META_SIZE_RE
674
- } from "@hyperframes/parsers";
727
+ } from "@hyperframes/parsers/composition";
675
728
  function repeatedDescendantId(selector) {
676
729
  let repeated = null;
677
730
  const requiredPseudoIds = (pseudo) => {
@@ -1049,7 +1102,7 @@ var coreRules = [
1049
1102
  return findings;
1050
1103
  },
1051
1104
  // CSS selector safety
1052
- ({ styles }) => {
1105
+ ({ styles, locate }) => {
1053
1106
  const findings = [];
1054
1107
  const reportedRepeatedIds = /* @__PURE__ */ new Set();
1055
1108
  const reportedHiddenStyleSelectors = /* @__PURE__ */ new Set();
@@ -1059,6 +1112,10 @@ var coreRules = [
1059
1112
  root = postcss.parse(style.content);
1060
1113
  } catch (error) {
1061
1114
  findings.push({
1115
+ ...locate(
1116
+ style,
1117
+ error instanceof postcss.CssSyntaxError ? cssErrorOffset(style.content, error) : void 0
1118
+ ),
1062
1119
  code: "css_parse_error",
1063
1120
  severity: "error",
1064
1121
  message: `CSS parse error: ${error instanceof Error ? error.message : "unknown"}`
@@ -1124,7 +1181,7 @@ var coreRules = [
1124
1181
  ];
1125
1182
  },
1126
1183
  // invalid_inline_script_syntax (JS parse error)
1127
- ({ scripts }) => {
1184
+ ({ scripts, locate }) => {
1128
1185
  const findings = [];
1129
1186
  for (const script of scripts) {
1130
1187
  const attrs = script.attrs || "";
@@ -1135,9 +1192,10 @@ var coreRules = [
1135
1192
  const syntaxError = getInlineScriptSyntaxError(script.content);
1136
1193
  if (!syntaxError) continue;
1137
1194
  findings.push({
1195
+ ...locate(script, syntaxError.offset),
1138
1196
  code: "invalid_inline_script_syntax",
1139
1197
  severity: "error",
1140
- message: `Inline script has invalid syntax: ${syntaxError}`,
1198
+ message: `Inline script has invalid syntax: ${syntaxError.message}`,
1141
1199
  fixHint: "Fix the inline script syntax before render verification.",
1142
1200
  snippet: truncateSnippet(script.content)
1143
1201
  });
@@ -1203,7 +1261,7 @@ var coreRules = [
1203
1261
  return findings;
1204
1262
  },
1205
1263
  // non_deterministic_code
1206
- ({ scripts }) => {
1264
+ ({ scripts, locate }) => {
1207
1265
  const findings = [];
1208
1266
  const patterns = [
1209
1267
  {
@@ -1254,8 +1312,10 @@ var coreRules = [
1254
1312
  const withoutComments = stripJsComments(script.content);
1255
1313
  const executable = stripStringLiterals(withoutComments);
1256
1314
  for (const { pattern, label, hint, scansStrings } of patterns) {
1257
- if (pattern.test(scansStrings ? withoutComments : executable)) {
1315
+ const match = pattern.exec(scansStrings ? withoutComments : executable);
1316
+ if (match) {
1258
1317
  findings.push({
1318
+ ...locate(script, match.index),
1259
1319
  code: "non_deterministic_code",
1260
1320
  severity: "error",
1261
1321
  message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
@@ -1268,10 +1328,24 @@ var coreRules = [
1268
1328
  return findings;
1269
1329
  }
1270
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
+ }
1271
1345
 
1272
1346
  // src/rules/media.ts
1273
1347
  import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
1274
- import { extractMediaSrcMutations } from "@hyperframes/parsers";
1348
+ import { extractMediaSrcMutations } from "@hyperframes/parsers/composition";
1275
1349
  import { parseHTML } from "linkedom";
1276
1350
  function tweensVolumeInSameCall(script, id) {
1277
1351
  const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
@@ -2130,6 +2204,45 @@ function findAudioGroupCarveAttrFindings(ctx) {
2130
2204
  return findings;
2131
2205
  }
2132
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
+
2133
2246
  // src/rules/composition.ts
2134
2247
  import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
2135
2248
  import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
@@ -3339,8 +3452,8 @@ function targetedSelectorTokens(selector) {
3339
3452
  const compounds = group.trim().split(/[\s>+~]+/).filter(Boolean);
3340
3453
  const last = compounds[compounds.length - 1];
3341
3454
  if (!last) continue;
3342
- const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
3343
- 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);
3344
3457
  }
3345
3458
  return tokens;
3346
3459
  }
@@ -3379,7 +3492,7 @@ function scanScriptsForRegexMatches(scripts, pattern, options) {
3379
3492
  content.length,
3380
3493
  match.index + match[0].length + options.contextAfter
3381
3494
  );
3382
- hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
3495
+ hits.push({ match, snippet: content.slice(contextStart, contextEnd), script });
3383
3496
  }
3384
3497
  }
3385
3498
  return hits;
@@ -4085,19 +4198,20 @@ ${right.raw}`)
4085
4198
  return findings;
4086
4199
  },
4087
4200
  // gsap_infinite_repeat
4088
- ({ scripts, rootTag }) => {
4201
+ ({ scripts, rootTag, locate }) => {
4089
4202
  const findings = [];
4090
4203
  const declaredDuration = Number.parseFloat(
4091
4204
  rootTag ? readAttr(rootTag.raw, "data-duration") ?? "" : ""
4092
4205
  );
4093
4206
  const hasFiniteCompositionWindow = Number.isFinite(declaredDuration) && declaredDuration > 0;
4094
4207
  const pattern = /repeat\s*:\s*-1(?!\d)/g;
4095
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4208
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4096
4209
  stripComments: true,
4097
4210
  contextBefore: 60,
4098
4211
  contextAfter: 60
4099
4212
  })) {
4100
4213
  findings.push({
4214
+ ...locate(script, containingCallOffset(script, match.index)),
4101
4215
  code: "gsap_infinite_repeat",
4102
4216
  severity: hasFiniteCompositionWindow ? "warning" : "error",
4103
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.",
@@ -4108,15 +4222,16 @@ ${right.raw}`)
4108
4222
  return findings;
4109
4223
  },
4110
4224
  // gsap_repeat_ceil_overshoot
4111
- ({ scripts }) => {
4225
+ ({ scripts, locate }) => {
4112
4226
  const findings = [];
4113
4227
  const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
4114
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4228
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4115
4229
  stripComments: false,
4116
4230
  contextBefore: 40,
4117
4231
  contextAfter: 40
4118
4232
  })) {
4119
4233
  findings.push({
4234
+ ...locate(script, containingCallOffset(script, match.index)),
4120
4235
  code: "gsap_repeat_ceil_overshoot",
4121
4236
  severity: "warning",
4122
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.",
@@ -4127,15 +4242,16 @@ ${right.raw}`)
4127
4242
  return findings;
4128
4243
  },
4129
4244
  // gsap_repeat_floor_unclamped
4130
- ({ scripts }) => {
4245
+ ({ scripts, locate }) => {
4131
4246
  const findings = [];
4132
4247
  const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
4133
- for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
4248
+ for (const { snippet, match, script } of scanScriptsForRegexMatches(scripts, pattern, {
4134
4249
  stripComments: false,
4135
4250
  contextBefore: 40,
4136
4251
  contextAfter: 40
4137
4252
  })) {
4138
4253
  findings.push({
4254
+ ...locate(script, containingCallOffset(script, match.index)),
4139
4255
  code: "gsap_repeat_floor_unclamped",
4140
4256
  severity: "warning",
4141
4257
  message: "GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, which GSAP interprets as an infinite repeat.",
@@ -4146,7 +4262,7 @@ ${right.raw}`)
4146
4262
  return findings;
4147
4263
  },
4148
4264
  // gsap_timeline_not_registered
4149
- ({ scripts, rawSource, options }) => {
4265
+ ({ scripts, rawSource, options, locate }) => {
4150
4266
  const findings = [];
4151
4267
  const canInheritFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
4152
4268
  for (const script of scripts) {
@@ -4155,6 +4271,7 @@ ${right.raw}`)
4155
4271
  const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) || TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
4156
4272
  if (hasRegistration || canInheritFromHost) continue;
4157
4273
  findings.push({
4274
+ ...locate(script, gsapCallOffset(script, "timeline")),
4158
4275
  code: "gsap_timeline_not_registered",
4159
4276
  severity: "error",
4160
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.",
@@ -5197,6 +5314,7 @@ var textureRules = [
5197
5314
  ];
5198
5315
 
5199
5316
  // src/rules/fonts.ts
5317
+ import postcss3 from "postcss";
5200
5318
  import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
5201
5319
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
5202
5320
  "serif",
@@ -5343,7 +5461,7 @@ var fontRules = [
5343
5461
  ];
5344
5462
  },
5345
5463
  // font_family_without_font_face
5346
- ({ styles, source, rawSource, options }) => {
5464
+ ({ styles, source, rawSource, options, locate }) => {
5347
5465
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
5348
5466
  const findings = [];
5349
5467
  const declared = extractFontFaceFamilies(styles);
@@ -5354,6 +5472,7 @@ var fontRules = [
5354
5472
  );
5355
5473
  if (undeclared.length === 0) return findings;
5356
5474
  findings.push({
5475
+ ...uniqueFontLocation(styles, undeclared, locate),
5357
5476
  code: "font_family_without_font_face",
5358
5477
  severity: "error",
5359
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.`,
@@ -5362,6 +5481,26 @@ var fontRules = [
5362
5481
  return findings;
5363
5482
  }
5364
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
+ }
5365
5504
 
5366
5505
  // src/rules/slideshow.ts
5367
5506
  import {
@@ -5634,7 +5773,7 @@ function collectFindings(produced, seen, filePath, into) {
5634
5773
  const dedupeKey = dedupeKeyFor(finding);
5635
5774
  if (seen.has(dedupeKey)) continue;
5636
5775
  seen.add(dedupeKey);
5637
- into.push(filePath ? { ...finding, file: filePath } : finding);
5776
+ into.push(filePath ? { ...finding, file: finding.file ?? filePath } : finding);
5638
5777
  }
5639
5778
  }
5640
5779
  async function lintHyperframeHtml(html, options = {}) {
@@ -5643,6 +5782,12 @@ async function lintHyperframeHtml(html, options = {}) {
5643
5782
  const { findings: rawFindings, timings } = await runRules(ctx, options.filePath);
5644
5783
  const locate = createSourceLocator(html);
5645
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;
5646
5791
  if (finding.snippet && options.externalStyles?.some(
5647
5792
  (style) => style.content.replace(/\s+/g, " ").includes(finding.snippet.replace(/\.\.\.$/, ""))
5648
5793
  ))