Preview in the browser, shape the output, and keep a reproducible debug bundle beside every export.
- MarkNest analyzes ZIP structure in WASM, keeps diagnostics visible, lets you adjust themes, CSS,
- metadata, and print settings, then reuses the same option model for browser export, debug bundles,
- and the local Chromium fallback service.
+ MarkNest analyzes ZIP archives, folders, and single Markdown files in WASM, keeps diagnostics visible,
+ lets you adjust themes, CSS, metadata, and print settings, then reuses the same option model for browser
+ export, debug bundles, and the local Chromium fallback service.
@@ -43,14 +44,23 @@
Preview in the browser, shape the output, and keep a reproducible debug bund
Input
-
ZIP Upload
+
Upload
-
@@ -264,7 +274,7 @@
Controls
Markdown Candidates
- Load a ZIP archive to inspect its Markdown entry candidates.
+ Load a ZIP archive, folder, or Markdown file to inspect entry candidates.
@@ -297,13 +307,13 @@
Errors
Preview
No entry selected
-
Upload a ZIP to render the selected Markdown entry as HTML.
+
Upload a ZIP, folder, or Markdown file to render the selected entry as HTML.
diff --git a/web/app.css b/web/app.css
index a20de68..fa5cdc9 100644
--- a/web/app.css
+++ b/web/app.css
@@ -169,6 +169,16 @@ body::before {
box-shadow: 0 12px 26px rgba(15, 108, 124, 0.15);
}
+.dropzone.is-drag-over {
+ transform: translateY(-2px);
+ border-color: var(--accent-strong);
+ border-style: solid;
+ box-shadow: 0 16px 32px rgba(15, 108, 124, 0.2);
+ background:
+ linear-gradient(135deg, rgba(15, 108, 124, 0.18), transparent),
+ var(--paper-strong);
+}
+
.dropzone-title {
font-weight: 700;
font-size: 1.05rem;
@@ -191,6 +201,13 @@ body::before {
color: var(--warning);
}
+.upload-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
.visually-hidden {
position: absolute;
width: 1px;
diff --git a/web/app.js b/web/app.js
index d4b07b3..1dbbfe7 100644
--- a/web/app.js
+++ b/web/app.js
@@ -20,6 +20,11 @@ import {
runtimeDiagnosticsForEntry,
waitForFrameRenderStatus,
} from "./runtime_sync.mjs";
+import {
+ buildZipFromFiles,
+ detectInputKind,
+ readDirectoryEntries,
+} from "./zip_from_files.mjs";
const state = {
wasm: null,
@@ -39,6 +44,11 @@ const state = {
const elements = {
zipInput: document.getElementById("zip-input"),
+ folderInput: document.getElementById("folder-input"),
+ browseFile: document.getElementById("browse-file"),
+ browseFolder: document.getElementById("browse-folder"),
+ dropzoneLabel: document.getElementById("dropzone-label"),
+ localImageWarning: document.getElementById("local-image-warning"),
fileName: document.getElementById("file-name"),
statusChip: document.getElementById("status-chip"),
statusMessage: document.getElementById("status-message"),
@@ -456,6 +466,44 @@ function schedulePreviewRefresh() {
}, 180);
}
+async function processInput(files) {
+ const inputKind = detectInputKind(files);
+
+ if (inputKind === "unsupported") {
+ setStatus("error", "Unsupported", "Please upload a .zip archive, a .md file, or drag a folder.");
+ return;
+ }
+
+ elements.localImageWarning.hidden = true;
+
+ if (inputKind === "zip") {
+ await analyzeZip(files[0]);
+ return;
+ }
+
+ if (!state.wasm) {
+ setStatus("waiting", "Booting", "The WASM runtime is still loading. Try again in a moment.");
+ return;
+ }
+
+ try {
+ setStatus("ready", "Converting", "Building an in-memory archive from the selected input.");
+ const result = await buildZipFromFiles(state.wasm, files);
+
+ if (result.warnings.length > 0) {
+ elements.localImageWarning.hidden = false;
+ elements.localImageWarning.textContent = result.warnings[0];
+ }
+
+ const syntheticFile = new File([result.zipBytes], result.fileName, {
+ type: "application/zip",
+ });
+ await analyzeZip(syntheticFile);
+ } catch (error) {
+ setStatus("error", "Failed", `Input processing failed: ${String(error)}`);
+ }
+}
+
async function analyzeZip(file) {
if (!state.wasm) {
setStatus("waiting", "Booting", "The WASM runtime is still loading. Try again in a moment.");
@@ -489,7 +537,7 @@ async function analyzeZip(file) {
if (projectIndex.entry_candidates.length === 0) {
elements.previewTitle.textContent = "No preview available";
- elements.previewCaption.textContent = "The uploaded archive did not expose a Markdown entry candidate.";
+ elements.previewCaption.textContent = "The input did not expose a Markdown entry candidate.";
setStatus("warning", "No entries", "Analysis completed, but there is no Markdown entry to preview.");
syncActionButtons();
return;
@@ -511,9 +559,9 @@ async function analyzeZip(file) {
setTextList(elements.warningList, [], "Warnings will appear after analysis.");
setTextList(elements.errorList, [String(error)], "No errors.");
elements.previewTitle.textContent = "Preview unavailable";
- elements.previewCaption.textContent = "The ZIP could not be analyzed.";
+ elements.previewCaption.textContent = "The input could not be analyzed.";
updateTemplatePreviews();
- setStatus("error", "Failed", `ZIP analysis failed: ${String(error)}`);
+ setStatus("error", "Failed", `Analysis failed: ${String(error)}`);
syncActionButtons();
} finally {
setBusy(false);
@@ -1013,7 +1061,7 @@ function connectWasmBindings() {
}
state.wasm = window.wasmBindings;
- setStatus("ready", "Ready", "The WASM runtime is ready. Upload a ZIP archive to inspect it.");
+ setStatus("ready", "Ready", "The WASM runtime is ready. Upload a ZIP, Markdown file, or folder to inspect it.");
syncActionButtons();
}
@@ -1042,12 +1090,70 @@ updateScaleNote();
updateTemplatePreviews();
elements.zipInput.addEventListener("change", async (event) => {
- const file = event.target.files?.[0];
- if (!file) {
+ const files = event.target.files;
+ if (!files || files.length === 0) {
return;
}
- await analyzeZip(file);
+ await processInput(Array.from(files));
+});
+
+elements.folderInput.addEventListener("change", async (event) => {
+ const files = event.target.files;
+ if (!files || files.length === 0) {
+ return;
+ }
+
+ await processInput(Array.from(files));
+});
+
+elements.browseFile.addEventListener("click", () => {
+ elements.zipInput.click();
+});
+
+elements.browseFolder.addEventListener("click", () => {
+ elements.folderInput.click();
+});
+
+// Drag-and-drop on the dropzone.
+elements.dropzoneLabel.addEventListener("dragover", (event) => {
+ event.preventDefault();
+ elements.dropzoneLabel.classList.add("is-drag-over");
+});
+
+elements.dropzoneLabel.addEventListener("dragleave", () => {
+ elements.dropzoneLabel.classList.remove("is-drag-over");
+});
+
+elements.dropzoneLabel.addEventListener("drop", async (event) => {
+ event.preventDefault();
+ elements.dropzoneLabel.classList.remove("is-drag-over");
+
+ const items = event.dataTransfer?.items;
+ if (!items || items.length === 0) {
+ return;
+ }
+
+ // Check if any item is a directory.
+ const entries = [];
+ for (const item of items) {
+ const entry = item.webkitGetAsEntry?.();
+ if (entry) {
+ entries.push(entry);
+ }
+ }
+
+ if (entries.length === 1 && entries[0].isDirectory) {
+ const files = await readDirectoryEntries(entries[0]);
+ await processInput(files);
+ return;
+ }
+
+ // Single file drop (ZIP or .md).
+ const droppedFiles = event.dataTransfer.files;
+ if (droppedFiles.length > 0) {
+ await processInput(Array.from(droppedFiles));
+ }
});
elements.qualityMode.addEventListener("change", (event) => {
diff --git a/web/zip_from_files.mjs b/web/zip_from_files.mjs
new file mode 100644
index 0000000..9b50afc
--- /dev/null
+++ b/web/zip_from_files.mjs
@@ -0,0 +1,233 @@
+/**
+ * Converts folder contents or a single Markdown file into ZIP bytes
+ * that the existing WASM analyzeZip / renderHtml pipeline can consume.
+ */
+
+/**
+ * Classify the user-provided file list.
+ *
+ * @param {File[]} files
+ * @returns {"zip" | "folder" | "markdown" | "unsupported"}
+ */
+export function detectInputKind(files) {
+ if (!files || files.length === 0) {
+ return "unsupported";
+ }
+
+ if (files.length === 1) {
+ const file = files[0];
+ const name = file.name.toLowerCase();
+
+ if (name.endsWith(".zip") || file.type === "application/zip") {
+ return "zip";
+ }
+
+ if (name.endsWith(".md") || name.endsWith(".markdown")) {
+ return "markdown";
+ }
+
+ return "unsupported";
+ }
+
+ // Multiple files: either a folder upload (webkitRelativePath set) or
+ // a drag-and-drop directory read (relativePath set).
+ const hasRelativePaths = files.some(
+ (file) => file.webkitRelativePath || file.relativePath,
+ );
+ if (hasRelativePaths) {
+ return "folder";
+ }
+
+ // Multiple files without relative paths โ could be a multi-select.
+ // Check if any are markdown files; treat as folder-like input.
+ const hasMarkdown = files.some((file) => {
+ const name = file.name.toLowerCase();
+ return name.endsWith(".md") || name.endsWith(".markdown");
+ });
+
+ return hasMarkdown ? "folder" : "unsupported";
+}
+
+/**
+ * Check whether a Markdown source references local images that cannot be
+ * resolved when the browser only has the single file.
+ *
+ * @param {string} markdownText
+ * @returns {boolean}
+ */
+export function hasLocalImageReferences(markdownText) {
+ // Markdown image syntax: 
+ const markdownImagePattern = /!\[[^\]]*\]\(([^)]+)\)/g;
+ // HTML image syntax:
+ const htmlImagePattern = /]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
+
+ for (const pattern of [markdownImagePattern, htmlImagePattern]) {
+ let match;
+ while ((match = pattern.exec(markdownText)) !== null) {
+ const reference = match[1].trim();
+ if (!reference) {
+ continue;
+ }
+
+ // External URLs are fine โ they will be fetched by the remote asset pipeline.
+ if (/^https?:\/\//i.test(reference)) {
+ continue;
+ }
+
+ // Data URIs are already inline.
+ if (/^data:/i.test(reference)) {
+ continue;
+ }
+
+ // Anything else is a local reference that cannot be resolved.
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Remove a single shared directory prefix from all entry paths.
+ *
+ * When uploading a folder named `my-project/`, every file path starts with
+ * `my-project/`. The ZIP analysis pipeline works better without that
+ * top-level wrapper.
+ *
+ * @param {{path: string, bytes: Uint8Array}[]} entries
+ * @returns {{path: string, bytes: Uint8Array}[]}
+ */
+export function stripCommonPrefix(entries) {
+ if (entries.length === 0) {
+ return entries;
+ }
+
+ const firstSlash = entries[0].path.indexOf("/");
+ if (firstSlash < 0) {
+ return entries;
+ }
+
+ const prefix = entries[0].path.slice(0, firstSlash + 1);
+ const allMatch = entries.every((entry) => entry.path.startsWith(prefix));
+ if (!allMatch) {
+ return entries;
+ }
+
+ return entries.map((entry) => ({
+ path: entry.path.slice(prefix.length),
+ bytes: entry.bytes,
+ }));
+}
+
+/**
+ * Read a list of `File` objects into `{path, bytes}[]` entries suitable
+ * for `buildPdfArchive`.
+ *
+ * @param {File[]} files
+ * @returns {Promise<{path: string, bytes: Uint8Array}[]>}
+ */
+export async function filesToArchiveEntries(files) {
+ const entries = [];
+
+ for (const file of files) {
+ const path = file.relativePath || file.webkitRelativePath || file.name;
+ if (!path) {
+ continue;
+ }
+
+ const arrayBuffer = await file.arrayBuffer();
+ entries.push({
+ path,
+ bytes: new Uint8Array(arrayBuffer),
+ });
+ }
+
+ return stripCommonPrefix(entries);
+}
+
+/**
+ * Convert a list of `File` objects (from folder selection, drag-and-drop, or
+ * a single `.md` upload) into ZIP bytes that the WASM pipeline can analyze.
+ *
+ * @param {{ buildPdfArchive: (files: {path: string, bytes: Uint8Array}[]) => Uint8Array }} wasm
+ * @param {File[]} files
+ * @returns {Promise<{zipBytes: Uint8Array, fileName: string, warnings: string[]}>}
+ */
+export async function buildZipFromFiles(wasm, files) {
+ const inputKind = detectInputKind(files);
+ const warnings = [];
+
+ if (inputKind === "markdown") {
+ const file = files[0];
+ const text = await file.text();
+
+ if (hasLocalImageReferences(text)) {
+ warnings.push(
+ "This Markdown file references local images that cannot be resolved in the browser. " +
+ "Only external (HTTP) images will be displayed. To include local images, upload the parent folder instead.",
+ );
+ }
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const archiveEntries = [{ path: file.name, bytes }];
+ const zipBytes = wasm.buildPdfArchive(archiveEntries);
+ const fileName = file.name.replace(/\.(md|markdown)$/i, ".zip");
+ return { zipBytes, fileName, warnings };
+ }
+
+ // Folder input: read all files and build a ZIP.
+ const archiveEntries = await filesToArchiveEntries(files);
+ if (archiveEntries.length === 0) {
+ throw new Error("The selected folder does not contain any files.");
+ }
+
+ const zipBytes = wasm.buildPdfArchive(archiveEntries);
+
+ // Derive a folder name from the first file's original relative path.
+ const firstFile = files[0];
+ const relativePath = firstFile.relativePath || firstFile.webkitRelativePath || "";
+ const folderName = relativePath.split("/")[0] || "workspace";
+ const fileName = `${folderName}.zip`;
+
+ return { zipBytes, fileName, warnings };
+}
+
+/**
+ * Recursively read all files from a dropped directory entry.
+ *
+ * @param {FileSystemDirectoryEntry} directoryEntry
+ * @returns {Promise}
+ */
+export async function readDirectoryEntries(directoryEntry) {
+ const files = [];
+ const reader = directoryEntry.createReader();
+
+ async function readBatch() {
+ return new Promise((resolve, reject) => {
+ reader.readEntries(resolve, reject);
+ });
+ }
+
+ let batch;
+ do {
+ batch = await readBatch();
+ for (const entry of batch) {
+ if (entry.isFile) {
+ const file = await new Promise((resolve, reject) =>
+ entry.file(resolve, reject),
+ );
+ // Attach the full path โ entry.fullPath starts with "/".
+ Object.defineProperty(file, "relativePath", {
+ value: entry.fullPath.replace(/^\//, ""),
+ writable: false,
+ enumerable: true,
+ });
+ files.push(file);
+ } else if (entry.isDirectory) {
+ files.push(...(await readDirectoryEntries(entry)));
+ }
+ }
+ } while (batch.length > 0);
+
+ return files;
+}
diff --git a/web/zip_from_files.test.mjs b/web/zip_from_files.test.mjs
new file mode 100644
index 0000000..5131ae7
--- /dev/null
+++ b/web/zip_from_files.test.mjs
@@ -0,0 +1,304 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ detectInputKind,
+ hasLocalImageReferences,
+ stripCommonPrefix,
+ filesToArchiveEntries,
+ buildZipFromFiles,
+} from "./zip_from_files.mjs";
+
+// --- helpers ---
+
+function fakeFile(name, content = "", options = {}) {
+ const blob = new Blob([content], { type: options.type ?? "" });
+ const file = new File([blob], name, { type: options.type ?? "" });
+ if (options.webkitRelativePath !== undefined) {
+ Object.defineProperty(file, "webkitRelativePath", {
+ value: options.webkitRelativePath,
+ writable: false,
+ enumerable: true,
+ });
+ }
+ if (options.relativePath !== undefined) {
+ Object.defineProperty(file, "relativePath", {
+ value: options.relativePath,
+ writable: false,
+ enumerable: true,
+ });
+ }
+ return file;
+}
+
+function fakeWasm() {
+ return {
+ buildPdfArchive(entries) {
+ return new Uint8Array(
+ new TextEncoder().encode(JSON.stringify(entries.map((e) => e.path))),
+ );
+ },
+ };
+}
+
+// --- detectInputKind ---
+
+test("detectInputKind returns unsupported for null or empty list", () => {
+ assert.equal(detectInputKind(null), "unsupported");
+ assert.equal(detectInputKind([]), "unsupported");
+});
+
+test("detectInputKind returns zip for a .zip file", () => {
+ assert.equal(detectInputKind([fakeFile("docs.zip")]), "zip");
+});
+
+test("detectInputKind returns zip for application/zip mime type", () => {
+ assert.equal(
+ detectInputKind([fakeFile("archive", "", { type: "application/zip" })]),
+ "zip",
+ );
+});
+
+test("detectInputKind returns markdown for a .md file", () => {
+ assert.equal(detectInputKind([fakeFile("README.md")]), "markdown");
+});
+
+test("detectInputKind returns markdown for a .markdown file", () => {
+ assert.equal(detectInputKind([fakeFile("guide.markdown")]), "markdown");
+});
+
+test("detectInputKind returns unsupported for a single non-zip non-md file", () => {
+ assert.equal(detectInputKind([fakeFile("photo.png")]), "unsupported");
+});
+
+test("detectInputKind returns folder when multiple files have webkitRelativePath", () => {
+ const files = [
+ fakeFile("README.md", "", { webkitRelativePath: "project/README.md" }),
+ fakeFile("logo.png", "", { webkitRelativePath: "project/logo.png" }),
+ ];
+ assert.equal(detectInputKind(files), "folder");
+});
+
+test("detectInputKind returns folder when multiple files have relativePath from drag-and-drop", () => {
+ const files = [
+ fakeFile("README.md", "", { relativePath: "project/README.md" }),
+ fakeFile("logo.png", "", { relativePath: "project/images/logo.png" }),
+ ];
+ assert.equal(detectInputKind(files), "folder");
+});
+
+test("detectInputKind returns folder for multi-select containing markdown files", () => {
+ const files = [fakeFile("README.md"), fakeFile("notes.txt")];
+ assert.equal(detectInputKind(files), "folder");
+});
+
+test("detectInputKind returns unsupported for multi-select without markdown files", () => {
+ const files = [fakeFile("photo.png"), fakeFile("data.csv")];
+ assert.equal(detectInputKind(files), "unsupported");
+});
+
+// --- hasLocalImageReferences ---
+
+test("hasLocalImageReferences detects relative markdown image paths", () => {
+ assert.equal(hasLocalImageReferences(""), true);
+ assert.equal(hasLocalImageReferences(""), true);
+ assert.equal(hasLocalImageReferences(""), true);
+});
+
+test("hasLocalImageReferences detects local html img tags", () => {
+ assert.equal(
+ hasLocalImageReferences(''),
+ true,
+ );
+ assert.equal(
+ hasLocalImageReferences(""),
+ true,
+ );
+});
+
+test("hasLocalImageReferences ignores http and https urls", () => {
+ assert.equal(
+ hasLocalImageReferences(""),
+ false,
+ );
+ assert.equal(
+ hasLocalImageReferences(""),
+ false,
+ );
+ assert.equal(
+ hasLocalImageReferences(''),
+ false,
+ );
+});
+
+test("hasLocalImageReferences ignores data URIs", () => {
+ assert.equal(
+ hasLocalImageReferences(""),
+ false,
+ );
+});
+
+test("hasLocalImageReferences returns false for text-only markdown", () => {
+ assert.equal(hasLocalImageReferences("# Title\n\nSome paragraph."), false);
+});
+
+test("hasLocalImageReferences detects local among mixed references", () => {
+ const md =
+ "\n\n";
+ assert.equal(hasLocalImageReferences(md), true);
+});
+
+// --- stripCommonPrefix ---
+
+test("stripCommonPrefix removes a shared top-level directory", () => {
+ const entries = [
+ { path: "project/README.md", bytes: new Uint8Array([1]) },
+ { path: "project/images/logo.png", bytes: new Uint8Array([2]) },
+ ];
+
+ const stripped = stripCommonPrefix(entries);
+
+ assert.equal(stripped[0].path, "README.md");
+ assert.equal(stripped[1].path, "images/logo.png");
+});
+
+test("stripCommonPrefix preserves paths when there is no shared prefix", () => {
+ const entries = [
+ { path: "docs/README.md", bytes: new Uint8Array([1]) },
+ { path: "src/main.rs", bytes: new Uint8Array([2]) },
+ ];
+
+ const stripped = stripCommonPrefix(entries);
+
+ assert.equal(stripped[0].path, "docs/README.md");
+ assert.equal(stripped[1].path, "src/main.rs");
+});
+
+test("stripCommonPrefix preserves paths when files are at root level", () => {
+ const entries = [
+ { path: "README.md", bytes: new Uint8Array([1]) },
+ { path: "LICENSE", bytes: new Uint8Array([2]) },
+ ];
+
+ const stripped = stripCommonPrefix(entries);
+
+ assert.equal(stripped[0].path, "README.md");
+ assert.equal(stripped[1].path, "LICENSE");
+});
+
+test("stripCommonPrefix returns empty array unchanged", () => {
+ assert.deepEqual(stripCommonPrefix([]), []);
+});
+
+// --- filesToArchiveEntries ---
+
+test("filesToArchiveEntries reads files using webkitRelativePath and strips common prefix", async () => {
+ const files = [
+ fakeFile("README.md", "# Hello", {
+ webkitRelativePath: "my-project/README.md",
+ }),
+ fakeFile("logo.png", "\x89PNG", {
+ webkitRelativePath: "my-project/images/logo.png",
+ }),
+ ];
+
+ const entries = await filesToArchiveEntries(files);
+
+ assert.equal(entries.length, 2);
+ assert.equal(entries[0].path, "README.md");
+ assert.equal(entries[1].path, "images/logo.png");
+ assert.deepEqual(entries[0].bytes, new Uint8Array(new TextEncoder().encode("# Hello")));
+});
+
+test("filesToArchiveEntries uses relativePath from drag-and-drop", async () => {
+ const files = [
+ fakeFile("guide.md", "# Guide", { relativePath: "docs/guide.md" }),
+ ];
+
+ const entries = await filesToArchiveEntries(files);
+
+ assert.equal(entries.length, 1);
+ assert.equal(entries[0].path, "guide.md");
+});
+
+test("filesToArchiveEntries falls back to file name when no relative path exists", async () => {
+ const files = [fakeFile("README.md", "# Readme")];
+
+ const entries = await filesToArchiveEntries(files);
+
+ assert.equal(entries.length, 1);
+ assert.equal(entries[0].path, "README.md");
+});
+
+// --- buildZipFromFiles ---
+
+test("buildZipFromFiles wraps a single markdown file into a zip with no warnings when image-free", async () => {
+ const wasm = fakeWasm();
+ const files = [fakeFile("guide.md", "# Guide\n\nNo images here.")];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.equal(result.fileName, "guide.zip");
+ assert.deepEqual(result.warnings, []);
+ assert.ok(result.zipBytes instanceof Uint8Array);
+ assert.ok(result.zipBytes.length > 0);
+});
+
+test("buildZipFromFiles warns when a single markdown file has local image references", async () => {
+ const wasm = fakeWasm();
+ const files = [fakeFile("doc.md", "# Doc\n\n")];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.equal(result.fileName, "doc.zip");
+ assert.equal(result.warnings.length, 1);
+ assert.match(result.warnings[0], /local images/i);
+ assert.match(result.warnings[0], /upload the parent folder/i);
+});
+
+test("buildZipFromFiles does not warn for markdown with only remote images", async () => {
+ const wasm = fakeWasm();
+ const files = [
+ fakeFile("doc.md", ""),
+ ];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.deepEqual(result.warnings, []);
+});
+
+test("buildZipFromFiles processes folder files and derives folder name", async () => {
+ const wasm = fakeWasm();
+ const files = [
+ fakeFile("README.md", "# Hi", {
+ webkitRelativePath: "my-docs/README.md",
+ }),
+ fakeFile("logo.png", "\x89PNG", {
+ webkitRelativePath: "my-docs/images/logo.png",
+ }),
+ ];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.equal(result.fileName, "my-docs.zip");
+ assert.deepEqual(result.warnings, []);
+ assert.ok(result.zipBytes instanceof Uint8Array);
+});
+
+test("buildZipFromFiles uses fallback folder name when relativePath has no directory", async () => {
+ const wasm = fakeWasm();
+ const files = [fakeFile("README.md", "# Hi"), fakeFile("notes.md", "# N")];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.equal(result.fileName, "workspace.zip");
+});
+
+test("buildZipFromFiles converts .markdown extension to .zip in filename", async () => {
+ const wasm = fakeWasm();
+ const files = [fakeFile("notes.markdown", "# Notes")];
+
+ const result = await buildZipFromFiles(wasm, files);
+
+ assert.equal(result.fileName, "notes.zip");
+});