markdown source

← 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.

ItemDefinedWorked exampleCall sites
html_escape fn81MKD-003135 136 154 162 262 295 … (18 total)
render fn113MKD-00164 66 69 72 76 78 … (47 total)
slugify fn450—153 161 294 1008 1017 1034 … (10 total)
render_inline fn601—155 163 213 216 238 241 … (27 total)
rewrite_link fn863MKD-001799 49 51 52 54 56 … (12 total)
Heading struct969—986 987 1005 1014 1031
extract_headings fn986MKD-002258 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 ![alt](url)
//   - 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 += "&amp;";
        } else if c == '<' {
            out += "&lt;";
        } else if c == '>' {
            out += "&gt;";
        } else if c == '"' {
            out += "&quot;";
        } 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 += "&amp;"; }
                else if next_c == '<' { out += "&lt;"; }
                else if next_c == '>' { out += "&gt;"; }
                else if next_c == '"' { out += "&quot;"; }
                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 += "&amp;"; i = i + 1; }
        else if c == '<' { out += "&lt;"; i = i + 1; }
        else if c == '>' { out += "&gt;"; i = i + 1; }
        else if c == '"' { out += "&quot;"; 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&lt;b&gt;&amp;&quot;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("![alt text](images/logo.png)\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, "&lt;script&gt;", "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("![pic](images/logo.png)\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("![ext](https://example.com/x.png)\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("![pic](images/logo.png)\n", "doc", "", "");
    must_contain(img_off, "<img src=\"images/logo.png\" alt=\"pic\">",
                 "image not rewritten when prefix empty");
    img_dotdot = markdown::render("![up](../images/logo.png)\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&quot;&lt;&amp;&gt;", "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}");
}