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