Markdown in a Terminal: Building a MarkdownWidget for jterm
September 23, 2026
Every BBS has the same problem: text that wants to be formatted. Help screens with headings and bullet lists. News articles with paragraphs. Mail that would really use a code block for that error dump. The traditional answers are all bad in the same way — hard-coded ANSI sequences baked into strings, a private wiki-syntax nobody else can read, or walls of plain text with no structure at all.
jterm now ships a third answer: a MarkdownWidget that parses real markdown and renders it as styled terminal lines.
Why markdown, and why a subset
Markdown is the right input format for BBS content for three reasons. It’s plain text, so it’s readable in its raw form on a dumb client. It’s ubiquitous — sysops already know it, and any external content pasted in is likely to be in it. And it degrades gracefully: a parser that doesn’t understand a construct can still render the text underneath.
But full CommonMark is a spec measured in hundreds of pages, written for HTML renderers. A terminal widget needs a lite dialect — the constructs that carry 95% of real formatting, implemented in 505 lines:
- Blocks: ATX headings (
#through####), paragraphs (word-wrapped to the display width), bullet lists (-and*, one nesting level), fenced code blocks (```), horizontal rules (---,***), and block quotes (>) - Inline:
**bold**,*italic*,`code`, and[text](url)links, rendered astext (url)— because a terminal has no click handler
Anything unrecognized degrades to a plain paragraph line. Text is never dropped — that’s the cardinal rule. A parser for user-generated content must treat every input as renderable, because the alternative is silently eating someone’s message.
The architecture: parse to lines, not to DOM
The design decision that shaped everything: the parser outputs lines, not a tree.
markdown source → MarkdownParser → MarkdownDocument → MarkdownWidget
(list of MarkdownLine)
Each MarkdownLine is a finished row of styled spans — heading level, list depth, quote depth, code-block flag, and the span list (text + style + column offset) ready to draw. There’s no intermediate AST the widget re-walks, no layout pass, no measurement phase. Word-wrap happens at parse time, against the target width, so the widget’s draw is a straight iteration: for each line, draw its spans.
That’s possible because terminal “layout” is trivial — one column of lines — and because word-wrap is the only geometry markdown needs. A DOM-and-CSS-shaped design would have bought generality we don’t need and cost the thing that matters: parse() is a pure function, and the output is inspectable. Tests assert on spans, not on pixels.
One detail worth calling out: span offsets are measured in terminal columns, not characters. CJK and fullwidth characters count as 2. Every wrap decision and every inline-style boundary is computed with TerminalTextUtils width helpers, so bold text inside a wrapped line containing Japanese text still lines up. Getting this wrong is invisible in ASCII and immediately obvious in anything else.
The widget
MarkdownWidget takes the parsed document and draws it with the theme’s palette — headings sized and brightened by level, quotes indented with a gutter, code blocks framed, code spans inverted. It scrolls (mouse wheel and keys), and it has a raw view toggle that shows the un-parsed markdown source, which doubles as a debugging aid and a “show original” feature.
The parser is deliberately independent of the widget, so BBS screens that already own their own drawing — the mail reader, help system, news service — can call MarkdownParser.parse() directly and draw the returned lines with whatever primitives they already use.
Why this matters for the BBS
Phosphor’s mail, boards, and help all become markdown-first. A sysop writes a help screen as a markdown file; the BBS hot-reloads it and the heading renders bold and bright without anyone embedding ANSI escape codes in text. And because the parser is a subset, a document with a construct we don’t support still displays — as plain text with the syntax characters showing, ugly but complete.
The unfashionable but crucial property: the worst case of markdown in a terminal is readable plain text, never garbage. That’s what makes it safe as a lingua franca for user-generated content.
The parser shipped with 479 lines of parser tests and 177 lines of widget tests, and it’s already the renderer for everything that used to be pre-formatted text blobs. It’s in jterm 0.1.1.