commit - /dev/null
commit + f0385e45c78990de3039bd8ccf4f19714cb8c4ba
blob - /dev/null
blob + 947f12064007cf2380c05936d5124819c5d65301 (mode 644)
--- /dev/null
+++ .gitattributes
+*.png binary
blob - /dev/null
blob + 411a5b8a437447565a9a48f32fde3c2aed94b004 (mode 644)
--- /dev/null
+++ .gitignore
+build
+vendor
blob - /dev/null
blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644)
--- /dev/null
+++ .rustfmt.toml
+max_width = 80
blob - /dev/null
blob + ccb3f3d7f0d5c2380172474e614e4d98aa56aa1f (mode 644)
--- /dev/null
+++ LICENSE
+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
+#
+# 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
+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
+# 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
+.\"
+.\" 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
+// 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
+}