fix(cli): include encoded external assets in archives (#6266)

This commit is contained in:
Akshar Patel
2026-10-25 03:45:43 +00:00
committed by GitHub
parent f5e15b2f77
commit c6c8d81ba3
3 changed files with 64 additions and 9 deletions
+3 -1
View File
@@ -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,
+50 -1
View File
@@ -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 = `<html><body><img src="${assetPath}"><video src="../clip.mp4" poster="${assetPath}"></video></body></html>`;
writeFileSync(join(projectDir, "index.html"), "<html></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 = `<html><body>${posters.map((poster) => `<video src="clip.mp4" poster="${poster}"></video>`).join("")}</body></html>`;
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 {
+11 -7
View File
@@ -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