diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts
index 71885aba0f..ef57766efa 100644
--- a/packages/lint/src/project.test.ts
+++ b/packages/lint/src/project.test.ts
@@ -1236,3 +1236,40 @@ describe("sub-composition files", () => {
);
});
});
+
+describe("a media file whose own name holds # or ?", () => {
+ const name = "track #1.mp3";
+
+ async function findingFor(tag: string, code: string) {
+ const project = makeProject(`
+
+ ${tag}
+`);
+ mkdirSync(join(project, "assets"), { recursive: true });
+ writeFileSync(join(project, "assets", name), "");
+ writeFileSync(join(project, "assets", "pic #2.png"), "");
+ const { results } = await lintProject(project);
+ return results.flatMap((entry) => entry.result.findings).find((f) => f.code === code);
+ }
+
+ it("tells an audio src the name is cut at the # and how to write it", async () => {
+ const finding = await findingFor(
+ ``,
+ "audio_src_not_found",
+ );
+ expect(finding?.fixHint).toContain('"assets/track%20%231.mp3"');
+ });
+
+ it("tells an image src the same", async () => {
+ const finding = await findingFor(`
`, "missing_local_asset");
+ expect(finding?.fixHint).toContain('"assets/pic%20%232.png"');
+ });
+
+ it("keeps the add-the-file hint when no file by that name exists", async () => {
+ const finding = await findingFor(
+ ``,
+ "audio_src_not_found",
+ );
+ expect(finding?.fixHint).toContain("Add the file");
+ });
+});
diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts
index 1337f9c58e..da71259d1e 100644
--- a/packages/lint/src/project.ts
+++ b/packages/lint/src/project.ts
@@ -33,6 +33,7 @@ import {
resolveLocalAssetCandidates,
resolveProjectRelativeSrc,
} from "@hyperframes/parsers/asset-resolution";
+import { encodeUrlPath } from "@hyperframes/parsers";
import {
collectLocalVideoCandidates,
lintHevcPreviewCodec,
@@ -385,6 +386,18 @@ function lintProjectAudioFiles(
return findings;
}
+/** The src that loads a project file whose own name holds `#` or `?`, which a URL cuts the name at; null when the raw
+ * src names no such file. */
+function urlSafeSrcOf(projectDir: string, rootRelative: string): string | null {
+ if (!/[?#]/.test(rootRelative)) return null;
+ const literal = resolve(projectDir, rootRelative.replace(/^\//, ""));
+ if (!existsSync(literal)) return null;
+ return encodeUrlPath(rootRelative);
+}
+
+const urlSafeHint = (safe: string) =>
+ `The file exists, but a "#" or "?" in a src ends the file name there. Write the src as "${safe}".`;
+
function lintAudioSrcNotFound(
projectDir: string,
htmlSources: HtmlSource[],
@@ -394,6 +407,7 @@ function lintAudioSrcNotFound(
const audioSrcRe = mediaSrcTagRe("audio");
const missingByFile = new Map>();
+ const urlSafe = new Map();
for (const { html, compSrcPath } of htmlSources) {
const scannable = maskNonScannableRanges(html);
let match: RegExpExecArray | null;
@@ -405,6 +419,8 @@ function lintAudioSrcNotFound(
? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path)))
: src;
if (!existsSync(resolveProjectRelativeSrc(rootRelative, projectDir))) {
+ const safe = urlSafeSrcOf(projectDir, rootRelative);
+ if (safe) urlSafe.set(src, safe);
const file = sourceFile(projectDir, compSrcPath);
missingByFile.set(file, (missingByFile.get(file) ?? new Set()).add(src));
}
@@ -419,9 +435,11 @@ function lintAudioSrcNotFound(
file,
message: `