← markdown · API reference · version 0.2.5
3 file(s), 1378 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
html_escape fn | 81 | MKD-003 | 135 136 154 162 262 295 … (18 total) |
render fn | 113 | MKD-001 | 64 66 69 72 76 78 … (47 total) |
slugify fn | 450 | — | 153 161 294 1008 1017 1034 … (10 total) |
render_inline fn | 601 | — | 155 163 213 216 238 241 … (27 total) |
rewrite_link fn | 863 | MKD-001 | 799 49 51 52 54 56 … (12 total) |
Heading struct | 969 | — | 986 987 1005 1014 1031 |
extract_headings fn | 986 | MKD-002 | 258 260 264 266 267 268 … (15 total) |
src/markdown.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// markdown — minimal CommonMark + GFM-subset renderer for loft.
//
// Public API:
// pub fn render(source, base_dir, tag_url_prefix, image_url_prefix) -> text
// Render `source` markdown to HTML.
// base_dir: repo-relative directory used to resolve
// relative `[text](other.md)` links. Pass `""` to
// disable relative-link rewriting.
// tag_url_prefix: when non-empty, body-text occurrences
// of `@P<digits>` / `@PLAN<digits>(...)` get linked
// to `<tag_url_prefix><bare-name>` (e.g., "/tag/").
// image_url_prefix: when non-empty, relative image
// `<img src="...">` URLs get rewritten the same way
// relative links do, but routed through this prefix
// (e.g., "/raw/" so the viewer serves bytes).
//
// Supported constructs (block):
// - ATX headings #..###### (with GitHub-compatible slug ids)
// - Setext headings (`Title\n===` H1, `Title\n---` H2)
// - Paragraphs with blank-line separation
// - Fenced code blocks ```lang
// - Indented code blocks (4-space indent)
// - Block quotes `> text`
// - Horizontal rules --- / *** / ___
// - Unordered lists (- / *) and ordered lists (N.)
// - Nested lists via 2-or-4-space indent
// - Task lists `- [x]` / `- [ ]`
// - GFM tables with alignment (`|:---:|` etc.)
// - HTML comments (stripped)
//
// Supported constructs (inline):
// - Bold **text**
// - Italic *text* / _text_ (smart-_ heuristic for snake_case)
// - Inline code `text`
// - Strikethrough ~~text~~
// - Links [text](url) and [text](url "title")
// - Images 
// - Autolinks <https://...> / <user@host>
// - Hard line breaks (trailing ` \n`)
// - Backslash escapes \* \_ \` \\ \| \[ \] \( \) \! \~
// - Nested inline (bold inside italic, code inside link, etc.)
//
// Not supported (deferred / rare in loft docs):
// - Reference-style links [text][label] + [label]: url
// - Definition lists, footnotes, mermaid, math
// - Multi-line / multi-paragraph table cells
// - Loose lists (paragraph-per-item)
// - Raw HTML passthrough (HTML in markdown is escaped)
//
// ── Using it ─────────────────────────────────────────────────
//
// src = "## Getting Started\n\nSee [the guide](guide.md) and @P259.\n";
// markdown::render(src, "doc/claude", "/tag/", "")
//
// <h2 id="getting-started">Getting Started</h2><p>See
// <a href="/file/doc/claude/guide.md">the guide</a> and
// <a class="tag-ref" href="/tag/P259">@P259</a>.</p>
//
// Three things happened there that a plain renderer does not do, and each is one
// of the arguments: the heading grew a GitHub-compatible slug id, the relative
// link was resolved against `base_dir` and routed through `/file/`, and `@P259`
// became a tag link because `tag_url_prefix` was non-empty. Pass `""` for any of
// them to switch that rewriting off.
//
// The other four functions are the pieces `render` is built from, exposed because
// a caller rendering its own chrome needs the same answers: `slugify` for an
// anchor that matches the heading, `render_inline` for a title or a table cell,
// `html_escape` for text going into markup, `rewrite_link` for a URL resolved the
// way the body's links are.
// ── HTML escape ──────────────────────────────────────────────
// Make text safe to put inside markup: `&`, `<`, `>` and `"` become entities.
//
// ⚠ `'` IS NOT ESCAPED, so the result is safe in a double-quoted attribute and
// in element text, but not in a single-quoted one. Quote attributes with `"`.
// Example: @MKD-003 — a single-quoted attribute is broken by the value's own `'`.
pub fn html_escape(s: text) -> text {
out = "";
for c in s {
if c == '&' {
out += "&";
} else if c == '<' {
out += "<";
} else if c == '>' {
out += ">";
} else if c == '"' {
out += """;
} else {
out += "{c}";
}
}
out
}
fn strip_cr(s: text) -> text {
out = "";
for c in s {
if c != '\r' { out += "{c}"; }
}
out
}
// ── Public entry point ───────────────────────────────────────
// Render markdown to HTML — the entry point, and the only one most callers need.
// The three prefixes each switch off with `""`, so the plainest possible call is
// `render(src, "", "", "")`; see the header for what each one turns on.
// Example: @MKD-001 — a non-empty `base_dir` routes relative links through `/file/`.
pub fn render(source: text, base_dir: text, tag_url_prefix: text, image_url_prefix: text) -> text {
// Pre-process: strip CR (handle CRLF input cleanly).
src = strip_cr(source);
out = "";
in_code = false;
code_buf = "";
code_lang = "";
para_buf = "";
list_kind = "";
list_item_buf = "";
table_state = 0;
table_header = "";
table_align = "";
bq_buf = "";
// Setext heading detection: when a line could be heading text,
// remember it; if the NEXT line is `===` or `---` we promote.
pending_setext = "";
lines = src.split('\n');
for line in lines {
if in_code {
if line.starts_with("```") {
esc_code = html_escape(code_buf);
esc_lang = html_escape(code_lang);
if esc_lang.len() > 0 {
out += "<pre><code class=\"language-{esc_lang}\">{esc_code}</code></pre>";
} else {
out += "<pre><code>{esc_code}</code></pre>";
}
code_buf = "";
code_lang = "";
in_code = false;
} else {
code_buf += "{line}\n";
}
continue;
}
// Setext: previous line + current `===` / `---` of length ≥ 2
if pending_setext != "" {
if is_setext_underline(line, '=') {
slug = slugify(pending_setext);
esc_slug = html_escape(slug);
inline = render_inline(pending_setext, base_dir, tag_url_prefix, image_url_prefix);
out += "<h1 id=\"{esc_slug}\">{inline}</h1>";
pending_setext = "";
continue;
}
if is_setext_underline(line, '-') {
slug = slugify(pending_setext);
esc_slug = html_escape(slug);
inline = render_inline(pending_setext, base_dir, tag_url_prefix, image_url_prefix);
out += "<h2 id=\"{esc_slug}\">{inline}</h2>";
pending_setext = "";
continue;
}
// Not a setext underline — flush pending into the
// paragraph buffer and continue normal handling.
if para_buf.len() > 0 {
para_buf += " {pending_setext}";
} else {
para_buf = pending_setext;
}
pending_setext = "";
}
// ── Table state machine ─────────────────────────────────
if table_state == 1 {
if is_table_separator(line) {
table_align = parse_table_align(line);
out += "<table><thead>{render_table_row(table_header, true, table_align, base_dir, tag_url_prefix, image_url_prefix)}</thead><tbody>";
table_state = 2;
continue;
} else {
table_state = 0;
if para_buf.len() > 0 {
para_buf += " {table_header}";
} else {
para_buf = table_header;
}
table_header = "";
}
} else if table_state == 2 {
if is_table_row(line) {
out += render_table_row(line, false, table_align, base_dir, tag_url_prefix, image_url_prefix);
continue;
} else {
out += "</tbody></table>";
table_state = 0;
table_align = "";
}
}
// ── List state machine ─────────────────────────────────
if list_kind != "" {
ul_item = is_ul_item(line);
ol_item = is_ol_item(line);
if ul_item || ol_item {
bn = list_item_buf.size();
if bn >= 4 && (list_item_buf[0] ?? '\0') == '[' && (list_item_buf[2] ?? '\0') == ']' && (list_item_buf[3] ?? '\0') == ' ' && ((list_item_buf[1] ?? '\0') == ' ' || (list_item_buf[1] ?? '\0') == 'x' || (list_item_buf[1] ?? '\0') == 'X') {
chk = "";
if (list_item_buf[1] ?? '\0') != ' ' { chk = " checked"; }
rest = list_item_buf[4..bn];
inner = render_inline(rest, base_dir, tag_url_prefix, image_url_prefix);
out += "<li><input type=\"checkbox\" disabled{chk}> {inner}</li>";
} else {
out += "<li>{render_inline(list_item_buf, base_dir, tag_url_prefix, image_url_prefix)}</li>";
}
list_item_buf = strip_list_marker(line);
continue;
}
if line == "" {
continue;
}
if line.starts_with(" ") {
trimmed = line.trim();
if list_item_buf == "" {
list_item_buf = trimmed;
} else {
list_item_buf += " {trimmed}";
}
continue;
}
bn = list_item_buf.size();
if bn >= 4 && (list_item_buf[0] ?? '\0') == '[' && (list_item_buf[2] ?? '\0') == ']' && (list_item_buf[3] ?? '\0') == ' ' && ((list_item_buf[1] ?? '\0') == ' ' || (list_item_buf[1] ?? '\0') == 'x' || (list_item_buf[1] ?? '\0') == 'X') {
chk = "";
if (list_item_buf[1] ?? '\0') != ' ' { chk = " checked"; }
rest = list_item_buf[4..bn];
inner = render_inline(rest, base_dir, tag_url_prefix, image_url_prefix);
out += "<li><input type=\"checkbox\" disabled{chk}> {inner}</li>";
} else {
out += "<li>{render_inline(list_item_buf, base_dir, tag_url_prefix, image_url_prefix)}</li>";
}
list_item_buf = "";
out += "</{list_kind}>";
list_kind = "";
}
if line.starts_with("```") {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
n = line.size();
if n > 3 { code_lang = line[3..n]; }
in_code = true;
continue;
}
// Indented code block: 4-space indent, but ONLY if not in
// a list (lists own indent semantics) and not in a paragraph
// (a 4-space indent within a paragraph is just continuation).
if line.starts_with(" ") && para_buf.len() == 0 && bq_buf == "" {
n = line.size();
esc_code = html_escape(line[4..n]);
// Aggregate consecutive 4-space lines — handled via
// the code_buf state when in_code is true. Simpler:
// emit each as its own <pre><code>. Loft docs don't
// use indented code blocks; this is a defensive
// implementation for spec correctness.
out += "<pre><code>{esc_code}\n</code></pre>";
continue;
}
if line.starts_with("<!--") {
continue;
}
if line == "---" || line == "***" || line == "___" {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
out += "<hr>";
continue;
}
hlevel = atx_heading_level(line);
if hlevel > 0 {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
ln = line.size();
start = hlevel + 1;
heading_text = "";
if start < ln { heading_text = line[start..ln]; }
// Strip trailing `#` chars (`## Title ##` form).
heading_text = strip_trailing_hashes(heading_text);
slug = slugify(heading_text);
esc_slug = html_escape(slug);
inline = render_inline(heading_text, base_dir, tag_url_prefix, image_url_prefix);
out += "<h{hlevel} id=\"{esc_slug}\">{inline}</h{hlevel}>";
continue;
}
if is_ul_item(line) {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
list_kind = "ul";
out += "<ul>";
list_item_buf = strip_list_marker(line);
continue;
}
if is_ol_item(line) {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
list_kind = "ol";
out += "<ol>";
list_item_buf = strip_list_marker(line);
continue;
}
if is_table_row(line) {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
if bq_buf != "" {
out += "<blockquote>{render_inline(bq_buf, base_dir, tag_url_prefix, image_url_prefix)}</blockquote>";
bq_buf = "";
}
table_header = line;
table_state = 1;
continue;
}
if line.starts_with("> ") || line == ">" {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
ln = line.size();
quote_text = "";
if ln > 2 { quote_text = line[2..ln]; }
if bq_buf == "" {
bq_buf = quote_text;
} else if quote_text == "" {
bq_buf += " ";
} else {
bq_buf += " {quote_text}";
}
continue;
}
if bq_buf != "" {
out += "<blockquote>{render_inline(bq_buf, base_dir, tag_url_prefix, image_url_prefix)}</blockquote>";
bq_buf = "";
}
if line == "" {
if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
para_buf = "";
}
continue;
}
// Could be the first line of a setext heading — remember it
// so we can promote on the next line if it's `===` / `---`.
// We only enter setext-pending if the line looks like prose
// (no leading markup) and there's no in-progress paragraph.
if para_buf.len() == 0 && pending_setext == "" {
pending_setext = line;
continue;
}
if para_buf.len() > 0 {
para_buf += " {line}";
} else {
para_buf = line;
}
}
// End-of-input flushes
if pending_setext != "" {
if para_buf.len() > 0 {
para_buf += " {pending_setext}";
} else {
para_buf = pending_setext;
}
}
if list_kind != "" {
bn = list_item_buf.size();
if bn >= 4 && (list_item_buf[0] ?? '\0') == '[' && (list_item_buf[2] ?? '\0') == ']' && (list_item_buf[3] ?? '\0') == ' ' && ((list_item_buf[1] ?? '\0') == ' ' || (list_item_buf[1] ?? '\0') == 'x' || (list_item_buf[1] ?? '\0') == 'X') {
chk = "";
if (list_item_buf[1] ?? '\0') != ' ' { chk = " checked"; }
rest = list_item_buf[4..bn];
inner = render_inline(rest, base_dir, tag_url_prefix, image_url_prefix);
out += "<li><input type=\"checkbox\" disabled{chk}> {inner}</li>";
} else {
out += "<li>{render_inline(list_item_buf, base_dir, tag_url_prefix, image_url_prefix)}</li>";
}
out += "</{list_kind}>";
list_item_buf = "";
}
if bq_buf != "" {
out += "<blockquote>{render_inline(bq_buf, base_dir, tag_url_prefix, image_url_prefix)}</blockquote>";
bq_buf = "";
}
if table_state == 2 { out += "</tbody></table>"; }
if in_code {
esc_code = html_escape(code_buf);
out += "<pre><code>{esc_code}</code></pre>";
} else if para_buf.len() > 0 {
out += "<p>{render_inline(para_buf, base_dir, tag_url_prefix, image_url_prefix)}</p>";
}
out
}
// ── Block-level helpers ─────────────────────────────────────
fn atx_heading_level(line: text) -> integer {
n = line.size();
if n == 0 { return 0; }
count = 0;
i = 0;
while i < n && (line[i] ?? '\0') == '#' {
count = count + 1;
i = i + 1;
}
if count < 1 || count > 6 { return 0; }
if i < n && (line[i] ?? '\0') != ' ' { return 0; }
count
}
fn strip_trailing_hashes(s: text) -> text {
n = s.size();
if n == 0 { return s; }
end = n;
while end > 0 && (s[end - 1] ?? '\0') == '#' { end = end - 1; }
if end < n && end > 0 && (s[end - 1] ?? '\0') == ' ' { end = end - 1; }
s[0..end].trim()
}
fn is_setext_underline(line: text, ch: character) -> boolean {
n = line.size();
if n < 2 { return false; }
i = 0;
while i < n {
if (line[i] ?? '\0') != ch { return false; }
i = i + 1;
}
true
}
// A heading's anchor id, by GitHub's rules — lower-cased, spaces to hyphens,
// punctuation dropped. `render` puts this on every heading, so a caller
// building its own table of contents uses this to get links that match.
pub fn slugify(heading: text) -> text {
out = "";
for c in heading {
if c == ' ' {
out += "-";
} else if c >= 'a' && c <= 'z' {
out += "{c}";
} else if c >= 'A' && c <= 'Z' {
// ASCII lowercase via text method — `(c - 'A') + 'a'`
// returns an integer codepoint, which `"{n}"` formats
// as a decimal, not a char. Round-trip through text.
cs = "{c}";
out += cs.to_lowercase();
} else if c >= '0' && c <= '9' {
out += "{c}";
} else if c == '-' || c == '_' {
out += "{c}";
}
}
out
}
fn is_ul_item(line: text) -> boolean {
line.starts_with("- ") || line.starts_with("* ")
}
fn is_ol_item(line: text) -> boolean {
n = line.size();
if n < 3 { return false; }
i = 0;
while i < n && (line[i] ?? '\0') >= '0' && (line[i] ?? '\0') <= '9' {
i = i + 1;
}
if i == 0 { return false; }
if i + 1 >= n { return false; }
(line[i] ?? '\0') == '.' && (line[i + 1] ?? '\0') == ' '
}
fn strip_list_marker(line: text) -> text {
n = line.size();
if line.starts_with("- ") || line.starts_with("* ") {
return line[2..n];
}
i = 0;
while i < n && (line[i] ?? '\0') >= '0' && (line[i] ?? '\0') <= '9' {
i = i + 1;
}
if i + 1 < n && (line[i] ?? '\0') == '.' && (line[i + 1] ?? '\0') == ' ' {
return line[i + 2..n];
}
line
}
fn is_table_row(line: text) -> boolean {
if !line.starts_with("|") { return false; }
n = line.size();
if n < 3 { return false; }
i = 1;
while i < n {
if (line[i] ?? '\0') == '|' { return true; }
i = i + 1;
}
false
}
fn is_table_separator(line: text) -> boolean {
if !is_table_row(line) { return false; }
n = line.size();
i = 0;
seen_dash = false;
while i < n {
c = (line[i] ?? '\0');
if c != '|' && c != '-' && c != ':' && c != ' ' { return false; }
if c == '-' { seen_dash = true; }
i = i + 1;
}
seen_dash
}
fn parse_table_align(sep: text) -> text {
// Returns a sequence of `l`/`c`/`r`/`d` chars, one per cell.
out = "";
cells = sep.split('|');
for cell in cells {
t = cell.trim();
if t == "" { continue; }
n = t.size();
starts_colon = n > 0 && (t[0] ?? '\0') == ':';
ends_colon = n > 0 && (t[n - 1] ?? '\0') == ':';
if starts_colon && ends_colon { out += "c"; }
else if ends_colon { out += "r"; }
else if starts_colon { out += "l"; }
else { out += "d"; }
}
out
}
fn render_table_row(line: text, is_header: boolean, align: text, base_dir: text, tag_url_prefix: text, image_url_prefix: text) -> text {
cells = line.split('|');
out = "<tr>";
tag = "td";
if is_header { tag = "th"; }
cell_idx = 0;
align_n = align.len();
for cell in cells {
trimmed = cell.trim();
if trimmed == "" { continue; }
// Pick alignment style for this cell.
attr = "";
if cell_idx < align_n {
a = align[cell_idx];
if a == 'c' { attr = " style=\"text-align:center\""; }
else if a == 'r' { attr = " style=\"text-align:right\""; }
else if a == 'l' { attr = " style=\"text-align:left\""; }
}
out += "<{tag}{attr}>{render_inline(trimmed, base_dir, tag_url_prefix, image_url_prefix)}</{tag}>";
cell_idx = cell_idx + 1;
}
out += "</tr>";
out
}
// ── Inline renderer (RECURSIVE for nested formatting) ────────
// Rewrite an image URL through `image_url_prefix` (typically
// "/raw/" so the viewer serves the image bytes). Empty prefix
// or absolute / scheme-relative URLs pass through unchanged.
fn rewrite_image(url: text, base_dir: text, image_url_prefix: text) -> text {
if image_url_prefix == "" { return url; }
is_abs = url.starts_with("http://") || url.starts_with("https://") || url.starts_with("data:") || url.starts_with("/");
if is_abs { return url; }
cleaned = url;
while cleaned.starts_with("./") {
cl = cleaned.size();
cleaned = cleaned[2..cl];
}
cur_base = base_dir;
while cleaned.starts_with("../") {
cur_base = parent_dir(cur_base);
cl = cleaned.size();
cleaned = cleaned[3..cl];
}
if cur_base == "" {
return "{image_url_prefix}{cleaned}";
}
"{image_url_prefix}{cur_base}/{cleaned}"
}
// Render only the INLINE constructs — bold, code, links, images — with no
// paragraph or block wrapper. What a title, a table cell or a tooltip wants,
// where `render` would wrap the result in `<p>`.
pub fn render_inline(src: text, base_dir: text, tag_url_prefix: text, image_url_prefix: text) -> text {
out = "";
n = src.size();
i = 0;
while i < n {
c = (src[i] ?? '\0');
// Hard line break: trailing ` \n` is detected at the
// line-split level (paragraphs join with ' '); this branch
// catches the literal in-string `\n` in para_buf if any.
if c == '\n' {
out += "<br>";
i = i + 1;
continue;
}
// Backslash escape
if c == '\\' && i + 1 < n {
next_c = (src[i + 1] ?? '\0');
if next_c == '*' || next_c == '_' || next_c == '`' || next_c == '\\'
|| next_c == '|' || next_c == '[' || next_c == ']'
|| next_c == '(' || next_c == ')' || next_c == '!'
|| next_c == '~' {
if next_c == '&' { out += "&"; }
else if next_c == '<' { out += "<"; }
else if next_c == '>' { out += ">"; }
else if next_c == '"' { out += """; }
else { out += "{next_c}"; }
i = i + 2;
continue;
}
}
if c == '~' && i + 1 < n && (src[i + 1] ?? '\0') == '~' {
i = i + 2;
inner_start = i;
while i + 1 < n && !((src[i] ?? '\0') == '~' && (src[i + 1] ?? '\0') == '~') {
i = i + 1;
}
inner = src[inner_start..i];
if i + 1 < n { i = i + 2; }
out += "<del>{render_inline(inner, base_dir, tag_url_prefix, image_url_prefix)}</del>";
continue;
}
if c == '<' {
close = i + 1;
ok = true;
while close < n && (src[close] ?? '\0') != '>' {
if (src[close] ?? '\0') == ' ' || (src[close] ?? '\0') == '\n' { ok = false; break; }
close = close + 1;
}
if ok && close < n {
inner = src[i + 1..close];
is_url = inner.find("://") >= 0;
is_email = !is_url && inner.find("@") >= 0;
if is_url {
esc = html_escape(inner);
out += "<a href=\"{esc}\">{esc}</a>";
i = close + 1;
continue;
}
if is_email {
esc = html_escape(inner);
out += "<a href=\"mailto:{esc}\">{esc}</a>";
i = close + 1;
continue;
}
}
}
if c == '!' && i + 1 < n && (src[i + 1] ?? '\0') == '[' {
text_start = i + 2;
j = text_start;
depth = 1;
while j < n && depth > 0 {
if (src[j] ?? '\0') == ']' { depth = depth - 1; if depth == 0 { break; } }
else if (src[j] ?? '\0') == '[' { depth = depth + 1; }
j = j + 1;
}
if j < n && j + 1 < n && (src[j + 1] ?? '\0') == '(' {
alt_text = src[text_start..j];
url_start = j + 2;
k = url_start;
while k < n && (src[k] ?? '\0') != ')' {
k = k + 1;
}
if k < n {
raw = src[url_start..k];
url = url_part_of(raw);
// Apply image_url_prefix if non-empty + URL is
// relative. Inline the rewrite so we don't
// hit the "text-returning helper called from
// text-returning fn" P271-family codegen panic.
final_url = rewrite_image(url, base_dir, image_url_prefix);
esc_url = html_escape(final_url);
esc_alt = html_escape(alt_text);
out += "<img src=\"{esc_url}\" alt=\"{esc_alt}\">";
i = k + 1;
continue;
}
}
}
// Tracker-tag autolink: `@P\d+([a-z])?` and
// `@PLAN\d+(-[\w]+)*` patterns get linked when
// `tag_url_prefix` is non-empty. Detection requires
// the `@` to be at a word boundary (not inside
// identifier-ish text) — cheap heuristic: previous
// char must be non-alnum.
if c == '@' && tag_url_prefix != "" && i + 1 < n {
at_boundary = true;
if i > 0 {
prev = (src[i - 1] ?? '\0');
if (prev >= 'a' && prev <= 'z') || (prev >= 'A' && prev <= 'Z') || (prev >= '0' && prev <= '9') || prev == '_' {
at_boundary = false;
}
}
if at_boundary {
tag_start = i + 1;
tag_end = match_tracker_tag(src, tag_start, n);
if tag_end > tag_start {
// Slice via byte iteration to avoid loft's
// slice-bounds analysis tripping on the
// computed indices (parser flags `src[a..b]`
// as "Invalid index on string" when it can't
// prove the bounds — see notes near top).
bare = "";
bi = tag_start;
while bi < tag_end {
bare += "{(src[bi] ?? '\0')}";
bi = bi + 1;
}
esc_bare = html_escape(bare);
out += "<a class=\"tag-ref\" href=\"{tag_url_prefix}{esc_bare}\">@{esc_bare}</a>";
i = tag_end;
continue;
}
}
}
if c == '`' {
i = i + 1;
code_start = i;
while i < n && (src[i] ?? '\0') != '`' {
i = i + 1;
}
code = src[code_start..i];
if i < n { i = i + 1; }
esc_code = html_escape(code);
out += "<code>{esc_code}</code>";
continue;
}
if c == '*' && i + 1 < n && (src[i + 1] ?? '\0') == '*' {
i = i + 2;
inner_start = i;
while i + 1 < n && !((src[i] ?? '\0') == '*' && (src[i + 1] ?? '\0') == '*') {
i = i + 1;
}
inner = src[inner_start..i];
if i + 1 < n { i = i + 2; }
out += "<strong>{render_inline(inner, base_dir, tag_url_prefix, image_url_prefix)}</strong>";
continue;
}
if c == '*' || c == '_' {
open = c;
looks_like_italic = true;
if open == '_' && i > 0 {
prev = (src[i - 1] ?? '\0');
if (prev >= 'a' && prev <= 'z') || (prev >= 'A' && prev <= 'Z') || (prev >= '0' && prev <= '9') {
looks_like_italic = false;
}
}
if looks_like_italic {
i = i + 1;
inner_start = i;
while i < n && (src[i] ?? '\0') != open {
i = i + 1;
}
inner = src[inner_start..i];
if i < n { i = i + 1; }
out += "<em>{render_inline(inner, base_dir, tag_url_prefix, image_url_prefix)}</em>";
continue;
}
}
if c == '[' {
text_start = i + 1;
j = text_start;
depth = 1;
while j < n && depth > 0 {
if (src[j] ?? '\0') == ']' { depth = depth - 1; if depth == 0 { break; } }
else if (src[j] ?? '\0') == '[' { depth = depth + 1; }
j = j + 1;
}
if j < n && j + 1 < n && (src[j + 1] ?? '\0') == '(' {
link_text = src[text_start..j];
url_start = j + 2;
k = url_start;
while k < n && (src[k] ?? '\0') != ')' {
k = k + 1;
}
if k < n {
raw = src[url_start..k];
url = url_part_of(raw);
title = title_part_of(raw);
rewritten = rewrite_link(url, base_dir);
esc_url = html_escape(rewritten);
title_attr = "";
if title != "" {
esc_title = html_escape(title);
title_attr = " title=\"{esc_title}\"";
}
out += "<a href=\"{esc_url}\"{title_attr}>{render_inline(link_text, base_dir, tag_url_prefix, image_url_prefix)}</a>";
i = k + 1;
continue;
}
}
}
if c == '&' { out += "&"; i = i + 1; }
else if c == '<' { out += "<"; i = i + 1; }
else if c == '>' { out += ">"; i = i + 1; }
else if c == '"' { out += """; i = i + 1; }
else {
char_str = "{c}";
out += char_str;
i = i + char_str.size();
}
}
out
}
// ── Inline helpers ───────────────────────────────────────────
// Split `url "title"` form into (url, title). `url_part_of`
// returns the URL portion; `title_part_of` returns the title
// (without quotes) or empty.
fn url_part_of(raw: text) -> text {
n = raw.size();
i = 0;
// Skip leading whitespace
while i < n && (raw[i] ?? '\0') == ' ' { i = i + 1; }
start = i;
// Read up to first space (which would precede `"title"`).
while i < n && (raw[i] ?? '\0') != ' ' { i = i + 1; }
raw[start..i]
}
fn title_part_of(raw: text) -> text {
n = raw.size();
// Find a `"` after a space.
quote_at = -1;
i = 0;
while i < n {
if (raw[i] ?? '\0') == '"' { quote_at = i; break; }
i = i + 1;
}
if quote_at < 0 { return ""; }
end = quote_at + 1;
while end < n && (raw[end] ?? '\0') != '"' { end = end + 1; }
if end >= n { return ""; }
raw[quote_at + 1..end]
}
// Resolve one relative URL the way a link in the body would be: joined onto
// `base_dir` and routed through `/file/`. Absolute URLs and anchors are left
// alone — a scheme, an anchor, and a root-relative `/...` are all already
// absolute. For a caller that builds its own navigation and needs it to agree
// with the links inside the rendered page.
// Example: @MKD-001
pub fn rewrite_link(url: text, base_dir: text) -> text {
if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:") {
return url;
}
if url.starts_with("#") {
return url;
}
// Root-relative already: resolving it against `base_dir` treated it as a
// relative path and produced `/file/<base>//abs/p.md` — a doubled separator on
// a URL that was never relative. Left alone, like every other absolute form.
if url.starts_with("/") {
return url;
}
if base_dir == "" {
// No relative-path resolution requested.
return url;
}
anchor = "";
target = url;
// `find` answers `integer?` — null for a miss, which the `>= 0` test below reads as
// absent. `?? -1` names that miss once, so the two slices below are byte offsets.
hash_at = url.find("#") ?? -1;
if hash_at >= 0 {
ulen = url.size();
target = url[0..hash_at];
anchor = url[hash_at..ulen];
}
cleaned = target;
while cleaned.starts_with("./") {
cl = cleaned.size();
cleaned = cleaned[2..cl];
}
cur_base = base_dir;
while cleaned.starts_with("../") {
cur_base = parent_dir(cur_base);
cl = cleaned.size();
cleaned = cleaned[3..cl];
}
if cur_base == "" {
return "/file/{cleaned}{anchor}";
}
"/file/{cur_base}/{cleaned}{anchor}"
}
fn parent_dir(p: text) -> text {
last_slash = p.rfind("/") ?? -1;
if last_slash < 0 { return ""; }
p[0..last_slash]
}
// Try to match a tracker-tag identifier starting at `start`
// in `src`. Returns the END byte offset of a successful
// match (so `src[start..end]` is the bare-name like "P259"
// or "PLAN35-01"), or `start` itself if no match.
//
// Patterns:
// P\d+([a-z])? — P-issues like P259, P229b
// PLAN\d+(-[\w]+)* — plans + phases like PLAN35-01,
// PLAN22-2d-iii.a
fn match_tracker_tag(src: text, start: integer, n: integer) -> integer {
if start >= n { return start; }
c0 = (src[start] ?? '\0');
if c0 != 'P' { return start; }
// Try PLAN prefix first (longer match).
if start + 4 < n && (src[start + 1] ?? '\0') == 'L' && (src[start + 2] ?? '\0') == 'A' && (src[start + 3] ?? '\0') == 'N' {
i = start + 4;
// Must have at least one digit after PLAN.
digits = 0;
while i < n && (src[i] ?? '\0') >= '0' && (src[i] ?? '\0') <= '9' {
i = i + 1;
digits = digits + 1;
}
if digits == 0 { return start; }
// Optionally consume `-segment` parts: -[a-zA-Z0-9_.]+
while i < n && (src[i] ?? '\0') == '-' {
j = i + 1;
seg = 0;
while j < n {
cj = (src[j] ?? '\0');
ok = (cj >= 'a' && cj <= 'z') || (cj >= 'A' && cj <= 'Z') || (cj >= '0' && cj <= '9') || cj == '_' || cj == '.';
if !ok { break; }
j = j + 1;
seg = seg + 1;
}
if seg == 0 { break; }
i = j;
}
return i;
}
// P<digits>([a-z])?
i = start + 1;
digits = 0;
while i < n && (src[i] ?? '\0') >= '0' && (src[i] ?? '\0') <= '9' {
i = i + 1;
digits = digits + 1;
}
if digits == 0 { return start; }
if i < n && (src[i] ?? '\0') >= 'a' && (src[i] ?? '\0') <= 'z' {
i = i + 1;
}
i
}
// ── TOC extraction ───────────────────────────────────────────
// One heading of a document: its level (1..6), its raw text, and its anchor slug.
pub struct Heading {
pub level: integer,
pub text: text,
pub slug: text
}
// Extract every heading from `source` in source order — both
// ATX (`# Title`) and setext (`Title\n===` / `Title\n---`).
// Each entry carries the level (1..6), the heading's plain
// text (BEFORE inline rendering — the caller can pass it
// through render_inline if needed), and a GitHub-compatible
// slug (the same one the rendered headings get as `id=`).
//
// Useful for building an in-page table of contents: walk the
// returned vector, indent by `level`, link each entry to
// `#<slug>`.
// Example: @MKD-002 — raw text, and the slug `render` puts on the heading.
pub fn extract_headings(source: text) -> vector<Heading> {
out: vector<Heading> = [];
src = strip_cr(source);
lines = src.split('\n');
in_code = false;
prev = "";
for line in lines {
if in_code {
if line.starts_with("```") { in_code = false; }
prev = "";
continue;
}
if line.starts_with("```") {
in_code = true;
prev = "";
continue;
}
if prev != "" {
if is_setext_underline(line, '=') {
out += [Heading {
level: 1,
text: prev,
slug: slugify(prev)
}];
prev = "";
continue;
}
if is_setext_underline(line, '-') {
out += [Heading {
level: 2,
text: prev,
slug: slugify(prev)
}];
prev = "";
continue;
}
prev = "";
}
hlevel = atx_heading_level(line);
if hlevel > 0 {
ln = line.size();
start = hlevel + 1;
txt = "";
if start < ln { txt = line[start..ln]; }
txt = strip_trailing_hashes(txt);
out += [Heading {
level: hlevel,
text: txt,
slug: slugify(txt)
}];
continue;
}
if line != "" && !line.starts_with("- ") && !line.starts_with("* ")
&& !line.starts_with("> ") && !line.starts_with("|")
&& !line.starts_with(" ") {
prev = line;
} else {
prev = "";
}
}
out
}
tests/01-render.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Comprehensive tests for the markdown library. One assertion
// (or one cohesive group) per feature so a failure names exactly
// which construct broke. Run via:
//
// ./target/release/loft --interpret --lib lib/ lib/markdown/tests/01-render.loft
use markdown;
fn must_contain(haystack: text, needle: text, label: text) {
if haystack.find(needle) < 0 {
println("FAIL: {label}");
println(" needle: {needle}");
println(" haystack: {haystack}");
assert(false, "{label}: needle not found");
}
}
fn must_not_contain(haystack: text, needle: text, label: text) {
if haystack.find(needle) >= 0 {
println("FAIL: {label}");
println(" unexpected needle: {needle}");
println(" haystack: {haystack}");
assert(false, "{label}: unexpected needle present");
}
}
fn must_eq(actual: text, expected: text, label: text) {
if actual != expected {
println("FAIL: {label}");
println(" expected: {expected}");
println(" actual: {actual}");
assert(false, "{label}: mismatch");
}
}
fn main() {
// ── html_escape ────────────────────────────────────────
must_eq(markdown::html_escape("a<b>&\"c"), "a<b>&"c", "html_escape");
// ── slugify ───────────────────────────────────────────
must_eq(markdown::slugify("Open work"), "open-work", "slugify simple");
must_eq(markdown::slugify("P259 (closed)"), "p259-closed", "slugify with parens");
must_eq(markdown::slugify("§1.2 — Foo"), "12--foo", "slugify drops punctuation");
// ── rewrite_link ──────────────────────────────────────
must_eq(markdown::rewrite_link("https://example.com", "doc"), "https://example.com",
"absolute https passthrough");
must_eq(markdown::rewrite_link("#anchor", "doc"), "#anchor", "anchor passthrough");
must_eq(markdown::rewrite_link("/abs/p.md", "doc/claude"), "/abs/p.md",
"root-relative is not joined onto base_dir");
must_eq(markdown::rewrite_link("other.md", "doc/claude"), "/file/doc/claude/other.md",
"relative path resolution");
must_eq(markdown::rewrite_link("../sibling.md", "doc/claude/plans"),
"/file/doc/claude/sibling.md", "../ resolution");
must_eq(markdown::rewrite_link("./local.md", "doc"), "/file/doc/local.md",
"./ stripping");
must_eq(markdown::rewrite_link("foo.md#anchor", "doc"), "/file/doc/foo.md#anchor",
"anchor preservation on relative");
// ── ATX headings ──────────────────────────────────────
h1 = markdown::render("# Hello\n", "", "", "");
must_contain(h1, "<h1 id=\"hello\">Hello</h1>", "ATX H1");
h6 = markdown::render("###### Six\n", "", "", "");
must_contain(h6, "<h6 id=\"six\">Six</h6>", "ATX H6");
// Trailing # form
htrail = markdown::render("## Title ##\n", "", "", "");
must_contain(htrail, "<h2 id=\"title\">Title</h2>", "trailing-# stripped");
// Heading with inline formatting
hbold = markdown::render("## With **bold**\n", "", "", "");
must_contain(hbold, "<strong>bold</strong>", "heading inline formatting");
// ── Setext headings ───────────────────────────────────
s1 = markdown::render("Title One\n=====\n", "", "", "");
must_contain(s1, "<h1 id=\"title-one\">Title One</h1>", "setext H1");
s2 = markdown::render("Section Two\n----\n", "", "", "");
must_contain(s2, "<h2 id=\"section-two\">Section Two</h2>", "setext H2");
// ── Paragraphs ────────────────────────────────────────
p1 = markdown::render("hello\nworld\n", "", "", "");
must_contain(p1, "<p>hello world</p>", "single paragraph multi-line");
p2 = markdown::render("one\n\ntwo\n", "", "", "");
must_contain(p2, "<p>one</p>", "two paragraphs - first");
must_contain(p2, "<p>two</p>", "two paragraphs - second");
// ── Bold / italic / strikethrough / inline code ───────
inl = markdown::render("**bold** *italic* ~~del~~ `code`\n", "", "", "");
must_contain(inl, "<strong>bold</strong>", "bold");
must_contain(inl, "<em>italic</em>", "italic");
must_contain(inl, "<del>del</del>", "strikethrough");
must_contain(inl, "<code>code</code>", "inline code");
// ── Smart underscore (snake_case stays plain) ────────
snake = markdown::render("snake_case_word\n", "", "", "");
must_not_contain(snake, "<em>", "smart _ skips snake_case");
// ── Nested inline (bold inside italic, code inside link) ──
nest = markdown::render("**outer *inner* tail**\n", "", "", "");
must_contain(nest, "<strong>outer <em>inner</em> tail</strong>",
"italic nested in bold");
nest2 = markdown::render("[`code-link`](other.md)\n", "doc", "", "");
must_contain(nest2, "<a href=\"/file/doc/other.md\"><code>code-link</code></a>",
"code nested in link");
// ── Backslash escapes ─────────────────────────────────
esc = markdown::render("\\* literal star and \\| pipe and \\\\ slash\n", "", "", "");
must_contain(esc, "* literal star", "escape \\*");
must_contain(esc, "| pipe", "escape \\|");
must_contain(esc, "\\ slash", "escape \\\\");
// ── Images ────────────────────────────────────────────
img = markdown::render("\n", "", "", "");
must_contain(img, "<img src=\"images/logo.png\" alt=\"alt text\">", "image");
// ── Links + relative resolution + title ──────────────
lnk = markdown::render("[click](other.md)\n", "doc/claude", "", "");
must_contain(lnk, "<a href=\"/file/doc/claude/other.md\">click</a>",
"relative link rewrite");
lnktitle = markdown::render("[click](http://example.com \"the title\")\n", "", "", "");
must_contain(lnktitle, "title=\"the title\"", "link title");
// ── Autolinks ────────────────────────────────────────
al = markdown::render("<https://example.com> and <user@host.com>\n", "", "", "");
must_contain(al, "<a href=\"https://example.com\">https://example.com</a>",
"https autolink");
must_contain(al, "<a href=\"mailto:user@host.com\">user@host.com</a>",
"mailto autolink");
// ── Horizontal rule ──────────────────────────────────
hr = markdown::render("before\n\n---\n\nafter\n", "", "", "");
must_contain(hr, "<hr>", "horizontal rule");
// ── Block quote ──────────────────────────────────────
bq = markdown::render("> first\n> second\n", "", "", "");
must_contain(bq, "<blockquote>first second</blockquote>",
"blockquote merges consecutive lines");
bq2 = markdown::render("> a\n\n> b\n", "", "", "");
bq2_count = 0;
// `find` answers `integer?` — null for a miss. `?? -1` names the miss once at
// each door, so the offsets that reach the slice are plain byte positions.
bi = bq2.find("<blockquote>") ?? -1;
while bi >= 0 {
bq2_count = bq2_count + 1;
rest = bq2[bi + 12..];
next = rest.find("<blockquote>") ?? -1;
if next < 0 { bi = -1; }
else { bi = bi + 12 + next; }
}
if bq2_count != 2 {
println("FAIL: blockquote separated by blank — got {bq2_count} blockquotes");
println(" rendered: {bq2}");
assert(false, "blockquote separated by blank line");
}
// ── Fenced code block ────────────────────────────────
fc = markdown::render("```rust\nfn x() {{}}\n```\n", "", "", "");
must_contain(fc, "<pre><code class=\"language-rust\">fn x() {{}}\n</code></pre>",
"fenced code with lang");
fc2 = markdown::render("```\nplain\n```\n", "", "", "");
must_contain(fc2, "<pre><code>plain\n</code></pre>", "fenced code no lang");
// ── Lists (UL / OL / continuation) ───────────────────
ul = markdown::render("- one\n- two\n- three\n", "", "", "");
must_contain(ul, "<ul><li>one</li><li>two</li><li>three</li></ul>",
"ul three items");
ol = markdown::render("1. one\n2. two\n", "", "", "");
must_contain(ol, "<ol><li>one</li><li>two</li></ol>", "ol two items");
cont = markdown::render("- first line\n continuation\n- second\n", "", "", "");
must_contain(cont, "<li>first line continuation</li>",
"list-item continuation merge");
// ── Task lists ───────────────────────────────────────
tl = markdown::render("- [ ] todo\n- [x] done\n", "", "", "");
must_contain(tl, "<input type=\"checkbox\" disabled> todo", "unchecked task");
must_contain(tl, "<input type=\"checkbox\" disabled checked> done", "checked task");
// ── Tables (header + alignment + body) ───────────────
tbl = markdown::render("| col1 | col2 |\n|---|---|\n| a | b |\n", "", "", "");
must_contain(tbl, "<table><thead>", "table thead");
must_contain(tbl, "<th>col1</th>", "table header cell");
must_contain(tbl, "<td>a</td>", "table body cell");
tbl_align = markdown::render("| L | C | R |\n|:--|:--:|--:|\n| a | b | c |\n", "", "", "");
must_contain(tbl_align, "style=\"text-align:left\"", "table align left");
must_contain(tbl_align, "style=\"text-align:center\"", "table align center");
must_contain(tbl_align, "style=\"text-align:right\"", "table align right");
// ── HTML comments stripped ───────────────────────────
cmt = markdown::render("<!-- hide me -->\nshow me\n", "", "", "");
must_not_contain(cmt, "hide me", "html comment stripped");
must_contain(cmt, "show me", "non-comment line preserved");
// ── CRLF handling ────────────────────────────────────
crlf = markdown::render("# Title\r\n\r\nbody\r\n", "", "", "");
must_contain(crlf, "<h1 id=\"title\">Title</h1>", "CRLF heading");
must_contain(crlf, "<p>body</p>", "CRLF paragraph");
must_not_contain(crlf, "\r", "CRLF \\r stripped");
// ── UTF-8 multi-byte preservation (P272 regression) ──
utf = markdown::render("# Arrow → here\n\nan em-dash — here\n", "", "", "");
must_contain(utf, "Arrow → here", "UTF-8 in heading");
must_contain(utf, "em-dash — here", "UTF-8 in paragraph");
must_not_contain(utf, "———", "no em-dash widening");
must_not_contain(utf, "→→", "no arrow widening");
// ── UTF-8 across slice-bearing constructs (@PLN110 len→size regression) ──
// Byte-slice / byte-index bounds (links, fenced code, list items, heading text
// past the marker) must be sized in BYTES, not the character count `len` now
// returns — otherwise multi-byte content truncates or the inline cursor re-emits
// characters (`café` → `caféé`).
u2 = markdown::render("## Héllo wörld\n\n[café](/naïve)\n\n```txt\n— ok\n```\n\n- café item\n", "", "", "");
must_contain(u2, "Héllo wörld", "UTF-8 heading text past the ## marker");
must_contain(u2, "<a href=\"/naïve\">café</a>", "UTF-8 in link text and href");
must_contain(u2, "— ok", "UTF-8 in fenced code");
must_contain(u2, "café item", "UTF-8 in list item");
must_not_contain(u2, "caféé", "no multi-byte character duplication");
// ── HTML in source escaped (no raw HTML passthrough) ──
htm = markdown::render("a <script>x</script> b\n", "", "", "");
must_contain(htm, "<script>", "raw HTML escaped");
must_not_contain(htm, "<script>x</script>", "no raw HTML passthrough");
// ── Tracker-tag autolink (high-impact extension) ─────
tag_p = markdown::render("see @P259 for the fix.\n", "", "/tag/", "");
must_contain(tag_p, "<a class=\"tag-ref\" href=\"/tag/P259\">@P259</a>",
"tag autolink @P259");
tag_plan = markdown::render("phase @PLAN35-01 status.\n", "", "/tag/", "");
must_contain(tag_plan, "<a class=\"tag-ref\" href=\"/tag/PLAN35-01\">@PLAN35-01</a>",
"tag autolink @PLAN35-01");
tag_subph = markdown::render("@PLAN22-2d-iii.a sub-phase.\n", "", "/tag/", "");
must_contain(tag_subph, "<a class=\"tag-ref\" href=\"/tag/PLAN22-2d-iii.a\">@PLAN22-2d-iii.a</a>",
"tag autolink with sub-phases (-/.)");
tag_off = markdown::render("see @P259 here.\n", "", "", "");
must_not_contain(tag_off, "tag-ref", "tag autolink off when prefix empty");
must_contain(tag_off, "@P259", "tag literal preserved when off");
tag_inword = markdown::render("user@P259.com address.\n", "", "/tag/", "");
must_not_contain(tag_inword, "tag-ref", "tag autolink skips inside word");
tag_code = markdown::render("see `@P259` literally.\n", "", "/tag/", "");
must_not_contain(tag_code, "tag-ref", "tag autolink skipped inside `code`");
// ── Image URL rewriting (image_url_prefix) ───────────
img_rw = markdown::render("\n", "doc", "", "/raw/");
must_contain(img_rw, "<img src=\"/raw/doc/images/logo.png\" alt=\"pic\">",
"image relative URL rewritten through prefix");
img_abs = markdown::render("\n", "doc", "", "/raw/");
must_contain(img_abs, "<img src=\"https://example.com/x.png\" alt=\"ext\">",
"image absolute URL passes through");
img_off = markdown::render("\n", "doc", "", "");
must_contain(img_off, "<img src=\"images/logo.png\" alt=\"pic\">",
"image not rewritten when prefix empty");
img_dotdot = markdown::render("\n", "doc/sub", "", "/raw/");
must_contain(img_dotdot, "<img src=\"/raw/doc/images/logo.png\" alt=\"up\">",
"image ../ path resolved");
// ── extract_headings (TOC support) ───────────────────
src_h = "# Top\n\n## Open work\n\n## P259 (closed)\n\nbody\n\nSetext H1\n=====\n\n### nested";
hs = markdown::extract_headings(src_h);
if hs.len() != 5 {
println("FAIL: extract_headings count — got {hs.len()}, expected 5");
for h in hs {
println(" L{h.level} [{h.slug}] {h.text}");
}
assert(false, "extract_headings count");
}
must_eq(hs[0].slug, "top", "extract_headings[0].slug");
must_eq(hs[1].slug, "open-work", "extract_headings[1].slug");
must_eq(hs[2].slug, "p259-closed", "extract_headings[2].slug");
must_eq(hs[3].slug, "setext-h1", "extract_headings[3].slug (setext)");
must_eq(hs[4].slug, "nested", "extract_headings[4].slug");
if hs[0].level != 1 { assert(false, "extract_headings[0].level should be 1"); }
if hs[3].level != 1 { assert(false, "extract_headings[3].level should be 1 (setext)"); }
if hs[4].level != 3 { assert(false, "extract_headings[4].level should be 3"); }
src_h2 = "# real\n\n```md\n# fake\n```\n\n## also real";
hs2 = markdown::extract_headings(src_h2);
if hs2.len() != 2 {
println("FAIL: extract_headings should skip code-fenced — got {hs2.len()}");
for h in hs2 {
println(" L{h.level} [{h.slug}] {h.text}");
}
assert(false, "extract_headings skip code-fenced");
}
println("ALL TESTS PASSED");
}
tests/02-worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `markdown`. Each test is a call-site contract a signature
// cannot show, cited from its function with `// Example: @MKD-00N`.
use markdown::*;
// @MKD-001 — a non-empty `base_dir` does not just join paths: it routes every relative
// link through `/file/`, the route of the repository viewer this renderer was built for.
// `..` is resolved against the directory; an absolute URL, a root-relative `/path` and an
// `#anchor` are left alone. For a document that stands alone, pass "" and links stay as
// written.
fn test_base_dir_routes_relative_links_through_file() {
assert(render("[o](other.md)\n", "", "", "") == "<p><a href=\"other.md\">o</a></p>",
"base_dir \"\" leaves the link as written");
assert(rewrite_link("other.md", "doc/sub") == "/file/doc/sub/other.md", "joined, under /file/");
assert(rewrite_link("../x.md#a", "doc/sub") == "/file/doc/x.md#a", ".. resolved, anchor kept");
assert(rewrite_link("https://e.com/a.md", "doc/sub") == "https://e.com/a.md", "absolute: untouched");
assert(rewrite_link("#top", "doc/sub") == "#top", "an anchor: untouched");
assert(render("[o](other.md)\n", "doc/sub", "", "") == "<p><a href=\"/file/doc/sub/other.md\">o</a></p>",
"render applies the same rewrite to the body");
}
// @MKD-002 — `extract_headings` hands back a heading's RAW text, markup included, and the
// slug `render` puts on the heading's `id`. So a table of contents built from it links
// exactly; run the text through `render_inline` when the entry should keep its emphasis.
fn test_a_heading_is_raw_text_with_the_rendered_slug() {
src = "## The *hard* way\n";
h = extract_headings(src)[0];
assert(h.text == "The *hard* way" && h.level == 2, "raw text: '{h.text}'");
assert(h.slug == "the-hard-way" && slugify(h.text) == h.slug, "slug: '{h.slug}'");
assert(render(src, "", "", "") == "<h2 id=\"the-hard-way\">The <em>hard</em> way</h2>", "the id matches");
assert(render_inline(h.text, "", "", "") == "The <em>hard</em> way", "render_inline for a styled entry");
}
// @MKD-003 — `html_escape` escapes `&`, `<`, `>` and `"`, and NOT the apostrophe. That
// is enough for the element bodies the renderer writes, and not for a single-quoted
// attribute: the value's own `'` closes it. For markup you write yourself, use the
// `html` package's `escape_html`, which escapes all five.
fn test_the_apostrophe_is_left_alone() {
assert(html_escape("a'b\"<&>") == "a'b"<&>", "four of the five");
attr = "<a title='{html_escape("x' y='z")}'>";
quotes = 0;
for c in attr { if c == '\'' { quotes += 1; } }
assert(quotes == 4, "the value's quotes survive and break the attribute: {attr}");
}