mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(lint): flag HEVC video assets with info-level preview note
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { isAbsolute, posix, relative, resolve } from "node:path";
|
||||
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
||||
|
||||
/**
|
||||
* Shared local-asset resolution helpers used by both the project-level lint
|
||||
* rules (`project.ts`) and the HEVC preview-codec check
|
||||
* (`hevcPreviewLint.ts`). Split out so the latter doesn't need to import from
|
||||
* `project.ts` (which imports it back to run the rule) — that would be a
|
||||
* circular import within the package.
|
||||
*/
|
||||
|
||||
export function isRemoteOrInlineUrl(url: string): boolean {
|
||||
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
|
||||
}
|
||||
|
||||
export function cleanAssetUrl(url: string): string {
|
||||
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
export function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const relativePath = relative(projectRoot, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function addCandidate(candidates: string[], candidate: string): void {
|
||||
if (!candidates.includes(candidate)) candidates.push(candidate);
|
||||
}
|
||||
|
||||
export function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
|
||||
const cleanUrl = cleanAssetUrl(url);
|
||||
const projectRoot = resolve(projectDir);
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const variant of decodeUrlPathVariants(cleanUrl)) {
|
||||
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
|
||||
const resolved = resolve(projectRoot, projectRelative);
|
||||
if (isWithinProjectRoot(projectRoot, resolved)) {
|
||||
addCandidate(candidates, resolved);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
|
||||
const clamped = normalized.replace(/^(\.\.\/)+/, "");
|
||||
if (clamped && !clamped.startsWith("..")) {
|
||||
addCandidate(candidates, resolve(projectRoot, clamped));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveExistingLocalAsset(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
): { resolved: string; rootRelativePath: string } | null {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
|
||||
if (!resolved) return null;
|
||||
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
|
||||
}
|
||||
|
||||
function maskRange(src: string, pattern: RegExp): string {
|
||||
return src.replace(pattern, (m) => " ".repeat(m.length));
|
||||
}
|
||||
|
||||
/** Blanks out comments, `<style>`, and `<script>` bodies so tag-scanning
|
||||
* regexes don't false-positive on commented-out or scripted markup. */
|
||||
export function maskNonScannableRanges(html: string): string {
|
||||
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
||||
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
||||
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { execFile, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||
import {
|
||||
cleanAssetUrl,
|
||||
isRemoteOrInlineUrl,
|
||||
maskNonScannableRanges,
|
||||
resolveExistingLocalAsset,
|
||||
} from "./assetResolution.js";
|
||||
import type { HyperframeLintFinding } from "./types.js";
|
||||
|
||||
/** Structurally compatible with `project.ts`'s (unexported) `HtmlSource` —
|
||||
* duplicated as a shape, not imported, to avoid a circular import between
|
||||
* this file and `project.ts` (which imports `lintHevcPreviewCodec` below). */
|
||||
interface HtmlSourceLike {
|
||||
html: string;
|
||||
compSrcPath?: string;
|
||||
}
|
||||
|
||||
const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||
const PROBE_TIMEOUT_MS = 4000;
|
||||
|
||||
// Minimal PATH/env-based ffprobe resolution, duplicated from
|
||||
// packages/cli/src/browser/ffmpeg.ts (findFFprobe). packages/lint must not
|
||||
// depend on packages/cli (the CLI depends on @hyperframes/lint, which would
|
||||
// create an import cycle) or packages/engine (too heavy for a lint check),
|
||||
// so only the ffprobe-lookup half is re-implemented here — no ffmpeg lookup,
|
||||
// no install-hint text, no Linux-distro detection.
|
||||
|
||||
function chooseBestFfprobeCandidate(candidates: string[]): string | undefined {
|
||||
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
|
||||
if (normalized.length === 0) return undefined;
|
||||
const preferredExe = normalized.find((c) => c.toLowerCase().endsWith("ffprobe.exe"));
|
||||
if (preferredExe) return preferredExe;
|
||||
const exact = normalized.find((c) => c.toLowerCase().endsWith("ffprobe"));
|
||||
if (exact) return exact;
|
||||
const nonShellShim = normalized.find((c) => {
|
||||
const lower = c.toLowerCase();
|
||||
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
|
||||
});
|
||||
return nonShellShim ?? normalized[0];
|
||||
}
|
||||
|
||||
function findFFprobeOnPath(): string | undefined {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? "where ffprobe" : "which ffprobe";
|
||||
const output = execSync(cmd, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
});
|
||||
const candidate = chooseBestFfprobeCandidate(output.split(/\r?\n/));
|
||||
return candidate ? resolve(candidate) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const COMMON_BIN_DIRS =
|
||||
process.platform === "win32"
|
||||
? []
|
||||
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
|
||||
|
||||
function findFFprobeInCommonDirs(): string | undefined {
|
||||
for (const dir of COMMON_BIN_DIRS) {
|
||||
const candidate = `${dir}/ffprobe`;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFFprobe(): string | undefined {
|
||||
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
|
||||
return findFFprobeOnPath() ?? findFFprobeInCommonDirs();
|
||||
}
|
||||
|
||||
function execFileAsync(file: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
|
||||
if (error) reject(error);
|
||||
else resolvePromise(stdout.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasHevcStream(json: unknown): boolean {
|
||||
if (typeof json !== "object" || json === null) return false;
|
||||
const streams = Reflect.get(json, "streams");
|
||||
if (!Array.isArray(streams)) return false;
|
||||
return streams.some((stream) => {
|
||||
if (typeof stream !== "object" || stream === null) return false;
|
||||
return Reflect.get(stream, "codec_name") === "hevc";
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort: any failure (ffprobe missing, times out, non-video file,
|
||||
// unparsable output) resolves to "not HEVC" rather than throwing. This rule
|
||||
// must never fail lint/check just because ffprobe isn't installed.
|
||||
async function probeIsHevc(ffprobePath: string, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stdout = await execFileAsync(ffprobePath, [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
filePath,
|
||||
]);
|
||||
return hasHevcStream(JSON.parse(stdout));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects local `<video src>` references, resolved to their absolute path
|
||||
* and deduped by that path — this is both the candidate set AND the in-run
|
||||
* probe cache for `lintHevcPreviewCodec` below: the same file referenced
|
||||
* twice only ends up as one map entry, so it's only probed once.
|
||||
*
|
||||
* Files that don't resolve to an existing local asset are skipped here —
|
||||
* `missing_local_asset` already reports those, and hevc_preview_codec never
|
||||
* probes a file that doesn't exist.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function collectLocalVideoCandidates(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
): Map<string, string> {
|
||||
const candidates = new Map<string, string>();
|
||||
const videoSrcRe = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(scannable)) !== null) {
|
||||
const src = cleanAssetUrl(match[1] ?? "");
|
||||
if (!src) continue;
|
||||
if (isRemoteOrInlineUrl(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (!resolvedAsset) continue;
|
||||
if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* INFO-only finding: a locally referenced `<video>` file is encoded as
|
||||
* HEVC/H.265. The render pipeline pre-decodes video with FFmpeg (never the
|
||||
* browser decoder) so rendering is unaffected, but live preview and the
|
||||
* embeddable player play the file directly in-browser, where HEVC support
|
||||
* varies. Never escalated beyond "info" — this must not fail lint or check.
|
||||
*
|
||||
* `candidates` maps each unique resolved file path to a display src string
|
||||
* (already deduped by the caller, so each file is probed exactly once here);
|
||||
* files missing from disk are the caller's responsibility to have excluded —
|
||||
* `missing_local_asset` covers those and this rule never probes them.
|
||||
*/
|
||||
export async function lintHevcPreviewCodec(
|
||||
candidates: Map<string, string>,
|
||||
): Promise<HyperframeLintFinding[]> {
|
||||
if (candidates.size === 0) return [];
|
||||
|
||||
const ffprobePath = findFFprobe();
|
||||
if (!ffprobePath) return [];
|
||||
|
||||
const entries = [...candidates.entries()];
|
||||
const isHevc = await Promise.all(
|
||||
entries.map(([resolvedPath]) => probeIsHevc(ffprobePath, resolvedPath)),
|
||||
);
|
||||
|
||||
const hevcSrcs = entries.filter((_, i) => isHevc[i]).map(([, src]) => src);
|
||||
if (hevcSrcs.length === 0) return [];
|
||||
|
||||
const unique = [...new Set(hevcSrcs)];
|
||||
return [
|
||||
{
|
||||
code: "hevc_preview_codec",
|
||||
severity: "info",
|
||||
message:
|
||||
`Video file(s) use the HEVC/H.265 codec: ${unique.join(", ")}. ` +
|
||||
"The render pipeline pre-decodes video with FFmpeg and never uses the browser's video decoder, so these render correctly. " +
|
||||
"Live preview/player playback requires a browser with HEVC support. " +
|
||||
"If preview playback fails, generate an H.264 proxy (e.g. via the media-use skill) and reference that instead.",
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Generate an H.264 proxy for "${unique[0]}" (e.g. via the media-use skill) if it fails to play in preview.`
|
||||
: "Generate H.264 proxies for these files (e.g. via the media-use skill) if they fail to play in preview.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { ChildProcess, execFile } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HyperframeLintFinding } from "./types.js";
|
||||
import { lintProject } from "./project.js";
|
||||
|
||||
// Keep project lint tests independent of the host's ffprobe installation.
|
||||
vi.mock("node:child_process", () => {
|
||||
const mocked = { ChildProcess: class {}, execFile: vi.fn(), execSync: vi.fn() };
|
||||
return { ...mocked, default: mocked };
|
||||
});
|
||||
|
||||
function tmpProject(name: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`));
|
||||
}
|
||||
@@ -235,3 +242,163 @@ describe("template shell style sources", () => {
|
||||
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hevc_preview_codec", () => {
|
||||
interface ProbeStream {
|
||||
codec_name: string;
|
||||
codec_tag_string: string;
|
||||
}
|
||||
|
||||
const mockExecFile = vi.mocked(execFile);
|
||||
|
||||
// Any real file works as a stand-in "ffprobe" path — execFile itself is
|
||||
// mocked below, so it's never actually spawned.
|
||||
const FAKE_FFPROBE_PATH = process.execPath;
|
||||
|
||||
function mockFfprobeStreams(streamsByFile: Record<string, ProbeStream[]>): void {
|
||||
mockExecFile.mockImplementation((_file, args, _options, callback) => {
|
||||
const filePath = args[args.length - 1] ?? "";
|
||||
callback(
|
||||
null,
|
||||
Buffer.from(JSON.stringify({ streams: streamsByFile[filePath] ?? [] })),
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
return new ChildProcess();
|
||||
});
|
||||
}
|
||||
|
||||
function videoHtml(...videoSrcs: string[]): string {
|
||||
const videoTags = videoSrcs
|
||||
.map(
|
||||
(src, i) =>
|
||||
`<video id="v${i}" class="clip" src="${src}" muted data-start="${i * 5}" data-duration="5"></video>`,
|
||||
)
|
||||
.join("\n ");
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
${videoTags}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function makeVideoProject(
|
||||
videoSrc: string,
|
||||
writeVideoFile = true,
|
||||
): { project: string; videoAbsPath: string } {
|
||||
const project = makeProject(videoHtml(videoSrc));
|
||||
const videoAbsPath = join(project, videoSrc);
|
||||
if (writeVideoFile) writeFileSync(videoAbsPath, "fake video bytes");
|
||||
return { project, videoAbsPath };
|
||||
}
|
||||
|
||||
async function hevcFindings(project: string): Promise<HyperframeLintFinding[]> {
|
||||
const { results } = await lintProject(project);
|
||||
return results.flatMap((r) => r.result.findings).filter((f) => f.code === "hevc_preview_codec");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = FAKE_FFPROBE_PATH;
|
||||
mockExecFile.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
mockExecFile.mockReset();
|
||||
});
|
||||
|
||||
it("flags an HEVC video with exactly one info finding naming the file", async () => {
|
||||
const { project, videoAbsPath } = makeVideoProject("clip.mp4");
|
||||
mockFfprobeStreams({
|
||||
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hvc1" }],
|
||||
});
|
||||
|
||||
const result = await lintProject(project);
|
||||
const findings = result.results
|
||||
.flatMap((entry) => entry.result.findings)
|
||||
.filter((finding) => finding.code === "hevc_preview_codec");
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.severity).toBe("info");
|
||||
expect(findings[0]?.code).toBe("hevc_preview_codec");
|
||||
expect(findings[0]?.message).toContain("clip.mp4");
|
||||
expect(findings[0]?.message).toContain("requires a browser with HEVC support");
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(result.results[0]?.result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('flags an hev1-tagged HEVC video the same way (ffprobe reports codec_name "hevc" regardless of the container fourcc)', async () => {
|
||||
const { project, videoAbsPath } = makeVideoProject("clip-hev1.mp4");
|
||||
mockFfprobeStreams({
|
||||
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hev1" }],
|
||||
});
|
||||
|
||||
const findings = await hevcFindings(project);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.message).toContain("clip-hev1.mp4");
|
||||
});
|
||||
|
||||
it("does not flag an H.264 video", async () => {
|
||||
const { project, videoAbsPath } = makeVideoProject("clip.mp4");
|
||||
mockFfprobeStreams({
|
||||
[videoAbsPath]: [{ codec_name: "h264", codec_tag_string: "avc1" }],
|
||||
});
|
||||
|
||||
const findings = await hevcFindings(project);
|
||||
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag anything, and lint completes normally, when ffprobe cannot be resolved", async () => {
|
||||
const { project } = makeVideoProject("clip.mp4");
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = join(project, "missing-ffprobe");
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
|
||||
const findings = results.flatMap((r) => r.result.findings);
|
||||
expect(findings.some((f) => f.code === "hevc_preview_codec")).toBe(false);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(totalErrors).toBe(0);
|
||||
});
|
||||
|
||||
it("silently skips the finding when ffprobe errors or times out", async () => {
|
||||
const { project } = makeVideoProject("clip.mp4");
|
||||
mockExecFile.mockImplementation((_file, _args, _options, callback) => {
|
||||
callback(new Error("ffprobe timed out"), Buffer.alloc(0), Buffer.alloc(0));
|
||||
return new ChildProcess();
|
||||
});
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
|
||||
const findings = results.flatMap((entry) => entry.result.findings);
|
||||
expect(findings.some((finding) => finding.code === "hevc_preview_codec")).toBe(false);
|
||||
expect(totalErrors).toBe(0);
|
||||
});
|
||||
|
||||
it("does not probe or flag a missing video file — missing_local_asset covers it instead", async () => {
|
||||
const { project } = makeVideoProject("missing.mp4", false);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const findings = results.flatMap((r) => r.result.findings);
|
||||
expect(findings.some((f) => f.code === "hevc_preview_codec")).toBe(false);
|
||||
expect(findings.some((f) => f.code === "missing_local_asset")).toBe(true);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("probes the same HEVC file once when referenced twice (per-run cache)", async () => {
|
||||
const project = makeProject(videoHtml("clip.mp4", "clip.mp4"));
|
||||
const videoAbsPath = join(project, "clip.mp4");
|
||||
writeFileSync(videoAbsPath, "fake video bytes");
|
||||
mockFfprobeStreams({
|
||||
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hvc1" }],
|
||||
});
|
||||
|
||||
const findings = await hevcFindings(project);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
export { shouldBlockRender } from "./shouldBlockRender.js";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
||||
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
cleanAssetUrl,
|
||||
isRemoteOrInlineUrl,
|
||||
isWithinProjectRoot,
|
||||
maskNonScannableRanges,
|
||||
resolveExistingLocalAsset,
|
||||
resolveLocalAssetCandidates,
|
||||
} from "./assetResolution.js";
|
||||
import { collectLocalVideoCandidates, lintHevcPreviewCodec } from "./hevcPreviewLint.js";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
|
||||
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
|
||||
@@ -113,57 +121,6 @@ function collectCssSources(projectDir: string, html: string, compSrcPath?: strin
|
||||
return sources;
|
||||
}
|
||||
|
||||
function isRemoteOrInlineUrl(url: string): boolean {
|
||||
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
|
||||
}
|
||||
|
||||
function cleanAssetUrl(url: string): string {
|
||||
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const relativePath = relative(projectRoot, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function addCandidate(candidates: string[], candidate: string): void {
|
||||
if (!candidates.includes(candidate)) candidates.push(candidate);
|
||||
}
|
||||
|
||||
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
|
||||
const cleanUrl = cleanAssetUrl(url);
|
||||
const projectRoot = resolve(projectDir);
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const variant of decodeUrlPathVariants(cleanUrl)) {
|
||||
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
|
||||
const resolved = resolve(projectRoot, projectRelative);
|
||||
if (isWithinProjectRoot(projectRoot, resolved)) {
|
||||
addCandidate(candidates, resolved);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
|
||||
const clamped = normalized.replace(/^(\.\.\/)+/, "");
|
||||
if (clamped && !clamped.startsWith("..")) {
|
||||
addCandidate(candidates, resolve(projectRoot, clamped));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveExistingLocalAsset(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
): { resolved: string; rootRelativePath: string } | null {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
|
||||
if (!resolved) return null;
|
||||
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
|
||||
}
|
||||
|
||||
function resolveCssAssetCandidates(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
@@ -255,6 +212,7 @@ export async function lintProject(
|
||||
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
||||
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
for (const finding of projectFindings) {
|
||||
@@ -348,17 +306,6 @@ function lintAudioSrcNotFound(
|
||||
return findings;
|
||||
}
|
||||
|
||||
function maskRange(src: string, pattern: RegExp): string {
|
||||
return src.replace(pattern, (m) => " ".repeat(m.length));
|
||||
}
|
||||
|
||||
function maskNonScannableRanges(html: string): string {
|
||||
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
||||
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
||||
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function lintMissingLocalAsset(
|
||||
projectDir: string,
|
||||
|
||||
Reference in New Issue
Block a user