fix(cli): localize external assets in publish archive (#1160)

Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

localizeExternalAssets() now scans all HTML and CSS files in the archive
for src, href, and url() references that resolve outside the project
dir. For each, it copies the file into the archive under _ext/ and
rewrites the reference to point there.

Handles: src/href attributes, <style> url(), inline style url(),
standalone CSS url(), sub-composition HTML files, deduplication of
the same asset referenced from multiple files.

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
This commit is contained in:
Miguel Ángel
2026-06-01 21:06:35 -04:00
committed by GitHub
parent 598e3e957a
commit a4706da513
7 changed files with 486 additions and 33 deletions
+263 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative } from "node:path";
import AdmZip from "adm-zip";
import {
createPublishArchive,
getPublishApiBaseUrl,
localizeExternalAssets,
publishProjectArchive,
uploadTimeoutMs,
} from "./publishProject.js";
@@ -36,6 +38,266 @@ describe("createPublishArchive", () => {
});
});
describe("localizeExternalAssets", () => {
it("copies external src/href assets and rewrites HTML paths", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "logo.png"), "PNG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "logo.png")).replaceAll("\\", "/");
const html = `<html><body><img src="${relToExt}"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).not.toContain(relToExt);
expect(rewrittenHtml).toContain("_ext/");
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
expect(extEntries).toHaveLength(1);
expect(files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA");
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("rewrites CSS url() in <style> blocks", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
const html = `<html><head><style>body { background: url("${relToExt}"); }</style></head></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("url(");
expect(rewrittenHtml).toContain("_ext/");
expect(rewrittenHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("rewrites url() in standalone CSS files", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "font.woff2"), "FONT_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "font.woff2")).replaceAll("\\", "/");
const css = `@font-face { src: url("${relToExt}"); }`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
files.set("styles.css", Buffer.from(css, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenCss = files.get("styles.css")!.toString("utf-8");
expect(rewrittenCss).toContain("_ext/");
expect(rewrittenCss).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("leaves internal assets unchanged", () => {
const projectDir = makeProjectDir();
try {
mkdirSync(join(projectDir, "assets"));
writeFileSync(join(projectDir, "assets", "logo.svg"), "<svg/>", "utf-8");
const html = `<html><body><img src="assets/logo.svg"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
files.set("assets/logo.svg", Buffer.from("<svg/>", "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain('src="assets/logo.svg"');
expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("leaves remote URLs unchanged", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("https://cdn.example.com/logo.png");
expect(rewrittenHtml).toContain("http://cdn.example.com/vid.mp4");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("deduplicates: same external asset referenced from multiple files", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "shared.png"), "SHARED", "utf-8");
const relToExt = relative(projectDir, join(extDir, "shared.png")).replaceAll("\\", "/");
const files = new Map<string, Buffer>();
files.set(
"index.html",
Buffer.from(`<html><body><img src="${relToExt}"></body></html>`, "utf-8"),
);
mkdirSync(join(projectDir, "compositions"));
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="../${relToExt}"></body></html>`, "utf-8"),
);
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/"));
expect(extEntries).toHaveLength(1);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("handles sub-composition HTML with external refs", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
mkdirSync(join(projectDir, "compositions"));
writeFileSync(join(extDir, "overlay.png"), "OVERLAY", "utf-8");
const relFromComps = relative(
join(projectDir, "compositions"),
join(extDir, "overlay.png"),
).replaceAll("\\", "/");
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="${relFromComps}"></body></html>`, "utf-8"),
);
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewritten = files.get("compositions/scene.html")!.toString("utf-8");
expect(rewritten).toContain("_ext/");
expect(rewritten).not.toContain(relFromComps);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("no-op when no external assets exist", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><p>Hello</p></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
expect(files.size).toBe(1);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("skips references to non-existent external files", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><img src="../nonexistent/file.png"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(0);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("../nonexistent/file.png");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("rewrites inline style url() references", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/");
const html = `<html><body><div style="background-image: url('${relToExt}')"></div></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).toContain("_ext/");
expect(rewrittenHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
it("createPublishArchive includes localized external assets", () => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
writeFileSync(join(extDir, "video.mp4"), "MP4_DATA", "utf-8");
const relToExt = relative(projectDir, join(extDir, "video.mp4")).replaceAll("\\", "/");
writeFileSync(
join(projectDir, "index.html"),
`<html><body><video src="${relToExt}"></video></body></html>`,
"utf-8",
);
const archive = createPublishArchive(projectDir);
expect(archive.fileCount).toBe(2);
const zip = new AdmZip(archive.buffer);
const entries = zip.getEntries().map((e) => e.entryName);
expect(entries).toContain("index.html");
expect(entries.some((e) => e.startsWith("_ext/") && e.endsWith("video.mp4"))).toBe(true);
const indexHtml = zip.readAsText("index.html");
expect(indexHtml).toContain("_ext/");
expect(indexHtml).not.toContain(relToExt);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
});
});
describe("uploadTimeoutMs", () => {
it("returns the minimum timeout for small files", () => {
expect(uploadTimeoutMs(0)).toBe(120_000);
+175 -6
View File
@@ -1,6 +1,8 @@
import { basename, join, relative } from "node:path";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { basename, dirname, join, posix, relative, resolve } from "node:path";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { parseHTML } from "linkedom";
import AdmZip from "adm-zip";
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]);
const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]);
@@ -178,21 +180,188 @@ function collectProjectFiles(rootDir: string, currentDir: string, paths: string[
}
}
const EXT_ASSETS_PREFIX = "_ext";
interface ExternalAssetContext {
absProjectDir: string;
fileContents: Map<string, Buffer>;
externalMap: Map<string, string>;
usedArchivePaths: Set<string>;
}
function addExternalAsset(ctx: ExternalAssetContext, absPath: string): string {
const existing = ctx.externalMap.get(absPath);
if (existing) return existing;
const rel = relative(ctx.absProjectDir, absPath).replaceAll("\\", "/");
const stripped = rel.replace(/^(?:\.\.\/)+/, "");
let archivePath = `${EXT_ASSETS_PREFIX}/${stripped}`;
if (ctx.usedArchivePaths.has(archivePath)) {
const ext = posix.extname(archivePath);
const base = archivePath.slice(0, archivePath.length - ext.length);
let i = 2;
while (ctx.usedArchivePaths.has(`${base}_${i}${ext}`)) i++;
archivePath = `${base}_${i}${ext}`;
}
ctx.fileContents.set(archivePath, readFileSync(absPath));
ctx.externalMap.set(absPath, archivePath);
ctx.usedArchivePaths.add(archivePath);
return archivePath;
}
function tryResolveExternal(
ctx: ExternalAssetContext,
rawPath: string,
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;
}
return addExternalAsset(ctx, absPath);
}
function rewriteCssUrls(
ctx: ExternalAssetContext,
css: string,
referrerAbsDir: string,
entryPath: string,
): { css: string; modified: boolean } {
let modified = false;
const rewritten = css.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
const archivePath = tryResolveExternal(ctx, (rawUrl || "").trim(), referrerAbsDir);
if (!archivePath) return full;
modified = true;
return `url(${quote || ""}${posix.relative(posix.dirname(entryPath), archivePath)}${quote || ""})`;
});
return { css: rewritten, modified };
}
function rewriteHtmlAttributes(
ctx: ExternalAssetContext,
document: Document,
referrerAbsDir: string,
entryPath: string,
): boolean {
let modified = false;
for (const el of document.querySelectorAll("[src], [href]")) {
for (const attr of ["src", "href"]) {
const val = (el.getAttribute(attr) || "").trim();
if (!val) continue;
const archivePath = tryResolveExternal(ctx, val, referrerAbsDir);
if (!archivePath) continue;
el.setAttribute(attr, posix.relative(posix.dirname(entryPath), archivePath));
modified = true;
}
}
return modified;
}
function rewriteStyleBlocks(
ctx: ExternalAssetContext,
document: Document,
referrerAbsDir: string,
entryPath: string,
): boolean {
let modified = false;
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent || "";
if (!css.includes("url(")) continue;
const result = rewriteCssUrls(ctx, css, referrerAbsDir, entryPath);
if (result.modified) {
styleEl.textContent = result.css;
modified = true;
}
}
for (const el of document.querySelectorAll("[style]")) {
const style = el.getAttribute("style") || "";
if (!style.includes("url(")) continue;
const result = rewriteCssUrls(ctx, style, referrerAbsDir, entryPath);
if (result.modified) {
el.setAttribute("style", result.css);
modified = true;
}
}
return modified;
}
function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content: Buffer): void {
const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath));
const { document } = parseHTML(content.toString("utf-8"));
const attrsChanged = rewriteHtmlAttributes(ctx, document, referrerAbsDir, entryPath);
const stylesChanged = rewriteStyleBlocks(ctx, document, referrerAbsDir, entryPath);
if (attrsChanged || stylesChanged) {
ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8"));
}
}
function localizeCssEntry(ctx: ExternalAssetContext, entryPath: string, content: Buffer): void {
const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath));
const css = content.toString("utf-8");
if (!css.includes("url(")) return;
const result = rewriteCssUrls(ctx, css, referrerAbsDir, entryPath);
if (result.modified) {
ctx.fileContents.set(entryPath, Buffer.from(result.css, "utf-8"));
}
}
/**
* Scan HTML and CSS files for asset references that resolve outside the
* project directory. Copy those files into the archive under `_ext/` and
* rewrite the references so the published project is self-contained.
*/
export function localizeExternalAssets(
absProjectDir: string,
fileContents: Map<string, Buffer>,
): number {
const ctx: ExternalAssetContext = {
absProjectDir,
fileContents,
externalMap: new Map(),
usedArchivePaths: new Set(),
};
for (const [entryPath, content] of [...fileContents.entries()]) {
if (entryPath.startsWith(EXT_ASSETS_PREFIX + "/")) continue;
if (entryPath.endsWith(".html") || entryPath.endsWith(".htm")) {
localizeHtmlEntry(ctx, entryPath, content);
} else if (entryPath.endsWith(".css")) {
localizeCssEntry(ctx, entryPath, content);
}
}
return ctx.externalMap.size;
}
export function createPublishArchive(projectDir: string): PublishArchiveResult {
const absProjectDir = resolve(projectDir);
const filePaths: string[] = [];
collectProjectFiles(projectDir, projectDir, filePaths);
collectProjectFiles(absProjectDir, absProjectDir, filePaths);
if (!filePaths.includes("index.html")) {
throw new Error("Project must include an index.html file at the root before publish.");
}
const archive = new AdmZip();
const fileContents = new Map<string, Buffer>();
for (const filePath of filePaths) {
archive.addFile(filePath, readFileSync(join(projectDir, filePath)));
fileContents.set(filePath, readFileSync(join(absProjectDir, filePath)));
}
localizeExternalAssets(absProjectDir, fileContents);
const archive = new AdmZip();
for (const [filePath, content] of fileContents) {
archive.addFile(filePath, content);
}
return {
buffer: archive.toBuffer(),
fileCount: filePaths.length,
fileCount: fileContents.size,
};
}