@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 +4 -0
- package/dist/browser.js +260 -52
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +266 -54
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
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
|
-
|
|
210
|
+
parse(source, {
|
|
211
|
+
ecmaVersion: "latest",
|
|
212
|
+
sourceType: "script",
|
|
213
|
+
allowReturnOutsideFunction: true
|
|
214
|
+
});
|
|
206
215
|
return null;
|
|
207
216
|
} catch (error) {
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
561
|
-
if (!
|
|
562
|
-
if (
|
|
563
|
-
return `${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1370
|
-
if (!
|
|
1371
|
-
if (mediaIndex.hasVideo && /\bvideo\b/i.test(
|
|
1372
|
-
if (mediaIndex.hasAudio && /\baudio\b/i.test(
|
|
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(
|
|
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(
|
|
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
|
|
2120
|
-
const withoutFinalNewline =
|
|
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
|
|
2134
|
-
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(
|
|
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
|
|
3085
|
-
return !
|
|
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
|
|
3213
|
-
if (
|
|
3214
|
-
if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(
|
|
3215
|
-
if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(
|
|
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
|
|
3291
|
-
if (
|
|
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
|
|
3598
|
-
if (!
|
|
3599
|
-
return
|
|
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(
|
|
5970
|
-
|
|
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
|
}
|