commit f0385e45c78990de3039bd8ccf4f19714cb8c4ba from: Murilo Ijanc date: Fri Apr 10 23:04:09 2026 UTC initial import of tmdr, a tiny markdown terminal reader commit - /dev/null commit + f0385e45c78990de3039bd8ccf4f19714cb8c4ba blob - /dev/null blob + 947f12064007cf2380c05936d5124819c5d65301 (mode 644) --- /dev/null +++ .gitattributes @@ -0,0 +1 @@ +*.png binary blob - /dev/null blob + 411a5b8a437447565a9a48f32fde3c2aed94b004 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,2 @@ +build +vendor blob - /dev/null blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644) --- /dev/null +++ .rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 blob - /dev/null blob + ccb3f3d7f0d5c2380172474e614e4d98aa56aa1f (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2025-2026 Murilo Ijanc' + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + 71038016c26aaf78898a948523ed3220a28fb7ff (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,93 @@ +# +# Copyright (c) 2025-2026 Murilo Ijanc' +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc) +RUSTFLAGS ?= -C opt-level=2 -C strip=symbols +VERSION = 0.1.0 +CURL ?= curl +PREFIX ?= /usr/local +MANDIR ?= $(PREFIX)/share/man + +BUILD = build +BIN = $(BUILD)/tmdr + +CRATES_IO = https://crates.io/api/v1/crates +MARKDOWN_VER = 1.0.0 +UNICODE_ID_VER = 0.3.6 + +UNICODE_ID = vendor/unicode-id/src/lib.rs +MARKDOWN = vendor/markdown/src/lib.rs +MAIN = tmdr.rs + +CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null) +RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) + +.PHONY: all clean install install ci fmt-check clippy vendor + +all: $(BIN) + +$(BUILD)/libunicode_id.rlib: $(UNICODE_ID) + mkdir -p $(BUILD) + $(RUSTC) --edition 2021 --crate-type rlib \ + --crate-name unicode_id $(RUSTFLAGS) \ + -o $@ $< + +$(BUILD)/libmarkdown.rlib: $(MARKDOWN) $(BUILD)/libunicode_id.rlib + TMPDIR=/tmp $(RUSTC) --edition 2018 --crate-type rlib \ + --crate-name markdown $(RUSTFLAGS) \ + -L $(BUILD) --extern unicode_id=$(BUILD)/libunicode_id.rlib \ + -o $@ $< + +$(BIN): $(MAIN) $(BUILD)/libmarkdown.rlib + TMDR_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \ + --crate-type bin --crate-name tmdr $(RUSTFLAGS) \ + -L $(BUILD) --extern markdown=$(BUILD)/libmarkdown.rlib \ + -o $@ $< + +clean: + rm -rf $(BUILD) + +install: $(BIN) + install -d $(PREFIX)/bin $(MANDIR)/man1 + install -m 755 $(BIN) $(PREFIX)/bin/tmdr + install -m 644 tmdr.1 $(MANDIR)/man1/tmdr.1 + +vendor: $(UNICODE_ID) $(MARKDOWN) + +$(UNICODE_ID): + mkdir -p vendor + $(CURL) -sL $(CRATES_IO)/unicode-id/$(UNICODE_ID_VER)/download \ + | tar xz -C vendor + mv vendor/unicode-id-$(UNICODE_ID_VER) vendor/unicode-id + +$(MARKDOWN): + mkdir -p vendor + $(CURL) -sL $(CRATES_IO)/markdown/$(MARKDOWN_VER)/download \ + | tar xz -C vendor + mv vendor/markdown-$(MARKDOWN_VER) vendor/markdown + +fmt-check: + $(RUSTFMT) --edition 2024 --check $(MAIN) + +clippy: + TMDR_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \ + --crate-type bin --crate-name tmdr \ + -L $(BUILD) --extern markdown=$(BUILD)/libmarkdown.rlib \ + -W clippy::all -o /tmp/tmdr.clippy $(MAIN) + @rm -f /tmp/tmdr.clippy + +ci: fmt-check clippy $(BIN) + blob - /dev/null blob + 43f89f433f8e39eb591076c361f8451c73cbad8e (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,52 @@ +tmdr - Tiny Markdown Reader +================================== +tmdr is a tiny markdown terminal reader written in Rust. +It renders headings, paragraphs, lists, code blocks, bold, +italic, and inline code directly in the terminal. + + +Requirements +------------ +In order to build tmdr you need rustc. + + +Installation +------------ +Edit Makefile to match your local setup (tmdr is installed into +the /usr/local/bin namespace by default). + +Afterwards enter the following command to build and install tmdr: + + make clean install + +To fetch vendored dependencies from crates.io: + + make vendor + + +Running tmdr +------------ +Render a file: + + tmdr README.md + +Pipe from standard input: + + curl -s https://example.com/file.md | tmdr + +Set column width to 60: + + tmdr -w 60 README.md + + +Download +-------- + got clone ssh://anon@ijanc.org/tmdr + git clone https://git.ijanc.org/tmdr.git + git clone https://git.sr.ht/~ijanc/tmdr + git clone https://github.com/ijanc/tmdr.git + + +License +------- +ISC — see LICENSE. blob - /dev/null blob + 70a54755de0dd103c40033671267e6764d8ce8b9 (mode 644) --- /dev/null +++ example.md @@ -0,0 +1,58 @@ +# tmdr example + +This file exercises every element that **tmdr** supports. + +## Paragraphs + +A short paragraph. + +A longer paragraph that contains enough words to verify that line wrapping +works correctly when the text exceeds the configured column width. It should +break at word boundaries and never split a word in half. + +## Inline formatting + +This is **bold**, this is *italic*, and this is `inline code`. +Mixing **bold with *italic* inside** also works. + +## Lists + +Unordered: + +- First item +- Second item with **bold** and *italic* +- Third item that is long enough to test wrapping inside a list item with + a hanging indent + +Ordered: + +1. One +2. Two +3. Three with `inline code` in it + +## Code + +``` +#include + +int +main(void) +{ + printf("hello, world\n"); + return 0; +} +``` + +## Headers + +### Level 3 + +#### Level 4 + +##### Level 5 + +###### Level 6 + +--- + +End of example. blob - /dev/null blob + 67bb29347b45ab0187990b47579f7323f2e75eba (mode 644) --- /dev/null +++ tmdr.1 @@ -0,0 +1,68 @@ +.\" +.\" Copyright (c) 2025-2026 Murilo Ijanc' +.\" +.\" Permission to use, copy, modify, and/or distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd $Mdocdate: April 10 2026 $ +.Dt TMDR 1 +.Os +.Sh NAME +.Nm tmdr +.Nd tiny markdown terminal reader +.Sh SYNOPSIS +.Nm tmdr +.Op Fl V +.Op Fl w Ar cols +.Op Ar file +.Sh DESCRIPTION +.Nm +reads a Markdown file and renders it for the terminal. +If no +.Ar file +is given, standard input is read. +.Pp +Supported elements: headings, paragraphs, lists, code blocks, +bold, italic, and inline code. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl V +Print the version and exit. +.It Fl w Ar cols +Set the output width in columns. +The default is 80. +.El +.Sh ENVIRONMENT +.Bl -tag -width "TMDR_LOG" +.It Ev TMDR_LOG +When set, enable logging to standard error with UTC timestamps. +.El +.Sh EXIT STATUS +.Ex -std tmdr +.Sh EXAMPLES +Render a file: +.Bd -literal -offset indent +$ tmdr README.md +.Ed +.Pp +Pipe from standard input: +.Bd -literal -offset indent +$ curl -s https://example.com/file.md | tmdr +.Ed +.Pp +Set column width to 60: +.Bd -literal -offset indent +$ tmdr -w 60 README.md +.Ed +.Sh AUTHORS +.An Murilo Ijanc' Aq Mt murilo@ijanc.org blob - /dev/null blob + 326f55a87dab6b3e124c26428d8f9bd898cedd5f (mode 644) Binary files /dev/null and tmdr.png differ blob - /dev/null blob + 46721e3c203de1b22328c738ff5d2ff07fd51193 (mode 644) --- /dev/null +++ tmdr.rs @@ -0,0 +1,248 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +use std::{ + env, fs, + io::{self, Read, Write}, +}; + +use markdown::{ParseOptions, mdast::Node, to_mdast}; + +fn main() { + let args: Vec = env::args().collect(); + let (cols, file) = parse_args(&args); + + let input = read_input(file); + let tree = match to_mdast(&input, &ParseOptions::default()) { + Ok(t) => t, + Err(e) => { + eprintln!("tmdr: {}", e); + std::process::exit(1); + } + }; + + let out = io::stdout(); + let mut w = out.lock(); + render(&mut w, &tree, cols); +} + +fn parse_args(args: &[String]) -> (usize, Option<&str>) { + let mut cols = 80usize; + let mut file = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-V" => { + println!("tmdr {}", env!("TMDR_VERSION")); + std::process::exit(0); + } + "-w" => { + i += 1; + cols = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(80); + } + s if !s.starts_with('-') => file = Some(s), + _ => usage(), + } + i += 1; + } + (cols, file) +} + +fn usage() -> ! { + eprintln!("usage: tmdr [-V] [-w cols] [file]"); + std::process::exit(1) +} + +fn read_input(file: Option<&str>) -> String { + match file { + Some(path) => fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("tmdr: {}: {}", path, e); + std::process::exit(1); + }), + None => { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf).unwrap_or_else(|e| { + eprintln!("tmdr: {}", e); + std::process::exit(1); + }); + buf + } + } +} + +////////////////////////////////////////////////////////////////////////////// +// Render +////////////////////////////////////////////////////////////////////////////// + +const BOLD: &str = "\x1b[1m"; +const BOLD_OFF: &str = "\x1b[22m"; +const ITALIC: &str = "\x1b[3m"; +const ITALIC_OFF: &str = "\x1b[23m"; +const UL: &str = "\x1b[4m"; +const RESET: &str = "\x1b[0m"; + +fn render(w: &mut impl Write, node: &Node, cols: usize) { + let children = match node { + Node::Root(r) => &r.children, + _ => return, + }; + + let mut first = true; + for child in children { + if !first { + let _ = writeln!(w); + } + first = false; + render_block(w, child, cols); + } +} + +fn render_block(w: &mut impl Write, node: &Node, cols: usize) { + match node { + Node::Heading(h) => { + let prefix = "#".repeat(h.depth as usize); + let text = inline_text(&h.children); + let _ = writeln!(w, "{}{}{} {}{}", BOLD, UL, prefix, text, RESET); + } + Node::Paragraph(p) => { + let text = inline_text(&p.children); + print_wrapped(w, &text, cols, ""); + } + Node::Code(c) => { + for line in c.value.lines() { + let _ = writeln!(w, " {}", line); + } + } + Node::List(list) => { + for (i, child) in list.children.iter().enumerate() { + if let Node::ListItem(item) = child { + let marker = if list.ordered { + let n = list.start.unwrap_or(1) as usize + i; + format!("{}. ", n) + } else { + "- ".to_string() + }; + let text = list_item_text(&item.children); + print_wrapped(w, &text, cols, &marker); + } + } + } + Node::ThematicBreak(_) => { + let _ = writeln!(w, "{}", "\u{2500}".repeat(cols.min(72))); + } + Node::Blockquote(bq) => { + for child in &bq.children { + render_block(w, child, cols.saturating_sub(2)); + } + } + _ => {} + } +} + +fn inline_text(nodes: &[Node]) -> String { + let mut out = String::new(); + for node in nodes { + match node { + Node::Text(t) => out.push_str(&t.value), + Node::Strong(s) => { + out.push_str(BOLD); + out.push_str(&inline_text(&s.children)); + out.push_str(BOLD_OFF); + } + Node::Emphasis(e) => { + out.push_str(ITALIC); + out.push_str(&inline_text(&e.children)); + out.push_str(ITALIC_OFF); + } + Node::InlineCode(c) => out.push_str(&c.value), + Node::Break(_) => out.push('\n'), + Node::Link(l) => { + out.push_str(&inline_text(&l.children)); + } + Node::Image(img) => out.push_str(&img.alt), + _ => {} + } + } + out +} + +fn list_item_text(nodes: &[Node]) -> String { + let mut out = String::new(); + for node in nodes { + if let Node::Paragraph(p) = node { + if !out.is_empty() { + out.push(' '); + } + out.push_str(&inline_text(&p.children)); + } + } + out +} + +fn print_wrapped(w: &mut impl Write, text: &str, cols: usize, prefix: &str) { + let plen = visible_len(prefix); + let wrap_at = cols.saturating_sub(plen); + let indent: String = " ".repeat(plen); + let mut first_line = true; + + for segment in text.split('\n') { + let words: Vec<&str> = segment.split_whitespace().collect(); + if words.is_empty() { + let _ = writeln!(w); + first_line = false; + continue; + } + + let pfx = if first_line { prefix } else { &indent }; + first_line = false; + let _ = write!(w, "{}", pfx); + let mut col = 0usize; + + for word in &words { + let wlen = visible_len(word); + if col > 0 && col + 1 + wlen > wrap_at { + let _ = writeln!(w); + let _ = write!(w, "{}", indent); + col = 0; + } + if col > 0 { + let _ = write!(w, " "); + col += 1; + } + let _ = write!(w, "{}", word); + col += wlen; + } + let _ = writeln!(w); + } +} + +fn visible_len(s: &str) -> usize { + let mut len = 0; + let mut in_esc = false; + for c in s.chars() { + if in_esc { + if c == 'm' { + in_esc = false; + } + } else if c == '\x1b' { + in_esc = true; + } else { + len += 1; + } + } + len +}