From 44284bb08ffb3097f2fb3c25260f976614c5b462 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Tue, 6 Oct 2026 23:27:06 -0700 Subject: [PATCH] Split lib.rs by its four real concerns C4 / Beads mdo-6w5.6 (epic mdo-6w5, PR order 6 of 6 - last so code moves once into the shapes C1/C2/C3/C5/C6 created). lib.rs was the #2 churn file: 1703 lines carrying four implementations plus a 565-line inline test module. Pure relocation, external interface unchanged (zero caller churn: the crate root re-exports every public name at its old path): - src/render.rs (1006) - the render pipeline per GLOSSARY: parsing, highlight splicing, sanitation, title derivation, stragglers sorted by caller at relocation time (dir_to_file_url: only caller is convert; front-matter/strip helpers: derive_title's), and the single deep entry point convert() with its request/outcome/error types and the O_NOFOLLOW output writer. - src/page.rs (379) - page assembly: assets and CSS/JS consts, wrap_html5, escape_style_end_tags, plus the calendar/datetime math stragglers sorted by caller (wrap_html5's timestamp footer) and html_escape (all callers are wrap_html5's). wrap_html5/source_modified_unix_secs become pub(crate) for convert's base-href and timestamp hand-off. - src/temp_output.rs (167) - temp output: per-source private paths, stem sanitation, private dirs (pub(crate) ensure_private_dir/private_temp_ root for the sample lifecycle at the root). - src/launch.rs (98) - browser openers: ShellExecuteW/opener path, macOS `open`, the xdg-open/gio fallback chain and reaper window. - src/lib.rs (76) - crate docs, module decls, root re-exports, and the setup-sample orchestration (open_setup_sample composes render + temp_output + launch through the same root names). The inline tests moved beside the concepts they test: render owns the splice/sanitize/front-matter/placeholder-safety/title/dir_to_file_url groups; page owns wrap_html5, the CSS-escape test, and the datetime tests; temp_output owns the stem tests and their helpers; the root keeps the setup-sample test. Test counts unchanged. Gates: fmt --check, clippy --all-targets --all-features -D warnings, cargo test --all-targets --all-features --locked all green (113 tests); the byte-identity harness against the pre-epic binary passes all 11 scenarios including rendered-HTML cmp - the split changed no behavior. Beads-Ref: mdo-6w5.6 Agent-Signature: kilo-kilo-auto-efficient on behalf of matt --- src/launch.rs | 102 +++ src/lib.rs | 1660 +------------------------------------------- src/page.rs | 380 ++++++++++ src/render.rs | 1026 +++++++++++++++++++++++++++ src/temp_output.rs | 176 +++++ 5 files changed, 1706 insertions(+), 1638 deletions(-) create mode 100644 src/launch.rs create mode 100644 src/page.rs create mode 100644 src/render.rs create mode 100644 src/temp_output.rs 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