diff --git a/README.md b/README.md index f83486a..730494b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Key capabilities: - `marknest` provides a `validate` CLI for `.md`, `.zip`, and folder inputs. - `marknest` provides a conversion CLI with config file, debug artifact, and print template support. - `marknest` now also exposes a reusable HTML-to-PDF helper for local fallback services. -- `marknest-wasm` exposes browser bindings for ZIP analysis, output-aware HTML preview rendering, batch preview rendering, ZIP packaging of generated PDFs, and browser-side debug bundle generation. +- `marknest-wasm` exposes browser bindings for ZIP analysis, output-aware HTML preview rendering, direct single-markdown rendering (bypassing ZIP), batch preview rendering, ZIP packaging of generated PDFs, and browser-side debug bundle generation. - `marknest-server` provides a local Axum fallback service that accepts multipart ZIP uploads plus shared output options, returns single PDF or batch ZIP downloads through a Playwright-driven Chromium/Chrome path, and emits structured request logs. - `index.html` plus `web/app.js`, `web/app.css`, `web/output_options.mjs`, and `web/runtime_sync.mjs` provide a web app for ZIP upload, entry selection, warnings, rendered HTML preview, runtime-aware browser export, debug bundle download, and optional local server fallback. - Validation supports `--entry`, `--all`, `--strict`, and `--report`. diff --git a/crates/marknest-core/src/lib.rs b/crates/marknest-core/src/lib.rs index daf5b16..59ff3a5 100644 --- a/crates/marknest-core/src/lib.rs +++ b/crates/marknest-core/src/lib.rs @@ -24,6 +24,7 @@ pub const MATHJAX_SCRIPT_URL: &str = "./runtime-assets/mathjax/es5/tex-svg.js"; pub enum ProjectSourceKind { Workspace, Zip, + SingleMarkdown, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -238,6 +239,23 @@ pub fn render_zip_entry_with_options( render_entry_with_options(&zip_file_system, entry_path, options) } +pub fn render_markdown_entry( + bytes: &[u8], + filename: &str, +) -> Result { + render_markdown_entry_with_options(bytes, filename, &RenderOptions::default()) +} + +pub fn render_markdown_entry_with_options( + bytes: &[u8], + filename: &str, + options: &RenderOptions, +) -> Result { + let file_system = SingleMarkdownFileSystem::new(bytes, filename)?; + let entry_path: &str = &file_system.files[0].normalized_path; + render_entry_with_options(&file_system, entry_path, options) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RenderedHeading { level: HeadingLevel, @@ -486,6 +504,36 @@ impl IndexedFileSystem for ZipMemoryFileSystem { } } +struct SingleMarkdownFileSystem { + files: Vec, +} + +impl SingleMarkdownFileSystem { + fn new(contents: &[u8], filename: &str) -> Result { + let normalized_path: String = + normalize_relative_string(filename).map_err(|_| RenderHtmlError::InvalidEntryPath { + entry_path: filename.to_string(), + })?; + + Ok(Self { + files: vec![IndexedFile { + normalized_path, + contents: contents.to_vec(), + }], + }) + } +} + +impl IndexedFileSystem for SingleMarkdownFileSystem { + fn source_kind(&self) -> ProjectSourceKind { + ProjectSourceKind::SingleMarkdown + } + + fn files(&self) -> &[IndexedFile] { + &self.files + } +} + fn analyze_project(file_system: &dyn IndexedFileSystem) -> Result { let mut diagnostic: Diagnostic = Diagnostic::default(); let mut markdown_files: Vec<&IndexedFile> = Vec::new(); diff --git a/crates/marknest-core/tests/render_markdown_html.rs b/crates/marknest-core/tests/render_markdown_html.rs new file mode 100644 index 0000000..b070dc1 --- /dev/null +++ b/crates/marknest-core/tests/render_markdown_html.rs @@ -0,0 +1,85 @@ +use marknest_core::{ + PdfMetadata, RenderHtmlError, RenderOptions, ThemePreset, render_markdown_entry, + render_markdown_entry_with_options, +}; + +#[test] +fn renders_a_single_markdown_as_self_contained_html() { + let markdown_bytes = b"# Hello World\n\nThis is a test paragraph.\n"; + + let rendered = + render_markdown_entry(markdown_bytes, "README.md").expect("single markdown should render"); + + assert_eq!(rendered.title, "README"); + assert!( + rendered + .html + .contains("

Hello World

") + ); + assert!(rendered.html.contains("This is a test paragraph.")); +} + +#[test] +fn renders_single_markdown_with_custom_options() { + let markdown_bytes = b"# Preview\n\nSome content.\n"; + + let rendered = render_markdown_entry_with_options( + markdown_bytes, + "guide.md", + &RenderOptions { + theme: ThemePreset::Docs, + metadata: PdfMetadata { + title: Some("Custom Title".to_string()), + author: Some("Test Author".to_string()), + subject: Some("Testing".to_string()), + }, + ..RenderOptions::default() + }, + ) + .expect("single markdown should render with options"); + + assert_eq!(rendered.title, "Custom Title"); + assert!(rendered.html.contains("theme-docs")); + assert!( + rendered + .html + .contains("meta name=\"author\" content=\"Test Author\"") + ); +} + +#[test] +fn derives_title_from_filename_when_no_metadata_title() { + let markdown_bytes = b"# Content\n"; + + let rendered = render_markdown_entry(markdown_bytes, "getting-started.md") + .expect("title should derive from filename"); + + assert_eq!(rendered.title, "getting-started"); +} + +#[test] +fn renders_single_markdown_with_unresolvable_local_images() { + let markdown_bytes = b"# Guide\n\n![Diagram](./images/arch.png)\n"; + + let rendered = render_markdown_entry(markdown_bytes, "guide.md") + .expect("single markdown should render even with unresolvable local images"); + + assert!(rendered.html.contains("

Guide

")); + // Local image reference stays as-is since there are no accompanying files + assert!(rendered.html.contains("./images/arch.png")); +} + +#[test] +fn rejects_non_utf8_markdown_bytes() { + let invalid_utf8: &[u8] = &[0xFF, 0xFE, 0x00, 0x01]; + + let error = render_markdown_entry(invalid_utf8, "broken.md") + .expect_err("non-UTF-8 markdown should fail"); + + assert_eq!( + error, + RenderHtmlError::InvalidUtf8 { + entry_path: "broken.md".to_string(), + } + ); +} diff --git a/crates/marknest-wasm/src/lib.rs b/crates/marknest-wasm/src/lib.rs index ecce37a..7fcd782 100644 --- a/crates/marknest-wasm/src/lib.rs +++ b/crates/marknest-wasm/src/lib.rs @@ -5,7 +5,7 @@ use marknest_core::{ AssetRef, DEFAULT_MATH_TIMEOUT_MS, DEFAULT_MERMAID_TIMEOUT_MS, MATHJAX_SCRIPT_URL, MATHJAX_VERSION, MERMAID_SCRIPT_URL, MERMAID_VERSION, MathMode, MermaidMode, PdfMetadata, ProjectIndex, ProjectSourceKind, RUNTIME_ASSET_MODE, RenderOptions, RenderedHtmlDocument, - ThemePreset, analyze_zip, render_zip_entry_with_options, + ThemePreset, analyze_zip, render_markdown_entry_with_options, render_zip_entry_with_options, }; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -278,6 +278,20 @@ pub fn build_debug_bundle_binding( .map_err(|message| JsValue::from_str(&message)) } +#[wasm_bindgen(js_name = renderMarkdown)] +pub fn render_markdown_binding( + md_bytes: Vec, + filename: String, + options: JsValue, +) -> Result { + let parsed_options = + parse_browser_output_options(options).map_err(|message| JsValue::from_str(&message))?; + let preview = render_markdown_model(&md_bytes, &filename, &parsed_options) + .map_err(|message| JsValue::from_str(&message))?; + serde_wasm_bindgen::to_value(&preview) + .map_err(|error| JsValue::from_str(&format!("Failed to encode rendered HTML: {error}"))) +} + fn analyze_zip_model(zip_bytes: &[u8]) -> Result { analyze_zip(zip_bytes).map_err(|error| error.to_string()) } @@ -307,6 +321,21 @@ fn render_preview_model( }) } +fn render_markdown_model( + md_bytes: &[u8], + filename: &str, + options: &BrowserOutputOptions, +) -> Result { + let rendered_document: RenderedHtmlDocument = + render_markdown_entry_with_options(md_bytes, filename, &options.render_options()) + .map_err(|error| error.to_string())?; + + Ok(RenderPreview { + title: rendered_document.title, + html: rendered_document.html, + }) +} + fn render_preview_batch_model( zip_bytes: &[u8], entry_paths: &[String], @@ -482,7 +511,8 @@ mod tests { use super::{ BrowserOutputOptions, PdfArchiveFile, analyze_zip_model, build_debug_bundle_model, - build_pdf_archive_model, render_preview_batch_model, render_preview_model, + build_pdf_archive_model, render_markdown_model, render_preview_batch_model, + render_preview_model, }; use marknest_core::{MathMode, MermaidMode, ThemePreset}; use zip::write::SimpleFileOptions; @@ -760,4 +790,46 @@ mod tests { .is_ok() ); } + + #[test] + fn renders_single_markdown_preview_without_zip() { + let md_bytes = b"# Direct Render\n\nNo ZIP wrapping needed.\n"; + + let preview = + render_markdown_model(md_bytes, "README.md", &BrowserOutputOptions::default()) + .expect("single markdown should render"); + + assert_eq!(preview.title, "README"); + assert!(preview.html.contains("theme-github")); + assert!( + preview + .html + .contains("

Direct Render

") + ); + } + + #[test] + fn renders_single_markdown_preview_with_custom_options() { + let md_bytes = b"# Styled\n\nContent here.\n"; + + let preview = render_markdown_model( + md_bytes, + "guide.md", + &BrowserOutputOptions { + theme: ThemePreset::Docs, + title: Some("Custom Guide".to_string()), + author: Some("Team".to_string()), + ..BrowserOutputOptions::default() + }, + ) + .expect("single markdown should render with options"); + + assert_eq!(preview.title, "Custom Guide"); + assert!(preview.html.contains("theme-docs")); + assert!( + preview + .html + .contains("meta name=\"author\" content=\"Team\"") + ); + } } diff --git a/web/app.js b/web/app.js index 1dbbfe7..6b7029c 100644 --- a/web/app.js +++ b/web/app.js @@ -23,12 +23,15 @@ import { import { buildZipFromFiles, detectInputKind, + hasLocalImageReferences, readDirectoryEntries, } from "./zip_from_files.mjs"; const state = { wasm: null, zipBytes: null, + markdownBytes: null, + markdownFileName: null, projectIndex: null, selectedEntry: null, renderedPreview: null, @@ -475,6 +478,8 @@ async function processInput(files) { } elements.localImageWarning.hidden = true; + state.markdownBytes = null; + state.markdownFileName = null; if (inputKind === "zip") { await analyzeZip(files[0]); @@ -486,6 +491,35 @@ async function processInput(files) { return; } + if (inputKind === "markdown") { + try { + const file = files[0]; + const text = await file.text(); + + if (hasLocalImageReferences(text)) { + elements.localImageWarning.hidden = false; + elements.localImageWarning.textContent = + "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 mdBytes = new Uint8Array(await file.arrayBuffer()); + state.markdownBytes = mdBytes; + state.markdownFileName = file.name; + + // Still build ZIP for operations that need it (debug bundle, server fallback) + const archiveEntries = [{ path: file.name, bytes: mdBytes }]; + const zipBytes = state.wasm.buildPdfArchive(archiveEntries); + const syntheticFile = new File([zipBytes], file.name.replace(/\.(md|markdown)$/i, ".zip"), { + type: "application/zip", + }); + await analyzeZip(syntheticFile); + } catch (error) { + setStatus("error", "Failed", `Input processing failed: ${String(error)}`); + } + return; + } + try { setStatus("ready", "Converting", "Building an in-memory archive from the selected input."); const result = await buildZipFromFiles(state.wasm, files); @@ -547,6 +581,8 @@ async function analyzeZip(file) { } catch (error) { state.previewRenderVersion += 1; state.zipBytes = null; + state.markdownBytes = null; + state.markdownFileName = null; state.projectIndex = null; state.selectedEntry = null; state.renderedPreview = null; @@ -635,7 +671,9 @@ async function renderPreview(entryPath, outputOptions = currentOutputOptions()) const optionsKey = currentOutputOptionsKey(outputOptions); try { - const preview = state.wasm.renderHtml(state.zipBytes, entryPath, outputOptions); + const preview = state.markdownBytes + ? state.wasm.renderMarkdown(state.markdownBytes, state.markdownFileName, outputOptions) + : state.wasm.renderHtml(state.zipBytes, entryPath, outputOptions); const materializedPreview = await materializePreview(preview, outputOptions); if (previewRenderVersion !== state.previewRenderVersion) { return;