@hyperframes/lint 0.8.4 → 0.8.5
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 -289
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +100 -291
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/browser.d.ts
CHANGED
|
@@ -9,12 +9,24 @@ type HyperframeLintFinding = {
|
|
|
9
9
|
fixHint?: string;
|
|
10
10
|
snippet?: string;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Where a single lint pass spent its time. Attributed per rule-source module
|
|
14
|
+
* ("gsap", "core", ...) rather than per rule, plus the single slowest rule as
|
|
15
|
+
* `<group>#<index-within-group>` so a pathological rule is locatable.
|
|
16
|
+
*/
|
|
17
|
+
type LintTimings = {
|
|
18
|
+
totalMs: number;
|
|
19
|
+
groupMs: Record<string, number>;
|
|
20
|
+
slowestRule: string;
|
|
21
|
+
slowestRuleMs: number;
|
|
22
|
+
};
|
|
12
23
|
type HyperframeLintResult = {
|
|
13
24
|
ok: boolean;
|
|
14
25
|
errorCount: number;
|
|
15
26
|
warningCount: number;
|
|
16
27
|
infoCount: number;
|
|
17
28
|
findings: HyperframeLintFinding[];
|
|
29
|
+
timings?: LintTimings;
|
|
18
30
|
};
|
|
19
31
|
type HyperframeLinterOptions = {
|
|
20
32
|
filePath?: string;
|
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) => {
|
|
@@ -705,36 +696,6 @@ var coreRules = [
|
|
|
705
696
|
}
|
|
706
697
|
return findings;
|
|
707
698
|
},
|
|
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
699
|
// studio_missing_editable_id
|
|
739
700
|
({ tags, rootTag }) => {
|
|
740
701
|
const findings = [];
|
|
@@ -810,60 +771,13 @@ var coreRules = [
|
|
|
810
771
|
}
|
|
811
772
|
}
|
|
812
773
|
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
774
|
}
|
|
861
775
|
];
|
|
862
776
|
|
|
863
777
|
// src/rules/media.ts
|
|
864
778
|
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
|
|
865
779
|
function tweensVolumeInSameCall(script, id) {
|
|
866
|
-
const selector = new RegExp(`#${
|
|
780
|
+
const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
|
|
867
781
|
for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
|
|
868
782
|
let depth = 0;
|
|
869
783
|
const limit = Math.min(script.length, hit.index + 2e3);
|
|
@@ -879,11 +793,11 @@ function tweensVolumeInSameCall(script, id) {
|
|
|
879
793
|
}
|
|
880
794
|
return false;
|
|
881
795
|
}
|
|
882
|
-
function
|
|
796
|
+
function escapeRegExp(value) {
|
|
883
797
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
884
798
|
}
|
|
885
799
|
function hasAttrName(tagSource, attr) {
|
|
886
|
-
const escaped =
|
|
800
|
+
const escaped = escapeRegExp(attr);
|
|
887
801
|
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
|
|
888
802
|
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
|
|
889
803
|
}
|
|
@@ -897,13 +811,13 @@ function selectorTargetsManagedMedia(selector, mediaIndex) {
|
|
|
897
811
|
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
|
|
898
812
|
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
|
|
899
813
|
for (const mediaId of mediaIndex.ids) {
|
|
900
|
-
const escapedId =
|
|
814
|
+
const escapedId = escapeRegExp(mediaId);
|
|
901
815
|
if (new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) || normalized.includes(`[id="${mediaId}"]`) || normalized.includes(`[id='${mediaId}']`)) {
|
|
902
816
|
return true;
|
|
903
817
|
}
|
|
904
818
|
}
|
|
905
819
|
for (const className of mediaIndex.classes) {
|
|
906
|
-
if (new RegExp(`\\.${
|
|
820
|
+
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
|
|
907
821
|
return true;
|
|
908
822
|
}
|
|
909
823
|
}
|
|
@@ -1006,7 +920,7 @@ function findImperativeMediaControlFindings(ctx) {
|
|
|
1006
920
|
}
|
|
1007
921
|
}
|
|
1008
922
|
for (const [variableName, elementId] of mediaVars) {
|
|
1009
|
-
const escapedVar =
|
|
923
|
+
const escapedVar = escapeRegExp(variableName);
|
|
1010
924
|
const variablePatterns = [
|
|
1011
925
|
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
|
|
1012
926
|
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
|
|
@@ -1460,17 +1374,6 @@ function targetHasNoStableIdentity(selector, identity) {
|
|
|
1460
1374
|
if (identity) return false;
|
|
1461
1375
|
return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
|
|
1462
1376
|
}
|
|
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
1377
|
function readRegisteredTimelineCompositionId(script) {
|
|
1475
1378
|
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
1476
1379
|
return match?.[1] || match?.[2] || null;
|
|
@@ -1686,16 +1589,6 @@ function findMatchingSceneBoundary(time, boundaries) {
|
|
|
1686
1589
|
}
|
|
1687
1590
|
return null;
|
|
1688
1591
|
}
|
|
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
1592
|
function readStyleProperty(style, property) {
|
|
1700
1593
|
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1701
1594
|
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
|
@@ -1939,7 +1832,7 @@ function isInsideGsapTweenVars(source, index, timelineVars) {
|
|
|
1939
1832
|
else if (ch === "{") {
|
|
1940
1833
|
if (depth === 0) {
|
|
1941
1834
|
const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
|
|
1942
|
-
const receivers = ["gsap", ...timelineVars].map(
|
|
1835
|
+
const receivers = ["gsap", ...timelineVars].map(escapeRegExp2).join("|");
|
|
1943
1836
|
return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
|
|
1944
1837
|
before
|
|
1945
1838
|
);
|
|
@@ -1975,7 +1868,7 @@ function parseFunctionValueSource(code) {
|
|
|
1975
1868
|
const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
|
|
1976
1869
|
return { firstParam, body: src.slice(match[0].length) };
|
|
1977
1870
|
}
|
|
1978
|
-
function
|
|
1871
|
+
function escapeRegExp2(value) {
|
|
1979
1872
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1980
1873
|
}
|
|
1981
1874
|
var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -1989,7 +1882,7 @@ var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
|
1989
1882
|
function firstParamMemberAccessHazard(fn) {
|
|
1990
1883
|
if (!fn.firstParam) return null;
|
|
1991
1884
|
const pattern = new RegExp(
|
|
1992
|
-
`\\b${
|
|
1885
|
+
`\\b${escapeRegExp2(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
|
|
1993
1886
|
"g"
|
|
1994
1887
|
);
|
|
1995
1888
|
let match;
|
|
@@ -2033,7 +1926,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2033
1926
|
for (const [name, body] of bodies) {
|
|
2034
1927
|
if (measuring.has(name)) continue;
|
|
2035
1928
|
for (const measured of measuring) {
|
|
2036
|
-
if (new RegExp(`\\b${
|
|
1929
|
+
if (new RegExp(`\\b${escapeRegExp2(measured)}\\s*\\(`).test(body)) {
|
|
2037
1930
|
measuring.add(name);
|
|
2038
1931
|
grew = true;
|
|
2039
1932
|
break;
|
|
@@ -2047,7 +1940,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2047
1940
|
function expressionReachesMeasurement(expression, measuring) {
|
|
2048
1941
|
if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
|
|
2049
1942
|
for (const name of measuring) {
|
|
2050
|
-
if (new RegExp(`\\b${
|
|
1943
|
+
if (new RegExp(`\\b${escapeRegExp2(name)}\\b`).test(expression)) return true;
|
|
2051
1944
|
}
|
|
2052
1945
|
return false;
|
|
2053
1946
|
}
|
|
@@ -2070,7 +1963,7 @@ function resolveScriptElementTokens(source, tags) {
|
|
|
2070
1963
|
const template = match[2] ?? "";
|
|
2071
1964
|
const staticParts = template.split(/\$\{[^}]*\}/);
|
|
2072
1965
|
if (staticParts.every((part) => part === "")) continue;
|
|
2073
|
-
const idPattern = new RegExp(`^${staticParts.map(
|
|
1966
|
+
const idPattern = new RegExp(`^${staticParts.map(escapeRegExp2).join(".*")}$`);
|
|
2074
1967
|
for (const id of documentIds) {
|
|
2075
1968
|
if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
|
|
2076
1969
|
}
|
|
@@ -2162,7 +2055,7 @@ function collectCssOpacityZeroSelectors(styles, tags) {
|
|
|
2162
2055
|
return selectors;
|
|
2163
2056
|
}
|
|
2164
2057
|
var gsapRules = [
|
|
2165
|
-
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
2058
|
+
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
2166
2059
|
// fallow-ignore-next-line complexity
|
|
2167
2060
|
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
|
2168
2061
|
const findings = [];
|
|
@@ -2182,7 +2075,6 @@ var gsapRules = [
|
|
|
2182
2075
|
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
|
2183
2076
|
}
|
|
2184
2077
|
}
|
|
2185
|
-
const classUsage = countClassUsage(tags);
|
|
2186
2078
|
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
|
2187
2079
|
const styleRules = collectSimpleStyleRules(styles);
|
|
2188
2080
|
const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
|
|
@@ -2305,20 +2197,6 @@ ${right.raw}`)
|
|
|
2305
2197
|
snippet: truncateSnippet(win.raw)
|
|
2306
2198
|
});
|
|
2307
2199
|
}
|
|
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
2200
|
}
|
|
2323
2201
|
return findings;
|
|
2324
2202
|
},
|
|
@@ -2575,39 +2453,6 @@ ${right.raw}`)
|
|
|
2575
2453
|
}
|
|
2576
2454
|
return findings;
|
|
2577
2455
|
},
|
|
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
2456
|
// gsap_timeline_not_registered
|
|
2612
2457
|
({ scripts, rawSource, options }) => {
|
|
2613
2458
|
const findings = [];
|
|
@@ -2970,7 +2815,7 @@ ${other.raw}`)
|
|
|
2970
2815
|
const timelineVars = collectTimelineVarNames(source);
|
|
2971
2816
|
for (const timelineVar of timelineVars) {
|
|
2972
2817
|
const callPattern = new RegExp(
|
|
2973
|
-
`\\b${
|
|
2818
|
+
`\\b${escapeRegExp2(timelineVar)}\\.(?:add|call)\\s*\\(`,
|
|
2974
2819
|
"g"
|
|
2975
2820
|
);
|
|
2976
2821
|
let match2;
|
|
@@ -2983,7 +2828,7 @@ ${other.raw}`)
|
|
|
2983
2828
|
if (callbackExpressionHazard(firstArg)) report(site, site);
|
|
2984
2829
|
}
|
|
2985
2830
|
const eventCallbackPattern = new RegExp(
|
|
2986
|
-
`\\b${
|
|
2831
|
+
`\\b${escapeRegExp2(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
|
|
2987
2832
|
"g"
|
|
2988
2833
|
);
|
|
2989
2834
|
while ((match2 = eventCallbackPattern.exec(source)) !== null) {
|
|
@@ -3225,31 +3070,6 @@ ${other.raw}`)
|
|
|
3225
3070
|
];
|
|
3226
3071
|
|
|
3227
3072
|
// 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
3073
|
var captionRules = [
|
|
3254
3074
|
// caption_exit_missing_hard_kill
|
|
3255
3075
|
({ scripts, styles, options, rootCompositionId }) => {
|
|
@@ -3317,22 +3137,6 @@ var captionRules = [
|
|
|
3317
3137
|
fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
|
|
3318
3138
|
});
|
|
3319
3139
|
}
|
|
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
3140
|
return findings;
|
|
3337
3141
|
},
|
|
3338
3142
|
// caption_container_relative_position
|
|
@@ -3717,33 +3521,6 @@ var compositionRules = [
|
|
|
3717
3521
|
}
|
|
3718
3522
|
return findings;
|
|
3719
3523
|
},
|
|
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
3524
|
// deprecated_data_layer + deprecated_data_end
|
|
3748
3525
|
// fallow-ignore-next-line complexity
|
|
3749
3526
|
({ tags }) => {
|
|
@@ -3824,7 +3601,7 @@ var compositionRules = [
|
|
|
3824
3601
|
// fallow-ignore-next-line complexity
|
|
3825
3602
|
({ tags }) => {
|
|
3826
3603
|
const findings = [];
|
|
3827
|
-
const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
|
|
3604
|
+
const skipTags = /* @__PURE__ */ new Set(["audio", "img", "video", "script", "style", "template"]);
|
|
3828
3605
|
for (const tag of tags) {
|
|
3829
3606
|
if (skipTags.has(tag.name)) continue;
|
|
3830
3607
|
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
@@ -3838,10 +3615,17 @@ var compositionRules = [
|
|
|
3838
3615
|
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
3839
3616
|
findings.push({
|
|
3840
3617
|
code: "timed_element_missing_clip_class",
|
|
3841
|
-
|
|
3842
|
-
|
|
3618
|
+
// Not an error: the runtime drives timed visibility off the `data-start`
|
|
3619
|
+
// ATTRIBUTE, not this class — `syncTimedElementVisibility` walks
|
|
3620
|
+
// `querySelectorAll("[data-start]")` and toggles `style.visibility`
|
|
3621
|
+
// regardless of class (pinned by the runtime's own init test, which
|
|
3622
|
+
// uses a bare `<div data-start data-duration>` with no `class="clip"`).
|
|
3623
|
+
// The class is an authoring convention the tooling reads, so a missing
|
|
3624
|
+
// one is worth flagging but does not break the render.
|
|
3625
|
+
severity: "warning",
|
|
3626
|
+
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
3627
|
elementId,
|
|
3844
|
-
fixHint: 'Add class="clip" to the element
|
|
3628
|
+
fixHint: 'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
|
|
3845
3629
|
snippet: truncateSnippet(tag.raw)
|
|
3846
3630
|
});
|
|
3847
3631
|
}
|
|
@@ -4700,36 +4484,22 @@ function collectGoogleFontFamilies(source, styles) {
|
|
|
4700
4484
|
return families;
|
|
4701
4485
|
}
|
|
4702
4486
|
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
|
|
4487
|
+
// system_font_will_alias — only for distributed / Lambda renders, where
|
|
4488
|
+
// system-font capture is disabled and the alias substitution does NOT happen,
|
|
4489
|
+
// so the font silently falls back to whatever the OS provides. Under a local
|
|
4490
|
+
// render the substitution is the renderer working as designed, not a defect,
|
|
4491
|
+
// so there is nothing for the author to act on.
|
|
4722
4492
|
({ styles, options }) => {
|
|
4493
|
+
if (!options.distributed) return [];
|
|
4723
4494
|
const declared = extractFontFaceFamilies(styles);
|
|
4724
4495
|
const used = extractUsedFontFamilies(styles);
|
|
4725
4496
|
const aliased = collectAliasedFonts(used, declared);
|
|
4726
4497
|
if (aliased.length === 0) return [];
|
|
4727
|
-
const severity = options.distributed ? "warning" : "info";
|
|
4728
4498
|
return [
|
|
4729
4499
|
{
|
|
4730
4500
|
code: "system_font_will_alias",
|
|
4731
|
-
severity,
|
|
4732
|
-
message: `Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}.
|
|
4501
|
+
severity: "warning",
|
|
4502
|
+
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
4503
|
}
|
|
4734
4504
|
];
|
|
4735
4505
|
},
|
|
@@ -4825,35 +4595,63 @@ var slideshowRules = [
|
|
|
4825
4595
|
];
|
|
4826
4596
|
|
|
4827
4597
|
// src/hyperframeLinter.ts
|
|
4828
|
-
var
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4598
|
+
var RULE_GROUPS = [
|
|
4599
|
+
{ group: "core", rules: coreRules },
|
|
4600
|
+
{ group: "media", rules: mediaRules },
|
|
4601
|
+
{ group: "gsap", rules: gsapRules },
|
|
4602
|
+
{ group: "captions", rules: captionRules },
|
|
4603
|
+
{ group: "composition", rules: compositionRules },
|
|
4604
|
+
{ group: "adapters", rules: adapterRules },
|
|
4605
|
+
{ group: "textures", rules: textureRules },
|
|
4606
|
+
{ group: "fonts", rules: fontRules },
|
|
4607
|
+
{ group: "slideshow", rules: slideshowRules }
|
|
4838
4608
|
];
|
|
4839
|
-
|
|
4840
|
-
|
|
4609
|
+
var LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
|
|
4610
|
+
var LINT_RULE_GROUP_COUNTS = Object.fromEntries(
|
|
4611
|
+
RULE_GROUPS.map(({ group, rules }) => [group, rules.length])
|
|
4612
|
+
);
|
|
4613
|
+
function dedupeKeyFor(finding) {
|
|
4614
|
+
return [
|
|
4615
|
+
finding.code,
|
|
4616
|
+
finding.severity,
|
|
4617
|
+
finding.selector || "",
|
|
4618
|
+
finding.elementId || "",
|
|
4619
|
+
finding.message
|
|
4620
|
+
].join("|");
|
|
4621
|
+
}
|
|
4622
|
+
async function runRules(ctx, filePath) {
|
|
4841
4623
|
const findings = [];
|
|
4842
4624
|
const seen = /* @__PURE__ */ new Set();
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
]
|
|
4852
|
-
if (
|
|
4853
|
-
|
|
4854
|
-
|
|
4625
|
+
const groupMs = {};
|
|
4626
|
+
let slowestRule = "";
|
|
4627
|
+
let slowestRuleMs = 0;
|
|
4628
|
+
for (const { group, rules } of RULE_GROUPS) {
|
|
4629
|
+
for (let index = 0; index < rules.length; index++) {
|
|
4630
|
+
const ruleStartedAt = performance.now();
|
|
4631
|
+
const produced = await Promise.resolve(rules[index](ctx));
|
|
4632
|
+
const ruleMs = performance.now() - ruleStartedAt;
|
|
4633
|
+
groupMs[group] = (groupMs[group] ?? 0) + ruleMs;
|
|
4634
|
+
if (ruleMs > slowestRuleMs) {
|
|
4635
|
+
slowestRuleMs = ruleMs;
|
|
4636
|
+
slowestRule = `${group}#${index}`;
|
|
4637
|
+
}
|
|
4638
|
+
collectFindings(produced, seen, filePath, findings);
|
|
4855
4639
|
}
|
|
4856
4640
|
}
|
|
4641
|
+
return { findings, timings: { groupMs, slowestRule, slowestRuleMs } };
|
|
4642
|
+
}
|
|
4643
|
+
function collectFindings(produced, seen, filePath, into) {
|
|
4644
|
+
for (const finding of produced) {
|
|
4645
|
+
const dedupeKey = dedupeKeyFor(finding);
|
|
4646
|
+
if (seen.has(dedupeKey)) continue;
|
|
4647
|
+
seen.add(dedupeKey);
|
|
4648
|
+
into.push(filePath ? { ...finding, file: filePath } : finding);
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
async function lintHyperframeHtml(html, options = {}) {
|
|
4652
|
+
const startedAt = performance.now();
|
|
4653
|
+
const ctx = buildLintContext(html, options);
|
|
4654
|
+
const { findings, timings } = await runRules(ctx, options.filePath);
|
|
4857
4655
|
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
4858
4656
|
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
4859
4657
|
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
@@ -4862,7 +4660,8 @@ async function lintHyperframeHtml(html, options = {}) {
|
|
|
4862
4660
|
errorCount,
|
|
4863
4661
|
warningCount,
|
|
4864
4662
|
infoCount,
|
|
4865
|
-
findings
|
|
4663
|
+
findings,
|
|
4664
|
+
timings: { totalMs: performance.now() - startedAt, ...timings }
|
|
4866
4665
|
};
|
|
4867
4666
|
}
|
|
4868
4667
|
function extractMediaUrls(html) {
|