markdown - render a Markdown subset to HTML and ANSI
Import with import "markdown.j" as markdown;. Renders a small CommonMark subset to HTML (through the html module, so escaping is handled for you) and to styled terminal text (through the ansi module). Pure Jennifer: line-oriented block parsing with a small inline scanner. Runs on either binary.
use io;
import "markdown.j" as markdown;
io.printf("%s\n", markdown.toHtml("# Hi\n\nA **bold** word."));
# <h1>Hi</h1><p>A <strong>bold</strong> word.</p>
io.printf("%s\n", markdown.toAnsi("- one\n- two")); # styled on a TTYRunnable: examples/modules/markdown_demo.j.
Surface
Rendering (Markdown in, HTML / terminal text / PDF out):
| Call | Returns | Notes |
|---|---|---|
markdown.toHtml(md) | string | Render to HTML: block elements concatenated, no indentation. Safe by default: raw HTML in the source is escaped. |
markdown.toHtmlWith(md, opts) | string | toHtml with an HtmlOptions; set allowRawHtml: true to pass raw HTML blocks through (trusted input only). |
markdown.toXhtml(md) | string | Render to well-formed XHTML (void elements self-close, boolean attributes expand) - an XHTML / EPUB content document. Raw HTML is escaped, as toHtml. |
markdown.toAnsi(md) | string | Render to terminal text with ansi styling (self-suppressing). |
markdown.render(doc, format) | string | Render a parsed (or hand-built) tree; format is "html" / "xhtml" / "ansi". |
markdown.toPdf(md) | bytes | Lay the document out to a paginated PDF (through pdf). |
markdown.toPdfWith(md, opts) | bytes | toPdf with a custom PdfOptions (page size, margins, fonts, document metadata, bookmarks). |
markdown.renderPdf(doc, opts) | bytes | Lay a parsed (or transformed) tree out to a PDF. |
markdown.renderPdfDoc(doc, opts) | pdf.Document | Lay a tree out to a pdf.Document (not yet serialized), so document-level work - a running header / footer, extra metadata - can happen before pdf.render. |
markdown.pageBreak() | Node | A page-break node; renderPdf starts the next content on a fresh page. |
toHtml / toAnsi are thin wrappers over render(parse(md), ...), and toPdf over renderPdf(parse(md), pdfDefaults()), so build and parse share one document model.
Reading (surface the parse tree, walked like xml / html):
| Call | Returns | Notes |
|---|---|---|
markdown.parse(md) | Node | Parse to the document root (typeOf "document"). |
markdown.typeOf(node) | string | The node kind ("heading", "link", "text", ...). |
markdown.children(node) | list of Node | The node's direct children. |
markdown.text(node) | string | A leaf's text, else its descendants' text concatenated. |
markdown.level(node) | int | A heading's level (1-6); 0 otherwise. |
markdown.attr(node, name) | string | "href" / "title" / "lang" / "kind" / "align" / "ordered" / "level"; a code block's "info"; a task item's "task" / "checked"; an attribute list's "id" / "class" / any key; else "". |
markdown.get(node, sel) | Node | First node matching a /-separated selector, or an empty node. |
markdown.findAll(node, sel) | list of Node | Every node matching the selector. |
markdown.has(node, sel) | bool | Whether any node matches. |
Authoring (build Markdown text - the inverse):
| Call | Returns | Notes |
|---|---|---|
markdown.header(level, s) | string | ATX heading; level is "h1".."h6" (throws otherwise). |
markdown.style(kind, s) | string | Inline emphasis; kind is "bold" / "italic" / "code". |
markdown.link(text, url) | string | [text](url). |
markdown.bullets(items) | string | Unordered list, one - item per line. |
markdown.numbered(items) | string | Ordered list, 1. item upward. |
markdown.codeBlock(text) | string | Fenced code block around verbatim text. |
markdown.table(headings, aligns, rows) | string | GFM table from column headings, per-column alignment, and rows. |
markdown.tablePretty(md) | string | Reformat every table's source columns to line up; other lines untouched. |
Supported Markdown
A deliberately small CommonMark subset:
| Block | Syntax | HTML |
|---|---|---|
| Heading (levels 1-6) | # H ... ###### H | <h1> ... <h6> |
| Paragraph | consecutive text lines | <p> (lines joined by ) |
| Unordered list | - x / * x / + x | <ul><li> |
| Ordered list | 1. x | <ol><li> |
| Nested list | indent a sub-list under an item | a child <ul> / <ol> inside the parent <li> |
| Task list (GFM) | - [ ] todo / - [x] done | <li class="task-list-item"> + a disabled checkbox |
| Definition list | term then : definition | <dl><dt><dd> |
| Blockquote | > x (recursive: > > y) | <blockquote> (inner text parsed as blocks) |
| Admonition | > [!NOTE] opening a blockquote | <div class="admonition admonition-note"> with a title paragraph |
| Fenced code block | py ... | <pre><code class="language-py"> (info kept as attr "info") |
| Indented code block | four-space indent after a blank line | <pre><code> |
| Thematic break | --- / *** / ___ (3+, spaced ok) | <hr> |
| Raw HTML block | a line opening with <tag / </ / <!-- to the next blank line | passed through verbatim |
| Table (GFM) | | a | b | + | --- | --- | row | <table> (aligned terminal columns in ANSI) |
| Inline | Syntax | HTML | ANSI |
|---|---|---|---|
| Bold | **text** | <strong> | bold |
| Italic | *text* | <em> | italic |
| Code | text | <code> | cyan |
| Link | [text](url) | <a href="url"> | underline + (url) |
| Autolink | <https://x> / <a@b.com> | <a href="..."> (email gets mailto:) | underline + (url) |
| Image |  | <img src alt> | [image] alt (url) |
| Strikethrough (GFM) | ~~text~~ | <del> | strikethrough |
| Highlight | ==text== | <mark> | reverse |
| Subscript | ~text~ | <sub> | plain |
| Superscript | ^text^ | <sup> | plain |
Attribute lists. A {#id .class key="value"} trailing a heading, or immediately after a link or image, sets that node's attributes - a stable heading anchor (## Title {#my-anchor} -> <h2 id="my-anchor">), extra classes, a target="_blank" link, an image width. HTML/XHTML emit them; ANSI/PDF ignore them. Read any of them with attr(node, name). Note: in a .j string literal a cooked "...{...}" treats {...} as interpolation, so write Markdown that contains attribute lists (or any literal brace) with a raw '...' string, or read it from a file.
Inline spans nest. A link inside **...**, a code span or *emphasis* inside a link label, and so on all parse to a nested tree, so **[label](x)** renders <strong><a href="x">label</a></strong> rather than dropping the link. A link label may contain a ] inside a code span or a balanced nested [...] pair ([the $xs[] sugar](x) parses correctly).
A destination may contain parentheses when they balance, which is what makes a DOI a link: [NW](https://doi.org/10.1016/0022-2836(70)90057-4) keeps the whole URL rather than ending at the first ). One that does not balance ends the destination, and a \( or \) is data - escaped in the source, plain in the href.
A lazy continuation line - a soft-wrapped line indented to an item's content column - stays part of that item rather than splitting the list. A blank line still ends the item. A nested list is a more-indented list under a parent item; a blockquote's inner lines are parsed as blocks, so a quote can hold paragraphs, lists, or nested quotes. A link's href, an autolink's target, and an image's src all pass through the same scheme allowlist (html.safeUrl), so javascript: and other script schemes render as #. A raw HTML block is escaped by toHtml (rendered as literal text, not markup) and dropped by toAnsi / the PDF renderer, which have no HTML.
Admonitions
A blockquote that opens with an alert marker is an admonition node rather than a quote:
> [!NOTE]
> Useful to know, and not in the way of the sentence you were reading.
> [!WARNING] Mind the gap
> A marker can carry its own title.Five kinds - NOTE, TIP, IMPORTANT, WARNING, CAUTION - spelled in any case. attr(node, "kind") is the kind in lower case, attr(node, "title") the custom title or "", and the marker itself is gone: it is structure rather than content, so text(node) is the body alone. Everything else the quotation holds - paragraphs, lists, code, a nested admonition - is its children.
A sixth word, or a marker that is not the whole of what precedes the title, is a quotation: > [!NOTES] and > [!NOTE]: as it happens both parse as quote - which is also what a renderer that has never heard of the syntax makes of all of them, and the reason for this syntax rather than :::note. It is already Markdown, so nothing breaks anywhere else.
The title is plain text, not inline Markdown, and only the parser can see it: by the time the block exists the line break after the marker has been folded into a space, and > [!NOTE] Mind the gap is then indistinguishable from a marker with a body on the next line.
toHtml writes the conventional class names, so a stylesheet written for another generator's callouts already matches - this module ships none. toAnsi prints the label in bold above the quotation. In a PDF the label is a bold paragraph at the head of the panel, which is what a callout looks like in print, and PdfOptions.admonitionLabels is how a book in another language says so:
$o.admonitionLabels = {"note": "Hinweis", "warning": "Warnung"};A kind the table does not name is called by its own name; a title, being the author's words rather than the renderer's, outranks both.
HTML output and safety
toHtml builds an html node tree and renders it, so all text and every link target are correctly escaped - &, <, > in text and code, and &/" in an href - and you cannot produce malformed markup:
markdown.toHtml("[t](http://x/?a=1&b=2) and <b> & `x<y`");
# <p><a href="http://x/?a=1&b=2">t</a> and <b> & <code>x<y</code></p>Safe by default for untrusted Markdown. A raw HTML block in the source (a line opening with <tag>) is escaped, so rendering a README, a comment, or any publisher-supplied Markdown cannot inject a <script> or an event-handler attribute (<img onerror=...>) - it renders as visible text instead. This closes the stored-XSS vector that verbatim HTML passthrough would open on the rendering origin. Inline raw HTML was already escaped, and everything the renderer builds (text, link URLs, image attributes) is escaped too.
For trusted Markdown where raw HTML is intended (your own docs using <details>, <div align>, badges), opt back in to verbatim passthrough:
markdown.toHtmlWith($trustedMd, markdown.HtmlOptions{allowRawHtml: true});allowRawHtml defaults to false, so toHtml(md) and toHtmlWith(md, markdown.HtmlOptions{allowRawHtml: false}) are identical.
Output is compact (no newlines between block elements), which diffs and round-trips predictably; wrap it in your own formatter if you need indented source. A code block's content is escaped but never treated as Markdown.
ANSI output
toAnsi renders for a terminal: headings and **bold** in bold, *italic* in italic, inline code in cyan, links underlined with their URL in parentheses, list items with - / N. markers, and fenced code indented and dimmed. Styling comes from the ansi module, which suppresses itself when stdout is not a terminal (or NO_COLOR is set) and is forced on by FORCE_COLOR - so piping the output gives clean plain text, and ansi.strip(markdown.toAnsi(md)) gives it unconditionally.
Reading the document tree
markdown.parse(md) returns the document as a tree of Nodes, walked with the same accessor vocabulary as xml / html - so a document can be inspected or transformed (pull the headings for a table of contents, rewrite links, lint) and then rendered, rather than going straight to a string. A node's typeOf is a block kind ("document", "heading", "paragraph", "code", "list", "item", "table", "row", "cell", "quote", "admonition", "definition_list", "def_term", "def_desc") or an inline kind ("text", "codespan", "strong", "emphasis", "strikethrough", "highlight", "subscript", "superscript", "link", "image"). get / findAll / has take a /-separated selector whose steps are kind names, * (any kind), or name[k] (the k-th such child, 1-based), matching direct children.
def doc as markdown.Node init markdown.parse("# Title\n\nSee [docs](https://example.com).\n");
# Walk the headings for an outline.
for (def h in markdown.findAll($doc, "heading")) {
io.printf("H{markdown.level($h)}: {markdown.text($h)}\n"); # H1: Title
}
# Pull a link's target.
def a as markdown.Node init markdown.get($doc, "paragraph/link");
io.printf("{markdown.text($a)} -> {markdown.attr($a, 'href')}\n"); # docs -> https://example.com
# Render straight from the (possibly transformed) tree.
io.printf("{markdown.render($doc, 'html')}\n");attr reads a node's string attributes: "href" / "title" (a link or image), "lang" (a fenced code block's language), "align" (a table cell), "ordered" ("true" / "false", a list), and "level" (a heading). render(doc, format) renders a "document" node's children (or any single node as one block); it and the parse are hardened with a nesting-depth cap and a total-node budget, so a pathological document is a catchable "markdown" error rather than an unbounded parse.
Authoring Markdown
The authoring helpers are the inverse of the renderer: they build Markdown text, so a program can assemble a document (and, since it is Markdown, round-trip it through toHtml / toAnsi):
use io;
import "markdown.j" as markdown;
def items as list of string init ["fast", "small", "strict"];
def doc as string init markdown.header("h1", "Jennifer") + "\n\n";
$doc = $doc + "It is " + markdown.style("bold", "great") + ". Features:\n\n";
$doc = $doc + markdown.bullets($items) + "\n\n";
$doc = $doc + "See " + markdown.link("the docs", "https://example/docs") + ".";
io.printf("%s\n", $doc); # Markdown source
io.printf("%s\n", markdown.toHtml($doc)); # ... or renderedThe text is inserted literally: a caller passing Markdown metacharacters (a * or inside a heading, say) is responsible for escaping them. header throws a catchable value error on a level outside "h1".."h6", and style on a kind other than "bold" / "italic" / "code"`.
Tables
table turns tabular data into a GFM table in one call: column headings, per-column aligns ("left" / "right" / "center" / "none", or [] for all-default), and rows (each a list of string):
def rows as list of list of string init [];
$rows[] = ["Ada", "95"];
$rows[] = ["Bo", "88"];
io.printf("%s\n", markdown.table(["Name", "Score"], ["left", "right"], $rows));
# | Name | Score |
# | :--- | ---: |
# | Ada | 95 |
# | Bo | 88 |Columns follow headings: a short row is padded with empty cells and extra cells are dropped, so every row is the same width. A | in a cell is escaped to \| and a newline becomes a space, so cell content can't break the table. An align value outside the four names throws a catchable value error.
The reader understands GFM tables too, so an authored table round-trips: toHtml(markdown.table(...)) renders a <table> (with per-column align), and toAnsi renders aligned terminal columns. A parsed table needs a header row, a delimiter row (| --- | :--: |), and its data rows; cell content is inline-parsed (emphasis / code / links work in cells), and a table interrupts an open paragraph.
tablePretty reformats the source of every table in a document so its columns line up - the handcraft-then-prettify workflow, in one call - and leaves every non-table line exactly as written:
def messy as string init "| Name | Score |\n|:-|-:|\n| Ada | 95 |";
io.printf("%s\n", markdown.tablePretty($messy));
# | Name | Score |
# | :--- | ----: |
# | Ada | 95 |Each column is padded to its widest cell (minimum three, so the delimiter keeps its dashes), data cells follow the column's alignment, and an escaped \| is preserved. It is idempotent: prettifying an already-pretty table is a no-op.
Not supported
This is a subset, chosen to stay small and TinyGo-clean:
- No reference links (
[text][ref]), setext (underlined) headings, or hard-break-on-two-spaces. - Autolinks cover
<scheme:...>and<user@host>; bare (unbracketed) URLs in prose are not linkified. - The raw-HTML block rule is CommonMark's "type 6" shape (a tag at the line start, to the next blank line); inline raw HTML in a paragraph is still escaped.
For anything beyond this subset, render with an external tool. The module is sized for READMEs, help text, and comment / docblock bodies, not general-purpose CommonMark conformance.
Note
toPdffolds PDF rendering intomarkdownas a third output besidetoHtml/toAnsi, so it importsfont). That makes everymarkdownimport a bit heavier even for atoHtml-only program - a deliberate trade for one unified rendering surface; see design-decisions.md. Text outside the WinAnsi range the standard-14 fonts encode is replaced per theunencodableoption (below) rather than aborting the render.PdfOptionscomes frommarkdown.pdfDefaults()(US Letter, 54-pt margins, Helvetica / Courier); copy it and tweak the fields (pageWidth/pageHeight/margin/bodyFont/boldFont/italicFont/monoFont/headingFont/bodySize/tablePad). A level-1 heading starts a new page; table columns are sized to their content (a short column doesn't crowd a long one), andtablePadtunes cell density.Backgrounds.
tableHeaderFill(aFill) shades a table's header row, andheadingStyles(alist of HeadingStyle, index 0 = h1, 1 = h2, ...) shades each heading level. Build a colour withmarkdown.gray(level)/markdown.rgb(r, g, b)(ormarkdown.noFill()for none) and a heading style withmarkdown.headingStyle(fill):jenniferdef o as markdown.PdfOptions init markdown.pdfDefaults(); $o.tableHeaderFill = markdown.gray(232); $o.headingStyles = [markdown.headingStyle(markdown.gray(205)), markdown.headingStyle(markdown.gray(225))]; def out as bytes init markdown.toPdfWith($md, $o);Document metadata + bookmarks. Set
title/author/subject/keywordsonPdfOptionsto write the PDF Info dictionary (shown in a viewer's Document Properties). SetbookmarkLevelto add a navigation outline: every heading up to that level becomes a bookmark, nested by heading level -bookmarkLevel = 2bookmarks all#and##headings,1only#,0(the default) none.jenniferdef o as markdown.PdfOptions init markdown.pdfDefaults(); $o.title = "My Report"; $o.author = "Ada Lovelace"; $o.bookmarkLevel = 2; def out as bytes init markdown.toPdfWith($md, $o);Block panels + un-encodable text.
codeFill/codeBorder(each aFill, off by default) draw a background and / or border behind every code block, andquoteFill/quoteRuledo the same behind a blockquote (quoteRuleis the vertical bar down its left edge) - the waytableHeaderFillshades a table header. A long code line or an unbreakable table token is folded to the column instead of running off the page. A character the standard-14 fonts cannot encode is replaced byunencodable(default"?",""drops it) so one stray glyph never fails the whole document. The substitution is for what is drawn: a bookmark is read by the viewer rather than set in a font, so it keeps the heading whole in every script, and the Info dictionary keeps its values whole too.More document metadata. Alongside
title/author/subject/keywords, setcreator(the application that produced the document) andproduceronPdfOptions; each is written to the PDF Info dictionary when non-empty (an emptyproducerkeeps"Jennifer pdf"default).Page breaks. A level-1 heading starts a fresh page, but a document can force a break anywhere with
markdown.pageBreak()- a node placed in a tree handed tomarkdown.renderPdf, or, in Markdown source, a lone<!-- pagebreak -->comment.toHtml/toAnsiignore it.jenniferdef o as markdown.PdfOptions init markdown.pdfDefaults(); $o.codeFill = markdown.gray(240); $o.codeBorder = markdown.gray(150); $o.quoteFill = markdown.gray(244); $o.quoteRule = markdown.gray(150); $o.creator = "Grimoire"; def out as bytes init markdown.toPdfWith($md, $o);Images. By default
renders as its[alt]text in the PDF (the same fallbacktoAnsiuses), so nothing is lost when no picture is supplied. To draw the actual picture, load its bytes withpdf.loadImageand put it inPdfOptions.images, amap of string to pdf.Imagekeyed by the image URL exactly as it appears in the Markdown source.markdownnever touches the filesystem - the caller owns loading the bytes (and so resolving relative paths), which is what keeps a rendering module free of I/O and path opinions.jenniferimport "markdown.j" as markdown; import "pdf.j" as pdf; use fs; def o as markdown.PdfOptions init markdown.pdfDefaults(); $o.images["figure.png"] = pdf.loadImage("Figure", fs.readBytes("figure.png")); def out as bytes init markdown.toPdfWith("# Report\n\n\n", $o);Only a paragraph that is a single image (a picture on its own line) is drawn; an image inline in a sentence keeps its
[alt]run, and an image in a list item, quote, or table cell keeps the fallback too. A drawn picture is scaled to keep its aspect ratio, never wider than the text column and never taller than one page;imageDpi(default 96) sets the pixel-to-point scale, so a screenshot wider than the column shrinks to fit. Each image's resourcename(the firstloadImageargument) must be unique across the map - a duplicate is a catchablemarkdownerror. Registration order follows the map's insertion order (a language guarantee), so output stays byte-identical run to run.Running headers / footers (page numbers).
toPdf/renderPdfreturnbytes, butpdf.Document. Usemarkdown.renderPdfDocto get the laid-out document, attach the footer withpdf.setFooter(its%page%/%pages%placeholders fill in per page, which needs the total - known only once the whole book is laid out), then serialize withpdf.render:jenniferimport "markdown.j" as markdown; import "pdf.j" as pdf; def doc as pdf.Document init markdown.renderPdfDoc(markdown.parse($md), markdown.pdfDefaults()); def lbl as pdf.PageLabel init pdf.pageLabel(); $lbl.right = "%page%/%pages%"; def out as bytes init pdf.render(pdf.setFooter($doc, $lbl));
See also
- html.md - the HTML backend
toHtmlrenders through. - ansi.md - the terminal styling
toAnsirenders through. - modules/index.md - the module catalog and import rules.