diff --git a/src/bin/mdo-open.rs b/src/bin/mdo-open.rs
index df36de0..5cbb1f6 100644
--- a/src/bin/mdo-open.rs
+++ b/src/bin/mdo-open.rs
@@ -10,10 +10,13 @@
//! but break terminal usage (no stdout to the parent shell, output races the
//! returning prompt under cmd/PowerShell).
//!
-//! Instead, `mdo-open[.exe]` is a tiny desktop wrapper that:
-//! 1. Locates `mdo[.exe]` next to itself.
-//! 2. Spawns it with `--open` and the rest of the args, using
-//! `CREATE_NO_WINDOW` so the child never gets a console allocated.
+//! Instead, `mdo-open[.exe]` is a tiny desktop wrapper that consumes the
+//! shared handler invocation contract (`mdo_cli::handler`):
+//! 1. Locates `mdo[.exe]` next to itself (the one sibling-binary
+//! discovery function, so a missing sibling gets one clear message).
+//! 2. Spawns it with the contract argv (`--open` plus the file args),
+//! using `CREATE_NO_WINDOW` so the child never gets a console
+//! allocated.
//! 3. If launched directly with no file args, opens onboarding: Windows
//! starts the terminal setup in Windows Terminal when available; Linux
//! opens `mdo-setup` for native first-run onboarding. This step is
@@ -48,8 +51,8 @@ use std::process::{Command, ExitCode};
#[cfg(target_os = "windows")]
use std::io;
-#[cfg(target_os = "windows")]
-use std::path::Path;
+
+use mdo_cli::handler;
#[cfg(target_os = "windows")]
const MDO_BIN: &str = "mdo.exe";
@@ -60,12 +63,16 @@ const MDO_BIN: &str = "mdo";
const SETUP_BIN: &str = "mdo-setup";
fn main() -> ExitCode {
- let mut exe_path = match env::current_exe() {
+ // A GUI-launched binary often has no console attached, so this message
+ // may still go nowhere on Windows — but silent failure is worse
+ // everywhere, and on Linux it reaches the file manager's stderr log.
+ let exe_path = match env::current_exe() {
Ok(p) => p,
- Err(_) => return ExitCode::from(1),
+ Err(e) => {
+ eprintln!("mdo-open: cannot locate its own executable: {e}");
+ return ExitCode::from(1);
+ }
};
- exe_path.pop(); // strip the file name, keep the directory
-
let args = env::args_os().skip(1).collect::>();
#[cfg(target_os = "windows")]
@@ -77,7 +84,10 @@ fn main() -> ExitCode {
if args.is_empty() {
return match spawn_windows_onboarding(&exe_path) {
Ok(()) => ExitCode::SUCCESS,
- Err(_) => ExitCode::from(1),
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ ExitCode::from(1)
+ }
};
}
@@ -90,33 +100,47 @@ fn main() -> ExitCode {
// instead of always reporting success.
match spawn_windows_open(&exe_path, args) {
Ok(status) => exit_code_from_status(status),
- Err(_) => ExitCode::from(1),
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ ExitCode::from(1)
+ }
}
}
#[cfg(not(target_os = "windows"))]
{
#[cfg(target_os = "linux")]
- let mut cmd = if args.is_empty() {
- let setup = exe_path.join(SETUP_BIN);
- if !setup.exists() {
- return ExitCode::from(1);
+ if args.is_empty() {
+ // No-file launch is onboarding, not open: hand off to the
+ // sibling `mdo-setup`.
+ let setup = match handler::sibling_binary(&exe_path, SETUP_BIN) {
+ Ok(path) => path,
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ return ExitCode::from(1);
+ }
+ };
+ let mut cmd = Command::new(setup);
+ match cmd.status() {
+ Ok(status) => return exit_code_from_status(status),
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ return ExitCode::from(1);
+ }
}
- Command::new(setup)
- } else {
- let mut cmd = Command::new(exe_path.join(MDO_BIN));
- cmd.arg("--open");
- cmd.args(args);
- cmd
- };
+ }
- #[cfg(not(target_os = "linux"))]
- let mut cmd = {
- let mut cmd = Command::new(exe_path.join(MDO_BIN));
- cmd.arg("--open");
- cmd.args(args);
- cmd
+ // Everything else is the forward: sibling mdo with the contract
+ // argv (`--open` plus the files as received).
+ let mdo_bin = match handler::sibling_binary(&exe_path, MDO_BIN) {
+ Ok(path) => path,
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ return ExitCode::from(1);
+ }
};
+ let mut cmd = Command::new(mdo_bin);
+ cmd.args(handler::open_argv(&args));
// Non-Windows binaries never had a console-flash problem to begin
// with (that's Windows Explorer + console-subsystem-exe behavior),
@@ -126,7 +150,10 @@ fn main() -> ExitCode {
// in an already-attached terminal) and propagate its exit status.
match cmd.status() {
Ok(status) => exit_code_from_status(status),
- Err(_) => ExitCode::from(1),
+ Err(e) => {
+ eprintln!("mdo-open: {e}");
+ ExitCode::from(1)
+ }
}
}
}
@@ -137,10 +164,17 @@ fn main() -> ExitCode {
/// a failure whose low byte is zero (e.g. 0x100) must not truncate to
/// "success"; any failing status maps to a non-zero code.
fn exit_code_from_status(status: std::process::ExitStatus) -> ExitCode {
- if status.success() {
+ exit_code_from_parts(status.success(), status.code())
+}
+
+/// The truncation rule of [`exit_code_from_status`], split from
+/// `ExitStatus` so it is unit-testable portably (Unix cannot even produce a
+/// failed status with a zero low byte; Windows can).
+fn exit_code_from_parts(success: bool, code: Option) -> ExitCode {
+ if success {
return ExitCode::SUCCESS;
}
- match status.code() {
+ match code {
Some(code) if code as u8 != 0 => ExitCode::from(code as u8),
_ => ExitCode::FAILURE,
}
@@ -148,28 +182,18 @@ fn exit_code_from_status(status: std::process::ExitStatus) -> ExitCode {
#[cfg(target_os = "windows")]
fn spawn_windows_open(
- exe_dir: &Path,
+ anchor_exe: &std::path::Path,
args: Vec,
) -> io::Result {
- let mut cmd = Command::new(exe_dir.join(MDO_BIN));
- cmd.arg("--open");
- cmd.args(args);
+ let mdo_bin = handler::sibling_binary(anchor_exe, MDO_BIN)?;
+ let mut cmd = Command::new(mdo_bin);
+ cmd.args(handler::open_argv(&args));
spawn_without_console(cmd)
}
#[cfg(target_os = "windows")]
-fn spawn_windows_onboarding(exe_dir: &Path) -> io::Result<()> {
- let mdo = exe_dir.join(MDO_BIN);
- if !mdo.exists() {
- return Err(io::Error::new(
- io::ErrorKind::NotFound,
- format!(
- "expected {MDO_BIN} next to mdo-open.exe at {}",
- mdo.display()
- ),
- ));
- }
-
+fn spawn_windows_onboarding(anchor_exe: &std::path::Path) -> io::Result<()> {
+ let mdo = handler::sibling_binary(anchor_exe, MDO_BIN)?;
mdo_cli::windows_setup::spawn_terminal_setup(&mdo)
}
@@ -189,3 +213,51 @@ fn spawn_without_console(mut cmd: Command) -> io::Result io::Result<()> {
continue_after_launcher_registration(register_launcher());
- let mdo = sibling_binary("mdo")?;
- if !mdo.exists() {
- return Err(io::Error::new(
- io::ErrorKind::NotFound,
- format!("expected mdo next to mdo-setup at {}", mdo.display()),
- ));
- }
+ let mdo = mdo_cli::handler::sibling_binary(&std::env::current_exe()?, "mdo")?;
// Already attached to a terminal (e.g. run from a shell): show guided setup
// right here instead of spawning a second window. The `?` still
@@ -254,12 +248,6 @@ mod linux_setup {
.unwrap_or(false)
}
- fn sibling_binary(name: &str) -> io::Result {
- let mut path = std::env::current_exe()?;
- path.pop();
- Ok(path.join(name))
- }
-
#[cfg(test)]
mod tests {
use super::*;
@@ -338,7 +326,7 @@ mod windows_setup {
};
pub fn run() -> io::Result<()> {
- let mdo = sibling_binary("mdo.exe")?;
+ let mdo = mdo_cli::handler::sibling_binary(&std::env::current_exe()?, "mdo.exe")?;
if !mdo.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
@@ -375,12 +363,6 @@ mod windows_setup {
Ok(button)
}
- fn sibling_binary(name: &str) -> io::Result {
- let mut path = std::env::current_exe()?;
- path.pop();
- Ok(path.join(name))
- }
-
fn wide(value: &str) -> Vec {
value.encode_utf16().chain(iter::once(0)).collect()
}
diff --git a/src/file_manager.rs b/src/file_manager.rs
index 3674b2f..8140994 100644
--- a/src/file_manager.rs
+++ b/src/file_manager.rs
@@ -4,6 +4,8 @@ use std::process::Command;
#[cfg(target_os = "windows")]
use std::process::Stdio;
+use crate::handler;
+
const APP_DISPLAY_NAME: &str = "Open as HTML";
#[cfg(target_os = "linux")]
const DESKTOP_FILE_NAME: &str = "mdo.desktop";
@@ -27,103 +29,284 @@ const SVG_ICON: &str = r##"
", "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