@hyperframes/lint 0.7.21 → 0.7.23
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.js +163 -5
- package/dist/browser.js.map +1 -1
- package/dist/index.js +216 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -222,8 +222,12 @@ function truncateSnippet(value, maxLength = 220) {
|
|
|
222
222
|
function buildLintContext(html, options = {}) {
|
|
223
223
|
const rawSource = html || "";
|
|
224
224
|
let source = stripHtmlComments(rawSource);
|
|
225
|
+
const sourceWithoutTemplates = source.replace(
|
|
226
|
+
/<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
|
|
227
|
+
" "
|
|
228
|
+
);
|
|
225
229
|
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
|
226
|
-
if (templateMatch?.[1]) source = templateMatch[1];
|
|
230
|
+
if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];
|
|
227
231
|
const tags = extractOpenTags(source);
|
|
228
232
|
const styles = [
|
|
229
233
|
...extractBlocks(source, STYLE_BLOCK_PATTERN),
|
|
@@ -294,6 +298,8 @@ var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
|
|
294
298
|
var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
|
295
299
|
var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
|
296
300
|
var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
|
301
|
+
var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
|
|
302
|
+
var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
|
|
297
303
|
function findCodeFenceLeak(headWithoutValidBlocks) {
|
|
298
304
|
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
|
299
305
|
}
|
|
@@ -332,6 +338,46 @@ function findLeakedTextBeforeCompositionRoot(source, rootTag) {
|
|
|
332
338
|
if (prefixEnd <= prefixStart) return null;
|
|
333
339
|
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
|
334
340
|
}
|
|
341
|
+
function findProtectedVisibleMarkupRanges(source) {
|
|
342
|
+
const ranges = [];
|
|
343
|
+
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
|
|
344
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
345
|
+
}
|
|
346
|
+
return ranges;
|
|
347
|
+
}
|
|
348
|
+
function isInsideSourceRange(index, ranges) {
|
|
349
|
+
return ranges.some((range) => range.start <= index && index < range.end);
|
|
350
|
+
}
|
|
351
|
+
function isInsideHtmlTag(source, index) {
|
|
352
|
+
let inTag = false;
|
|
353
|
+
let quote = null;
|
|
354
|
+
for (let i = 0; i < index; i++) {
|
|
355
|
+
const char = source[i];
|
|
356
|
+
if (!inTag) {
|
|
357
|
+
if (char === "<") inTag = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (quote) {
|
|
361
|
+
if (char === quote) quote = null;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (char === '"' || char === "'") {
|
|
365
|
+
quote = char;
|
|
366
|
+
} else if (char === ">") {
|
|
367
|
+
inTag = false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return inTag;
|
|
371
|
+
}
|
|
372
|
+
function findVisibleMarkupCommentLeak(source) {
|
|
373
|
+
const protectedRanges = findProtectedVisibleMarkupRanges(source);
|
|
374
|
+
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PATTERN)) {
|
|
375
|
+
if (isInsideHtmlTag(source, match.index)) continue;
|
|
376
|
+
if (isInsideSourceRange(match.index, protectedRanges)) continue;
|
|
377
|
+
return match[0];
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
335
381
|
var coreRules = [
|
|
336
382
|
// root_missing_composition_id + root_missing_dimensions
|
|
337
383
|
({ rootTag }) => {
|
|
@@ -372,6 +418,20 @@ var coreRules = [
|
|
|
372
418
|
}
|
|
373
419
|
];
|
|
374
420
|
},
|
|
421
|
+
// visible_markup_comment
|
|
422
|
+
({ source }) => {
|
|
423
|
+
const snippet = findVisibleMarkupCommentLeak(source);
|
|
424
|
+
if (!snippet) return [];
|
|
425
|
+
return [
|
|
426
|
+
{
|
|
427
|
+
code: "visible_markup_comment",
|
|
428
|
+
severity: "error",
|
|
429
|
+
message: "CSS/JS block comment syntax (`/* ... */`) appears in visible HTML markup. HTML only treats `<!-- ... -->` as comments, so this renders as on-screen text.",
|
|
430
|
+
fixHint: "Remove the text or convert it to a real HTML comment (`<!-- ... -->`). Keep CSS comments inside `<style>` and JS comments inside `<script>`.",
|
|
431
|
+
snippet: truncateSnippet(snippet)
|
|
432
|
+
}
|
|
433
|
+
];
|
|
434
|
+
},
|
|
375
435
|
// missing_timeline_registry + timeline_registry_missing_init
|
|
376
436
|
({ source, rawSource, options }) => {
|
|
377
437
|
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
|
@@ -1127,6 +1187,7 @@ async function loadParseGsapScript() {
|
|
|
1127
1187
|
return mod.parseGsapScriptAcorn;
|
|
1128
1188
|
}
|
|
1129
1189
|
var SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
|
1190
|
+
var UNRESOLVED_TARGET = "__unresolved__";
|
|
1130
1191
|
function countClassUsage(tags) {
|
|
1131
1192
|
const counts = /* @__PURE__ */ new Map();
|
|
1132
1193
|
for (const tag of tags) {
|
|
@@ -1489,6 +1550,7 @@ var gsapRules = [
|
|
|
1489
1550
|
const left = gsapWindows[i];
|
|
1490
1551
|
if (!left) continue;
|
|
1491
1552
|
if (left.end <= left.position) continue;
|
|
1553
|
+
if (left.targetSelector === UNRESOLVED_TARGET) continue;
|
|
1492
1554
|
for (let j = i + 1; j < gsapWindows.length; j++) {
|
|
1493
1555
|
const right = gsapWindows[j];
|
|
1494
1556
|
if (!right) continue;
|
|
@@ -1515,6 +1577,7 @@ ${right.raw}`)
|
|
|
1515
1577
|
}
|
|
1516
1578
|
if (clipStartBoundaries.length > 0) {
|
|
1517
1579
|
for (const win of gsapWindows) {
|
|
1580
|
+
if (win.targetSelector === UNRESOLVED_TARGET) continue;
|
|
1518
1581
|
if (!isSceneBoundaryExit(win)) continue;
|
|
1519
1582
|
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
|
|
1520
1583
|
if (boundary == null) continue;
|
|
@@ -2719,6 +2782,91 @@ var compositionRules = [
|
|
|
2719
2782
|
snippet: truncateSnippet(rootTag.raw)
|
|
2720
2783
|
}
|
|
2721
2784
|
];
|
|
2785
|
+
},
|
|
2786
|
+
// root_composition_missing_duration_source
|
|
2787
|
+
//
|
|
2788
|
+
// The render engine (packages/engine/src/services/frameCapture.ts) needs a
|
|
2789
|
+
// positive window.__hf.duration to know how many frames to capture. GSAP
|
|
2790
|
+
// timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
|
|
2791
|
+
// are now auto-inferred by the runtime too (see
|
|
2792
|
+
// packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
|
|
2793
|
+
// the adapters' getInferredDurationSeconds) — so data-duration is optional
|
|
2794
|
+
// wherever the runtime can work it out on its own.
|
|
2795
|
+
//
|
|
2796
|
+
// This rule fires for cases where the total render length is not reliably
|
|
2797
|
+
// determinable without an explicit data-duration:
|
|
2798
|
+
// - No GSAP timeline AND no data-duration AND no non-GSAP animation
|
|
2799
|
+
// signal at all (nothing for any adapter to discover — render fails).
|
|
2800
|
+
// - Three.js used with no data-duration (no discoverable AnimationClip
|
|
2801
|
+
// duration in this codebase's adapter — see adapters/three.ts).
|
|
2802
|
+
// - Any infinite CSS animation-iteration-count with no data-duration,
|
|
2803
|
+
// EVEN when a finite CSS animation is present alongside it. An unbounded
|
|
2804
|
+
// animation makes the intended total length ambiguous — the runtime will
|
|
2805
|
+
// infer a finite sibling's length if one exists, but that's a fallback,
|
|
2806
|
+
// not a declaration of intent, so we still require data-duration here.
|
|
2807
|
+
// (This is intentionally stricter than the runtime's own inference.)
|
|
2808
|
+
// Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
|
|
2809
|
+
// infers those unambiguously, so requiring data-duration there would be a
|
|
2810
|
+
// false positive against the runtime's own auto-inference. Note lint is
|
|
2811
|
+
// advisory by default (see shouldBlockRender) — it only blocks render under
|
|
2812
|
+
// --strict/--strict-all — so a strict flag here nudges toward an explicit,
|
|
2813
|
+
// guaranteed-correct value without failing renders that would succeed.
|
|
2814
|
+
// fallow-ignore-next-line complexity
|
|
2815
|
+
({ rootTag, scripts, styles, tags, options }) => {
|
|
2816
|
+
if (options.isSubComposition) return [];
|
|
2817
|
+
if (!rootTag) return [];
|
|
2818
|
+
if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
|
|
2819
|
+
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
|
|
2820
|
+
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
|
|
2821
|
+
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
|
|
2822
|
+
const hasRegisteredTimeline = allScriptTexts.some(
|
|
2823
|
+
(t) => WINDOW_TIMELINE_ASSIGN_PATTERN.test(t)
|
|
2824
|
+
);
|
|
2825
|
+
if (hasGsapTimeline && hasRegisteredTimeline) return [];
|
|
2826
|
+
const allCss = styles.map((s) => s.content).join("\n");
|
|
2827
|
+
const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
|
|
2828
|
+
const combinedCss = `${allCss}
|
|
2829
|
+
${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2830
|
+
const usesLottie = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) || allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
|
|
2831
|
+
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
|
|
2832
|
+
const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
|
|
2833
|
+
const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
|
|
2834
|
+
const hasInfiniteCssAnimation = /\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
|
|
2835
|
+
const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
|
|
2836
|
+
if (!hasAnyNonGsapSignal) {
|
|
2837
|
+
return [
|
|
2838
|
+
{
|
|
2839
|
+
code: "root_composition_missing_duration_source",
|
|
2840
|
+
severity: "error",
|
|
2841
|
+
message: 'Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js animation for the runtime to infer a duration from. The render engine cannot determine how long to capture and will fail with "Composition has zero duration".',
|
|
2842
|
+
fixHint: 'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered on window.__timelines.',
|
|
2843
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
2844
|
+
}
|
|
2845
|
+
];
|
|
2846
|
+
}
|
|
2847
|
+
if (usesThree) {
|
|
2848
|
+
return [
|
|
2849
|
+
{
|
|
2850
|
+
code: "root_composition_missing_duration_source",
|
|
2851
|
+
severity: "error",
|
|
2852
|
+
message: `Root composition uses Three.js with no data-duration. The runtime cannot discover a Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) \u2014 render will fail with "Composition has zero duration".`,
|
|
2853
|
+
fixHint: 'Add data-duration="<seconds>" to the root element.',
|
|
2854
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
2855
|
+
}
|
|
2856
|
+
];
|
|
2857
|
+
}
|
|
2858
|
+
if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
|
|
2859
|
+
return [
|
|
2860
|
+
{
|
|
2861
|
+
code: "root_composition_missing_duration_source",
|
|
2862
|
+
severity: "error",
|
|
2863
|
+
message: 'Root composition uses a CSS animation with animation-iteration-count: infinite and no data-duration, so the intended total length is ambiguous. If a finite animation is also present the runtime infers that length; with no finite source the render fails with "Composition has zero duration". Declare the intended length explicitly.',
|
|
2864
|
+
fixHint: 'Add data-duration="<seconds>" to the root element with the intended total length.',
|
|
2865
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
2866
|
+
}
|
|
2867
|
+
];
|
|
2868
|
+
}
|
|
2869
|
+
return [];
|
|
2722
2870
|
}
|
|
2723
2871
|
];
|
|
2724
2872
|
|
|
@@ -2751,7 +2899,7 @@ var adapterRules = [
|
|
|
2751
2899
|
(t) => /["']three["']/.test(t) && /importmap/.test(scripts.find((s) => s.content === t)?.attrs || "")
|
|
2752
2900
|
);
|
|
2753
2901
|
const hasThreeModuleImport = texts.some(
|
|
2754
|
-
(t) => /\
|
|
2902
|
+
(t) => /\b(?:import|from)\s*[^;\n]*['"][^'"]*three[^'"]*['"]/i.test(t)
|
|
2755
2903
|
);
|
|
2756
2904
|
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) return [];
|
|
2757
2905
|
return [
|
|
@@ -2958,6 +3106,12 @@ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
2958
3106
|
"math",
|
|
2959
3107
|
"emoji",
|
|
2960
3108
|
"fangsong",
|
|
3109
|
+
// Vendor-prefixed system-font keywords. Like `system-ui`, the engine resolves
|
|
3110
|
+
// these to the OS UI font — they are never installable files and must not be
|
|
3111
|
+
// flagged as a missing @font-face, even when a generic fallback follows them
|
|
3112
|
+
// (e.g. `-apple-system, system-ui, sans-serif`).
|
|
3113
|
+
"-apple-system",
|
|
3114
|
+
"blinkmacsystemfont",
|
|
2961
3115
|
"inherit",
|
|
2962
3116
|
"initial",
|
|
2963
3117
|
"unset",
|
|
@@ -2982,6 +3136,11 @@ function extractFontFaceFamilies(styles) {
|
|
|
2982
3136
|
}
|
|
2983
3137
|
return families;
|
|
2984
3138
|
}
|
|
3139
|
+
function normalizeUsedFontName(part) {
|
|
3140
|
+
const name = part.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase();
|
|
3141
|
+
if (!name || name.includes("(") || name.includes(")")) return null;
|
|
3142
|
+
return name;
|
|
3143
|
+
}
|
|
2985
3144
|
function extractUsedFontFamilies(styles) {
|
|
2986
3145
|
const used = [];
|
|
2987
3146
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -2990,9 +3149,8 @@ function extractUsedFontFamilies(styles) {
|
|
|
2990
3149
|
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
|
|
2991
3150
|
let match;
|
|
2992
3151
|
while ((match = propRe.exec(withoutFontFace)) !== null) {
|
|
2993
|
-
const
|
|
2994
|
-
|
|
2995
|
-
const name = part.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase();
|
|
3152
|
+
for (const part of match[1].split(",")) {
|
|
3153
|
+
const name = normalizeUsedFontName(part);
|
|
2996
3154
|
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
|
|
2997
3155
|
seen.add(name);
|
|
2998
3156
|
used.push(name);
|
|
@@ -3301,6 +3459,11 @@ import { existsSync, readFileSync, readdirSync } from "fs";
|
|
|
3301
3459
|
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "path";
|
|
3302
3460
|
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
|
3303
3461
|
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
|
3462
|
+
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
|
3463
|
+
import { parseHTML } from "linkedom";
|
|
3464
|
+
function parseSubCompHtml(html) {
|
|
3465
|
+
return parseHTML(html).document;
|
|
3466
|
+
}
|
|
3304
3467
|
var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
|
3305
3468
|
var STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
3306
3469
|
var OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
|
@@ -3462,7 +3625,8 @@ async function lintProject(projectDir) {
|
|
|
3462
3625
|
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
|
3463
3626
|
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
|
3464
3627
|
...lintMultipleRootCompositions(projectDir),
|
|
3465
|
-
...lintDuplicateAudioTracks(allHtmlSources)
|
|
3628
|
+
...lintDuplicateAudioTracks(allHtmlSources),
|
|
3629
|
+
...lintMissingOrEmptySubComposition(projectDir, rootHtml)
|
|
3466
3630
|
];
|
|
3467
3631
|
if (projectFindings.length > 0) {
|
|
3468
3632
|
for (const finding of projectFindings) {
|
|
@@ -3680,6 +3844,52 @@ function lintDuplicateAudioTracks(htmlSources) {
|
|
|
3680
3844
|
}
|
|
3681
3845
|
return findings;
|
|
3682
3846
|
}
|
|
3847
|
+
function lintMissingOrEmptySubComposition(projectDir, rootHtml) {
|
|
3848
|
+
const checked = /* @__PURE__ */ new Map();
|
|
3849
|
+
const visited = /* @__PURE__ */ new Set();
|
|
3850
|
+
const walk = (html) => {
|
|
3851
|
+
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
3852
|
+
const scannable = maskNonScannableRanges(html);
|
|
3853
|
+
let match;
|
|
3854
|
+
while ((match = compositionSrcRe.exec(scannable)) !== null) {
|
|
3855
|
+
const srcPath = (match[1] ?? "").trim();
|
|
3856
|
+
if (!srcPath) continue;
|
|
3857
|
+
if (/^__[A-Z_]+__$/.test(srcPath)) continue;
|
|
3858
|
+
const filePath = resolve(projectDir, srcPath);
|
|
3859
|
+
if (visited.has(filePath)) continue;
|
|
3860
|
+
visited.add(filePath);
|
|
3861
|
+
if (!existsSync(filePath)) {
|
|
3862
|
+
if (!checked.has(srcPath)) {
|
|
3863
|
+
checked.set(srcPath, { srcPath, problem: "the file does not exist" });
|
|
3864
|
+
}
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
const fileHtml = readFileSync(filePath, "utf-8");
|
|
3868
|
+
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
|
|
3869
|
+
if (!validity.ok) {
|
|
3870
|
+
if (!checked.has(srcPath)) {
|
|
3871
|
+
checked.set(srcPath, {
|
|
3872
|
+
srcPath,
|
|
3873
|
+
problem: validity.detail ?? "the file is empty or could not be parsed"
|
|
3874
|
+
});
|
|
3875
|
+
}
|
|
3876
|
+
continue;
|
|
3877
|
+
}
|
|
3878
|
+
walk(fileHtml);
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
walk(rootHtml);
|
|
3882
|
+
const findings = [];
|
|
3883
|
+
for (const { srcPath, problem } of checked.values()) {
|
|
3884
|
+
findings.push({
|
|
3885
|
+
code: "missing_or_empty_sub_composition",
|
|
3886
|
+
severity: "error",
|
|
3887
|
+
message: `data-composition-src references "${srcPath}", but ${problem}.`,
|
|
3888
|
+
fixHint: `Fix this before rendering \u2014 the render pre-flight rejects unusable sub-compositions. Write valid HTML into "${srcPath}" \u2014 it needs a <template> or <body> containing an element with data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the scene while you author it. If a scene-authoring step is still running, wait for it to finish before referencing the file, or re-run the step that generates it.`
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3891
|
+
return findings;
|
|
3892
|
+
}
|
|
3683
3893
|
export {
|
|
3684
3894
|
lintHyperframeHtml,
|
|
3685
3895
|
lintMediaUrls,
|