From 17c689593c57b35ce6951f452ddb0788f3f40cb7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 00:21:28 -0700 Subject: [PATCH 1/6] fix(lint): a double-quoted media src with an apostrophe is read whole, not reported missing --- packages/lint/src/project.test.ts | 30 ++++++++++++++++++++++++++++++ packages/lint/src/utils.ts | 7 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 438c272ffa1..1035bd463a1 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -781,6 +781,36 @@ 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(` +
+ +`); + 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"); + }); + + it("finds the existing image through the shared src pattern too", async () => { + const image = "Ann's photo.png"; + const project = makeProject(` +
+ +
+`); + 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 diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index dc274a4cf4b..4e0117c4842 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -858,7 +858,7 @@ 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. + * group 1 and the src value, up to the quote that opened it, 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 @@ -867,5 +867,8 @@ export function truncateSnippet(value: string, maxLength = 220): string | undefi * file named after the variable id. */ export function mediaSrcTagRe(tagAlternation: string): RegExp { - return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']([^"']+)["'][^>]*>`, "gi"); + return new RegExp( + `<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']((?<=")[^"]+|(?<=')[^']+)["'][^>]*>`, + "gi", + ); } From 7a0ba111e64b9bd25db6a66471faf6c60c123815 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 00:59:22 -0700 Subject: [PATCH 2/6] fix(lint): every media src read goes through the attribute parser, decoded --- packages/lint/src/hevcPreviewLint.ts | 4 ++-- packages/lint/src/project.test.ts | 29 ++++++++++++++++++++++++++++ packages/lint/src/project.ts | 20 +++++++------------ packages/lint/src/utils.ts | 7 ++++++- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/lint/src/hevcPreviewLint.ts b/packages/lint/src/hevcPreviewLint.ts index 927d54d2a07..56a7561e0cf 100644 --- a/packages/lint/src/hevcPreviewLint.ts +++ b/packages/lint/src/hevcPreviewLint.ts @@ -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 @@ -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); diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 1035bd463a1..fb818753973 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -796,6 +796,35 @@ describe("a double-quoted src that contains an apostrophe", () => { expect(codes).not.toContain("audio_src_not_found"); }); + async function lintCodes(body: string, files: string[]): Promise { + const project = makeProject(` +
+ ${body} +`); + 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( + ``, + ["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) => + ``; + 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("finds the existing image through the shared src pattern too", async () => { const image = "Ann's photo.png"; const project = makeProject(` diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index afd4910d05f..914e9271df5 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -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 { @@ -372,7 +372,7 @@ function lintAudioSrcNotFound( for (const { html, compSrcPath } of htmlSources) { let match: RegExpExecArray | null; while ((match = audioSrcRe.exec(html)) !== null) { - const src = match[2]!; + const src = mediaSrcOf(match); if (/^(https?:|data:|blob:)/i.test(src)) continue; if (isUnresolvedAssetPlaceholder(src)) continue; const rootRelative = compSrcPath @@ -422,7 +422,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); @@ -544,12 +544,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(); @@ -559,10 +553,10 @@ function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFind while ((match = soundTagRe.exec(html)) !== null) { const tag = match[0]; if (/^