mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): include external srcset images in project archives (#6335)
This commit is contained in:
@@ -134,6 +134,10 @@ 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`.
|
||||
|
||||
Local images outside the project referenced by an `<img>` or `<source>` element's
|
||||
`srcset` are included too. Their size or pixel-density descriptors are preserved,
|
||||
and your source files stay unchanged.
|
||||
|
||||
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,
|
||||
timing, facts, or final approval.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, posix } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createPublishArchive, readStaged, stagePublishFiles } from "./publishProject.js";
|
||||
|
||||
const PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const fixtureDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of fixtureDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createFixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-publish-srcset-"));
|
||||
fixtureDirs.push(dir);
|
||||
const projectDir = join(dir, "project");
|
||||
const sharedDir = join(dir, "shared");
|
||||
mkdirSync(projectDir);
|
||||
mkdirSync(sharedDir);
|
||||
return { projectDir, sharedDir };
|
||||
}
|
||||
|
||||
describe("publishing external srcset assets", () => {
|
||||
it.each(["index.html", "compositions/scene.html"])(
|
||||
"archives image and picture candidates relative to %s without duplicating src assets",
|
||||
(entryPath) => {
|
||||
const { projectDir, sharedDir } = createFixture();
|
||||
mkdirSync(join(projectDir, dirname(entryPath)), { recursive: true });
|
||||
if (entryPath !== "index.html")
|
||||
writeFileSync(join(projectDir, "index.html"), "<html></html>");
|
||||
writeFileSync(join(sharedDir, "small.png"), PNG);
|
||||
writeFileSync(join(sharedDir, "large.png"), PNG);
|
||||
const sourcePrefix = entryPath === "index.html" ? "../shared" : "../../shared";
|
||||
const html = `<html><body><picture><source srcset="${sourcePrefix}/small.png 640w, ${sourcePrefix}/large.png 1280w"><img src="${sourcePrefix}/small.png" srcset="${sourcePrefix}/small.png 1x, ${sourcePrefix}/large.png 2x"></picture></body></html>`;
|
||||
writeFileSync(join(projectDir, entryPath), html);
|
||||
|
||||
const archive = createPublishArchive(projectDir);
|
||||
const zip = new AdmZip(archive.buffer);
|
||||
const externalPaths = zip
|
||||
.getEntries()
|
||||
.map((entry) => entry.entryName)
|
||||
.filter((path) => path.startsWith("_ext/"));
|
||||
expect(externalPaths.sort()).toEqual(["_ext/shared/large.png", "_ext/shared/small.png"]);
|
||||
for (const path of externalPaths) expect(zip.readFile(path)).toEqual(PNG);
|
||||
|
||||
const { document } = parseHTML(zip.readAsText(entryPath));
|
||||
const small = posix.relative(posix.dirname(entryPath), "_ext/shared/small.png");
|
||||
const large = posix.relative(posix.dirname(entryPath), "_ext/shared/large.png");
|
||||
expect(document.querySelector("img")?.getAttribute("src")).toBe(small);
|
||||
expect(document.querySelector("img")?.getAttribute("srcset")).toBe(
|
||||
`${small} 1x, ${large} 2x`,
|
||||
);
|
||||
expect(document.querySelector("source")?.getAttribute("srcset")).toBe(
|
||||
`${small} 640w, ${large} 1280w`,
|
||||
);
|
||||
expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4);
|
||||
expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves mixed data URLs, URL commas, descriptors and separators", () => {
|
||||
const { projectDir, sharedDir } = createFixture();
|
||||
writeFileSync(join(sharedDir, "hero,blue.png"), PNG);
|
||||
writeFileSync(join(projectDir, "inside.png"), PNG);
|
||||
const srcset =
|
||||
" data:image/png;base64,AA== 1x,\n\t../shared/hero,blue.png 2x, https://cdn.example.com/a,b.png 3x, //cdn.example.com/c.png 4x, inside.png 5x, ../shared/missing.png 6x ";
|
||||
const html = `<html><body><img srcset="${srcset}"></body></html>`;
|
||||
writeFileSync(join(projectDir, "index.html"), html);
|
||||
|
||||
const zip = new AdmZip(createPublishArchive(projectDir).buffer);
|
||||
expect(zip.readFile("_ext/shared/hero,blue.png")).toEqual(PNG);
|
||||
const { document } = parseHTML(zip.readAsText("index.html"));
|
||||
expect(document.querySelector("img")?.getAttribute("srcset")).toBe(
|
||||
srcset.replace("../shared/hero,blue.png", "_ext/shared/hero%2Cblue.png"),
|
||||
);
|
||||
expect(
|
||||
zip
|
||||
.getEntries()
|
||||
.map((entry) => entry.entryName)
|
||||
.sort(),
|
||||
).toEqual(["_ext/shared/hero,blue.png", "index.html", "inside.png"]);
|
||||
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(html);
|
||||
});
|
||||
|
||||
it("keeps HTML bytes when all candidates are internal, remote, inline or missing", () => {
|
||||
const { projectDir } = createFixture();
|
||||
writeFileSync(join(projectDir, "inside.png"), PNG);
|
||||
const html =
|
||||
"<!doctype html>\n<!-- retain formatting -->\n<html><body><IMG srcset='data:image/png;base64,AA== 1x, https://cdn.example.com/a,b.png 2x, inside.png 3x, ../missing.png 4x'><source srcset=''></body></html>\n";
|
||||
writeFileSync(join(projectDir, "index.html"), html);
|
||||
|
||||
const zip = new AdmZip(createPublishArchive(projectDir).buffer);
|
||||
expect(zip.readAsText("index.html")).toBe(html);
|
||||
expect(
|
||||
zip
|
||||
.getEntries()
|
||||
.map((entry) => entry.entryName)
|
||||
.sort(),
|
||||
).toEqual(["index.html", "inside.png"]);
|
||||
});
|
||||
|
||||
it("localizes percent-encoded filenames without changing srcset descriptors", () => {
|
||||
const { projectDir, sharedDir } = createFixture();
|
||||
const filename = "poster #雪%.png";
|
||||
const encodedFilename = "poster%20%23%E9%9B%AA%25.png";
|
||||
writeFileSync(join(sharedDir, filename), PNG);
|
||||
const html = `<html><body><source srcset="../shared/${encodedFilename} 640w, ../shared/${encodedFilename} 1280w"></body></html>`;
|
||||
writeFileSync(join(projectDir, "index.html"), html);
|
||||
|
||||
const zip = new AdmZip(createPublishArchive(projectDir).buffer);
|
||||
expect(zip.readFile(`_ext/shared/${filename}`)).toEqual(PNG);
|
||||
expect(
|
||||
zip
|
||||
.getEntries()
|
||||
.map((entry) => entry.entryName)
|
||||
.sort(),
|
||||
).toEqual([`_ext/shared/${filename}`, "index.html"]);
|
||||
const { document } = parseHTML(zip.readAsText("index.html"));
|
||||
expect(document.querySelector("source")?.getAttribute("srcset")).toBe(
|
||||
`_ext/shared/${encodedFilename} 640w, _ext/shared/${encodedFilename} 1280w`,
|
||||
);
|
||||
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(html);
|
||||
});
|
||||
|
||||
it("stages srcset-only binary assets on disk for streamed uploads", () => {
|
||||
const { projectDir, sharedDir } = createFixture();
|
||||
const assetPath = join(sharedDir, "image.png");
|
||||
writeFileSync(assetPath, PNG);
|
||||
const html = '<html><body><img srcset="../shared/image.png 2x"></body></html>';
|
||||
writeFileSync(join(projectDir, "index.html"), html);
|
||||
|
||||
const files = stagePublishFiles(projectDir, 1);
|
||||
const image = files.get("_ext/shared/image.png");
|
||||
expect(image).toBeDefined();
|
||||
if (!image || Buffer.isBuffer(image)) throw new Error("Expected an image staged on disk");
|
||||
expect(image.absPath).toBe(assetPath);
|
||||
expect(image.size).toBe(PNG.byteLength);
|
||||
expect(readStaged(image)).toEqual(PNG);
|
||||
const entry = files.get("index.html");
|
||||
if (!entry) throw new Error("Expected the staged HTML entry");
|
||||
expect(Buffer.isBuffer(entry)).toBe(true);
|
||||
expect(readStaged(entry).toString("utf-8")).toContain('srcset="_ext/shared/image.png 2x"');
|
||||
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(html);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
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 { CSS_URL_RE, isNonRelativeUrl, isPathInside, rewriteSrcsetUrls } from "@hyperframes/core";
|
||||
import { decodeUrlPathVariants, encodeUrlPath } from "@hyperframes/parsers";
|
||||
import { buildAuthHeaders } from "../auth/client.js";
|
||||
import { tryResolveCredential } from "../auth/index.js";
|
||||
@@ -542,6 +542,27 @@ export function rewriteStyleBlocks(
|
||||
return modified;
|
||||
}
|
||||
|
||||
function rewriteHtmlSrcsets(
|
||||
document: Document,
|
||||
referrerAbsDir: string,
|
||||
entryPath: string,
|
||||
resolveTarget: HtmlAttributeResolver,
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const el of document.querySelectorAll("[srcset]")) {
|
||||
const srcset = el.getAttribute("srcset");
|
||||
if (!srcset) continue;
|
||||
const rewritten = rewriteSrcsetUrls(srcset, (url) => {
|
||||
const target = resolveTarget(url, referrerAbsDir);
|
||||
return target ? referenceFrom(entryPath, target) : url;
|
||||
});
|
||||
if (rewritten === srcset) continue;
|
||||
el.setAttribute("srcset", rewritten);
|
||||
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"));
|
||||
@@ -550,7 +571,8 @@ function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content
|
||||
attrs: ["src", "href", "poster"],
|
||||
});
|
||||
const stylesChanged = rewriteStyleBlocks(document, referrerAbsDir, entryPath, resolveTarget);
|
||||
if (attrsChanged || stylesChanged) {
|
||||
const srcsetsChanged = rewriteHtmlSrcsets(document, referrerAbsDir, entryPath, resolveTarget);
|
||||
if (attrsChanged || stylesChanged || srcsetsChanged) {
|
||||
ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
splitUrlSuffix,
|
||||
} from "@hyperframes/parsers/asset-paths";
|
||||
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
|
||||
import { rewriteSrcsetUrls } from "./srcset.js";
|
||||
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
import {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
@@ -182,39 +183,6 @@ function rebaseRelativePath(urlValue: string, fromDir: string, toDir: string): s
|
||||
return appendSuffixToUrl(rebased, suffix);
|
||||
}
|
||||
|
||||
function rewriteSrcsetUrls(srcset: string, rewriteUrl: (url: string) => string): string {
|
||||
const parts: string[] = [];
|
||||
let position = 0;
|
||||
let written = 0;
|
||||
|
||||
// Preserve URL commas per https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
|
||||
while (position < srcset.length) {
|
||||
while (position < srcset.length && /[ \t\n\r\f,]/.test(srcset.charAt(position))) position++;
|
||||
const start = position;
|
||||
while (position < srcset.length && !/[ \t\n\r\f]/.test(srcset.charAt(position))) position++;
|
||||
let end = position;
|
||||
while (end > start && srcset.charAt(end - 1) === ",") end--;
|
||||
if (end > start) {
|
||||
const url = srcset.slice(start, end);
|
||||
const rewritten = rewriteUrl(url);
|
||||
if (rewritten !== url) {
|
||||
parts.push(srcset.slice(written, start), rewritten);
|
||||
written = end;
|
||||
}
|
||||
}
|
||||
if (end < position) continue;
|
||||
|
||||
let inParens = false;
|
||||
while (position < srcset.length) {
|
||||
const char = srcset.charAt(position++);
|
||||
if (char === "(") inParens = true;
|
||||
else if (char === ")") inParens = false;
|
||||
else if (char === "," && !inParens) break;
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join("") + srcset.slice(written) : srcset;
|
||||
}
|
||||
|
||||
function rebaseColorGradingLutPath(value: string, fromDir: string, toDir: string): string {
|
||||
if (!value.trim().startsWith("{")) return value;
|
||||
let parsed: unknown;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
function skipSrcsetDescriptors(srcset: string, position: number): number {
|
||||
let inParens = false;
|
||||
while (position < srcset.length) {
|
||||
const char = srcset.charAt(position++);
|
||||
if (char === "(") inParens = true;
|
||||
else if (char === ")") inParens = false;
|
||||
else if (char === "," && !inParens) break;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
function readSrcsetUrl(srcset: string, position: number) {
|
||||
while (position < srcset.length && /[ \t\n\r\f,]/.test(srcset.charAt(position))) position++;
|
||||
const start = position;
|
||||
while (position < srcset.length && !/[ \t\n\r\f]/.test(srcset.charAt(position))) position++;
|
||||
let end = position;
|
||||
while (end > start && srcset.charAt(end - 1) === ",") end--;
|
||||
return { start, end, next: position };
|
||||
}
|
||||
|
||||
export function rewriteSrcsetUrls(srcset: string, rewriteUrl: (url: string) => string): string {
|
||||
const parts: string[] = [];
|
||||
let position = 0;
|
||||
let written = 0;
|
||||
|
||||
// Preserve URL commas per https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
|
||||
while (position < srcset.length) {
|
||||
const { start, end, next } = readSrcsetUrl(srcset, position);
|
||||
position = next;
|
||||
if (end > start) {
|
||||
const url = srcset.slice(start, end);
|
||||
const rewritten = rewriteUrl(url);
|
||||
if (rewritten !== url) {
|
||||
parts.push(srcset.slice(written, start), rewritten);
|
||||
written = end;
|
||||
}
|
||||
}
|
||||
if (end < position) continue;
|
||||
|
||||
position = skipSrcsetDescriptors(srcset, position);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("") + srcset.slice(written) : srcset;
|
||||
}
|
||||
@@ -177,6 +177,7 @@ export {
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./compiler/rewriteSubCompPaths";
|
||||
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
|
||||
export { rewriteSrcsetUrls } from "./compiler/srcset";
|
||||
export {
|
||||
checkSubCompositionUsability,
|
||||
type ParsableDocumentLike,
|
||||
|
||||
Reference in New Issue
Block a user