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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
48 changes: 48 additions & 0 deletions crates/marknest-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<RenderedHtmlDocument, RenderHtmlError> {
render_markdown_entry_with_options(bytes, filename, &RenderOptions::default())
}

pub fn render_markdown_entry_with_options(
bytes: &[u8],
filename: &str,
options: &RenderOptions,
) -> Result<RenderedHtmlDocument, RenderHtmlError> {
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,
Expand Down Expand Up @@ -486,6 +504,36 @@ impl IndexedFileSystem for ZipMemoryFileSystem {
}
}

struct SingleMarkdownFileSystem {
files: Vec<IndexedFile>,
}

impl SingleMarkdownFileSystem {
fn new(contents: &[u8], filename: &str) -> Result<Self, RenderHtmlError> {
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<ProjectIndex, AnalyzeError> {
let mut diagnostic: Diagnostic = Diagnostic::default();
let mut markdown_files: Vec<&IndexedFile> = Vec::new();
Expand Down
85 changes: 85 additions & 0 deletions crates/marknest-core/tests/render_markdown_html.rs
Original file line number Diff line number Diff line change
@@ -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("<h1 id=\"hello-world\">Hello World</h1>")
);
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("<h1 id=\"guide\">Guide</h1>"));
// 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(),
}
);
}
76 changes: 74 additions & 2 deletions crates/marknest-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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<u8>,
filename: String,
options: JsValue,
) -> Result<JsValue, JsValue> {
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<ProjectIndex, String> {
analyze_zip(zip_bytes).map_err(|error| error.to_string())
}
Expand Down Expand Up @@ -307,6 +321,21 @@ fn render_preview_model(
})
}

fn render_markdown_model(
md_bytes: &[u8],
filename: &str,
options: &BrowserOutputOptions,
) -> Result<RenderPreview, String> {
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],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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("<h1 id=\"direct-render\">Direct Render</h1>")
);
}

#[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\"")
);
}
}
40 changes: 39 additions & 1 deletion web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down