@hyperframes/lint 0.8.4 → 0.8.6
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 +12 -0
- package/dist/browser.js +88 -349
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +100 -351
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/browser.js
CHANGED
|
@@ -301,15 +301,6 @@ function buildLintContext(html, options = {}) {
|
|
|
301
301
|
// src/rules/core.ts
|
|
302
302
|
import postcss from "postcss";
|
|
303
303
|
import selectorParser from "postcss-selector-parser";
|
|
304
|
-
function escapeRegExp(value) {
|
|
305
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
306
|
-
}
|
|
307
|
-
function selectorTargetsCompositionId(selector, compositionId) {
|
|
308
|
-
const escaped = escapeRegExp(compositionId);
|
|
309
|
-
return new RegExp(
|
|
310
|
-
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`
|
|
311
|
-
).test(selector);
|
|
312
|
-
}
|
|
313
304
|
function repeatedDescendantId(selector) {
|
|
314
305
|
let repeated = null;
|
|
315
306
|
const requiredPseudoIds = (pseudo) => {
|
|
@@ -401,54 +392,8 @@ function describeStudioElement(tag) {
|
|
|
401
392
|
parts.push(">");
|
|
402
393
|
return parts.join("");
|
|
403
394
|
}
|
|
404
|
-
var HEAD_BLOCKS_TO_IGNORE_PATTERN = /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
|
|
405
|
-
var HTML_TAG_PATTERN = /<[^>]+>/g;
|
|
406
|
-
var HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
|
407
|
-
var AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
|
408
|
-
var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
|
409
|
-
var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
|
410
|
-
var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
|
411
|
-
var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
|
412
395
|
var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
|
|
413
396
|
var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
|
|
414
|
-
function findCodeFenceLeak(headWithoutValidBlocks) {
|
|
415
|
-
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
416
|
-
}
|
|
417
|
-
function findOrphanCssLeak(headContent) {
|
|
418
|
-
const residualText = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ").replace(HTML_TAG_PATTERN, " ");
|
|
419
|
-
return ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? null;
|
|
420
|
-
}
|
|
421
|
-
function findStrayCloseLeak(headWithoutValidBlocks) {
|
|
422
|
-
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
423
|
-
}
|
|
424
|
-
function findLeakedTextInHeadContent(headContent) {
|
|
425
|
-
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
|
|
426
|
-
return findCodeFenceLeak(withoutValidBlocks) ?? findOrphanCssLeak(headContent) ?? findStrayCloseLeak(withoutValidBlocks);
|
|
427
|
-
}
|
|
428
|
-
function findLeakedTextInHead(rawSource) {
|
|
429
|
-
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
|
|
430
|
-
for (const match of headMatches) {
|
|
431
|
-
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
432
|
-
if (leakedText) return leakedText;
|
|
433
|
-
}
|
|
434
|
-
return null;
|
|
435
|
-
}
|
|
436
|
-
function findLeakedTextBetweenHeadAndBody(rawSource) {
|
|
437
|
-
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
|
438
|
-
for (const match of boundaryMatches) {
|
|
439
|
-
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
|
440
|
-
if (leakedText) return leakedText;
|
|
441
|
-
}
|
|
442
|
-
return null;
|
|
443
|
-
}
|
|
444
|
-
function findLeakedTextBeforeCompositionRoot(source, rootTag) {
|
|
445
|
-
if (!rootTag || rootTag.name === "body") return null;
|
|
446
|
-
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
|
447
|
-
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
|
448
|
-
const prefixEnd = rootTag.index;
|
|
449
|
-
if (prefixEnd <= prefixStart) return null;
|
|
450
|
-
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
|
451
|
-
}
|
|
452
397
|
function findProtectedVisibleMarkupRanges(source) {
|
|
453
398
|
const ranges = [];
|
|
454
399
|
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
|
|
@@ -532,20 +477,6 @@ var coreRules = [
|
|
|
532
477
|
}
|
|
533
478
|
return findings;
|
|
534
479
|
},
|
|
535
|
-
// head_leaked_text
|
|
536
|
-
({ source, rootTag }) => {
|
|
537
|
-
const snippet = findLeakedTextInHead(source) ?? findLeakedTextBetweenHeadAndBody(source) ?? findLeakedTextBeforeCompositionRoot(source, rootTag);
|
|
538
|
-
if (!snippet) return [];
|
|
539
|
-
return [
|
|
540
|
-
{
|
|
541
|
-
code: "head_leaked_text",
|
|
542
|
-
severity: "error",
|
|
543
|
-
message: "Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
|
544
|
-
fixHint: "Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
|
545
|
-
snippet: truncateSnippet(snippet)
|
|
546
|
-
}
|
|
547
|
-
];
|
|
548
|
-
},
|
|
549
480
|
// visible_markup_comment
|
|
550
481
|
({ source }) => {
|
|
551
482
|
const snippet = findVisibleMarkupCommentLeak(source);
|
|
@@ -705,36 +636,6 @@ var coreRules = [
|
|
|
705
636
|
}
|
|
706
637
|
return findings;
|
|
707
638
|
},
|
|
708
|
-
// composition_self_attribute_selector
|
|
709
|
-
({ styles, rootCompositionId, rootTag }) => {
|
|
710
|
-
const findings = [];
|
|
711
|
-
if (!rootCompositionId) return findings;
|
|
712
|
-
const seenSelectors = /* @__PURE__ */ new Set();
|
|
713
|
-
const rootId = readAttr(rootTag?.raw || "", "id");
|
|
714
|
-
for (const style of styles) {
|
|
715
|
-
let root;
|
|
716
|
-
try {
|
|
717
|
-
root = postcss.parse(style.content);
|
|
718
|
-
} catch {
|
|
719
|
-
continue;
|
|
720
|
-
}
|
|
721
|
-
root.walkRules((rule) => {
|
|
722
|
-
for (const selector of rule.selectors) {
|
|
723
|
-
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
|
|
724
|
-
if (seenSelectors.has(selector)) continue;
|
|
725
|
-
seenSelectors.add(selector);
|
|
726
|
-
findings.push({
|
|
727
|
-
code: "composition_self_attribute_selector",
|
|
728
|
-
severity: "warning",
|
|
729
|
-
message: "Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
|
|
730
|
-
selector,
|
|
731
|
-
fixHint: rootId ? `Use #${rootId} for clearer authoring intent and instance-isolated styling.` : "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling."
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
return findings;
|
|
737
|
-
},
|
|
738
639
|
// studio_missing_editable_id
|
|
739
640
|
({ tags, rootTag }) => {
|
|
740
641
|
const findings = [];
|
|
@@ -810,60 +711,13 @@ var coreRules = [
|
|
|
810
711
|
}
|
|
811
712
|
}
|
|
812
713
|
return findings;
|
|
813
|
-
},
|
|
814
|
-
// pointer_events_none
|
|
815
|
-
// fallow-ignore-next-line complexity
|
|
816
|
-
({ tags, styles }) => {
|
|
817
|
-
const findings = [];
|
|
818
|
-
const reported = /* @__PURE__ */ new Set();
|
|
819
|
-
for (const tag of tags) {
|
|
820
|
-
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
|
|
821
|
-
const inlineStyle = readAttr(tag.raw, "style") ?? "";
|
|
822
|
-
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
|
|
823
|
-
const id = readAttr(tag.raw, "id");
|
|
824
|
-
const key = id ?? tag.raw;
|
|
825
|
-
if (reported.has(key)) continue;
|
|
826
|
-
reported.add(key);
|
|
827
|
-
findings.push({
|
|
828
|
-
code: "pointer_events_none",
|
|
829
|
-
severity: "info",
|
|
830
|
-
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
|
|
831
|
-
elementId: id || void 0,
|
|
832
|
-
fixHint: "If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
|
833
|
-
snippet: truncateSnippet(tag.raw)
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
for (const style of styles) {
|
|
837
|
-
let root;
|
|
838
|
-
try {
|
|
839
|
-
root = postcss.parse(style.content);
|
|
840
|
-
} catch {
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
root.walkDecls("pointer-events", (decl) => {
|
|
844
|
-
if (decl.value.trim().toLowerCase() !== "none") return;
|
|
845
|
-
const rule = decl.parent;
|
|
846
|
-
if (!rule || rule.type !== "rule") return;
|
|
847
|
-
const selector = rule.selector;
|
|
848
|
-
if (reported.has(selector)) return;
|
|
849
|
-
reported.add(selector);
|
|
850
|
-
findings.push({
|
|
851
|
-
code: "pointer_events_none",
|
|
852
|
-
severity: "info",
|
|
853
|
-
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
|
|
854
|
-
selector,
|
|
855
|
-
fixHint: "If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content."
|
|
856
|
-
});
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
return findings;
|
|
860
714
|
}
|
|
861
715
|
];
|
|
862
716
|
|
|
863
717
|
// src/rules/media.ts
|
|
864
718
|
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
|
|
865
719
|
function tweensVolumeInSameCall(script, id) {
|
|
866
|
-
const selector = new RegExp(`#${
|
|
720
|
+
const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
|
|
867
721
|
for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
|
|
868
722
|
let depth = 0;
|
|
869
723
|
const limit = Math.min(script.length, hit.index + 2e3);
|
|
@@ -879,11 +733,11 @@ function tweensVolumeInSameCall(script, id) {
|
|
|
879
733
|
}
|
|
880
734
|
return false;
|
|
881
735
|
}
|
|
882
|
-
function
|
|
736
|
+
function escapeRegExp(value) {
|
|
883
737
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
884
738
|
}
|
|
885
739
|
function hasAttrName(tagSource, attr) {
|
|
886
|
-
const escaped =
|
|
740
|
+
const escaped = escapeRegExp(attr);
|
|
887
741
|
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
|
|
888
742
|
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
|
|
889
743
|
}
|
|
@@ -897,13 +751,13 @@ function selectorTargetsManagedMedia(selector, mediaIndex) {
|
|
|
897
751
|
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
|
|
898
752
|
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
|
|
899
753
|
for (const mediaId of mediaIndex.ids) {
|
|
900
|
-
const escapedId =
|
|
754
|
+
const escapedId = escapeRegExp(mediaId);
|
|
901
755
|
if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
|
|
902
756
|
return true;
|
|
903
757
|
}
|
|
904
758
|
}
|
|
905
759
|
for (const className of mediaIndex.classes) {
|
|
906
|
-
if (new RegExp(`\\.${
|
|
760
|
+
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
|
|
907
761
|
return true;
|
|
908
762
|
}
|
|
909
763
|
}
|
|
@@ -1006,7 +860,7 @@ function findImperativeMediaControlFindings(ctx) {
|
|
|
1006
860
|
}
|
|
1007
861
|
}
|
|
1008
862
|
for (const [variableName, elementId] of mediaVars) {
|
|
1009
|
-
const escapedVar =
|
|
863
|
+
const escapedVar = escapeRegExp(variableName);
|
|
1010
864
|
const variablePatterns = [
|
|
1011
865
|
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
|
|
1012
866
|
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
|
|
@@ -1460,17 +1314,6 @@ function targetHasNoStableIdentity(selector, identity) {
|
|
|
1460
1314
|
if (identity) return false;
|
|
1461
1315
|
return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
|
|
1462
1316
|
}
|
|
1463
|
-
function countClassUsage(tags) {
|
|
1464
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1465
|
-
for (const tag of tags) {
|
|
1466
|
-
const classAttr = readAttr(tag.raw, "class");
|
|
1467
|
-
if (!classAttr) continue;
|
|
1468
|
-
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
|
1469
|
-
counts.set(className, (counts.get(className) || 0) + 1);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
return counts;
|
|
1473
|
-
}
|
|
1474
1317
|
function readRegisteredTimelineCompositionId(script) {
|
|
1475
1318
|
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
1476
1319
|
return match?.[1] || match?.[2] || null;
|
|
@@ -1686,16 +1529,6 @@ function findMatchingSceneBoundary(time, boundaries) {
|
|
|
1686
1529
|
}
|
|
1687
1530
|
return null;
|
|
1688
1531
|
}
|
|
1689
|
-
function isSuspiciousGlobalSelector(selector) {
|
|
1690
|
-
if (!selector) return false;
|
|
1691
|
-
if (selector.includes("[data-composition-id=")) return false;
|
|
1692
|
-
if (selector.startsWith("#")) return false;
|
|
1693
|
-
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
|
1694
|
-
}
|
|
1695
|
-
function getSingleClassSelector(selector) {
|
|
1696
|
-
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
|
1697
|
-
return match?.groups?.name || null;
|
|
1698
|
-
}
|
|
1699
1532
|
function readStyleProperty(style, property) {
|
|
1700
1533
|
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1701
1534
|
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
|
@@ -1939,7 +1772,7 @@ function isInsideGsapTweenVars(source, index, timelineVars) {
|
|
|
1939
1772
|
else if (ch === "{") {
|
|
1940
1773
|
if (depth === 0) {
|
|
1941
1774
|
const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
|
|
1942
|
-
const receivers = ["gsap", ...timelineVars].map(
|
|
1775
|
+
const receivers = ["gsap", ...timelineVars].map(escapeRegExp2).join("|");
|
|
1943
1776
|
return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
|
|
1944
1777
|
before
|
|
1945
1778
|
);
|
|
@@ -1975,7 +1808,7 @@ function parseFunctionValueSource(code) {
|
|
|
1975
1808
|
const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
|
|
1976
1809
|
return { firstParam, body: src.slice(match[0].length) };
|
|
1977
1810
|
}
|
|
1978
|
-
function
|
|
1811
|
+
function escapeRegExp2(value) {
|
|
1979
1812
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1980
1813
|
}
|
|
1981
1814
|
var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -1989,7 +1822,7 @@ var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
|
1989
1822
|
function firstParamMemberAccessHazard(fn) {
|
|
1990
1823
|
if (!fn.firstParam) return null;
|
|
1991
1824
|
const pattern = new RegExp(
|
|
1992
|
-
`\\b${
|
|
1825
|
+
`\\b${escapeRegExp2(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
|
|
1993
1826
|
"g"
|
|
1994
1827
|
);
|
|
1995
1828
|
let match;
|
|
@@ -2033,7 +1866,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2033
1866
|
for (const [name, body] of bodies) {
|
|
2034
1867
|
if (measuring.has(name)) continue;
|
|
2035
1868
|
for (const measured of measuring) {
|
|
2036
|
-
if (new RegExp(`\\b${
|
|
1869
|
+
if (new RegExp(`\\b${escapeRegExp2(measured)}\\s*\\(`).test(body)) {
|
|
2037
1870
|
measuring.add(name);
|
|
2038
1871
|
grew = true;
|
|
2039
1872
|
break;
|
|
@@ -2047,7 +1880,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2047
1880
|
function expressionReachesMeasurement(expression, measuring) {
|
|
2048
1881
|
if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
|
|
2049
1882
|
for (const name of measuring) {
|
|
2050
|
-
if (new RegExp(`\\b${
|
|
1883
|
+
if (new RegExp(`\\b${escapeRegExp2(name)}\\b`).test(expression)) return true;
|
|
2051
1884
|
}
|
|
2052
1885
|
return false;
|
|
2053
1886
|
}
|
|
@@ -2070,7 +1903,7 @@ function resolveScriptElementTokens(source, tags) {
|
|
|
2070
1903
|
const template = match[2] ?? "";
|
|
2071
1904
|
const staticParts = template.split(/\$\{[^}]*\}/);
|
|
2072
1905
|
if (staticParts.every((part) => part === "")) continue;
|
|
2073
|
-
const idPattern = new RegExp(`^${staticParts.map(
|
|
1906
|
+
const idPattern = new RegExp(`^${staticParts.map(escapeRegExp2).join(".*")}$`);
|
|
2074
1907
|
for (const id of documentIds) {
|
|
2075
1908
|
if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
|
|
2076
1909
|
}
|
|
@@ -2162,7 +1995,7 @@ function collectCssOpacityZeroSelectors(styles, tags) {
|
|
|
2162
1995
|
return selectors;
|
|
2163
1996
|
}
|
|
2164
1997
|
var gsapRules = [
|
|
2165
|
-
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
1998
|
+
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
2166
1999
|
// fallow-ignore-next-line complexity
|
|
2167
2000
|
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
|
2168
2001
|
const findings = [];
|
|
@@ -2182,7 +2015,6 @@ var gsapRules = [
|
|
|
2182
2015
|
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
|
2183
2016
|
}
|
|
2184
2017
|
}
|
|
2185
|
-
const classUsage = countClassUsage(tags);
|
|
2186
2018
|
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
|
2187
2019
|
const styleRules = collectSimpleStyleRules(styles);
|
|
2188
2020
|
const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
|
|
@@ -2305,20 +2137,6 @@ ${right.raw}`)
|
|
|
2305
2137
|
snippet: truncateSnippet(win.raw)
|
|
2306
2138
|
});
|
|
2307
2139
|
}
|
|
2308
|
-
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
|
|
2309
|
-
for (const win of gsapWindows) {
|
|
2310
|
-
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
|
|
2311
|
-
const className = getSingleClassSelector(win.targetSelector);
|
|
2312
|
-
if (className && (classUsage.get(className) || 0) < 2) continue;
|
|
2313
|
-
findings.push({
|
|
2314
|
-
code: "unscoped_gsap_selector",
|
|
2315
|
-
severity: "error",
|
|
2316
|
-
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
|
2317
|
-
selector: win.targetSelector,
|
|
2318
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
|
2319
|
-
snippet: truncateSnippet(win.raw)
|
|
2320
|
-
});
|
|
2321
|
-
}
|
|
2322
2140
|
}
|
|
2323
2141
|
return findings;
|
|
2324
2142
|
},
|
|
@@ -2575,39 +2393,6 @@ ${right.raw}`)
|
|
|
2575
2393
|
}
|
|
2576
2394
|
return findings;
|
|
2577
2395
|
},
|
|
2578
|
-
// scene_layer_missing_visibility_kill
|
|
2579
|
-
({ scripts, tags }) => {
|
|
2580
|
-
const findings = [];
|
|
2581
|
-
const sceneElements = tags.filter((t) => {
|
|
2582
|
-
const id = readAttr(t.raw, "id") || "";
|
|
2583
|
-
return /^scene\d+$/i.test(id);
|
|
2584
|
-
});
|
|
2585
|
-
if (sceneElements.length < 2) return findings;
|
|
2586
|
-
for (const script of scripts) {
|
|
2587
|
-
const content = stripJsComments(script.content);
|
|
2588
|
-
for (const tag of sceneElements) {
|
|
2589
|
-
const id = readAttr(tag.raw, "id") || "";
|
|
2590
|
-
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
|
|
2591
|
-
const hasExit = exitPattern.test(content);
|
|
2592
|
-
if (!hasExit) continue;
|
|
2593
|
-
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
|
|
2594
|
-
const hasKill = killPattern.test(content);
|
|
2595
|
-
if (!hasKill) {
|
|
2596
|
-
const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
2597
|
-
const isClip = classes.includes("clip");
|
|
2598
|
-
const fixHint = isClip ? `"#${id}" is a clip element \u2014 the framework already manages its visibility. Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill (\`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)\`) onto that wrapper instead.` : `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;
|
|
2599
|
-
findings.push({
|
|
2600
|
-
code: "scene_layer_missing_visibility_kill",
|
|
2601
|
-
severity: "error",
|
|
2602
|
-
elementId: id,
|
|
2603
|
-
message: `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.`,
|
|
2604
|
-
fixHint
|
|
2605
|
-
});
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
return findings;
|
|
2610
|
-
},
|
|
2611
2396
|
// gsap_timeline_not_registered
|
|
2612
2397
|
({ scripts, rawSource, options }) => {
|
|
2613
2398
|
const findings = [];
|
|
@@ -2970,7 +2755,7 @@ ${other.raw}`)
|
|
|
2970
2755
|
const timelineVars = collectTimelineVarNames(source);
|
|
2971
2756
|
for (const timelineVar of timelineVars) {
|
|
2972
2757
|
const callPattern = new RegExp(
|
|
2973
|
-
`\\b${
|
|
2758
|
+
`\\b${escapeRegExp2(timelineVar)}\\.(?:add|call)\\s*\\(`,
|
|
2974
2759
|
"g"
|
|
2975
2760
|
);
|
|
2976
2761
|
let match2;
|
|
@@ -2983,7 +2768,7 @@ ${other.raw}`)
|
|
|
2983
2768
|
if (callbackExpressionHazard(firstArg)) report(site, site);
|
|
2984
2769
|
}
|
|
2985
2770
|
const eventCallbackPattern = new RegExp(
|
|
2986
|
-
`\\b${
|
|
2771
|
+
`\\b${escapeRegExp2(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
|
|
2987
2772
|
"g"
|
|
2988
2773
|
);
|
|
2989
2774
|
while ((match2 = eventCallbackPattern.exec(source)) !== null) {
|
|
@@ -3225,31 +3010,6 @@ ${other.raw}`)
|
|
|
3225
3010
|
];
|
|
3226
3011
|
|
|
3227
3012
|
// src/rules/captions.ts
|
|
3228
|
-
function extractArrayLiteral(src, varMatch) {
|
|
3229
|
-
const openIdx = varMatch.index + varMatch[0].length - 1;
|
|
3230
|
-
let depth = 0;
|
|
3231
|
-
let inStr = false;
|
|
3232
|
-
let strChar = "";
|
|
3233
|
-
for (let i = openIdx; i < src.length; i++) {
|
|
3234
|
-
const c = src[i];
|
|
3235
|
-
if (inStr) {
|
|
3236
|
-
if (c === "\\") {
|
|
3237
|
-
i++;
|
|
3238
|
-
continue;
|
|
3239
|
-
}
|
|
3240
|
-
if (c === strChar) inStr = false;
|
|
3241
|
-
} else if (c === '"' || c === "'") {
|
|
3242
|
-
inStr = true;
|
|
3243
|
-
strChar = c;
|
|
3244
|
-
} else if (c === "[") {
|
|
3245
|
-
depth++;
|
|
3246
|
-
} else if (c === "]") {
|
|
3247
|
-
depth--;
|
|
3248
|
-
if (depth === 0) return src.slice(openIdx, i + 1);
|
|
3249
|
-
}
|
|
3250
|
-
}
|
|
3251
|
-
return null;
|
|
3252
|
-
}
|
|
3253
3013
|
var captionRules = [
|
|
3254
3014
|
// caption_exit_missing_hard_kill
|
|
3255
3015
|
({ scripts, styles, options, rootCompositionId }) => {
|
|
@@ -3317,22 +3077,6 @@ var captionRules = [
|
|
|
3317
3077
|
fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
|
|
3318
3078
|
});
|
|
3319
3079
|
}
|
|
3320
|
-
if (hasInlineTranscript) {
|
|
3321
|
-
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
|
3322
|
-
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
|
3323
|
-
if (transcriptJson) {
|
|
3324
|
-
try {
|
|
3325
|
-
JSON.parse(transcriptJson);
|
|
3326
|
-
} catch {
|
|
3327
|
-
findings.push({
|
|
3328
|
-
code: "caption_transcript_parse_error",
|
|
3329
|
-
severity: "error",
|
|
3330
|
-
message: "Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail to parse it. Common cause: unquoted property keys with apostrophes in text.",
|
|
3331
|
-
fixHint: `Use JSON-quoted keys: { "text": "don't", "start": 0, "end": 1 } instead of { text: "don't", start: 0, end: 1 }.`
|
|
3332
|
-
});
|
|
3333
|
-
}
|
|
3334
|
-
}
|
|
3335
|
-
}
|
|
3336
3080
|
return findings;
|
|
3337
3081
|
},
|
|
3338
3082
|
// caption_container_relative_position
|
|
@@ -3717,33 +3461,6 @@ var compositionRules = [
|
|
|
3717
3461
|
}
|
|
3718
3462
|
return findings;
|
|
3719
3463
|
},
|
|
3720
|
-
// timed_element_missing_visibility_hidden
|
|
3721
|
-
// fallow-ignore-next-line complexity
|
|
3722
|
-
({ tags }) => {
|
|
3723
|
-
const findings = [];
|
|
3724
|
-
for (const tag of tags) {
|
|
3725
|
-
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
|
3726
|
-
if (!readAttr(tag.raw, "data-start")) continue;
|
|
3727
|
-
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
3728
|
-
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
3729
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
3730
|
-
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
3731
|
-
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
3732
|
-
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
|
3733
|
-
if (!hasClip && !hasHiddenStyle) {
|
|
3734
|
-
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
3735
|
-
findings.push({
|
|
3736
|
-
code: "timed_element_missing_visibility_hidden",
|
|
3737
|
-
severity: "info",
|
|
3738
|
-
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
|
|
3739
|
-
elementId,
|
|
3740
|
-
fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
|
3741
|
-
snippet: truncateSnippet(tag.raw)
|
|
3742
|
-
});
|
|
3743
|
-
}
|
|
3744
|
-
}
|
|
3745
|
-
return findings;
|
|
3746
|
-
},
|
|
3747
3464
|
// deprecated_data_layer + deprecated_data_end
|
|
3748
3465
|
// fallow-ignore-next-line complexity
|
|
3749
3466
|
({ tags }) => {
|
|
@@ -3824,7 +3541,7 @@ var compositionRules = [
|
|
|
3824
3541
|
// fallow-ignore-next-line complexity
|
|
3825
3542
|
({ tags }) => {
|
|
3826
3543
|
const findings = [];
|
|
3827
|
-
const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
|
|
3544
|
+
const skipTags = /* @__PURE__ */ new Set(["audio", "img", "video", "script", "style", "template"]);
|
|
3828
3545
|
for (const tag of tags) {
|
|
3829
3546
|
if (skipTags.has(tag.name)) continue;
|
|
3830
3547
|
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
@@ -3838,10 +3555,17 @@ var compositionRules = [
|
|
|
3838
3555
|
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
3839
3556
|
findings.push({
|
|
3840
3557
|
code: "timed_element_missing_clip_class",
|
|
3841
|
-
|
|
3842
|
-
|
|
3558
|
+
// Not an error: the runtime drives timed visibility off the `data-start`
|
|
3559
|
+
// ATTRIBUTE, not this class — `syncTimedElementVisibility` walks
|
|
3560
|
+
// `querySelectorAll("[data-start]")` and toggles `style.visibility`
|
|
3561
|
+
// regardless of class (pinned by the runtime's own init test, which
|
|
3562
|
+
// uses a bare `<div data-start data-duration>` with no `class="clip"`).
|
|
3563
|
+
// The class is an authoring convention the tooling reads, so a missing
|
|
3564
|
+
// one is worth flagging but does not break the render.
|
|
3565
|
+
severity: "warning",
|
|
3566
|
+
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The runtime still hides it outside its time range, but Studio and the GSAP clip-ownership rules use .clip to recognise a clip, so leaving it off makes the element harder to edit and to lint.`,
|
|
3843
3567
|
elementId,
|
|
3844
|
-
fixHint: 'Add class="clip" to the element
|
|
3568
|
+
fixHint: 'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
|
|
3845
3569
|
snippet: truncateSnippet(tag.raw)
|
|
3846
3570
|
});
|
|
3847
3571
|
}
|
|
@@ -4700,36 +4424,22 @@ function collectGoogleFontFamilies(source, styles) {
|
|
|
4700
4424
|
return families;
|
|
4701
4425
|
}
|
|
4702
4426
|
var fontRules = [
|
|
4703
|
-
//
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
const googleFontsInImport = styles.some(
|
|
4709
|
-
(s) => /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content)
|
|
4710
|
-
);
|
|
4711
|
-
if (googleFontsInLink || googleFontsInImport) {
|
|
4712
|
-
findings.push({
|
|
4713
|
-
code: "google_fonts_import",
|
|
4714
|
-
severity: "warning",
|
|
4715
|
-
message: "Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts during compile/render, but raw external font requests add latency and can fail before canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
|
|
4716
|
-
fixHint: "For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }."
|
|
4717
|
-
});
|
|
4718
|
-
}
|
|
4719
|
-
return findings;
|
|
4720
|
-
},
|
|
4721
|
-
// system_font_will_alias — inform when a font will be silently substituted
|
|
4427
|
+
// system_font_will_alias — only for distributed / Lambda renders, where
|
|
4428
|
+
// system-font capture is disabled and the alias substitution does NOT happen,
|
|
4429
|
+
// so the font silently falls back to whatever the OS provides. Under a local
|
|
4430
|
+
// render the substitution is the renderer working as designed, not a defect,
|
|
4431
|
+
// so there is nothing for the author to act on.
|
|
4722
4432
|
({ styles, options }) => {
|
|
4433
|
+
if (!options.distributed) return [];
|
|
4723
4434
|
const declared = extractFontFaceFamilies(styles);
|
|
4724
4435
|
const used = extractUsedFontFamilies(styles);
|
|
4725
4436
|
const aliased = collectAliasedFonts(used, declared);
|
|
4726
4437
|
if (aliased.length === 0) return [];
|
|
4727
|
-
const severity = options.distributed ? "warning" : "info";
|
|
4728
4438
|
return [
|
|
4729
4439
|
{
|
|
4730
4440
|
code: "system_font_will_alias",
|
|
4731
|
-
severity,
|
|
4732
|
-
message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}.
|
|
4441
|
+
severity: "warning",
|
|
4442
|
+
message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. In distributed/Lambda rendering system-font capture is disabled \u2014 these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead.`
|
|
4733
4443
|
}
|
|
4734
4444
|
];
|
|
4735
4445
|
},
|
|
@@ -4825,35 +4535,63 @@ var slideshowRules = [
|
|
|
4825
4535
|
];
|
|
4826
4536
|
|
|
4827
4537
|
// src/hyperframeLinter.ts
|
|
4828
|
-
var
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4538
|
+
var RULE_GROUPS = [
|
|
4539
|
+
{ group: "core", rules: coreRules },
|
|
4540
|
+
{ group: "media", rules: mediaRules },
|
|
4541
|
+
{ group: "gsap", rules: gsapRules },
|
|
4542
|
+
{ group: "captions", rules: captionRules },
|
|
4543
|
+
{ group: "composition", rules: compositionRules },
|
|
4544
|
+
{ group: "adapters", rules: adapterRules },
|
|
4545
|
+
{ group: "textures", rules: textureRules },
|
|
4546
|
+
{ group: "fonts", rules: fontRules },
|
|
4547
|
+
{ group: "slideshow", rules: slideshowRules }
|
|
4838
4548
|
];
|
|
4839
|
-
|
|
4840
|
-
|
|
4549
|
+
var LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
|
|
4550
|
+
var LINT_RULE_GROUP_COUNTS = Object.fromEntries(
|
|
4551
|
+
RULE_GROUPS.map(({ group, rules }) => [group, rules.length])
|
|
4552
|
+
);
|
|
4553
|
+
function dedupeKeyFor(finding) {
|
|
4554
|
+
return [
|
|
4555
|
+
finding.code,
|
|
4556
|
+
finding.severity,
|
|
4557
|
+
finding.selector || "",
|
|
4558
|
+
finding.elementId || "",
|
|
4559
|
+
finding.message
|
|
4560
|
+
].join("|");
|
|
4561
|
+
}
|
|
4562
|
+
async function runRules(ctx, filePath) {
|
|
4841
4563
|
const findings = [];
|
|
4842
4564
|
const seen = /* @__PURE__ */ new Set();
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
]
|
|
4852
|
-
if (
|
|
4853
|
-
|
|
4854
|
-
|
|
4565
|
+
const groupMs = {};
|
|
4566
|
+
let slowestRule = "";
|
|
4567
|
+
let slowestRuleMs = 0;
|
|
4568
|
+
for (const { group, rules } of RULE_GROUPS) {
|
|
4569
|
+
for (let index = 0; index < rules.length; index++) {
|
|
4570
|
+
const ruleStartedAt = performance.now();
|
|
4571
|
+
const produced = await Promise.resolve(rules[index](ctx));
|
|
4572
|
+
const ruleMs = performance.now() - ruleStartedAt;
|
|
4573
|
+
groupMs[group] = (groupMs[group] ?? 0) + ruleMs;
|
|
4574
|
+
if (ruleMs > slowestRuleMs) {
|
|
4575
|
+
slowestRuleMs = ruleMs;
|
|
4576
|
+
slowestRule = `${group}#${index}`;
|
|
4577
|
+
}
|
|
4578
|
+
collectFindings(produced, seen, filePath, findings);
|
|
4855
4579
|
}
|
|
4856
4580
|
}
|
|
4581
|
+
return { findings, timings: { groupMs, slowestRule, slowestRuleMs } };
|
|
4582
|
+
}
|
|
4583
|
+
function collectFindings(produced, seen, filePath, into) {
|
|
4584
|
+
for (const finding of produced) {
|
|
4585
|
+
const dedupeKey = dedupeKeyFor(finding);
|
|
4586
|
+
if (seen.has(dedupeKey)) continue;
|
|
4587
|
+
seen.add(dedupeKey);
|
|
4588
|
+
into.push(filePath ? { ...finding, file: filePath } : finding);
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
async function lintHyperframeHtml(html, options = {}) {
|
|
4592
|
+
const startedAt = performance.now();
|
|
4593
|
+
const ctx = buildLintContext(html, options);
|
|
4594
|
+
const { findings, timings } = await runRules(ctx, options.filePath);
|
|
4857
4595
|
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
4858
4596
|
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
4859
4597
|
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
@@ -4862,7 +4600,8 @@ async function lintHyperframeHtml(html, options = {}) {
|
|
|
4862
4600
|
errorCount,
|
|
4863
4601
|
warningCount,
|
|
4864
4602
|
infoCount,
|
|
4865
|
-
findings
|
|
4603
|
+
findings,
|
|
4604
|
+
timings: { totalMs: performance.now() - startedAt, ...timings }
|
|
4866
4605
|
};
|
|
4867
4606
|
}
|
|
4868
4607
|
function extractMediaUrls(html) {
|