From 664c39db9e604a504bd1336c7b2ba3368cf1379d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 14 Jul 2026 19:26:50 -0400 Subject: [PATCH] feat(lint): flag HEVC video assets with info-level preview note --- packages/lint/src/assetResolution.ts | 75 ++++++++++ packages/lint/src/hevcPreviewLint.ts | 202 +++++++++++++++++++++++++++ packages/lint/src/project.test.ts | 169 +++++++++++++++++++++- packages/lint/src/project.ts | 75 ++-------- 4 files changed, 456 insertions(+), 65 deletions(-) create mode 100644 packages/lint/src/assetResolution.ts create mode 100644 packages/lint/src/hevcPreviewLint.ts diff --git a/packages/lint/src/assetResolution.ts b/packages/lint/src/assetResolution.ts new file mode 100644 index 000000000..4ff76a141 --- /dev/null +++ b/packages/lint/src/assetResolution.ts @@ -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, `