A zero-dependency C11 implementation that converts Markdown to ANSI-colored terminal output. Renders headers, lists, tables, code blocks with syntax highlighting, links, footnotes, and inline formatting directly to a TTY with correct Unicode width measurement and predictable performance.
Looking for a pure Bash implementation? See md2ansi.bash.
A pure C11 implementation of a Markdown → ANSI terminal renderer. The binary is statically self-contained (libc only), ~57KB, and renders a 39KB README in milliseconds.
Key Features:
- ✓ Zero runtime dependencies (libc only — no ncurses, no regex)
- ✓ Hand-rolled scanners — no
<regex.h>, no ReDoS surface - ✓ Correct Unicode width via
wcwidth(CJK = 2 cols, combining marks = 0) - ✓ Stable CLI surface: short + long flags, deterministic exit codes
- ✓ Indented code, reference links, autolinks, backslash escapes, YAML frontmatter strip
- ✓
NO_COLORenv honoured,--color=auto|always|neverflag - ✓ Syntax highlighting for Python, JavaScript, Bash
- ✓ Unicode box-drawing borders for tables (
─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) - ✓ Pipe-escape support in tables (
\|and`code with | inside`) - ✓ Bundled companion tools (
mdpager,mdviewbrowser preview,md-link-extract,ansi-info)
git clone https://github.com/Open-Technology-Foundation/md2ansi.c.git && cd md2ansi.c && make && sudo make installUnprivileged install to ~/.local (no sudo):
git clone https://github.com/Open-Technology-Foundation/md2ansi.c.git && cd md2ansi.c && make && make install PREFIX=$HOME/.localgit clone https://github.com/Open-Technology-Foundation/md2ansi.c.git
cd md2ansi.c
make
sudo make install # installs binary + companions + man + completion + mdview datamake install puts the C binary, the bundled bash companions (md, mdview, md-link-extract, ansi-info), the manual page, the bash completion file, and the mdview data files (themes + mdview.conf + rewrite-md-links.lua) under $(PREFIX):
| Component | Destination |
|---|---|
| Binaries | $(BINDIR) (default $(PREFIX)/bin) |
| Manual page | $(MANDIR) (default $(PREFIX)/share/man/man1) |
| Bash completion | $(COMPDIR) (default $(PREFIX)/share/bash-completion/completions) |
| mdview data | $(DATADIR) (default $(PREFIX)/share/mdview) |
The Makefile auto-selects PREFIX=/usr/local for root installs and PREFIX=$HOME/.local for unprivileged installs. Override explicitly with make PREFIX=/opt/local install. Granular subtargets (install-bin, install-companions, install-man, install-comp, install-data, install-themes) are available if you want to install only one slice.
Requires GCC or Clang with libc 2.17+ (wcwidth support). Builds clean under -O2 -std=c11 -Wall -Wextra -Wpedantic -Werror. The companion scripts additionally require bash 5.2+; mdview also requires pandoc and a browser (auto-detected: google-chrome, chromium-browser, chromium, then xdg-open).
md2ansi -V # md2ansi 1.1.0
md2ansi README.md
md README.md # paged
mdview README.md # browser previewsudo make uninstall# View a markdown file
md2ansi README.md
# Paginated viewing
md2ansi README.md | less -R
# Process from stdin
cat README.md | md2ansi
echo "# Hello **World**" | md2ansi
# Multiple files
md2ansi file1.md file2.md file3.md
# Force terminal width (auto-detected by default)
md2ansi --width 100 README.md
# Plain mode (disable all feature toggles)
md2ansi --plain README.md
# Force colors regardless of TTY / NO_COLOR
md2ansi --color=always README.md > /tmp/colored.txt# Read documentation in color, paginated (md is the bundled wrapper)
md README.md
# Read non-markdown docs in colour
md2ansi /usr/share/doc/bash/README | less -R
# Preview your markdown before committing
md2ansi CHANGELOG.md | less -R
# Process while honouring NO_COLOR
NO_COLOR=1 md2ansi README.md > plain.txt
# Browser-based preview with theming
mdview README.md
# Extract every link from a doc set
md-link-extract docs/*.mdBundled alongside the C md2ansi binary:
| Tool | Language | Purpose |
|---|---|---|
md |
bash | Pager wrapper. Delegates to md2ansi and pipes through less -FXRS. |
mdview |
bash + pandoc | Browser-based preview. CSS-themed HTML rendering; rewrites local .md links to .html via a Lua filter. Default theme github-dark; built-in github-light. |
md-link-extract |
bash | Extract every link from one or more markdown files. Strips UTM tracking params. |
ansi-info |
bash | Display 256-colour palette, SGR attributes, truecolor ramps, and terminal capability info. |
Installed by make install. Data files for mdview (theme CSS + .theme pandoc syntax styles, mdview.conf, rewrite-md-links.lua) live under $(DATADIR) (default $(PREFIX)/share/mdview). Users can override system themes by dropping files into $XDG_DATA_HOME/mdview/themes/.
Headers (H1–H6) with a 256-colour gradient (yellow → orange → green → blue → purple → dark gray). Inline formatting works inside header text.
Inline Formatting:
- ✓ Bold (
**text**) - ✓ Italic (
*text*) — neighbour-char guard to avoidsnake_caseclashes - ✓ Combined bold+italic (
***text***,**_text_**,_**text**_) - ✓ Strikethrough (
~~text~~) - ✓ Inline code (
`code`) — suppresses other formatting inside - ✓ Inline links (
[text](url)) with underline - ✓ Images (
) shown as[IMG: alt]placeholders - ✓ Reference links (
[text][ref]+[ref]: url) - ✓ Shortcut reference links (
[ref]matches def) - ✓ Angle autolinks (
<http://...>,<mailto:...>,<ftp://...>) - ✓ Bare URL autolinks (
https://...in text) — default ON, disable with--no-bare-urls - ✓ Backslash escapes (
\*,\_,\\,\#,\[,\],\(,\),\{,\},\!,\.,\+,\-,\>,\~,\`) - ✓ Footnote references (
[^id])
Lists:
- ✓ Unordered lists (
-,*) - ✓ Ordered lists (
1.,2., …) - ✓ Task lists (
- [ ],- [x]) - ✓ Nested lists (every 2 spaces = one level)
- ✓ Inline formatting within list items, with width-aware text wrapping
Tables:
- ✓ GFM pipe-delimited tables
- ✓ Left, center, right alignment (
:---,:---:,---:) - ✓ Inline formatting in cells (bold, italic, code, links)
- ✓ Unicode-aware column width (CJK chars = 2 cols)
- ✓ Box-drawing borders (
─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) - ✓ Backslash-escaped pipes (
\|) preserved as literal content - ✓ Backtick-wrapped pipes (
`--mode=a|b|c`) preserved as literal content - ✓ Overlong cells word-wrapped to fit the terminal width (disable with
--no-table-wrap)
Code Blocks:
- ✓ Fenced code blocks (
```and~~~) - ✓ Indented code blocks (4-space or 1-tab prefix)
- ✓ Syntax highlighting for
python,javascript,bash(with aliasespy,js,sh,shell) - ✓ Comment detection (whole-line gray) for all three languages
- ✓ Python docstring detection (
'''and"""→ green) - ✓ Mismatched fence inside code block rendered as content
Additional Elements:
- ✓ Blockquotes (
>) - ✓ Horizontal rules (
---,===,___) with Unicode─chars - ✓ Footnote definitions (
[^id]: text) - ✓ Footnote section auto-rendered at end of document
- ✓ Missing footnote definition shown in italic
- ✓ YAML frontmatter (
---\n…\n---or---\n…\n...) stripped - ✓ Shebang line stripped on file ingest
Advanced Features:
- ✓ ANSI-aware text wrapping
- ✓ Terminal width auto-detection via
TIOCGWINSZ→$COLUMNS→80, clamped[20, 500] - ✓ Per-feature toggles (
--no-tables,--no-syntax-highlight,--no-tasks,--no-images,--no-links,--no-footnotes,--no-bare-urls,--no-table-wrap) - ✓ Plain mode (
--plain/-t) disables all feature toggles - ✓ Debug mode (
--debug/-D) with timestamped sequential traces to stderr
Security:
- ✓ File size limit (10MB)
- ✓ Stdin size limit (10MB)
- ✓ ANSI escape sequence stripping on all input
- ✓ Hand-rolled scanners — no regex DoS surface
- ✓ Directory rejection with explicit exit code 4
md2ansi -h, --help # show help
md2ansi -V, --version # show version
md2ansi -D, --debug # enable debug traces (to stderr)
md2ansi -w, --width WIDTH # force width (20–500)
md2ansi -t, --plain # disable all feature toggles
md2ansi --color=MODE # auto (default) | always | nevermd2ansi --no-footnotes # don't render footnotes section
md2ansi --no-syntax-highlight # disable keyword colouring in code
md2ansi --no-tables # render tables as plain text
md2ansi --no-task-lists # render task items as regular bullets
md2ansi --no-images # show literal 
md2ansi --no-links # show literal [text](url)
md2ansi --no-bare-urls # don't auto-link bare http(s):// URLs
md2ansi --no-table-wrap # let wide tables overflow instead of wrapping| Code | Meaning |
|---|---|
0 |
Success |
3 |
File not found |
4 |
Path is a directory |
8 |
Missing argument for option |
9 |
Input exceeds 10MB |
13 |
Cannot read file (permission denied) |
22 |
Invalid option / argument |
Input:
# Main Title
## Subtitle
### Section Header
This is **bold**, *italic*, and ***bold italic*** text.
This is ~~strikethrough~~ and `inline code`.
Visit [GitHub](https://github.com) or just https://github.com directly.
Footnote with citation[^1].
[^1]: Source: Documentation, 2026Command:
md2ansi sample.mdResult: Headers colour-graded H1→H6; inline spans formatted; both [GitHub](url) and the bare URL render as underlined cyan links; footnote [^1] shown with dim bold marker and definition rendered in a "Footnotes:" section at end.
See the [project home][home] for details. Also <https://example.com>.
Shortcut form: [home] matches the def too.
[home]: https://example.com/project "Project home"All three forms render as cyan-underlined links.
| Feature | Status | 言語 |
|:--------|:------:|----:|
| Headers | ✓ | 英語 |
| Tables | ✓ | 日本語 |
| Code | ✓ | 中文 |Column widths are computed with wcwidth, so 日本語 (6 visible cols) aligns properly against Headers (7 cols).
| CLI option | Notes |
|:-----------|:------|
| `--color=auto\|always\|never` | backticked pipes preserved |
| `\|literal\|` | escaped pipes outside backticks |Both backtick-wrapped pipes and \| escapes are preserved as literal content within a single cell.
```python
def fibonacci(n):
"""Compute the n-th Fibonacci number."""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
```Keywords (def, return, if) coloured pink. Docstring line coloured green. Comments (whole-line #…) coloured gray.
normal paragraph text
this is a code block
no fence needed
4 spaces of indent
more textThe indented section is recognised as a MD_B_CODE_INDENTED block.
Escape literally: \*not italic\* and \[not a link\] and \\backslash.The 17-char CommonMark backslash-escape set is honoured.
---
title: My Document
author: Gary Dean
date: 2026-05-13
---
# Real Content Starts HereThe frontmatter block (between --- lines, terminated by --- or ...) is silently dropped.
| Item | Behaviour |
|---|---|
| Table borders | Unicode box-drawing ─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼ |
| Table chrome color | Three explicit levels: cell content 38;5;7 > inline code 90 > chrome 38;5;238 (borders/padding) |
| Overlong table cells | Word-wrapped to the terminal width by default (max-min fair-share column budgeting); --no-table-wrap keeps the natural layout and overflows. Divergence from Bash: the Bash version never wraps and always overflows. |
| Horizontal rules | Rendered with ─ chars |
Bold/italic inside `code` |
Suppressed (literal) inside code spans |
Trailing \033[0m |
One reset at end of stream |
| Indented code blocks | Recognised as MD_B_CODE_INDENTED |
| Reference links / autolinks / bare URLs | Rendered as cyan-underlined links |
| Backslash escapes | Honoured (17-char CommonMark set) |
| YAML frontmatter | Silently stripped |
| CJK column width | 2 cols per char via wcwidth |
| ANSI in input | Stripped everywhere before parsing |
Modular, not monolithic. Each markdown subsystem lives in its own translation unit, with public headers that declare types and function signatures. This shape favours hand-modification and per-module testing across ~3K lines of C.
Design Benefits:
| Benefit | Description |
|---|---|
| Modular split | Each feature isolated in its own .c/.h pair — easy to extend |
| Single binary deploy | Statically structured, libc-only — copy one ~57KB file to PATH |
| Hand-rolled scanners | No <regex.h> — eliminates ReDoS surface, predictable perf |
-Werror clean |
Zero warnings under -Wall -Wextra -Wpedantic -Werror |
| Type-safe core | md_buf_t, md_lines_t, md_block_t, md_doc_t give consistent ownership rules |
md2ansi.c/
├── md2ansi.c # main entry, argv parsing, file loop
├── md_common.h/c # die/warn/debug, buffers, line splitter, slurp, ANSI strip
├── ansi.h/c # palette, terminal detect, color decision, --color mode
├── unicode.h/c # UTF-8 decode, wcwidth wrapper, visible-width measurement
├── parser.h/c # line classifier → block list, link-ref + footnote-def tables
├── inline.h/c # inline span scanner (escape/code/link/bold/italic/strike/...)
├── render.h/c # block dispatch, per-kind renderers, word-wrap, footnotes
├── table.h/c # GFM pipe-table parser + box-drawing renderer
├── syntax.h/c # python/javascript/bash keyword + comment highlighters
├── md # bash pager wrapper (pipes md2ansi through less)
├── mdview # bash browser-preview wrapper (pandoc + themes)
├── md-link-extract # bash link extractor
├── ansi-info # bash 256-colour palette + terminal capability report
├── mdview.conf # default config for mdview
├── rewrite-md-links.lua # pandoc Lua filter — .md → .html for inter-file links
├── themes/ # mdview themes (github-dark, github-light: .css + .theme)
├── md2ansi.1 # roff manual page
├── md2ansi.bash_completion # completion for md2ansi + md + mdview + md-link-extract + ansi-info
├── .symlink # dev-machine PATH-exposure manifest (used by `symlink -S`)
├── Makefile # build/install/uninstall/test/clean targets
├── README.md # this file
├── LICENSE # GPL-3.0 full text
├── .gitignore
└── tests/
├── run-tests.sh # aggregator
├── utils.sh # assertion helpers
├── test-basic.sh # headers, inline formatting, HR
├── test-blockquotes.sh # > quote rendering
├── test-code.sh # fenced + indented + syntax highlight
├── test-lists.sh # ul, ol, task, nested, mixed
├── test-tables.sh # pipes, alignment, CJK, escape
├── test-footnotes.sh # def + ref + missing
├── test-options.sh # CLI flags + exit codes
├── test-security.sh # ANSI injection, size limits, fuzz
├── test-wrapping.sh # width-based word wrap
├── test-unicode.sh # UTF-8 + CJK width
├── test-links.sh # inline / ref / autolink / image
├── test-escapes.sh # backslash escapes + frontmatter
├── test-misc.sh # shebang strip, multi-file, --plain, --debug, perm denied
├── test-companions.sh # smoke tests for md / mdview / md-link-extract / ansi-info
└── fixtures/ # input + expected golden files
Total project size (run make stats for exact current figures):
- Source code: ~2,950 lines (9 modules + headers + main)
- Test suite: ~1,000 lines (14 test files + runner + utils)
- Binary: ~57KB (dynamically linked to libc only)
| Module | Responsibility |
|---|---|
md_common |
Messaging (md_die/md_warn/md_debugf), growable md_buf_t, line splitter md_lines_t, file/stdin slurpers with 10MB cap, in-place ANSI strip |
ansi |
256-colour palette as md_palette_t, terminal width via TIOCGWINSZ, color decision honouring NO_COLOR + TERM + isatty, ANSI sequence parser |
unicode |
UTF-8 codepoint decode, wcwidth wrapper, visible-width measurement that skips ANSI + counts CJK correctly |
parser |
Line-by-line classifier (header / HR / list / blockquote / code fence / table / footnote def / link ref def / paragraph), produces md_doc_t block list |
inline |
Single-pass token scanner for inline spans: escape, code, image, footnote ref, inline link, ref-link, shortcut ref, autolink, bare URL, 3-/2-/1-star, strike |
render |
Block dispatch via switch on md_block_kind_t, per-kind renderers, Unicode-aware word-wrap, footnotes section emit |
table |
Pipe-row splitter honouring | and backtick context, alignment row detector, per-column visible-width calc, box-drawing border emit |
syntax |
Per-language keyword tables, comment detection, Python docstring heuristic, single-line tokenizer (no multi-line strings) |
argv → md_cli_opts_t + file list
↓
file/stdin → md_slurp_* (10MB cap) → md_strip_ansi_inplace → md_lines_t
↓
parser → md_doc_t (block list + link refs + footnote defs)
↓
render → stdout
├── inline (per-line inline expansion)
├── table (table block branch with inline expansion in cells)
└── syntax (fenced code branch when --no-syntax-highlight not set and lang known)
typedef struct { char *data; size_t len, cap; } md_buf_t;
typedef struct { const char *start; size_t len; } md_line_t;
typedef struct { md_line_t *items; size_t count, cap; } md_lines_t;
typedef enum {
MD_B_EMPTY, MD_B_PARA,
MD_B_H1, MD_B_H2, MD_B_H3, MD_B_H4, MD_B_H5, MD_B_H6,
MD_B_HR, MD_B_BLOCKQUOTE,
MD_B_UL_ITEM, MD_B_OL_ITEM, MD_B_TASK_ITEM,
MD_B_CODE_FENCED, MD_B_CODE_INDENTED, MD_B_TABLE,
MD_B_FOOTNOTE_DEF, MD_B_FRONTMATTER
} md_block_kind_t;
typedef struct md_block {
md_block_kind_t kind;
int level; /* header level OR list indent */
int ordinal; /* OL number OR fence char */
int checked; /* task checkbox state */
char *lang; /* fenced code lang */
md_buf_t content; /* joined inline content */
md_lines_t lines; /* raw lines (for code/table) */
void *extra;
struct md_block *next;
} md_block_t;
typedef struct {
md_block_t *head, *tail;
md_link_ref_t *link_refs;
md_footnote_def_t *footnote_defs;
md_str_list_node_t *footnote_refs; /* appearance order */
} md_doc_t;11 per-feature test files plus a runner and shared assertion helpers:
| Test File | Coverage |
|---|---|
| run-tests.sh | Aggregator: runs every test-*.sh, summarises pass/fail |
| utils.sh | assert_equals, assert_contains, assert_not_contains, assert_exit_code, assert_file_equals |
| test-basic.sh | Headers H1–H6, bold, italic, strike, inline code, HR (all variants), triple-star |
| test-code.sh | Fenced (``` + ~~~), indented, python/js/bash syntax highlight, comment colouring, mismatched-fence handling, ANSI sanitisation in code |
| test-lists.sh | UL (-, *), OL, task lists, nested lists, --no-task-lists fallback |
| test-tables.sh | Pipe tables, alignment markers, CJK width, --no-tables fallback, escaped-pipe + backticked-pipe handling |
| test-footnotes.sh | Ref + def, missing def warning, appearance ordering, --no-footnotes |
| test-options.sh | --help/-V/--bogus exit codes, file-not-found / dir / invalid-width handling, --color modes, NO_COLOR env |
| test-security.sh | ANSI injection stripped from text, 10MB+1 stdin → exit 9, 10MB+1 file → exit 9, deeply nested lists, malformed fence, empty/very-long input |
| test-wrapping.sh | Width-based word wrap, short text emits single line, wrap respects requested width |
| test-unicode.sh | CJK passthrough, emoji, accented chars, CJK column-width wrap, CJK in table cells |
| test-links.sh | Inline [t](url), image , ref [t][r], shortcut [r], autolink <url>, bare URL autolink, --no-links / --no-images / --no-bare-urls |
| test-escapes.sh | \*, \\, \#, \[/\], \`, YAML frontmatter strip (both --- and ... terminators) |
Total test coverage: ~1,000 lines across 14 suites (run make stats).
# Build the binary first
make
# Run the full suite (uses ./md2ansi from project root)
make test
# Or invoke directly
tests/run-tests.sh
# Run a single suite
bash tests/test-tables.shAssertions emit colour-coded ✓ pass / ✗ fail lines, count failures, and exit non-zero on any failure:
assert_equals "$actual" "$expected" "message"
assert_contains "$haystack" "$needle" "message"
assert_not_contains "$haystack" "$forbidden" "message"
assert_exit_code $expected_code $actual_rc "message"
assert_file_equals "$actual_file" "$expected_file" "message"| Standard | Implementation |
|---|---|
| Language | ISO C11 (-std=c11) |
| Warnings | -Wall -Wextra -Wpedantic -Werror — zero tolerance |
| Optimisation | -O2 for release |
| Feature macros | -D_XOPEN_SOURCE=700 (implies POSIX 2008, enables wcwidth, strcasecmp) |
| Indentation | 4 spaces throughout (no tabs) |
| Naming | md_ prefix on all public symbols |
| Memory | Owned-pointer convention: md_doc_t owns blocks, blocks own content/lines/lang |
| Error handling | md_die(exit_code, fmt, …) — never returns, prefixed ✗ to stderr |
| EOF marker | Every .c and .h ends with /*fin*/ |
| No regex | All scanners hand-rolled to eliminate ReDoS surface |
make # build md2ansi binary
make test # run full test suite
make clean # remove binary + object files
make install # install to /usr/local/bin (requires sudo for system dirs)
make uninstall # remove from /usr/local/bin
make stats # print source/test LOC + binary size
make release-notes # print CHANGELOG body for the current version
make help # show targets + active variablesTo add inline formatting (e.g. underscore italics):
Edit inline.c and add a new branch in md_inline_expand's scanner loop. Use find_*_close helpers as a pattern. Update is_escape_char if the new token uses a special character.
To add block-level elements (e.g. definition lists):
- Add new enum value to
md_block_kind_tinparser.h. - Write a
match_*helper inparser.cand add to the mainmd_parseclassifier loop. - Add a
render_*function inrender.cand a case in theswitchinsidemd_render.
To add syntax highlighting for a new language:
- Define a keyword table (
static const char *MYLANG_KEYWORDS[] = { ... };) at the top ofsyntax.c. - Add the language name to
normalize_langif aliases are needed. - Add an
else if (strcmp(l, "mylang") == 0)branch inmd_syntax_highlightthat callshighlight_with_keywords(or a custom highlighter for special tokens). - Add
is_comment_linehandling for the language's comment syntax.
md2ansi -D file.md 2>debug.logEach debug message gets a timestamped sequence number:
[14:23:01.1] ⦿ width=80 color=1 files=1
The counter increments per message, so trace order is unambiguous even when piped.
Before submitting changes:
- Compiles clean under
-Wall -Wextra -Wpedantic -Werror - No new heap allocations without a corresponding free path
- All
.c/.hfiles end with/*fin*/ - New scanners are hand-rolled (no
<regex.h>) - New CLI flags documented in
show_helpAND README - Tests cover new features (assertion + edge cases)
-
make testpasses 14/14
A 39KB / ~1217-line README renders in ~2ms on a modern desktop CPU. The pipeline is a single in-process pass: zero subprocess spawning, hand-rolled scanners, in-process buffer reuse.
- Static: ~57KB binary, ~2MB resident on libc startup
- Dynamic: O(file size) — one slurp buffer per file, plus parsed
md_doc_tblock list. A 10MB markdown file uses roughly 25–30MB peak RSS
- Startup: <1ms (one libc relocation + initial palette setup)
- Throughput: ~5MB/sec on a typical desktop CPU
- File size limit: 10MB (configurable in
md_common.hviaMD_MAX_FILE_SIZE) - Terminal width: 20–500 columns
| Feature | Implementation | Limit/Behavior |
|---|---|---|
| File size limit | stat() + buffered slurp |
10MB max |
| Stdin size limit | Doubling buffer with cap check | 10MB max, fails fast with exit 9 |
| Input sanitisation | md_strip_ansi_inplace on slurped buffer |
All input cleaned (not just code blocks) |
| No regex DoS | Hand-rolled scanners only | Linear time, bounded memory |
| Bounds checking | Width clamped to [20, 500] |
Invalid → exit 22 |
| Directory protection | Explicit S_ISDIR check |
Rejects with exit 4 |
| Permission checks | access(R_OK) before open |
Exit 13 with clear message |
| No shell-out | Pure libc, no system() calls |
No shell injection surface |
- No external regex engine — hand-rolled scanners are linear-time, no catastrophic backtracking
- No
printfformat-string injection — all user content passes throughfputs/fwrite, neverprintf - No
eval/dynamic execution — pure parsing pipeline - No tempfile creation — entire pipeline is in-memory
Q: Does it handle GitHub Flavored Markdown?
A: All commonly-used GFM features: tables (with alignment + pipe escapes), task lists, strikethrough, fenced code blocks, autolinks. Emoji shortcodes and HTML passthrough are not implemented.
Q: Does it work on macOS / BSD?
A: It should — only requires libc with wcwidth. Tested on Linux (glibc 2.39); macOS (libSystem) and BSD (libc) should work but haven't been verified.
Q: Can I install without root?
A: Yes — make install PREFIX=$HOME/.local puts the binary in ~/.local/bin/md2ansi. Add ~/.local/bin to your PATH.
Q: Why does the binary need libc but nothing else?
A: All terminal detection (ioctl(TIOCGWINSZ)), Unicode width (wcwidth), and string handling come from libc. No ncurses, no terminfo lookup, no regex.
Q: Why are bare URL autolinks default ON?
A: Most users expect https://example.com in markdown text to render as a link. Disable with --no-bare-urls for stricter CommonMark semantics.
Q: Can I add colours?
A: Yes — edit ansi.c and modify the PAL_ON palette constants. Recompile with make.
Q: What about HTML passthrough?
A: Not implemented. Raw <tag> content renders as literal text.
Q: Can it be faster?
A: Possibly. Current bottleneck is per-line md_buf_t allocation in inline expansion. SIMD-accelerated scanning or arena allocators could help, but ~5MB/s throughput already exceeds the practical use case.
| Limitation | Impact | Workaround |
|---|---|---|
Underscore italics (_text_) |
Not honoured (clashes with snake_case) |
Use *text* instead |
| HTML passthrough | Raw <tag> rendered as literal text |
None |
Math ($...$, $$...$$) |
Not supported | Use a dedicated math renderer for math docs |
Definition lists (term : def) |
Not implemented | Use bullet lists instead |
| Multi-line table cells | Not supported | Each row must fit on one source line |
| Overlong unbreakable token in a wrapped cell | A single token wider than its column budget still overflows that column (word-wrap only) | Shorten the token, or use --no-table-wrap |
| Inline emphasis across a cell wrap boundary | **bold** spanning a wrapped line loses styling on the continuation line (no SGR-state tracker) |
Keep emphasised runs short; impact is minor on prose-heavy cells |
| OSC 8 terminal hyperlinks | Not emitted | Use a richer terminal (kitty, wezterm) with built-in URL handling |
| Syntax highlighter multi-line strings | Single-line tokenizer only | None |
Setext headers (==== underline) |
Not implemented | Use ATX # headers |
# 1. Make changes to source
$EDITOR parser.c inline.c
# 2. Build (must be clean under -Werror)
make
# 3. Run the test suite (must be 14/14)
make test
# 4. Smoke test manually
echo '# hi' | ./md2ansi --color=always
./md2ansi README.md | head -30
# 5. Commit
git add <files>
git commit -m "Add feature: description"- ✓ Compiles clean under
-O2 -std=c11 -Wall -Wextra -Wpedantic -Werror - ✓ Passes
make test(14/14) - ✓ New features have test coverage
- ✓ CLI changes documented in
show_helpAND this README - ✓ No new external dependencies
Version markers live in md_common.h (MD_VERSION), md2ansi.1 (.TH), and
README.md (badge + footer). Bump them together, then move the
CHANGELOG.md [Unreleased] entries under a new [X.Y.Z] heading and add the
compare links.
GitHub release notes come straight from the CHANGELOG via make release-notes,
so the two never drift:
# Notes for the current MD_VERSION
make release-notes
# Tag and publish — body is the CHANGELOG section, no manual copy/paste
git tag -a vX.Y.Z -m "md2ansi.c X.Y.Z" && git push origin vX.Y.Z
gh release create vX.Y.Z \
--title "vX.Y.Z — summary" \
--notes-file <(make -s release-notes RELEASE_VERSION=X.Y.Z)make release-notes bounds the section on the next version heading, the
link-reference footer, or #fin, so it stays correct even for the last entry
in the file.
| Tool / Library | Purpose |
|---|---|
libc (glibc / musl / libSystem / BSD libc) |
wcwidth, ioctl, getopt_long, fopen, all string handling |
That's it. No ncurses, no terminfo, no regex library, no JSON parser, no UTF-8 library. Pure libc.
GPL-3.0. See LICENSE for full text.
Figures are approximate — run make stats for exact current values.
| Metric | Value |
|---|---|
| Source code lines | ~2,965 (9 modules + headers + main) |
| Largest module | parser.c (565 lines) |
| Test code lines | ~1,000 (14 test files + runner + utils) |
| Test suites | 14 (all passing) |
| Binary size | ~57KB (dynamically linked) |
| Runtime dependencies | 1 (libc only) |
| External libraries | 0 |
| Build time | <2 seconds on a modern machine |
| Languages highlighted | 3 (python, javascript, bash) |
| CLI flags | 13 |
| Exit codes | 7 (0, 3, 4, 8, 9, 13, 22) |
Status: ✓ Production-ready C11 implementation
Version: 1.1.0
#fin