diff --git a/index.html b/index.html index 7964933..ab7d5b0 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ MarkNest Fallback Lab @@ -16,6 +16,7 @@ + @@ -26,9 +27,9 @@

Phase 8 ยท Output Controls

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: ![alt](path) + 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("![arch](./images/arch.svg)"), true); + assert.equal(hasLocalImageReferences("![logo](images/logo.png)"), true); + assert.equal(hasLocalImageReferences("![pic](../assets/pic.jpg)"), true); +}); + +test("hasLocalImageReferences detects local html img tags", () => { + assert.equal( + hasLocalImageReferences('Diagram'), + true, + ); + assert.equal( + hasLocalImageReferences(""), + true, + ); +}); + +test("hasLocalImageReferences ignores http and https urls", () => { + assert.equal( + hasLocalImageReferences("![logo](https://example.com/logo.png)"), + false, + ); + assert.equal( + hasLocalImageReferences("![logo](http://cdn.example.com/img.jpg)"), + false, + ); + assert.equal( + hasLocalImageReferences(''), + false, + ); +}); + +test("hasLocalImageReferences ignores data URIs", () => { + assert.equal( + hasLocalImageReferences("![inline](data:image/png;base64,abc123)"), + 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 = + "![remote](https://example.com/a.png)\n![local](./b.png)\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![arch](./images/arch.svg)")]; + + 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", "![logo](https://example.com/logo.png)"), + ]; + + 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"); +});