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 @@ -128,6 +128,10 @@ The command uploads the project and returns a stable URL. An anonymous first
publish creates a claimable link. Sign in when the CLI should own the project
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.

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
75 changes: 73 additions & 2 deletions packages/cli/src/utils/publishProject.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, renameSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import {
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { dirname, join, posix, relative } from "node:path";
import AdmZip from "adm-zip";

const authMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -216,6 +224,69 @@ async function runAuthenticatedPublish(
}

describe("createPublishArchive", () => {
it.each(["index.html", "compositions/scene.html"])(
"includes external video posters referenced from %s",
(entryPath) => {
const projectDir = makeProjectDir();
const extDir = mkdtempSync(join(tmpdir(), "hf-ext-"));
try {
const htmlDir = join(projectDir, dirname(entryPath));
mkdirSync(htmlDir, { recursive: true });
if (entryPath !== "index.html") {
writeFileSync(join(projectDir, "index.html"), "<html></html>");
}
const posterPath = stageExternalAsset(extDir, htmlDir, "poster.png", "PNG_DATA");
const videoPath = posix.relative(posix.dirname(entryPath), "clip.mp4");
const html = `<html><body><video src="${videoPath}" poster="${posterPath}"></video></body></html>`;
writeFileSync(join(projectDir, entryPath), html);
writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA");

const archive = createPublishArchive(projectDir);
const zip = new AdmZip(archive.buffer);
const posterEntries = zip
.getEntries()
.filter((entry) => entry.entryName.startsWith("_ext/") && entry.name === "poster.png");

expect(posterEntries).toHaveLength(1);
const archivedPoster = posterEntries[0]!;
expect(archivedPoster.getData()).toEqual(Buffer.from("PNG_DATA"));
expect(zip.readAsText(entryPath)).toContain(
`poster="${posix.relative(posix.dirname(entryPath), archivedPoster.entryName)}"`,
);
expect(zip.readAsText(entryPath)).toContain(`src="${videoPath}"`);
expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4);
expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
},
);

it("preserves internal, remote, and inline video posters", () => {
const projectDir = makeProjectDir();
try {
const posters = [
"poster.png",
"https://cdn.example.com/poster.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, "clip.mp4"), "MP4_DATA");

const archive = createPublishArchive(projectDir);
const zip = new AdmZip(archive.buffer);

expect(archive.fileCount).toBe(3);
expect(zip.readAsText("index.html")).toBe(html);
expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});

it("packages the project and skips hidden files and node_modules", () => {
const dir = makeProjectDir();
try {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/utils/publishProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,9 @@ function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content
const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath));
const { document } = parseHTML(content.toString("utf-8"));
const resolveTarget: HtmlAttributeResolver = (val, dir) => tryResolveExternal(ctx, val, dir);
const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget);
const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget, {
attrs: ["src", "href", "poster"],
});
const stylesChanged = rewriteStyleBlocks(document, referrerAbsDir, entryPath, resolveTarget);
if (attrsChanged || stylesChanged) {
ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8"));
Expand Down
Loading