@hyperframes/lint 0.8.3 → 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 +110 -290
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +122 -292
- 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()" },
|
|
@@ -1402,8 +1316,29 @@ var mediaRules = [
|
|
|
1402
1316
|
// imperative_media_control
|
|
1403
1317
|
findImperativeMediaControlFindings,
|
|
1404
1318
|
// audio_volume_double_automation
|
|
1405
|
-
findVolumeDoubleAutomationFindings
|
|
1319
|
+
findVolumeDoubleAutomationFindings,
|
|
1320
|
+
// audio_volume_tween_overrides_gain
|
|
1321
|
+
findVolumeTweenOverridesGainFindings
|
|
1406
1322
|
];
|
|
1323
|
+
function findVolumeTweenOverridesGainFindings(ctx) {
|
|
1324
|
+
const boosted = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, volume: Number(readAttr(tag.raw, "data-volume") ?? "1") })).filter((entry) => Number.isFinite(entry.volume) && entry.volume !== 1).filter((entry) => !readDecodedAttr(entry.tag.raw, "data-automation")).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
|
|
1325
|
+
if (boosted.length === 0) return [];
|
|
1326
|
+
const script = ctx.scripts.map((block) => stripJsComments(block.content)).join("\n");
|
|
1327
|
+
const findings = [];
|
|
1328
|
+
for (const { tag, id, volume } of boosted) {
|
|
1329
|
+
if (!tweensVolumeInSameCall(script, id)) continue;
|
|
1330
|
+
const db = volume > 0 ? `${(20 * Math.log10(volume)).toFixed(1)} dB` : "silence";
|
|
1331
|
+
findings.push({
|
|
1332
|
+
code: "audio_volume_tween_overrides_gain",
|
|
1333
|
+
severity: "warning",
|
|
1334
|
+
message: `#${id} has data-volume="${volume}" (${db}) and a GSAP tween on \`volume\`. Tween values are absolute \u2014 they REPLACE this gain rather than scale it \u2014 so wherever the tween names a value the clip plays at that value, not at ${db}.`,
|
|
1335
|
+
elementId: id,
|
|
1336
|
+
fixHint: "Write the tween's targets in the same absolute gain (e.g. `volume: 1.95`, not `volume: 1`), or reset data-volume to 1 and let the tween carry the level on its own.",
|
|
1337
|
+
snippet: truncateSnippet(tag.raw)
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return findings;
|
|
1341
|
+
}
|
|
1407
1342
|
function findVolumeDoubleAutomationFindings(ctx) {
|
|
1408
1343
|
const automated = ctx.tags.filter((tag) => isMediaTag(tag.name)).map((tag) => ({ tag, automation: readDecodedAttr(tag.raw, "data-automation") })).filter((entry) => entry.automation && /"target"\s*:\s*"volume"/.test(entry.automation)).map((entry) => ({ ...entry, id: readAttr(entry.tag.raw, "id") })).filter((entry) => Boolean(entry.id));
|
|
1409
1344
|
if (automated.length === 0) return [];
|
|
@@ -1439,17 +1374,6 @@ function targetHasNoStableIdentity(selector, identity) {
|
|
|
1439
1374
|
if (identity) return false;
|
|
1440
1375
|
return selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy \u2192 ");
|
|
1441
1376
|
}
|
|
1442
|
-
function countClassUsage(tags) {
|
|
1443
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1444
|
-
for (const tag of tags) {
|
|
1445
|
-
const classAttr = readAttr(tag.raw, "class");
|
|
1446
|
-
if (!classAttr) continue;
|
|
1447
|
-
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
|
1448
|
-
counts.set(className, (counts.get(className) || 0) + 1);
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
return counts;
|
|
1452
|
-
}
|
|
1453
1377
|
function readRegisteredTimelineCompositionId(script) {
|
|
1454
1378
|
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
1455
1379
|
return match?.[1] || match?.[2] || null;
|
|
@@ -1665,16 +1589,6 @@ function findMatchingSceneBoundary(time, boundaries) {
|
|
|
1665
1589
|
}
|
|
1666
1590
|
return null;
|
|
1667
1591
|
}
|
|
1668
|
-
function isSuspiciousGlobalSelector(selector) {
|
|
1669
|
-
if (!selector) return false;
|
|
1670
|
-
if (selector.includes("[data-composition-id=")) return false;
|
|
1671
|
-
if (selector.startsWith("#")) return false;
|
|
1672
|
-
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
|
1673
|
-
}
|
|
1674
|
-
function getSingleClassSelector(selector) {
|
|
1675
|
-
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
|
1676
|
-
return match?.groups?.name || null;
|
|
1677
|
-
}
|
|
1678
1592
|
function readStyleProperty(style, property) {
|
|
1679
1593
|
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1680
1594
|
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
|
@@ -1918,7 +1832,7 @@ function isInsideGsapTweenVars(source, index, timelineVars) {
|
|
|
1918
1832
|
else if (ch === "{") {
|
|
1919
1833
|
if (depth === 0) {
|
|
1920
1834
|
const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
|
|
1921
|
-
const receivers = ["gsap", ...timelineVars].map(
|
|
1835
|
+
const receivers = ["gsap", ...timelineVars].map(escapeRegExp2).join("|");
|
|
1922
1836
|
return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
|
|
1923
1837
|
before
|
|
1924
1838
|
);
|
|
@@ -1954,7 +1868,7 @@ function parseFunctionValueSource(code) {
|
|
|
1954
1868
|
const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
|
|
1955
1869
|
return { firstParam, body: src.slice(match[0].length) };
|
|
1956
1870
|
}
|
|
1957
|
-
function
|
|
1871
|
+
function escapeRegExp2(value) {
|
|
1958
1872
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1959
1873
|
}
|
|
1960
1874
|
var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -1968,7 +1882,7 @@ var NUMBER_METHODS = /* @__PURE__ */ new Set([
|
|
|
1968
1882
|
function firstParamMemberAccessHazard(fn) {
|
|
1969
1883
|
if (!fn.firstParam) return null;
|
|
1970
1884
|
const pattern = new RegExp(
|
|
1971
|
-
`\\b${
|
|
1885
|
+
`\\b${escapeRegExp2(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
|
|
1972
1886
|
"g"
|
|
1973
1887
|
);
|
|
1974
1888
|
let match;
|
|
@@ -2012,7 +1926,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2012
1926
|
for (const [name, body] of bodies) {
|
|
2013
1927
|
if (measuring.has(name)) continue;
|
|
2014
1928
|
for (const measured of measuring) {
|
|
2015
|
-
if (new RegExp(`\\b${
|
|
1929
|
+
if (new RegExp(`\\b${escapeRegExp2(measured)}\\s*\\(`).test(body)) {
|
|
2016
1930
|
measuring.add(name);
|
|
2017
1931
|
grew = true;
|
|
2018
1932
|
break;
|
|
@@ -2026,7 +1940,7 @@ function collectMeasuringFunctionNames(bodies) {
|
|
|
2026
1940
|
function expressionReachesMeasurement(expression, measuring) {
|
|
2027
1941
|
if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
|
|
2028
1942
|
for (const name of measuring) {
|
|
2029
|
-
if (new RegExp(`\\b${
|
|
1943
|
+
if (new RegExp(`\\b${escapeRegExp2(name)}\\b`).test(expression)) return true;
|
|
2030
1944
|
}
|
|
2031
1945
|
return false;
|
|
2032
1946
|
}
|
|
@@ -2049,7 +1963,7 @@ function resolveScriptElementTokens(source, tags) {
|
|
|
2049
1963
|
const template = match[2] ?? "";
|
|
2050
1964
|
const staticParts = template.split(/\$\{[^}]*\}/);
|
|
2051
1965
|
if (staticParts.every((part) => part === "")) continue;
|
|
2052
|
-
const idPattern = new RegExp(`^${staticParts.map(
|
|
1966
|
+
const idPattern = new RegExp(`^${staticParts.map(escapeRegExp2).join(".*")}$`);
|
|
2053
1967
|
for (const id of documentIds) {
|
|
2054
1968
|
if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
|
|
2055
1969
|
}
|
|
@@ -2141,7 +2055,7 @@ function collectCssOpacityZeroSelectors(styles, tags) {
|
|
|
2141
2055
|
return selectors;
|
|
2142
2056
|
}
|
|
2143
2057
|
var gsapRules = [
|
|
2144
|
-
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
2058
|
+
// overlapping_gsap_tweens + gsap_animates_clip_element
|
|
2145
2059
|
// fallow-ignore-next-line complexity
|
|
2146
2060
|
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
|
2147
2061
|
const findings = [];
|
|
@@ -2161,7 +2075,6 @@ var gsapRules = [
|
|
|
2161
2075
|
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
|
2162
2076
|
}
|
|
2163
2077
|
}
|
|
2164
|
-
const classUsage = countClassUsage(tags);
|
|
2165
2078
|
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
|
2166
2079
|
const styleRules = collectSimpleStyleRules(styles);
|
|
2167
2080
|
const reportedVisibleOverlayKeys = /* @__PURE__ */ new Set();
|
|
@@ -2284,20 +2197,6 @@ ${right.raw}`)
|
|
|
2284
2197
|
snippet: truncateSnippet(win.raw)
|
|
2285
2198
|
});
|
|
2286
2199
|
}
|
|
2287
|
-
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
|
|
2288
|
-
for (const win of gsapWindows) {
|
|
2289
|
-
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
|
|
2290
|
-
const className = getSingleClassSelector(win.targetSelector);
|
|
2291
|
-
if (className && (classUsage.get(className) || 0) < 2) continue;
|
|
2292
|
-
findings.push({
|
|
2293
|
-
code: "unscoped_gsap_selector",
|
|
2294
|
-
severity: "error",
|
|
2295
|
-
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
|
2296
|
-
selector: win.targetSelector,
|
|
2297
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
|
2298
|
-
snippet: truncateSnippet(win.raw)
|
|
2299
|
-
});
|
|
2300
|
-
}
|
|
2301
2200
|
}
|
|
2302
2201
|
return findings;
|
|
2303
2202
|
},
|
|
@@ -2554,39 +2453,6 @@ ${right.raw}`)
|
|
|
2554
2453
|
}
|
|
2555
2454
|
return findings;
|
|
2556
2455
|
},
|
|
2557
|
-
// scene_layer_missing_visibility_kill
|
|
2558
|
-
({ scripts, tags }) => {
|
|
2559
|
-
const findings = [];
|
|
2560
|
-
const sceneElements = tags.filter((t) => {
|
|
2561
|
-
const id = readAttr(t.raw, "id") || "";
|
|
2562
|
-
return /^scene\d+$/i.test(id);
|
|
2563
|
-
});
|
|
2564
|
-
if (sceneElements.length < 2) return findings;
|
|
2565
|
-
for (const script of scripts) {
|
|
2566
|
-
const content = stripJsComments(script.content);
|
|
2567
|
-
for (const tag of sceneElements) {
|
|
2568
|
-
const id = readAttr(tag.raw, "id") || "";
|
|
2569
|
-
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
|
|
2570
|
-
const hasExit = exitPattern.test(content);
|
|
2571
|
-
if (!hasExit) continue;
|
|
2572
|
-
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
|
|
2573
|
-
const hasKill = killPattern.test(content);
|
|
2574
|
-
if (!hasKill) {
|
|
2575
|
-
const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
2576
|
-
const isClip = classes.includes("clip");
|
|
2577
|
-
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.`;
|
|
2578
|
-
findings.push({
|
|
2579
|
-
code: "scene_layer_missing_visibility_kill",
|
|
2580
|
-
severity: "error",
|
|
2581
|
-
elementId: id,
|
|
2582
|
-
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.`,
|
|
2583
|
-
fixHint
|
|
2584
|
-
});
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
return findings;
|
|
2589
|
-
},
|
|
2590
2456
|
// gsap_timeline_not_registered
|
|
2591
2457
|
({ scripts, rawSource, options }) => {
|
|
2592
2458
|
const findings = [];
|
|
@@ -2949,7 +2815,7 @@ ${other.raw}`)
|
|
|
2949
2815
|
const timelineVars = collectTimelineVarNames(source);
|
|
2950
2816
|
for (const timelineVar of timelineVars) {
|
|
2951
2817
|
const callPattern = new RegExp(
|
|
2952
|
-
`\\b${
|
|
2818
|
+
`\\b${escapeRegExp2(timelineVar)}\\.(?:add|call)\\s*\\(`,
|
|
2953
2819
|
"g"
|
|
2954
2820
|
);
|
|
2955
2821
|
let match2;
|
|
@@ -2962,7 +2828,7 @@ ${other.raw}`)
|
|
|
2962
2828
|
if (callbackExpressionHazard(firstArg)) report(site, site);
|
|
2963
2829
|
}
|
|
2964
2830
|
const eventCallbackPattern = new RegExp(
|
|
2965
|
-
`\\b${
|
|
2831
|
+
`\\b${escapeRegExp2(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
|
|
2966
2832
|
"g"
|
|
2967
2833
|
);
|
|
2968
2834
|
while ((match2 = eventCallbackPattern.exec(source)) !== null) {
|
|
@@ -3204,31 +3070,6 @@ ${other.raw}`)
|
|
|
3204
3070
|
];
|
|
3205
3071
|
|
|
3206
3072
|
// src/rules/captions.ts
|
|
3207
|
-
function extractArrayLiteral(src, varMatch) {
|
|
3208
|
-
const openIdx = varMatch.index + varMatch[0].length - 1;
|
|
3209
|
-
let depth = 0;
|
|
3210
|
-
let inStr = false;
|
|
3211
|
-
let strChar = "";
|
|
3212
|
-
for (let i = openIdx; i < src.length; i++) {
|
|
3213
|
-
const c = src[i];
|
|
3214
|
-
if (inStr) {
|
|
3215
|
-
if (c === "\\") {
|
|
3216
|
-
i++;
|
|
3217
|
-
continue;
|
|
3218
|
-
}
|
|
3219
|
-
if (c === strChar) inStr = false;
|
|
3220
|
-
} else if (c === '"' || c === "'") {
|
|
3221
|
-
inStr = true;
|
|
3222
|
-
strChar = c;
|
|
3223
|
-
} else if (c === "[") {
|
|
3224
|
-
depth++;
|
|
3225
|
-
} else if (c === "]") {
|
|
3226
|
-
depth--;
|
|
3227
|
-
if (depth === 0) return src.slice(openIdx, i + 1);
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
return null;
|
|
3231
|
-
}
|
|
3232
3073
|
var captionRules = [
|
|
3233
3074
|
// caption_exit_missing_hard_kill
|
|
3234
3075
|
({ scripts, styles, options, rootCompositionId }) => {
|
|
@@ -3296,22 +3137,6 @@ var captionRules = [
|
|
|
3296
3137
|
fixHint: 'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` with JSON-quoted property keys. See the captions skill for details.'
|
|
3297
3138
|
});
|
|
3298
3139
|
}
|
|
3299
|
-
if (hasInlineTranscript) {
|
|
3300
|
-
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
|
3301
|
-
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
|
3302
|
-
if (transcriptJson) {
|
|
3303
|
-
try {
|
|
3304
|
-
JSON.parse(transcriptJson);
|
|
3305
|
-
} catch {
|
|
3306
|
-
findings.push({
|
|
3307
|
-
code: "caption_transcript_parse_error",
|
|
3308
|
-
severity: "error",
|
|
3309
|
-
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.",
|
|
3310
|
-
fixHint: `Use JSON-quoted keys: { "text": "don't", "start": 0, "end": 1 } instead of { text: "don't", start: 0, end: 1 }.`
|
|
3311
|
-
});
|
|
3312
|
-
}
|
|
3313
|
-
}
|
|
3314
|
-
}
|
|
3315
3140
|
return findings;
|
|
3316
3141
|
},
|
|
3317
3142
|
// caption_container_relative_position
|
|
@@ -3696,33 +3521,6 @@ var compositionRules = [
|
|
|
3696
3521
|
}
|
|
3697
3522
|
return findings;
|
|
3698
3523
|
},
|
|
3699
|
-
// timed_element_missing_visibility_hidden
|
|
3700
|
-
// fallow-ignore-next-line complexity
|
|
3701
|
-
({ tags }) => {
|
|
3702
|
-
const findings = [];
|
|
3703
|
-
for (const tag of tags) {
|
|
3704
|
-
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
|
3705
|
-
if (!readAttr(tag.raw, "data-start")) continue;
|
|
3706
|
-
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
3707
|
-
if (readAttr(tag.raw, "data-composition-src")) continue;
|
|
3708
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
3709
|
-
const styleAttr = readAttr(tag.raw, "style") || "";
|
|
3710
|
-
const hasClip = classAttr.split(/\s+/).includes("clip");
|
|
3711
|
-
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
|
3712
|
-
if (!hasClip && !hasHiddenStyle) {
|
|
3713
|
-
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
3714
|
-
findings.push({
|
|
3715
|
-
code: "timed_element_missing_visibility_hidden",
|
|
3716
|
-
severity: "info",
|
|
3717
|
-
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.`,
|
|
3718
|
-
elementId,
|
|
3719
|
-
fixHint: 'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
|
3720
|
-
snippet: truncateSnippet(tag.raw)
|
|
3721
|
-
});
|
|
3722
|
-
}
|
|
3723
|
-
}
|
|
3724
|
-
return findings;
|
|
3725
|
-
},
|
|
3726
3524
|
// deprecated_data_layer + deprecated_data_end
|
|
3727
3525
|
// fallow-ignore-next-line complexity
|
|
3728
3526
|
({ tags }) => {
|
|
@@ -3803,7 +3601,7 @@ var compositionRules = [
|
|
|
3803
3601
|
// fallow-ignore-next-line complexity
|
|
3804
3602
|
({ tags }) => {
|
|
3805
3603
|
const findings = [];
|
|
3806
|
-
const skipTags = /* @__PURE__ */ new Set(["audio", "video", "script", "style", "template"]);
|
|
3604
|
+
const skipTags = /* @__PURE__ */ new Set(["audio", "img", "video", "script", "style", "template"]);
|
|
3807
3605
|
for (const tag of tags) {
|
|
3808
3606
|
if (skipTags.has(tag.name)) continue;
|
|
3809
3607
|
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
|
@@ -3817,10 +3615,17 @@ var compositionRules = [
|
|
|
3817
3615
|
const elementId = readAttr(tag.raw, "id") || void 0;
|
|
3818
3616
|
findings.push({
|
|
3819
3617
|
code: "timed_element_missing_clip_class",
|
|
3820
|
-
|
|
3821
|
-
|
|
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.`,
|
|
3822
3627
|
elementId,
|
|
3823
|
-
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.',
|
|
3824
3629
|
snippet: truncateSnippet(tag.raw)
|
|
3825
3630
|
});
|
|
3826
3631
|
}
|
|
@@ -4679,36 +4484,22 @@ function collectGoogleFontFamilies(source, styles) {
|
|
|
4679
4484
|
return families;
|
|
4680
4485
|
}
|
|
4681
4486
|
var fontRules = [
|
|
4682
|
-
//
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
const googleFontsInImport = styles.some(
|
|
4688
|
-
(s) => /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content)
|
|
4689
|
-
);
|
|
4690
|
-
if (googleFontsInLink || googleFontsInImport) {
|
|
4691
|
-
findings.push({
|
|
4692
|
-
code: "google_fonts_import",
|
|
4693
|
-
severity: "warning",
|
|
4694
|
-
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.",
|
|
4695
|
-
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'); }."
|
|
4696
|
-
});
|
|
4697
|
-
}
|
|
4698
|
-
return findings;
|
|
4699
|
-
},
|
|
4700
|
-
// 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.
|
|
4701
4492
|
({ styles, options }) => {
|
|
4493
|
+
if (!options.distributed) return [];
|
|
4702
4494
|
const declared = extractFontFaceFamilies(styles);
|
|
4703
4495
|
const used = extractUsedFontFamilies(styles);
|
|
4704
4496
|
const aliased = collectAliasedFonts(used, declared);
|
|
4705
4497
|
if (aliased.length === 0) return [];
|
|
4706
|
-
const severity = options.distributed ? "warning" : "info";
|
|
4707
4498
|
return [
|
|
4708
4499
|
{
|
|
4709
4500
|
code: "system_font_will_alias",
|
|
4710
|
-
severity,
|
|
4711
|
-
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.`
|
|
4712
4503
|
}
|
|
4713
4504
|
];
|
|
4714
4505
|
},
|
|
@@ -4804,35 +4595,63 @@ var slideshowRules = [
|
|
|
4804
4595
|
];
|
|
4805
4596
|
|
|
4806
4597
|
// src/hyperframeLinter.ts
|
|
4807
|
-
var
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
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 }
|
|
4817
4608
|
];
|
|
4818
|
-
|
|
4819
|
-
|
|
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) {
|
|
4820
4623
|
const findings = [];
|
|
4821
4624
|
const seen = /* @__PURE__ */ new Set();
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
]
|
|
4831
|
-
if (
|
|
4832
|
-
|
|
4833
|
-
|
|
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);
|
|
4834
4639
|
}
|
|
4835
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);
|
|
4836
4655
|
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
4837
4656
|
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
4838
4657
|
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
@@ -4841,7 +4660,8 @@ async function lintHyperframeHtml(html, options = {}) {
|
|
|
4841
4660
|
errorCount,
|
|
4842
4661
|
warningCount,
|
|
4843
4662
|
infoCount,
|
|
4844
|
-
findings
|
|
4663
|
+
findings,
|
|
4664
|
+
timings: { totalMs: performance.now() - startedAt, ...timings }
|
|
4845
4665
|
};
|
|
4846
4666
|
}
|
|
4847
4667
|
function extractMediaUrls(html) {
|