Skip to content
10 changes: 10 additions & 0 deletions packages/lint/src/hevcPreviewLint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ describe("collectLocalVideoCandidates", () => {
expect([...candidates.keys()]).toEqual([join(dir, "design/styleframes/clip.mp4")]);
});

it("finds a double-quoted src whose name contains an apostrophe", () => {
const dir = makeProject(["assets/Ann's clip.mp4"]);

const candidates = collectLocalVideoCandidates(dir, [
{ html: `<video src="assets/Ann's clip.mp4"></video>` },
]);

expect([...candidates.keys()]).toEqual([join(dir, "assets/Ann's clip.mp4")]);
});

it("still resolves project-root refs that have no sibling", () => {
const dir = makeProject(["assets/clip.mp4"]);

Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/hevcPreviewLint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@hyperframes/parsers/asset-resolution";
import { parseHTML } from "linkedom";
import type { HyperframeLintFinding } from "./types.js";
import { mediaSrcTagRe } from "./utils";
import { mediaSrcOf, mediaSrcTagRe } from "./utils";

/** Structurally compatible with `project.ts`'s (unexported) `HtmlSource` —
* duplicated as a shape, not imported, to avoid a circular import between
Expand Down Expand Up @@ -126,7 +126,7 @@ export function collectLocalVideoCandidates(
const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(scannable)) !== null) {
const rawSrc = match[2] ?? "";
const rawSrc = mediaSrcOf(match);
const reference = resolveLocalVideoReference(projectDir, rawSrc, compSrcPath);
if (!reference || candidates.has(reference.resolved)) continue;
candidates.set(reference.resolved, reference.src);
Expand Down
80 changes: 80 additions & 0 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,86 @@ describe("audio_src_not_found with templating tokens", () => {
});
});

describe("a double-quoted src that contains an apostrophe", () => {
const name = "Narrator's voice take 1.mp3";

it("finds the existing audio file instead of cutting the src at the apostrophe", async () => {
const project = makeProject(`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="assets/${name}"></audio>
</body></html>`);
mkdirSync(join(project, "assets"), { recursive: true });
writeFileSync(join(project, "assets", name), "");
const { results } = await lintProject(project);
const codes = results.flatMap((entry) => entry.result.findings).map((f) => f.code);
expect(codes).not.toContain("audio_src_not_found");
});

async function lintCodes(body: string, files: string[]): Promise<string[]> {
const project = makeProject(`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
${body}
</body></html>`);
mkdirSync(join(project, "assets"), { recursive: true });
for (const file of files) writeFileSync(join(project, "assets", file), "");
const { results } = await lintProject(project);
return results.flatMap((entry) => entry.result.findings).map((f) => f.code);
}

it("finds an existing audio file whose apostrophe is written as a character reference", async () => {
const codes = await lintCodes(
`<audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="assets/Narrator&#39;s take.mp3"></audio>`,
["Narrator's take.mp3"],
);
expect(codes).not.toContain("audio_src_not_found");
});

it("tells two layered files apart when their names share text up to an apostrophe", async () => {
const audio = (id: string, file: string) =>
`<audio id="${id}" class="clip" data-start="0" data-duration="5" data-track-index="10" src="assets/${file}"></audio>`;
const codes = await lintCodes(
audio("a1", "Narrator's take 1.mp3") + audio("a2", "Narrator's take 2.mp3"),
["Narrator's take 1.mp3", "Narrator's take 2.mp3"],
);
expect(codes).toContain("duplicate_audio_track");
});

it("still reports a missing file whose name starts with a quote character", async () => {
const codes = await lintCodes(
`<audio id="a1" class="clip" data-start="0" data-duration="3" data-track-index="10" src="'90s theme.mp3"></audio>`,
[],
);
expect(codes).toContain("audio_src_not_found");
});

it("does not read an audio tag that an inline script builds as a string", async () => {
const codes = await lintCodes(
`<script>var s = '<audio src="' + u + '">'; var t = "<audio src='" + u + "'>";</script>`,
[],
);
expect(codes).not.toContain("audio_src_not_found");
});

it("does not read a src out of another attribute's text", async () => {
const codes = await lintCodes(`<img id="i1" alt="x src='ghost.png'" />`, []);
expect(codes).not.toContain("missing_local_asset");
});

it("finds the existing image through the shared src pattern too", async () => {
const image = "Ann's photo.png";
const project = makeProject(`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<img id="i1" class="clip" data-start="0" data-duration="3" data-track-index="0" src="assets/${image}">
</div>
</body></html>`);
mkdirSync(join(project, "assets"), { recursive: true });
writeFileSync(join(project, "assets", image), "");
const { results } = await lintProject(project);
const codes = results.flatMap((entry) => entry.result.findings).map((f) => f.code);
expect(codes).not.toContain("missing_local_asset");
});
});

describe("templating tokens are checked on the raw src, before cleanAssetUrl", () => {
// cleanAssetUrl splits on ?/#, which also chops inside a ${...} expression
// (e.g. `${asset?.url}` -> `${asset`). The token skip must run on the RAW value or
Expand Down
23 changes: 9 additions & 14 deletions packages/lint/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type {
HyperframeLinterOptions,
} from "./types.js";
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
import { isAudibleVideoTag, mediaSrcTagRe } from "./utils";
import { isAudibleVideoTag, mediaSrcOf, mediaSrcTagRe, readAttr, readDecodedAttr } from "./utils";

/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
function parseSubCompHtml(html: string): ParsableDocumentLike {
Expand Down Expand Up @@ -370,9 +370,10 @@ function lintAudioSrcNotFound(

const missingByFile = new Map<string, Set<string>>();
for (const { html, compSrcPath } of htmlSources) {
const scannable = maskNonScannableRanges(html);
let match: RegExpExecArray | null;
while ((match = audioSrcRe.exec(html)) !== null) {
const src = match[2]!;
while ((match = audioSrcRe.exec(scannable)) !== null) {
const src = mediaSrcOf(match);
if (/^(https?:|data:|blob:)/i.test(src)) continue;
if (isUnresolvedAssetPlaceholder(src)) continue;
const rootRelative = compSrcPath
Expand Down Expand Up @@ -422,7 +423,7 @@ function lintMissingLocalAsset(
let match: RegExpExecArray | null;
while ((match = re.exec(scannable)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const rawSrc = match[2] ?? "";
const rawSrc = mediaSrcOf(match);
// Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token.
if (isUnresolvedAssetPlaceholder(rawSrc)) continue;
const src = cleanAssetUrl(rawSrc);
Expand Down Expand Up @@ -544,12 +545,6 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding

function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
function extractAttr(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
const m = tag.match(re);
return m?.[1] ?? null;
}

const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
const seen = new Set<string>();

Expand All @@ -559,10 +554,10 @@ function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFind
while ((match = soundTagRe.exec(html)) !== null) {
const tag = match[0];
if (/^<video/i.test(tag) && !isAudibleVideoTag(tag)) continue;
const trackStr = extractAttr(tag, "data-track-index");
const startStr = extractAttr(tag, "data-start");
const durStr = extractAttr(tag, "data-duration");
const src = extractAttr(tag, "src") ?? "unknown";
const trackStr = readAttr(tag, "data-track-index");
const startStr = readAttr(tag, "data-start");
const durStr = readAttr(tag, "data-duration");
const src = readDecodedAttr(tag, "src") || "unknown";
if (!trackStr || !startStr) continue;

const trackIndex = parseInt(trackStr, 10);
Expand Down
15 changes: 7 additions & 8 deletions packages/lint/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,14 +858,13 @@ export function truncateSnippet(value: string, maxLength = 220): string | undefi

/**
* Matches a media tag carrying a real `src` attribute, capturing the tag name in
* group 1 and the src value in group 2.
*
* The leading whitespace before `src` is load-bearing: `\bsrc\s*=` also matches
* the tail of `data-var-src="bg"` (a hyphen/`s` boundary is a word boundary), and
* since `[^>]*` is greedy it wins over a real `src` earlier in the same tag. Every
* element using a variable binding was therefore reported as referencing a missing
* file named after the variable id.
* group 1 and the quoted src in group 2; {@link mediaSrcOf} reads its value.
* `src` must follow whitespace so the tail of `data-var-src="bg"` is not read as one.
*/
export function mediaSrcTagRe(tagAlternation: string): RegExp {
return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']([^"']+)["'][^>]*>`, "gi");
return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*("[^"]*"|'[^']*')[^>]*>`, "gi");
}

export function mediaSrcOf(match: RegExpExecArray): string {
return readDecodedAttr(match[0], "src") ?? "";
}
Loading