diff --git a/docs/guides/export-and-share.mdx b/docs/guides/export-and-share.mdx
index c81875b373..81a542d24a 100644
--- a/docs/guides/export-and-share.mdx
+++ b/docs/guides/export-and-share.mdx
@@ -130,7 +130,9 @@ immediately, update an existing link, or publish into a shared space.
A local video poster referenced with a relative path, such as
`../shared/poster.png`, is copied into the published project even when it lives
-outside the project directory.
+outside the project directory. Percent-encoded paths work too, such as
+`../shared/poster%20art.svg` for `poster art.svg` or
+`../shared/%E5%B0%81%E9%9D%A2.svg` for `封面.svg`.
Before sharing source, remove secrets and confirm that every included media file
can be shared. Tell a reviewer whether you need feedback on story, visuals,
diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts
index 22b0290edf..a8779c29ec 100644
--- a/packages/cli/src/utils/publishProject.test.ts
+++ b/packages/cli/src/utils/publishProject.test.ts
@@ -11,6 +11,7 @@ import {
import { tmpdir } from "node:os";
import { dirname, join, posix, relative } from "node:path";
import AdmZip from "adm-zip";
+import { parseHTML } from "linkedom";
const authMocks = vi.hoisted(() => ({
tryResolveCredential: vi.fn(),
@@ -224,6 +225,51 @@ async function runAuthenticatedPublish(
}
describe("createPublishArchive", () => {
+ it.each([
+ { filename: "poster art.svg", reference: "poster%20art.svg" },
+ { filename: "封面.svg", reference: "%E5%B0%81%E9%9D%A2.svg" },
+ { filename: "100%.svg", reference: "100%.svg" },
+ { filename: "poster%20art.svg", reference: "poster%20art.svg" },
+ { filename: "poster#art.svg", reference: "poster%23art.svg" },
+ { filename: "poster#art.svg", reference: "poster#art.svg" },
+ ])("archives $filename referenced as $reference", ({ filename, reference }) => {
+ const projectDir = makeProjectDir();
+ const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
+ try {
+ const entryPath = "compositions/scene.html";
+ const htmlDir = join(projectDir, dirname(entryPath));
+ mkdirSync(htmlDir, { recursive: true });
+ stageExternalAsset(extDir, htmlDir, filename, "POSTER_DATA");
+ if (reference !== filename) {
+ stageExternalAsset(extDir, htmlDir, reference, "LITERAL_DECOY");
+ }
+ const assetPath = relative(htmlDir, join(extDir, reference)).replaceAll("\\", "/");
+ const html = `
`;
+ writeFileSync(join(projectDir, "index.html"), "");
+ writeFileSync(join(projectDir, entryPath), html);
+ writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA");
+
+ const archive = createPublishArchive(projectDir);
+ const zip = new AdmZip(archive.buffer);
+ const { document } = parseHTML(zip.readAsText(entryPath));
+ const src = document.querySelector("img")?.getAttribute("src");
+ if (!src) throw new Error("Archived image has no src");
+ expect(document.querySelector("video")?.getAttribute("poster")).toBe(src);
+ const url = new URL(src, `https://archive.example/${entryPath}`);
+ expect(url.hash).toBe("");
+ expect(url.search).toBe("");
+ const archivedPath = decodeURIComponent(url.pathname.slice(1));
+ expect(archivedPath).toMatch(/^_ext\//);
+ expect(posix.basename(archivedPath)).toBe(filename);
+ expect(zip.readFile(archivedPath)).toEqual(Buffer.from("POSTER_DATA"));
+ expect(archive.fileCount).toBe(4);
+ expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html);
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true });
+ rmSync(extDir, { recursive: true, force: true });
+ }
+ });
+
it.each(["index.html", "compositions/scene.html"])(
"includes external video posters referenced from %s",
(entryPath) => {
@@ -268,18 +314,21 @@ describe("createPublishArchive", () => {
try {
const posters = [
"poster.png",
+ "poster%20art.png",
"https://cdn.example.com/poster.png",
+ "https://cdn.example.com/poster%20art.png",
"data:image/png;base64,AA==",
];
const html = `${posters.map((poster) => ``).join("")}`;
writeFileSync(join(projectDir, "index.html"), html);
writeFileSync(join(projectDir, "poster.png"), "PNG_DATA");
+ writeFileSync(join(projectDir, "poster art.png"), "ENCODED_PNG_DATA");
writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA");
const archive = createPublishArchive(projectDir);
const zip = new AdmZip(archive.buffer);
- expect(archive.fileCount).toBe(3);
+ expect(archive.fileCount).toBe(4);
expect(zip.readAsText("index.html")).toBe(html);
expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false);
} finally {
diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts
index 9c05f5c809..7de994a962 100644
--- a/packages/cli/src/utils/publishProject.ts
+++ b/packages/cli/src/utils/publishProject.ts
@@ -13,6 +13,7 @@ import { parseHTML } from "linkedom";
import AdmZip from "adm-zip";
import ignore, { type Ignore } from "ignore";
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
+import { decodeUrlPathVariants, encodeUrlPath } from "@hyperframes/parsers";
import { buildAuthHeaders } from "../auth/client.js";
import { tryResolveCredential } from "../auth/index.js";
import { isAuthError } from "../auth/errors.js";
@@ -434,14 +435,17 @@ function tryResolveExternal(
referrerAbsDir: string,
): string | null {
if (isNonRelativeUrl(rawPath)) return null;
- const absPath = resolve(referrerAbsDir, rawPath);
- if (isPathInside(absPath, ctx.absProjectDir)) return null;
- try {
- if (!existsSync(absPath) || !statSync(absPath).isFile()) return null;
- } catch {
- return null;
+ for (const path of decodeUrlPathVariants(rawPath)) {
+ const absPath = resolve(referrerAbsDir, path);
+ if (isPathInside(absPath, ctx.absProjectDir)) return null;
+ try {
+ if (!existsSync(absPath) || !statSync(absPath).isFile()) continue;
+ } catch {
+ continue;
+ }
+ return encodeUrlPath(addExternalAsset(ctx, absPath));
}
- return addExternalAsset(ctx, absPath);
+ return null;
}
/** Resolves a raw attribute or CSS `url()` value to the archive path or absolute