diff --git a/src/launch.rs b/src/launch.rs
new file mode 100644
index 0000000..b2c5ca4
--- /dev/null
+++ b/src/launch.rs
@@ -0,0 +1,102 @@
+//! Launch: browser openers. Non-blocking, fire-and-forget platform
+//! openers plus the xdg-open/gio fallback chain.
+
+use std::io;
+use std::path::Path;
+use std::time::Duration;
+/// Launch the platform's default handler for `path` (typically a web browser
+/// for `.html`). Non-blocking — the spawned process runs independently.
+pub fn launch_browser(path: &Path) -> std::io::Result<()> {
+ #[cfg(target_os = "windows")]
+ {
+ // SECURITY: open via the Win32 ShellExecuteW API (through the `opener`
+ // crate) rather than `cmd /C start`. `start` runs the path through
+ // cmd.exe's command-line parser, so cmd metacharacters (`&`, `^`, …) —
+ // which are legal in Windows filenames and reach us via the input
+ // file's stem in temp_output_for — would be interpreted as commands
+ // (e.g. opening `a&calc&.md` would launch calc.exe). ShellExecuteW
+ // takes the path as a single typed argument, so those characters stay
+ // inert. It is non-blocking, preserving the fire-and-forget behavior.
+ opener::open(path).map_err(io::Error::other)?;
+ }
+ #[cfg(target_os = "macos")]
+ {
+ // `open` returns promptly once Launch Services accepts the request,
+ // so waiting for it doesn't block on the browser itself — and a
+ // nonzero exit (no handler for the file) must surface as a launch
+ // failure rather than being discarded with the child handle.
+ let status = std::process::Command::new("open").arg(path).status()?;
+ if !status.success() {
+ return Err(io::Error::other(format!("open exited with {status}")));
+ }
+ }
+ #[cfg(all(unix, not(target_os = "macos")))]
+ {
+ launch_via_xdg_open(path)?;
+ }
+ Ok(())
+}
+
+/// Open `path` with the first available freedesktop opener. Falls back through
+/// common launchers when `xdg-open` (xdg-utils) is not installed, and reaps the
+/// launcher process in a detached thread so it does not linger as a zombie
+/// during long `--watch` sessions.
+///
+/// Spawning an opener is not the same as launching a browser: `xdg-open` can
+/// exist, start, and then exit nonzero because no browser handler is
+/// configured. We therefore listen for each opener's exit for a short window
+/// — a quick nonzero exit is an honest failure (and we fall through to the
+/// next opener), while an opener still running after the window has almost
+/// certainly handed off (or, in xdg-open's no-desktop fallback, IS the
+/// browser), so we detach and call it success. We never block on the browser
+/// itself beyond that window.
+#[cfg(all(unix, not(target_os = "macos")))]
+fn launch_via_xdg_open(path: &Path) -> std::io::Result<()> {
+ const OPENERS: &[(&str, &[&str])] = &[
+ ("xdg-open", &[]),
+ ("gio", &["open"]),
+ ("gnome-open", &[]),
+ ("kde-open5", &[]),
+ ("kde-open", &[]),
+ ("wslview", &[]),
+ ];
+ const LAUNCH_FAILURE_WINDOW: Duration = Duration::from_secs(2);
+
+ let mut last_failure: Option = None;
+ for (program, leading) in OPENERS {
+ let mut command = std::process::Command::new(program);
+ command.args(*leading).arg(path);
+ let Ok(mut child) = command.spawn() else {
+ continue; // not installed; try the next opener
+ };
+
+ // The waiting thread doubles as the reaper for the detached-success
+ // case: it always waits the child to completion, we just stop
+ // listening for the result after the failure window.
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(child.wait());
+ });
+
+ match rx.recv_timeout(LAUNCH_FAILURE_WINDOW) {
+ Ok(Ok(status)) if status.success() => return Ok(()),
+ Ok(Ok(status)) => {
+ last_failure = Some(format!("{program} exited with {status}"));
+ }
+ Ok(Err(e)) => {
+ last_failure = Some(format!("failed waiting on {program}: {e}"));
+ }
+ // Still running after the window: it handed off to (or is) the
+ // browser. The waiting thread reaps it eventually.
+ Err(_) => return Ok(()),
+ }
+ }
+
+ match last_failure {
+ Some(failure) => Err(io::Error::other(failure)),
+ None => Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ "no desktop opener found (install xdg-utils)",
+ )),
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index b3b102f..45990b4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,37 +1,38 @@
-//! Shared mdo rendering, watch-mode, temp-output, browser launch, and
-//! integration helpers.
+//! Shared mdo rendering, watch-mode, temp-output, browser launch, page
+//! assembly, and integration helpers.
+//!
+//! The crate is organized by its four real concerns (names per GLOSSARY.md):
+//! [`render`] (Markdown to fragment plus the deep `convert` workflow),
+//! [`page`] (assets, styles/scripts, document wrapping), [`temp_output`]
+//! (per-source private output locations), and [`launch`] (browser openers).
+//! Each keeps its internals private behind thin public exports re-exported
+//! here, so caller paths do not change when the layout does.
-use std::collections::hash_map::DefaultHasher;
-use std::collections::BTreeSet;
-use std::fmt;
use std::fs;
-use std::hash::{Hash, Hasher};
use std::io;
-#[cfg(unix)]
-use std::io::Write;
-use std::path::{Path, PathBuf};
-use std::time::{Duration, Instant, UNIX_EPOCH};
-
-#[cfg(unix)]
-use std::fs::OpenOptions;
-#[cfg(unix)]
-use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
-
-use pulldown_cmark::Options;
-use url::Url;
+use std::path::PathBuf;
#[cfg(target_os = "android")]
mod android;
pub mod file_manager;
pub mod handler;
mod highlight;
+pub mod launch;
+pub mod page;
+pub mod render;
+pub mod temp_output;
pub mod watch;
#[cfg(target_os = "windows")]
pub mod windows_setup;
-const SIMPLE_CSS: &str = include_str!("../assets/simple.min.css");
-const APP_DISPLAY_NAME: &str = "mdo";
-const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
+use temp_output::{ensure_private_dir, private_temp_root};
+
+pub use launch::launch_browser;
+pub use render::{
+ convert, render_markdown_document, ConvertOutcome, ConvertRequest, RenderError, StageTimings,
+};
+pub use temp_output::temp_output_for;
+
const SETUP_SAMPLE_FILE_NAME: &str = "welcome-to-open-as-html-with-mdo.md";
const SETUP_SAMPLE_MARKDOWN: &str = "\
# Welcome to the world of Open as HTML with mdo
@@ -42,1069 +43,6 @@ opened it successfully.
Next, right-click any `.md` file and choose **Open as HTML** to read it this way.
If you make mdo your default Markdown app, a double-click does the same.
";
-const UNSAFE_TEMP_OUTPUT_STEM_CHARS: &[char] = &[
- '&', '^', '%', '(', ')', '!', '"', '\'', '<', '>', '|', ';', '`', '$', '\\', '/', ':',
-];
-
-// mdo keeps simple.css as the base stylesheet but softens the heading/body
-// scale for generated documents. Users who prefer the unmodified vendored
-// simple.css typography can pass assets/restore-simple-css.css with --css.
-const MDO_DEFAULT_TYPOGRAPHY_CSS: &str = include_str!("../assets/mdo-default-typography.css");
-
-// pulldown-cmark emits GFM alerts as blockquotes with a
-// `markdown-alert-{kind}` class. Keep the presentation in the generated
-// document so alerts also work for bare installs with no external assets.
-// The plain `:root` and prefers-color-scheme rules keep alerts styled when
-// JavaScript is disabled (the theme-toggle script never runs, so no
-// data-theme attribute is ever set); the higher-specificity
-// `:root[data-theme=...]` rules then follow the theme toggle rather than the
-// OS preference once a reader has selected a manual theme.
-const GFM_ALERTS_CSS: &str = r#"
-:root{--alert-note:#0969da;--alert-tip:#1a7f37;--alert-important:#8250df;--alert-warning:#9a6700;--alert-caution:#cf222e}
-@media (prefers-color-scheme: dark){:root{--alert-note:#58a6ff;--alert-tip:#3fb950;--alert-important:#a371f7;--alert-warning:#d29922;--alert-caution:#f85149}}
-:root[data-theme="light"]{--alert-note:#0969da;--alert-tip:#1a7f37;--alert-important:#8250df;--alert-warning:#9a6700;--alert-caution:#cf222e}
-:root[data-theme="dark"]{--alert-note:#58a6ff;--alert-tip:#3fb950;--alert-important:#a371f7;--alert-warning:#d29922;--alert-caution:#f85149}
-blockquote[class^="markdown-alert-"]{--alert-color:var(--accent);border-left:.3rem solid var(--alert-color);background:var(--accent-bg);padding:.75rem 1rem}
-blockquote.markdown-alert-note{--alert-color:var(--alert-note)}blockquote.markdown-alert-tip{--alert-color:var(--alert-tip)}blockquote.markdown-alert-important{--alert-color:var(--alert-important)}blockquote.markdown-alert-warning{--alert-color:var(--alert-warning)}blockquote.markdown-alert-caution{--alert-color:var(--alert-caution)}
-"#;
-
-// ─── BEGIN THEME TOGGLE ────────────────────────────────────────────────
-// Self-contained light/dark mode toggle. To remove this feature entirely:
-// 1. Delete this block (down to "END THEME TOGGLE").
-// 2. Delete the `{theme_toggle}` line and `theme_toggle = ...` arg in
-// `wrap_html5` below.
-// Variable values mirror simple.css's @media (prefers-color-scheme: dark).
-//
-// Behavior notes:
-// - OS theme is detected automatically and tracked live until the user makes
-// a manual choice; the manual choice persists via localStorage.
-// - localStorage can throw on file:// pages in some browsers/configurations,
-// so every access is wrapped \u2014 the toggle still works for the current page
-// even when persistence is unavailable.
-// - An in-memory `manual` flag (not the localStorage read) is the source of
-// truth for "the user made an explicit choice". It is initialized from the
-// saved value and set on every toggle click regardless of whether
-// localStorage.setItem succeeds, so a later prefers-color-scheme change
-// can never clobber an explicit choice just because persistence failed
-// (e.g. on file:// pages).
-// - The button is a real (keyboard focusable/activatable) with a
-// state-aware aria-label.
-// - This
-
-"#;
-// ─── END THEME TOGGLE ──────────────────────────────────────────────────
-
-/// Stable per-source-path location under a private temp/cache dir, e.g.
-/// `%TEMP%\mdo-\\.html`. Re-opening the same source
-/// overwrites the same file rather than accumulating new ones.
-pub fn temp_output_for(input: &Path) -> io::Result {
- let canonical = fs::canonicalize(input).unwrap_or_else(|_| input.to_path_buf());
- let mut hasher = DefaultHasher::new();
- canonical.hash(&mut hasher);
- let hash = hasher.finish();
-
- let stem = input
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("document");
- let stem = sanitize_temp_output_stem(stem);
-
- let root = private_temp_root();
- ensure_private_dir(&root)?;
-
- let source_dir = root.join(format!("{:016x}", hash));
- ensure_private_dir(&source_dir)?;
-
- Ok(source_dir.join(format!("{stem}.html")))
-}
-
-fn sanitize_temp_output_stem(stem: &str) -> String {
- let mut sanitized = String::with_capacity(stem.len());
-
- for ch in stem.chars() {
- if ch.is_control() || UNSAFE_TEMP_OUTPUT_STEM_CHARS.contains(&ch) {
- sanitized.push('_');
- } else {
- sanitized.push(ch);
- }
- }
-
- if sanitized.chars().any(|ch| ch != '_') {
- sanitized
- } else {
- "document".to_string()
- }
-}
-
-fn private_temp_root() -> PathBuf {
- let mut p = std::env::temp_dir();
- #[cfg(unix)]
- p.push(format!("mdo-{}", unsafe { libc::geteuid() }));
- #[cfg(not(unix))]
- p.push("mdo");
- p
-}
-
-#[cfg(unix)]
-fn ensure_private_dir(path: &Path) -> io::Result<()> {
- match fs::symlink_metadata(path) {
- Ok(metadata) => {
- let file_type = metadata.file_type();
- if file_type.is_symlink() || !file_type.is_dir() {
- return Err(io::Error::new(
- io::ErrorKind::AlreadyExists,
- format!("{path:?} exists but is not a directory"),
- ));
- }
-
- let uid = unsafe { libc::geteuid() };
- if metadata.uid() != uid {
- return Err(io::Error::new(
- io::ErrorKind::PermissionDenied,
- format!("{path:?} is not owned by the current user"),
- ));
- }
-
- let mode = metadata.permissions().mode();
- if mode & 0o077 != 0 {
- fs::set_permissions(path, fs::Permissions::from_mode(mode & !0o077))?;
- }
- }
- Err(e) if e.kind() == io::ErrorKind::NotFound => {
- let mut builder = fs::DirBuilder::new();
- builder.mode(0o700);
- if let Err(create_error) = builder.create(path) {
- if create_error.kind() == io::ErrorKind::AlreadyExists {
- return ensure_private_dir(path);
- }
-
- return Err(create_error);
- }
- }
- Err(e) => return Err(e),
- }
-
- Ok(())
-}
-
-#[cfg(not(unix))]
-fn ensure_private_dir(path: &Path) -> io::Result<()> {
- fs::create_dir_all(path)
-}
-
-/// Build a `file://` URL for a directory, suitable for use as ` `.
-///
-/// Delegates to [`Url::from_directory_path`], which correctly
-/// percent-encodes reserved and non-ASCII characters (spaces, `#`, `%`, `?`,
-/// Unicode, …) and handles Windows drive letters, UNC paths, and the
-/// `\\?\`/`\\?\UNC\` extended-length prefixes that `fs::canonicalize`
-/// produces — a hand-rolled string transform got all of this subtly wrong
-/// (e.g. a literal `#` in a directory name would truncate the URL when a
-/// browser parsed it as a fragment). Always includes a trailing slash so
-/// relative refs resolve correctly against the directory.
-///
-/// Returns `None` if `dir` can't be represented as a `file://` URL (notably:
-/// relative paths). Callers should fall back to omitting the ` `
-/// tag entirely rather than emit a broken URL.
-fn dir_to_file_url(dir: &Path) -> Option {
- Url::from_directory_path(dir)
- .ok()
- .map(|url| url.to_string())
-}
-
-/// Launch the platform's default handler for `path` (typically a web browser
-/// for `.html`). Non-blocking — the spawned process runs independently.
-pub fn launch_browser(path: &Path) -> std::io::Result<()> {
- #[cfg(target_os = "windows")]
- {
- // SECURITY: open via the Win32 ShellExecuteW API (through the `opener`
- // crate) rather than `cmd /C start`. `start` runs the path through
- // cmd.exe's command-line parser, so cmd metacharacters (`&`, `^`, …) —
- // which are legal in Windows filenames and reach us via the input
- // file's stem in temp_output_for — would be interpreted as commands
- // (e.g. opening `a&calc&.md` would launch calc.exe). ShellExecuteW
- // takes the path as a single typed argument, so those characters stay
- // inert. It is non-blocking, preserving the fire-and-forget behavior.
- opener::open(path).map_err(io::Error::other)?;
- }
- #[cfg(target_os = "macos")]
- {
- // `open` returns promptly once Launch Services accepts the request,
- // so waiting for it doesn't block on the browser itself — and a
- // nonzero exit (no handler for the file) must surface as a launch
- // failure rather than being discarded with the child handle.
- let status = std::process::Command::new("open").arg(path).status()?;
- if !status.success() {
- return Err(io::Error::other(format!("open exited with {status}")));
- }
- }
- #[cfg(all(unix, not(target_os = "macos")))]
- {
- launch_via_xdg_open(path)?;
- }
- Ok(())
-}
-
-/// Open `path` with the first available freedesktop opener. Falls back through
-/// common launchers when `xdg-open` (xdg-utils) is not installed, and reaps the
-/// launcher process in a detached thread so it does not linger as a zombie
-/// during long `--watch` sessions.
-///
-/// Spawning an opener is not the same as launching a browser: `xdg-open` can
-/// exist, start, and then exit nonzero because no browser handler is
-/// configured. We therefore listen for each opener's exit for a short window
-/// — a quick nonzero exit is an honest failure (and we fall through to the
-/// next opener), while an opener still running after the window has almost
-/// certainly handed off (or, in xdg-open's no-desktop fallback, IS the
-/// browser), so we detach and call it success. We never block on the browser
-/// itself beyond that window.
-#[cfg(all(unix, not(target_os = "macos")))]
-fn launch_via_xdg_open(path: &Path) -> std::io::Result<()> {
- const OPENERS: &[(&str, &[&str])] = &[
- ("xdg-open", &[]),
- ("gio", &["open"]),
- ("gnome-open", &[]),
- ("kde-open5", &[]),
- ("kde-open", &[]),
- ("wslview", &[]),
- ];
- const LAUNCH_FAILURE_WINDOW: Duration = Duration::from_secs(2);
-
- let mut last_failure: Option = None;
- for (program, leading) in OPENERS {
- let mut command = std::process::Command::new(program);
- command.args(*leading).arg(path);
- let Ok(mut child) = command.spawn() else {
- continue; // not installed; try the next opener
- };
-
- // The waiting thread doubles as the reaper for the detached-success
- // case: it always waits the child to completion, we just stop
- // listening for the result after the failure window.
- let (tx, rx) = std::sync::mpsc::channel();
- std::thread::spawn(move || {
- let _ = tx.send(child.wait());
- });
-
- match rx.recv_timeout(LAUNCH_FAILURE_WINDOW) {
- Ok(Ok(status)) if status.success() => return Ok(()),
- Ok(Ok(status)) => {
- last_failure = Some(format!("{program} exited with {status}"));
- }
- Ok(Err(e)) => {
- last_failure = Some(format!("failed waiting on {program}: {e}"));
- }
- // Still running after the window: it handed off to (or is) the
- // browser. The waiting thread reaps it eventually.
- Err(_) => return Ok(()),
- }
- }
-
- match last_failure {
- Some(failure) => Err(io::Error::other(failure)),
- None => Err(io::Error::new(
- io::ErrorKind::NotFound,
- "no desktop opener found (install xdg-utils)",
- )),
- }
-}
-
-fn markdown_options() -> Options {
- let mut options = Options::empty();
- options.insert(Options::ENABLE_STRIKETHROUGH);
- options.insert(Options::ENABLE_TABLES);
- options.insert(Options::ENABLE_FOOTNOTES);
- options.insert(Options::ENABLE_TASKLISTS);
- options.insert(Options::ENABLE_GFM);
-
- options
-}
-
-fn markdown_to_html(
- markdown: &str,
- placeholder_discriminator: u64,
- plain_fences: &BTreeSet,
-) -> highlight::RenderedMarkdown {
- // Front matter is stripped here, before parsing, by the same
- // `yaml_front_matter` splitter that title derivation uses, so one set of
- // acceptance rules governs both. Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
- // is deliberately NOT enabled: pulldown-cmark 0.13 recognizes metadata
- // blocks anywhere at indent 0, not just at document start, which silently
- // swallows mid-document text framed by `---` lines.
- let markdown = yaml_front_matter(markdown)
- .map(|(_, body)| body)
- .unwrap_or(markdown);
-
- highlight::render(
- markdown,
- markdown_options(),
- placeholder_discriminator,
- plain_fences,
- )
-}
-
-/// Upper bound on placeholder-collision retries. A collision requires
-/// document text that normalizes into the current placeholder token, so more
-/// than a couple of retries only happens with adversarial input; past the cap
-/// the document renders with highlighting disabled rather than looping.
-const MAX_PLACEHOLDER_RETRIES: u32 = 16;
-
-/// Shared render/process/splice loop behind [`safe_markdown_body`] and
-/// [`convert`].
-///
-/// Highlighted spans are generated by mdo from escaped code text and spliced
-/// into the raw renderer output only after all placeholders have been
-/// validated. `process` (usually ammonia) then handles the completed document,
-/// including generated markup, so no string insertion happens beyond the
-/// sanitizer boundary. The loop is guaranteed to terminate:
-///
-/// - a destroyed placeholder can never survive under any discriminator, so
-/// its fence is added to the strictly growing `plain_fences` set and
-/// rendered as an ordinary escaped code block instead;
-/// - collisions retry with a fresh discriminator at most
-/// [`MAX_PLACEHOLDER_RETRIES`] times, after which every fence is rendered
-/// plain, leaving nothing to splice.
-fn spliced_markdown_body(
- markdown: &str,
- mut render: impl FnMut(&str, u64, &BTreeSet) -> highlight::RenderedMarkdown,
- mut process: impl FnMut(&str) -> String,
-) -> (String, Option<&'static str>) {
- let mut discriminator = 0_u64;
- let mut collision_retries = 0_u32;
- let mut plain_fences = BTreeSet::new();
-
- loop {
- let rendered = render(markdown, discriminator, &plain_fences);
- match rendered.finish(rendered.raw_html().to_string()) {
- Ok(body) => return (process(&body), rendered.css()),
- Err(highlight::SpliceError::Destroyed(fences)) => {
- plain_fences.extend(fences);
- }
- Err(highlight::SpliceError::Collision) => {
- collision_retries += 1;
- if collision_retries > MAX_PLACEHOLDER_RETRIES {
- plain_fences.extend(0..rendered.fence_count());
- }
- discriminator += 1;
- }
- }
- }
-}
-
-/// Render and sanitize Markdown, splicing generated highlighting before the
-/// completed document crosses the sanitizer boundary.
-fn safe_markdown_body(markdown: &str) -> (String, Option<&'static str>) {
- spliced_markdown_body(markdown, markdown_to_html, sanitize_html)
-}
-
-fn sanitize_html(html: &str) -> String {
- ammonia::Builder::default()
- // The global "class" allowance is intentional: user-authored class
- // names are inert here because classes only select styles from mdo's
- // own generated stylesheet. Highlight safety does not rest on this
- // allowlist either: highlighted markup is generated from escaped code
- // text, spliced before sanitization, and then cleaned with the rest of
- // the document.
- .add_generic_attributes(&["class", "id"])
- .add_tags(&["input"])
- .add_tag_attributes("input", &["checked", "type"])
- .add_tag_attribute_values("input", "checked", &[""])
- .add_tag_attribute_values("input", "type", &["checkbox"])
- .set_tag_attribute_value("input", "disabled", "")
- .clean(html)
- .to_string()
-}
-
-/// Render Markdown supplied in memory as a complete, self-contained HTML page.
-///
-/// This is the platform-neutral entry point used by the Android application.
-/// Raw HTML is always sanitized, matching the safe default of the desktop CLI.
-/// `fallback_title` is used only when the document has no top-level heading.
-pub fn render_markdown_document(
- markdown: &str,
- fallback_title: &str,
- source_modified_unix_secs: Option,
-) -> String {
- let (body, highlight_css) = safe_markdown_body(markdown);
- let title = derive_title(markdown, fallback_title);
- wrap_html5(
- &body,
- &title,
- None,
- None,
- highlight_css,
- source_modified_unix_secs,
- )
-}
-
-fn derive_title(markdown: &str, fallback: &str) -> String {
- if let Some(title) = front_matter_title(markdown) {
- return title;
- }
-
- // A valid metadata block is not document content. Exclude it from the
- // existing H1 search even when it has no usable title key.
- let markdown = yaml_front_matter(markdown)
- .map(|(_, document)| document)
- .unwrap_or(markdown);
-
- // Strip fenced code blocks first so a heading — whether an ATX `# ` line or
- // a raw — inside a ``` or ~~~ block is never mistaken for the title.
- let without_fences = strip_code_fences(markdown);
- for line in without_fences.lines() {
- let trimmed = line.trim_start();
- if let Some(rest) = trimmed.trim_end().strip_prefix("# ") {
- return rest.trim().to_string();
- }
- }
- if let Some(title) = derive_raw_html_h1_title(&without_fences) {
- return title;
- }
- fallback.to_string()
-}
-
-/// Extract a simple scalar `title:` from a complete YAML-style metadata block
-/// at the start of the document.
-///
-/// pulldown-cmark deliberately recognizes the block delimiters without
-/// interpreting YAML. mdo follows that narrow behavior: it reads only an
-/// unindented `title` key and does not add a general-purpose YAML parser (and
-/// its dependency/security surface) merely to title the generated page.
-fn front_matter_title(markdown: &str) -> Option {
- let (metadata, _) = yaml_front_matter(markdown)?;
- let mut title = None;
- for line in metadata.lines() {
- if line.starts_with(char::is_whitespace) {
- continue;
- }
- if let Some((key, value)) = line.split_once(':') {
- if key == "title" {
- let value = unquote_front_matter_scalar(value.trim());
- if !value.is_empty() {
- title = Some(value.to_string());
- }
- }
- }
- }
-
- title
-}
-
-/// Split a complete YAML-style metadata block from the document body. The
-/// returned metadata excludes delimiters.
-///
-/// This splitter is the single source of truth for front-matter recognition
-/// (both hiding the block from rendered output and title extraction), and its
-/// acceptance rules are deliberately conservative and GitHub-like:
-///
-/// - the opening `---` must be the very first line of the document;
-/// - the closing delimiter is `---` or `...` on its own line — trailing
-/// spaces are allowed but tabs are not;
-/// - the block must be non-empty and its first line must not be blank.
-/// GitHub and pulldown-cmark both reject blocks whose first content line is
-/// blank, so treating them as front matter here would hide visible content
-/// and let e.g. `---\n\ntitle: fake\n---` spoof the page title.
-fn yaml_front_matter(markdown: &str) -> Option<(&str, &str)> {
- fn delimiter(line: &str) -> Option<&str> {
- let line = line.trim_end_matches(['\r', '\n']).trim_end_matches(' ');
- (line == "---" || line == "...").then_some(line)
- }
-
- let first_line_end = markdown.find('\n').unwrap_or(markdown.len());
- if delimiter(&markdown[..first_line_end]) != Some("---") || first_line_end == markdown.len() {
- return None;
- }
-
- let metadata_start = first_line_end + 1;
- let remainder = &markdown[metadata_start..];
- let mut consumed = 0;
- // split_inclusive also yields a final segment without a trailing newline,
- // so a closing delimiter at end-of-input is found by the loop as well.
- for segment in remainder.split_inclusive('\n') {
- if delimiter(segment).is_some() {
- let metadata = &remainder[..consumed];
- let first_content_line = metadata.lines().next()?;
- if first_content_line.trim().is_empty() {
- return None;
- }
- return Some((metadata, &remainder[consumed + segment.len()..]));
- }
- consumed += segment.len();
- }
-
- None
-}
-
-fn unquote_front_matter_scalar(value: &str) -> &str {
- if value.len() < 2 {
- return value;
- }
-
- let bytes = value.as_bytes();
- if matches!(
- (bytes[0], bytes[value.len() - 1]),
- (b'\'', b'\'') | (b'"', b'"')
- ) {
- &value[1..value.len() - 1]
- } else {
- value
- }
-}
-
-/// Return `markdown` with fenced code blocks (``` or ~~~) removed, so title
-/// detection never reads inside code samples.
-fn strip_code_fences(markdown: &str) -> String {
- let mut out = String::with_capacity(markdown.len());
- let mut fence: Option = None;
- for line in markdown.lines() {
- if let Some(marker) = code_fence_marker(line.trim_start()) {
- match fence {
- Some(open) if open == marker => fence = None,
- Some(_) => {}
- None => fence = Some(marker),
- }
- continue;
- }
- if fence.is_some() {
- continue;
- }
- out.push_str(line);
- out.push('\n');
- }
- out
-}
-
-/// Detect a code-fence line, returning its marker char when the line starts
-/// with at least three backticks or tildes. This is a pragmatic subset of
-/// CommonMark (it does not bound the indent or require the closing fence to be
-/// at least as long), which is sufficient for title detection.
-fn code_fence_marker(trimmed_start: &str) -> Option {
- if trimmed_start.starts_with("```") {
- Some('`')
- } else if trimmed_start.starts_with("~~~") {
- Some('~')
- } else {
- None
- }
-}
-
-fn derive_raw_html_h1_title(markdown: &str) -> Option {
- let lower = markdown.to_ascii_lowercase();
- let mut search_start = 0;
-
- while let Some(offset) = lower[search_start..].find("') | Some(b' ') | Some(b'\t') | Some(b'\r') | Some(b'\n')
- ) {
- search_start = after_tag_name;
- continue;
- }
-
- let content_start = start + markdown[start..].find('>')? + 1;
- let content_end = content_start + lower[content_start..].find(" ")?;
- let title = strip_html_tags(&markdown[content_start..content_end])
- .split_whitespace()
- .collect::>()
- .join(" ");
- if !title.is_empty() {
- return Some(title);
- }
-
- search_start = content_end + 5;
- }
-
- None
-}
-
-fn strip_html_tags(input: &str) -> String {
- let mut output = String::with_capacity(input.len());
- let mut in_tag = false;
-
- for ch in input.chars() {
- match ch {
- '<' => in_tag = true,
- '>' if in_tag => in_tag = false,
- _ if !in_tag => output.push(ch),
- _ => {}
- }
- }
-
- output
-}
-
-fn wrap_html5(
- body: &str,
- title: &str,
- base_href: Option<&str>,
- css_override: Option<&str>,
- highlight_css: Option<&str>,
- source_modified_unix_secs: Option,
-) -> String {
- // ` ` makes relative image/link refs in the rendered HTML resolve
- // against the *source* directory even when the HTML lives elsewhere
- // (e.g. when --open writes to %TEMP%). Must come before any element that
- // references a URL, so we put it first inside .
- let base_tag = match base_href {
- Some(href) => format!(" \n", html_escape(href)),
- None => String::new(),
- };
- let css_override_block = css_override
- .filter(|css| !css.trim().is_empty())
- .map(|css| {
- format!(
- "\n",
- escape_style_end_tags(css)
- )
- })
- .unwrap_or_default();
- let mdo_default_typography = format!(
- "\n",
- escape_style_end_tags(MDO_DEFAULT_TYPOGRAPHY_CSS)
- );
- let highlight_css = highlight_css.unwrap_or_default();
- // Provenance lives only in ; the visible page
- // carries no mdo branding, version, or render timing (v0.6 quiet output).
- let generator = format!("{APP_DISPLAY_NAME} {APP_VERSION}");
- // Restrained source-freshness footer: the source file's filesystem
- // modification time, rendered as UTC with a machine-readable datetime.
- // The tiny inline script re-formats it in the reader's locale/timezone
- // when JavaScript is available. Omitted entirely when the timestamp is
- // missing or unreadable.
- let source_meta = source_modified_unix_secs
- .map(|secs| {
- let machine = utc_datetime_from_unix_secs(secs);
- let human = human_utc_datetime_from_unix_secs(secs);
- format!(
- "\n\
- \n",
- machine = html_escape(&machine),
- human = html_escape(&human),
- )
- })
- .unwrap_or_default();
- format!(
- "\n\
- \n\
- \n\
- \n\
- \n\
- \n\
- {base_tag}\
- {title} \n\
- \n\
- {theme_toggle}\
- {mdo_default_typography}\
- {css_override_block}\
- \n\
- \n\
- \n{body}\n \n\
- {source_meta}\
- \n\
- \n",
- generator = html_escape(&generator),
- base_tag = base_tag,
- title = html_escape(title),
- css = SIMPLE_CSS,
- gfm_alerts_css = GFM_ALERTS_CSS,
- highlight_css = highlight_css,
- theme_toggle = THEME_TOGGLE, // ← THEME TOGGLE injection point (delete this line to remove)
- mdo_default_typography = mdo_default_typography,
- css_override_block = css_override_block,
- body = body,
- source_meta = source_meta,
- )
-}
-
-fn escape_style_end_tags(css: &str) -> String {
- const STYLE_END_PREFIX: &[u8] = b" Option {
- fs::metadata(input)
- .and_then(|metadata| metadata.modified())
- .ok()
- .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
- .map(|elapsed| elapsed.as_secs())
-}
-
-/// ISO 8601 UTC datetime, e.g. `2026-07-14T17:42:05Z`, for ``.
-fn utc_datetime_from_unix_secs(secs: u64) -> String {
- let days = (secs / 86_400) as i64;
- let (year, month, day) = civil_from_days(days);
- let seconds_of_day = secs % 86_400;
- let hour = seconds_of_day / 3_600;
- let minute = (seconds_of_day % 3_600) / 60;
- let second = seconds_of_day % 60;
-
- format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
-}
-
-/// Readable UTC fallback text, e.g. `July 14, 2026, 5:42 PM UTC`, shown when
-/// JavaScript cannot re-format the timestamp in the reader's locale.
-fn human_utc_datetime_from_unix_secs(secs: u64) -> String {
- let days = (secs / 86_400) as i64;
- let (year, month, day) = civil_from_days(days);
- let seconds_of_day = secs % 86_400;
- let hour24 = seconds_of_day / 3_600;
- let minute = (seconds_of_day % 3_600) / 60;
- let (hour12, meridiem) = match hour24 {
- 0 => (12, "AM"),
- 1..=11 => (hour24, "AM"),
- 12 => (12, "PM"),
- _ => (hour24 - 12, "PM"),
- };
- let month_name = MONTH_NAMES[(month - 1) as usize];
-
- format!("{month_name} {day}, {year}, {hour12}:{minute:02} {meridiem} UTC")
-}
-
-fn civil_from_days(days_since_unix_epoch: i64) -> (i64, i64, i64) {
- // Howard Hinnant's civil-from-days algorithm, shifted for Unix epoch days.
- let z = days_since_unix_epoch + 719_468;
- let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
- let y = yoe + era * 400;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let day = doy - (153 * mp + 2) / 5 + 1;
- let month = mp + if mp < 10 { 3 } else { -9 };
- let year = y + if month <= 2 { 1 } else { 0 };
-
- (year, month, day)
-}
-
-fn html_escape(s: &str) -> String {
- s.replace('&', "&")
- .replace('<', "<")
- .replace('>', ">")
- .replace('"', """)
-}
-
-/// Wall-clock time spent in each stage of one render workflow, plus the
-/// workflow's total. Reported to stderr by the CLI's `--verbose` output.
-/// Stages that do not run for a given invocation (`sanitize` under
-/// `--unsafe-html`, `assemble` under `--bare`) report zero.
-#[derive(Debug, Default)]
-pub struct StageTimings {
- pub read: Duration,
- pub markdown: Duration,
- pub sanitize: Duration,
- pub assemble: Duration,
- pub write: Duration,
- /// Wall-clock time of the whole workflow, always at least the sum of
- /// the stages.
- pub total: Duration,
-}
-
-/// One render request for [`convert`]: a Markdown input, a destination, and
-/// the render options. `verbose` is deliberately absent — diagnostics are
-/// the caller's presentation concern, and [`ConvertOutcome::timings`]
-/// carries everything a caller needs to print them.
-#[derive(Debug)]
-pub struct ConvertRequest<'a> {
- /// The Markdown source file.
- pub input: &'a Path,
- /// The HTML file to write; created when missing, overwritten when
- /// present.
- pub output: &'a Path,
- /// Emit only the HTML fragment (no ``, ``, ``, no CSS).
- pub bare: bool,
- /// Preserve raw HTML from the Markdown source instead of sanitizing it.
- pub unsafe_html: bool,
- /// Write the output file readable only by the current user.
- pub private_output: bool,
- /// Custom CSS appended after mdo's default styling. Read from disk here
- /// and ignored under `bare`, because bare output emits no CSS.
- pub css_override: Option<&'a Path>,
-}
-
-/// What a successful [`convert`] wrote: the output file plus the workflow's
-/// stage-by-stage timing record.
-#[derive(Debug)]
-pub struct ConvertOutcome {
- /// The output file that was written.
- pub output: PathBuf,
- /// Wall-clock timing record for the completed workflow.
- pub timings: StageTimings,
-}
-
-/// Why a [`convert`] workflow failed, typed per failure site with the
-/// underlying `io::Error` and the path it concerns, so callers keep printing
-/// their own messages instead of the library doing it for them.
-#[derive(Debug)]
-pub enum RenderError {
- /// Reading the Markdown input file failed.
- Read { path: PathBuf, source: io::Error },
- /// Reading the `--css` override file failed.
- ReadCssOverride { path: PathBuf, source: io::Error },
- /// Creating the output file's parent directory failed.
- CreateOutputDir { path: PathBuf, source: io::Error },
- /// Writing the rendered HTML file failed.
- Write { path: PathBuf, source: io::Error },
-}
-
-impl fmt::Display for RenderError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let (action, path, source) = match self {
- RenderError::Read { path, source } => ("failed to read", path, source),
- RenderError::ReadCssOverride { path, source } => {
- ("failed to read CSS override", path, source)
- }
- RenderError::CreateOutputDir { path, source } => ("failed to create", path, source),
- RenderError::Write { path, source } => ("failed to write", path, source),
- };
- write!(f, "{} {}: {}", action, path.display(), source)
- }
-}
-
-impl std::error::Error for RenderError {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- Some(match self {
- RenderError::Read { source, .. }
- | RenderError::ReadCssOverride { source, .. }
- | RenderError::CreateOutputDir { source, .. }
- | RenderError::Write { source, .. } => source,
- })
- }
-}
-
-/// Render one Markdown file to HTML: the render pipeline's single deep
-/// entry point.
-///
-/// The workflow: read the source (and any CSS override), render and splice
-/// highlighting, sanitize (unless `unsafe_html`), assemble the page (unless
-/// `bare`), create the output's parent directory when missing, then write
-/// the file (O_NOFOLLOW, mode 0o600 under `private_output`). The workflow
-/// prints nothing itself — success carries a [`ConvertOutcome`] with the
-/// written file and timing record, failure a typed [`RenderError`] — so the
-/// same workflow serves the CLI, the setup sample, and any other host, and
-/// the generated HTML is byte-identical everywhere it is invoked from.
-pub fn convert(request: ConvertRequest) -> Result {
- let workflow_start = Instant::now();
- let mut timings = StageTimings::default();
-
- let stage_start = Instant::now();
- let markdown = fs::read_to_string(request.input).map_err(|source| RenderError::Read {
- path: request.input.to_path_buf(),
- source,
- })?;
- timings.read = stage_start.elapsed();
-
- let css_override = if request.bare {
- None
- } else {
- match request.css_override {
- Some(path) => {
- let stage_start = Instant::now();
- let css =
- fs::read_to_string(path).map_err(|source| RenderError::ReadCssOverride {
- path: path.to_path_buf(),
- source,
- })?;
- timings.read += stage_start.elapsed();
- Some(css)
- }
- None => None,
- }
- };
-
- // Same splice loop as `safe_markdown_body`, instrumented for the
- // outcome's timing record.
- let (body, highlight_css) = spliced_markdown_body(
- &markdown,
- |markdown, discriminator, plain_fences| {
- let stage_start = Instant::now();
- let rendered = markdown_to_html(markdown, discriminator, plain_fences);
- timings.markdown += stage_start.elapsed();
- rendered
- },
- |html| {
- if request.unsafe_html {
- html.to_string()
- } else {
- let sanitize_start = Instant::now();
- let sanitized = sanitize_html(html);
- timings.sanitize += sanitize_start.elapsed();
- sanitized
- }
- },
- );
-
- let final_html = if request.bare {
- body
- } else {
- let stage_start = Instant::now();
- let fallback = request
- .input
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("Document");
- let title = derive_title(&markdown, fallback);
-
- // Only inject when the output lives in a different
- // directory from the source — otherwise relative refs already
- // resolve correctly and a base tag would just add noise.
- let base_href = match (
- fs::canonicalize(request.input)
- .ok()
- .and_then(|p| p.parent().map(Path::to_path_buf)),
- request
- .output
- .parent()
- .and_then(|p| fs::canonicalize(p).ok()),
- ) {
- (Some(in_dir), Some(out_dir)) if in_dir != out_dir => dir_to_file_url(&in_dir),
- _ => None,
- };
-
- let wrapped = wrap_html5(
- &body,
- &title,
- base_href.as_deref(),
- css_override.as_deref(),
- highlight_css,
- source_modified_unix_secs(request.input),
- );
- timings.assemble = stage_start.elapsed();
- wrapped
- };
-
- let stage_start = Instant::now();
- if let Some(parent) = request.output.parent() {
- if !parent.as_os_str().is_empty() {
- fs::create_dir_all(parent).map_err(|source| RenderError::CreateOutputDir {
- path: parent.to_path_buf(),
- source,
- })?;
- }
- }
-
- write_output_file(request.output, &final_html, request.private_output).map_err(|source| {
- RenderError::Write {
- path: request.output.to_path_buf(),
- source,
- }
- })?;
- timings.write = stage_start.elapsed();
- timings.total = workflow_start.elapsed();
-
- Ok(ConvertOutcome {
- output: request.output.to_path_buf(),
- timings,
- })
-}
-
-#[cfg(unix)]
-fn write_output_file(output: &Path, contents: &str, private_output: bool) -> io::Result<()> {
- let mut options = OpenOptions::new();
- options
- .write(true)
- .create(true)
- .truncate(true)
- .custom_flags(libc::O_NOFOLLOW);
-
- if private_output {
- options.mode(0o600);
- }
-
- let mut file = options.open(output)?;
- file.write_all(contents.as_bytes())
-}
-
-#[cfg(not(unix))]
-fn write_output_file(output: &Path, contents: &str, _private_output: bool) -> io::Result<()> {
- fs::write(output, contents)
-}
pub fn open_setup_sample() -> io::Result<()> {
let source = setup_sample_input_path()?;
@@ -1133,563 +71,9 @@ fn setup_sample_input_path() -> io::Result {
ensure_private_dir(&dir)?;
Ok(dir.join(SETUP_SAMPLE_FILE_NAME))
}
-
#[cfg(test)]
mod tests {
use super::*;
- use std::sync::atomic::{AtomicU64, Ordering};
- use std::time::SystemTime;
-
- static NEXT_TEMP_FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
-
- fn rendered_markdown_html(markdown: &str) -> String {
- let rendered = markdown_to_html(markdown, 0, &BTreeSet::new());
- rendered
- .finish(rendered.raw_html().to_string())
- .expect("unsanitized renderer should preserve its unique placeholders")
- }
-
- #[test]
- fn temp_output_stem_replaces_shell_metacharacters() {
- let input = unique_temp_input("a&calc&.md");
- let output = temp_output_for(&input).expect("temp path should be built");
- let file_name = output
- .file_name()
- .and_then(|s| s.to_str())
- .expect("temp path should end with unicode filename");
-
- assert_eq!(file_name, "a_calc_.html");
- assert!(!file_name.contains('&'));
-
- cleanup_temp_fixture(&input, &output);
- }
-
- #[test]
- fn temp_output_stem_keeps_readable_normal_names() {
- let input = unique_temp_input("résumé-draft_2026.md");
- let output = temp_output_for(&input).expect("temp path should be built");
- let file_name = output
- .file_name()
- .and_then(|s| s.to_str())
- .expect("temp path should end with unicode filename");
-
- assert_eq!(file_name, "résumé-draft_2026.html");
-
- cleanup_temp_fixture(&input, &output);
- }
-
- fn unique_temp_input(file_name: &str) -> PathBuf {
- let nonce = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("system time should be after epoch")
- .as_nanos();
- let counter = NEXT_TEMP_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
- let dir = std::env::temp_dir().join(format!(
- "mdo-unit-test-{}-{nonce}-{counter}",
- std::process::id()
- ));
- fs::create_dir_all(&dir).expect("test temp dir should be created");
- let input = dir.join(file_name);
- fs::write(&input, "# Test\n").expect("test input should be written");
- input
- }
-
- fn cleanup_temp_fixture(input: &Path, output: &Path) {
- if let Some(parent) = output.parent() {
- let _ = fs::remove_dir_all(parent);
- }
- if let Some(parent) = input.parent() {
- let _ = fs::remove_dir_all(parent);
- }
- }
-
- #[test]
- fn formats_unix_timestamps_as_machine_utc_datetimes() {
- assert_eq!(utc_datetime_from_unix_secs(0), "1970-01-01T00:00:00Z");
- assert_eq!(
- utc_datetime_from_unix_secs(951_827_696),
- "2000-02-29T12:34:56Z"
- );
- }
-
- #[test]
- fn formats_unix_timestamps_as_readable_utc_datetimes() {
- // Midnight and noon exercise the 12-hour AM/PM edge cases.
- assert_eq!(
- human_utc_datetime_from_unix_secs(0),
- "January 1, 1970, 12:00 AM UTC"
- );
- assert_eq!(
- human_utc_datetime_from_unix_secs(951_827_696),
- "February 29, 2000, 12:34 PM UTC"
- );
- assert_eq!(
- human_utc_datetime_from_unix_secs(13 * 3_600 + 5 * 60),
- "January 1, 1970, 1:05 PM UTC"
- );
- }
-
- #[test]
- fn wrap_html5_keeps_generator_meta_without_visible_branding() {
- let html = wrap_html5("hi
", "Title", None, None, None, Some(0));
-
- assert!(html.contains(&format!(
- " "
- )));
- assert!(!html.contains("Generated by"));
- assert!(!html.contains("mdo-generated"));
- }
-
- #[test]
- fn wrap_html5_shows_source_modified_time_with_machine_datetime() {
- let html = wrap_html5("hi
", "Title", None, None, None, Some(951_827_696));
-
- assert!(html.contains(
- ""
- ));
- }
-
- #[test]
- fn wrap_html5_renders_without_source_modified_time() {
- let html = wrap_html5("hi
", "Title", None, None, None, None);
-
- assert!(html.contains("\nhi
\n "));
- assert!(!html.contains("Source modified"));
- assert!(!html.contains("alert('no')",
- "fallback.md",
- None,
- );
-
- assert!(html.contains("Phone note "));
- assert!(html.contains("Android "));
- assert!(html.contains(" alert('no')"));
- }
-
- #[test]
- fn markdown_extensions_keep_their_existing_html() {
- let markdown = "~~gone~~\n\n\
- | A | B |\n\
- | - | - |\n\
- | 1 | 2 |\n\n\
- Footnote[^one].\n\n\
- [^one]: Detail\n\n\
- - [x] done\n\
- - [ ] later\n";
- let expected = "gone
\n\
- \n\
- Footnote.
\n\
- \n\
- \n";
-
- assert_eq!(rendered_markdown_html(markdown), expected);
- }
-
- #[test]
- fn gfm_alerts_render_all_supported_classes_and_survive_sanitizing() {
- for kind in ["note", "tip", "important", "warning", "caution"] {
- let markdown = format!("> [!{}]\n> Alert text\n", kind.to_uppercase());
- let raw = rendered_markdown_html(&markdown);
- let class = format!("class=\"markdown-alert-{kind}\"");
-
- assert!(raw.contains(&class), "{raw}");
- assert!(!raw.contains("[!"), "{raw}");
- assert!(sanitize_html(&raw).contains(&class), "{raw}");
- }
- }
-
- #[test]
- fn wrapped_document_styles_alerts_in_light_and_dark_palettes() {
- let html = wrap_html5("hi
", "Title", None, None, None, None);
-
- // No-JS defaults: plain :root plus an OS dark-mode media query, so
- // alerts stay styled when the theme-toggle script never runs.
- assert!(html.contains("\n:root{--alert-note:"));
- assert!(html.contains("@media (prefers-color-scheme: dark){:root{--alert-note:"));
- // Manual theme-toggle overrides.
- assert!(html.contains(":root[data-theme=\"light\"]{--alert-note:"));
- assert!(html.contains(":root[data-theme=\"dark\"]{--alert-note:"));
- for kind in ["note", "tip", "important", "warning", "caution"] {
- assert!(html.contains(&format!("blockquote.markdown-alert-{kind}")));
- }
- }
-
- #[test]
- fn yaml_front_matter_is_hidden_and_supplies_document_title() {
- let markdown =
- "---\ntitle: 'Front Matter Title'\nauthor: Example\n...\n\n# Heading Title\n";
- let body = rendered_markdown_html(markdown);
-
- assert_eq!(body, "Heading Title \n");
- assert_eq!(derive_title(markdown, "fallback"), "Front Matter Title");
-
- let document = render_markdown_document(markdown, "fallback", None);
- assert!(document.contains("Front Matter Title "));
- assert!(!document.contains("author: Example"));
- }
-
- #[test]
- fn mid_document_metadata_style_blocks_are_rendered_not_swallowed() {
- let markdown = "# Top\n\nintro\n\n---\nnot a heading\n---\n\ntail\n";
- let html = rendered_markdown_html(markdown);
-
- assert!(html.contains("not a heading"), "{html}");
- assert!(html.contains("tail"), "{html}");
- assert_eq!(derive_title(markdown, "fallback"), "Top");
- }
-
- #[test]
- fn blank_first_line_block_is_not_front_matter() {
- // GitHub and pulldown-cmark reject metadata blocks whose first content
- // line is blank; accepting one here would hide visible text and let it
- // spoof the page title.
- let markdown = "---\n\ntitle: fake\n---\n\n# Real Heading\n";
- let html = rendered_markdown_html(markdown);
-
- assert!(html.contains("title: fake"), "{html}");
- assert_eq!(derive_title(markdown, "fallback"), "Real Heading");
-
- // An empty block is likewise not front matter.
- assert!(yaml_front_matter("---\n---\n# Heading\n").is_none());
- }
-
- #[test]
- fn front_matter_title_supports_colons_and_double_quotes() {
- let markdown = "---\ntitle: \"Project: Alpha\"\n---\n\n# Heading\n";
- assert_eq!(derive_title(markdown, "fallback"), "Project: Alpha");
- }
-
- #[test]
- fn missing_empty_or_unclosed_front_matter_title_uses_existing_fallbacks() {
- assert_eq!(
- derive_title("---\nauthor: Example\n---\n\n# Heading\n", "fallback"),
- "Heading"
- );
- assert_eq!(
- derive_title("---\ntitle: \n---\n", "Fallback Title"),
- "Fallback Title"
- );
- assert_eq!(
- derive_title("---\ntitle: Incomplete\n# Real Heading\n", "fallback"),
- "Real Heading"
- );
- assert_eq!(
- derive_title(
- "---\ndescription: |\n# Metadata, not a heading\n---\n\n# Real Heading\n",
- "fallback"
- ),
- "Real Heading"
- );
- }
-
- #[test]
- fn smart_punctuation_is_not_enabled() {
- let html = rendered_markdown_html("-- --- ... \"straight\" 'quotes'\n");
-
- assert_eq!(html, "-- --- ... \"straight\" 'quotes'
\n");
- assert!(!html.contains(['–', '—', '…', '“', '”', '‘', '’']));
- }
-
- #[test]
- fn recognized_fences_are_highlighted_with_both_theme_palettes() {
- let markdown = "```rust\nfn main() { let html = \"\"; }\n```\n";
- let html = render_markdown_document(markdown, "fallback", None);
-
- assert!(html.contains(
- ""
- ));
- assert!(html.contains("mdo-syntect-keyword"), "{html}");
- assert!(html.contains(":root[data-theme=\"light\"] .mdo-syntect-code"));
- assert!(html.contains(":root[data-theme=\"dark\"] .mdo-syntect-code"));
- assert!(!html.contains(""));
- assert!(html.contains("<script>"));
- }
-
- #[test]
- fn unknown_fences_remain_plain_and_do_not_add_theme_css() {
- let markdown = "```not-a-real-language\n & plain\n```\n";
- let html = render_markdown_document(markdown, "fallback", None);
-
- assert!(html.contains(
- "<unsafe> & plain\n "
- ));
- assert!(!html.contains("mdo-highlight"));
- assert!(!html.contains("Syntax highlighting generated by syntect"));
- }
-
- #[test]
- fn pages_without_code_do_not_add_highlight_css() {
- let html = render_markdown_document("# Just prose\n", "fallback", None);
- assert!(!html.contains("Syntax highlighting generated by syntect"));
- assert!(!html.contains("mdo-syntect-"));
-
- let unlabeled = render_markdown_document("```\nplain code\n```\n", "fallback", None);
- assert!(unlabeled.contains("plain code\n "));
- assert!(!unlabeled.contains("Syntax highlighting generated by syntect"));
- }
-
- #[test]
- fn literal_placeholder_spoof_does_not_capture_generated_markup() {
- let spoof = "__MDO_HIGHLIGHT_PLACEHOLDER_0_0__";
- let markdown = format!("`{spoof}`\n\n```rust\nlet answer = 42;\n```\n");
- let html = render_markdown_document(&markdown, "fallback", None);
-
- assert!(html.contains(spoof));
- assert_eq!(html.matches("probe\n\n```rust\nlet one = 1;\n```\n\n```python\nprint(2)\n```\n"
- );
- let html = render_markdown_document(&markdown, "fallback", None);
-
- assert!(html.contains(&format!("title=\"{normalized}\"")), "{html}");
- assert!(!html.contains("title=\"probe\n\n");
-
- assert!(escaped.contains("<\\/style>
+"#;
+// ─── END THEME TOGGLE ──────────────────────────────────────────────────
+
+pub(crate) fn wrap_html5(
+ body: &str,
+ title: &str,
+ base_href: Option<&str>,
+ css_override: Option<&str>,
+ highlight_css: Option<&str>,
+ source_modified_unix_secs: Option,
+) -> String {
+ // ` ` makes relative image/link refs in the rendered HTML resolve
+ // against the *source* directory even when the HTML lives elsewhere
+ // (e.g. when --open writes to %TEMP%). Must come before any element that
+ // references a URL, so we put it first inside .
+ let base_tag = match base_href {
+ Some(href) => format!(" \n", html_escape(href)),
+ None => String::new(),
+ };
+ let css_override_block = css_override
+ .filter(|css| !css.trim().is_empty())
+ .map(|css| {
+ format!(
+ "\n",
+ escape_style_end_tags(css)
+ )
+ })
+ .unwrap_or_default();
+ let mdo_default_typography = format!(
+ "\n",
+ escape_style_end_tags(MDO_DEFAULT_TYPOGRAPHY_CSS)
+ );
+ let highlight_css = highlight_css.unwrap_or_default();
+ // Provenance lives only in ; the visible page
+ // carries no mdo branding, version, or render timing (v0.6 quiet output).
+ let generator = format!("{APP_DISPLAY_NAME} {APP_VERSION}");
+ // Restrained source-freshness footer: the source file's filesystem
+ // modification time, rendered as UTC with a machine-readable datetime.
+ // The tiny inline script re-formats it in the reader's locale/timezone
+ // when JavaScript is available. Omitted entirely when the timestamp is
+ // missing or unreadable.
+ let source_meta = source_modified_unix_secs
+ .map(|secs| {
+ let machine = utc_datetime_from_unix_secs(secs);
+ let human = human_utc_datetime_from_unix_secs(secs);
+ format!(
+ "\n\
+ \n",
+ machine = html_escape(&machine),
+ human = html_escape(&human),
+ )
+ })
+ .unwrap_or_default();
+ format!(
+ "\n\
+ \n\
+ \n\
+ \n\
+ \n\
+ \n\
+ {base_tag}\
+ {title} \n\
+ \n\
+ {theme_toggle}\
+ {mdo_default_typography}\
+ {css_override_block}\
+ \n\
+ \n\
+ \n{body}\n \n\
+ {source_meta}\
+ \n\
+ \n",
+ generator = html_escape(&generator),
+ base_tag = base_tag,
+ title = html_escape(title),
+ css = SIMPLE_CSS,
+ gfm_alerts_css = GFM_ALERTS_CSS,
+ highlight_css = highlight_css,
+ theme_toggle = THEME_TOGGLE, // ← THEME TOGGLE injection point (delete this line to remove)
+ mdo_default_typography = mdo_default_typography,
+ css_override_block = css_override_block,
+ body = body,
+ source_meta = source_meta,
+ )
+}
+
+fn escape_style_end_tags(css: &str) -> String {
+ const STYLE_END_PREFIX: &[u8] = b" Option {
+ fs::metadata(input)
+ .and_then(|metadata| metadata.modified())
+ .ok()
+ .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
+ .map(|elapsed| elapsed.as_secs())
+}
+
+/// ISO 8601 UTC datetime, e.g. `2026-07-14T17:42:05Z`, for ``.
+fn utc_datetime_from_unix_secs(secs: u64) -> String {
+ let days = (secs / 86_400) as i64;
+ let (year, month, day) = civil_from_days(days);
+ let seconds_of_day = secs % 86_400;
+ let hour = seconds_of_day / 3_600;
+ let minute = (seconds_of_day % 3_600) / 60;
+ let second = seconds_of_day % 60;
+
+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
+}
+
+/// Readable UTC fallback text, e.g. `July 14, 2026, 5:42 PM UTC`, shown when
+/// JavaScript cannot re-format the timestamp in the reader's locale.
+fn human_utc_datetime_from_unix_secs(secs: u64) -> String {
+ let days = (secs / 86_400) as i64;
+ let (year, month, day) = civil_from_days(days);
+ let seconds_of_day = secs % 86_400;
+ let hour24 = seconds_of_day / 3_600;
+ let minute = (seconds_of_day % 3_600) / 60;
+ let (hour12, meridiem) = match hour24 {
+ 0 => (12, "AM"),
+ 1..=11 => (hour24, "AM"),
+ 12 => (12, "PM"),
+ _ => (hour24 - 12, "PM"),
+ };
+ let month_name = MONTH_NAMES[(month - 1) as usize];
+
+ format!("{month_name} {day}, {year}, {hour12}:{minute:02} {meridiem} UTC")
+}
+
+fn civil_from_days(days_since_unix_epoch: i64) -> (i64, i64, i64) {
+ // Howard Hinnant's civil-from-days algorithm, shifted for Unix epoch days.
+ let z = days_since_unix_epoch + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let day = doy - (153 * mp + 2) / 5 + 1;
+ let month = mp + if mp < 10 { 3 } else { -9 };
+ let year = y + if month <= 2 { 1 } else { 0 };
+
+ (year, month, day)
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn formats_unix_timestamps_as_machine_utc_datetimes() {
+ assert_eq!(utc_datetime_from_unix_secs(0), "1970-01-01T00:00:00Z");
+ assert_eq!(
+ utc_datetime_from_unix_secs(951_827_696),
+ "2000-02-29T12:34:56Z"
+ );
+ }
+
+ #[test]
+ fn formats_unix_timestamps_as_readable_utc_datetimes() {
+ // Midnight and noon exercise the 12-hour AM/PM edge cases.
+ assert_eq!(
+ human_utc_datetime_from_unix_secs(0),
+ "January 1, 1970, 12:00 AM UTC"
+ );
+ assert_eq!(
+ human_utc_datetime_from_unix_secs(951_827_696),
+ "February 29, 2000, 12:34 PM UTC"
+ );
+ assert_eq!(
+ human_utc_datetime_from_unix_secs(13 * 3_600 + 5 * 60),
+ "January 1, 1970, 1:05 PM UTC"
+ );
+ }
+
+ #[test]
+ fn wrap_html5_keeps_generator_meta_without_visible_branding() {
+ let html = wrap_html5("hi
", "Title", None, None, None, Some(0));
+
+ assert!(html.contains(&format!(
+ " "
+ )));
+ assert!(!html.contains("Generated by"));
+ assert!(!html.contains("mdo-generated"));
+ }
+
+ #[test]
+ fn wrap_html5_shows_source_modified_time_with_machine_datetime() {
+ let html = wrap_html5("hi
", "Title", None, None, None, Some(951_827_696));
+
+ assert!(html.contains(
+ ""
+ ));
+ }
+
+ #[test]
+ fn wrap_html5_renders_without_source_modified_time() {
+ let html = wrap_html5("hi
", "Title", None, None, None, None);
+
+ assert!(html.contains("\nhi
\n "));
+ assert!(!html.contains("Source modified"));
+ assert!(!html.contains("
", "Title", None, None, None, None);
+
+ // No-JS defaults: plain :root plus an OS dark-mode media query, so
+ // alerts stay styled when the theme-toggle script never runs.
+ assert!(html.contains("\n:root{--alert-note:"));
+ assert!(html.contains("@media (prefers-color-scheme: dark){:root{--alert-note:"));
+ // Manual theme-toggle overrides.
+ assert!(html.contains(":root[data-theme=\"light\"]{--alert-note:"));
+ assert!(html.contains(":root[data-theme=\"dark\"]{--alert-note:"));
+ for kind in ["note", "tip", "important", "warning", "caution"] {
+ assert!(html.contains(&format!("blockquote.markdown-alert-{kind}")));
+ }
+ }
+
+ #[test]
+ fn css_override_escapes_style_end_tags() {
+ let escaped = escape_style_end_tags("h1{} ");
+
+ assert!(escaped.contains("<\\/style>",
+ "fallback.md",
+ None,
+ );
+
+ assert!(html.contains("Phone note "));
+ assert!(html.contains("Android "));
+ assert!(html.contains(" alert('no')"));
+ }
+
+ #[test]
+ fn markdown_extensions_keep_their_existing_html() {
+ let markdown = "~~gone~~\n\n\
+ | A | B |\n\
+ | - | - |\n\
+ | 1 | 2 |\n\n\
+ Footnote[^one].\n\n\
+ [^one]: Detail\n\n\
+ - [x] done\n\
+ - [ ] later\n";
+ let expected = "gone
\n\
+ \n\
+ Footnote.
\n\
+ \n\
+ \n";
+
+ assert_eq!(rendered_markdown_html(markdown), expected);
+ }
+
+ #[test]
+ fn gfm_alerts_render_all_supported_classes_and_survive_sanitizing() {
+ for kind in ["note", "tip", "important", "warning", "caution"] {
+ let markdown = format!("> [!{}]\n> Alert text\n", kind.to_uppercase());
+ let raw = rendered_markdown_html(&markdown);
+ let class = format!("class=\"markdown-alert-{kind}\"");
+
+ assert!(raw.contains(&class), "{raw}");
+ assert!(!raw.contains("[!"), "{raw}");
+ assert!(sanitize_html(&raw).contains(&class), "{raw}");
+ }
+ }
+
+ #[test]
+ fn yaml_front_matter_is_hidden_and_supplies_document_title() {
+ let markdown =
+ "---\ntitle: 'Front Matter Title'\nauthor: Example\n...\n\n# Heading Title\n";
+ let body = rendered_markdown_html(markdown);
+
+ assert_eq!(body, "Heading Title \n");
+ assert_eq!(derive_title(markdown, "fallback"), "Front Matter Title");
+
+ let document = render_markdown_document(markdown, "fallback", None);
+ assert!(document.contains("Front Matter Title "));
+ assert!(!document.contains("author: Example"));
+ }
+
+ #[test]
+ fn mid_document_metadata_style_blocks_are_rendered_not_swallowed() {
+ let markdown = "# Top\n\nintro\n\n---\nnot a heading\n---\n\ntail\n";
+ let html = rendered_markdown_html(markdown);
+
+ assert!(html.contains("not a heading"), "{html}");
+ assert!(html.contains("tail"), "{html}");
+ assert_eq!(derive_title(markdown, "fallback"), "Top");
+ }
+
+ #[test]
+ fn blank_first_line_block_is_not_front_matter() {
+ // GitHub and pulldown-cmark reject metadata blocks whose first content
+ // line is blank; accepting one here would hide visible text and let it
+ // spoof the page title.
+ let markdown = "---\n\ntitle: fake\n---\n\n# Real Heading\n";
+ let html = rendered_markdown_html(markdown);
+
+ assert!(html.contains("title: fake"), "{html}");
+ assert_eq!(derive_title(markdown, "fallback"), "Real Heading");
+
+ // An empty block is likewise not front matter.
+ assert!(yaml_front_matter("---\n---\n# Heading\n").is_none());
+ }
+
+ #[test]
+ fn front_matter_title_supports_colons_and_double_quotes() {
+ let markdown = "---\ntitle: \"Project: Alpha\"\n---\n\n# Heading\n";
+ assert_eq!(derive_title(markdown, "fallback"), "Project: Alpha");
+ }
+
+ #[test]
+ fn missing_empty_or_unclosed_front_matter_title_uses_existing_fallbacks() {
+ assert_eq!(
+ derive_title("---\nauthor: Example\n---\n\n# Heading\n", "fallback"),
+ "Heading"
+ );
+ assert_eq!(
+ derive_title("---\ntitle: \n---\n", "Fallback Title"),
+ "Fallback Title"
+ );
+ assert_eq!(
+ derive_title("---\ntitle: Incomplete\n# Real Heading\n", "fallback"),
+ "Real Heading"
+ );
+ assert_eq!(
+ derive_title(
+ "---\ndescription: |\n# Metadata, not a heading\n---\n\n# Real Heading\n",
+ "fallback"
+ ),
+ "Real Heading"
+ );
+ }
+
+ #[test]
+ fn smart_punctuation_is_not_enabled() {
+ let html = rendered_markdown_html("-- --- ... \"straight\" 'quotes'\n");
+
+ assert_eq!(html, "-- --- ... \"straight\" 'quotes'
\n");
+ assert!(!html.contains(['–', '—', '…', '“', '”', '‘', '’']));
+ }
+
+ #[test]
+ fn recognized_fences_are_highlighted_with_both_theme_palettes() {
+ let markdown = "```rust\nfn main() { let html = \"\"; }\n```\n";
+ let html = render_markdown_document(markdown, "fallback", None);
+
+ assert!(html.contains(
+ ""
+ ));
+ assert!(html.contains("mdo-syntect-keyword"), "{html}");
+ assert!(html.contains(":root[data-theme=\"light\"] .mdo-syntect-code"));
+ assert!(html.contains(":root[data-theme=\"dark\"] .mdo-syntect-code"));
+ assert!(!html.contains(""));
+ assert!(html.contains("<script>"));
+ }
+
+ #[test]
+ fn unknown_fences_remain_plain_and_do_not_add_theme_css() {
+ let markdown = "```not-a-real-language\n & plain\n```\n";
+ let html = render_markdown_document(markdown, "fallback", None);
+
+ assert!(html.contains(
+ "<unsafe> & plain\n "
+ ));
+ assert!(!html.contains("mdo-highlight"));
+ assert!(!html.contains("Syntax highlighting generated by syntect"));
+ }
+
+ #[test]
+ fn pages_without_code_do_not_add_highlight_css() {
+ let html = render_markdown_document("# Just prose\n", "fallback", None);
+ assert!(!html.contains("Syntax highlighting generated by syntect"));
+ assert!(!html.contains("mdo-syntect-"));
+
+ let unlabeled = render_markdown_document("```\nplain code\n```\n", "fallback", None);
+ assert!(unlabeled.contains("plain code\n "));
+ assert!(!unlabeled.contains("Syntax highlighting generated by syntect"));
+ }
+
+ #[test]
+ fn literal_placeholder_spoof_does_not_capture_generated_markup() {
+ let spoof = "__MDO_HIGHLIGHT_PLACEHOLDER_0_0__";
+ let markdown = format!("`{spoof}`\n\n```rust\nlet answer = 42;\n```\n");
+ let html = render_markdown_document(&markdown, "fallback", None);
+
+ assert!(html.contains(spoof));
+ assert_eq!(html.matches("probe\n\n```rust\nlet one = 1;\n```\n\n```python\nprint(2)\n```\n"
+ );
+ let html = render_markdown_document(&markdown, "fallback", None);
+
+ assert!(html.contains(&format!("title=\"{normalized}\"")), "{html}");
+ assert!(!html.contains("title=\"probe\n\n