Commit Diff


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' <murilo@ijanc.org>
+
+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' <murilo@ijanc.org>
+#
+# 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 <stdio.h>
+
+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' <murilo@ijanc.org>
+.\"
+.\" 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' <murilo@ijanc.org>
+//
+// 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<String> = 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
+}