Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/guides/export-and-share.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
151 changes: 151 additions & 0 deletions packages/cli/src/utils/publishProject.srcset.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 24 additions & 2 deletions packages/cli/src/utils/publishProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));
Expand All @@ -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"));
}
}
Expand Down
34 changes: 1 addition & 33 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,39 +182,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;
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/compiler/srcset.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading