Zig 0.17.0

Markdown

spider.zmd turns Markdown into HTML. It is part of Spider: nothing to install, nothing to turn on. This site is written with it, and so are the posts of Posts, the example app in examples/posts (source).

Text someone typed, on a page

A post's body is Markdown in the database. The handler turns it into HTML and gives it to the template:

zig
pub fn show(c: *spider.Ctx) !Response {
    const post = (try policy.load(c)) orelse return error.NotFound;
    return c.view("posts/show", .{
        .title = post.title,
        .post = try present.card(c.arena, post),
        // The same rule the routes use decides whether the buttons show.
        .can_edit = try Posts.can(c, .update, &post),
        // The body is Markdown. Spider's renderer escapes what the author
        // typed, so its HTML can go into the page as it is.
        .body = spider.RawHtml{ .html = try spider.zmd.parse(c.arena, post.body, .{}) },
    }, .{});
}

A template escapes every value it prints, so HTML in a value would show as text. spider.RawHtml says that this one value is HTML already and goes in as it is. That is safe here for one reason: spider.zmd.parse escapes everything the author typed. Never wrap in RawHtml text that did not go through it.

parse(allocator, text, formatters) gives back a fragment, to put inside a page. With c.arena there is nothing to free.

What comes out

zig
test "markdown: what an author types, and the HTML that comes out" {
    const text =
        \\## A heading
        \\
        \\Some **bold**, *italic* and `code`, and a [link](https://example.org).
        \\
        \\- one
        \\- two
        \\
        \\1. first
        \\2. second
        \\
        \\> Someone said this.
        \\
        \\![A diagram](/img/diagram.png)
    ;
    const html = try spider.zmd.parse(std.testing.allocator, text, .{});
    defer std.testing.allocator.free(html);

    try std.testing.expectEqualStrings(
        \\<h2>A heading</h2>
        \\<p>Some <b>bold</b>, <i>italic</i> and <code>code</code>, and a <a href="https://example.org">link</a>.</p>
        \\<ul>
        \\  <li>one</li>
        \\  <li>two</li>
        \\</ul>
        \\<ol>
        \\  <li>first</li>
        \\  <li>second</li>
        \\</ol>
        \\<blockquote>Someone said this.</blockquote>
        \\<p><img src="/img/diagram.png" title="A diagram"></p>
        \\
    , html);
}

A block of code gets a bar with its language, and classes to style it by:

zig
test "markdown: a block of code keeps its language and escapes its text" {
    const text =
        \\
zig

\\const smaller = 1 < 2;

\\; const html = try spider.zmd.parse(std.testing.allocator, text, .{}); defer std.testing.allocator.free(html); try std.testing.expectEqualStrings( \\<div class="code-block"> \\ <div class="code-block-bar"><span class="code-block-lang">zig</span></div> \\ <pre><code>const smaller = 1 &lt; 2;</code></pre> \\</div> \\ , html); }

What it does not read yet: tables, horizontal rules (---), strikethrough, and a link whose text is code. They come out as the text that was typed.

What an author cannot do

HTML in the text is shown, not run. A link is kept only when its address is safe to follow: one that would run a script loses its link.

zig
test "markdown: HTML an author types is shown, not run; a link that would run a script is dropped" {
    const text = "<script>alert(1)</script> and [a trap](javascript:alert(1))";
    const html = try spider.zmd.parse(std.testing.allocator, text, .{});
    defer std.testing.allocator.free(html);

    try std.testing.expect(std.mem.indexOf(u8, html, "<script>") == null);
    try std.testing.expect(std.mem.indexOf(u8, html, "&lt;script&gt;alert(1)&lt;/script&gt;") != null);
    try std.testing.expect(std.mem.indexOf(u8, html, "javascript:") == null);
    try std.testing.expect(std.mem.indexOf(u8, html, "<a ") == null);
}

Your own HTML for an element

The third argument says how each kind of element is written. .{} is the default; name a field to replace one. A formatter gets the element (node.content is its inner HTML, already escaped) and returns its HTML:

zig
fn titled(allocator: std.mem.Allocator, node: spider.zmd.Node) std.mem.Allocator.Error![]const u8 {
    return std.fmt.allocPrint(allocator, "<h2 class=\"title\">{s}</h2>\n", .{node.content});
}

test "markdown: a formatter of the app's own writes one kind of element" {
    const html = try spider.zmd.parse(std.testing.allocator, "## Prices", .{ .h2 = titled });
    defer std.testing.allocator.free(html);
    try std.testing.expectEqualStrings("<h2 class=\"title\">Prices</h2>\n", html);
}

This site gives its headings an id that way, so a page can be linked to at one of them:

zig
/// Headings with an `id` made from their text: `<h3 id="ctx-json">`.
/// tools/api_reference.zig makes the same id for the links it writes.
const headings = struct {
    fn h2(allocator: std.mem.Allocator, node: spider.zmd.Node) std.mem.Allocator.Error![]const u8 {
        return heading(allocator, "h2", node.content);
    }
    fn h3(allocator: std.mem.Allocator, node: spider.zmd.Node) std.mem.Allocator.Error![]const u8 {
        return heading(allocator, "h3", node.content);
    }
    fn h4(allocator: std.mem.Allocator, node: spider.zmd.Node) std.mem.Allocator.Error![]const u8 {
        return heading(allocator, "h4", node.content);
    }

    fn heading(allocator: std.mem.Allocator, comptime tag: []const u8, content: []const u8) ![]const u8 {
        const id = try headingId(allocator, content);
        defer allocator.free(id);
        return std.fmt.allocPrint(allocator, "<" ++ tag ++ " id=\"{s}\">{s}</" ++ tag ++ ">\n", .{ id, content });
    }
};

A formatter that prints node.href, node.title or node.meta has to escape them itself: those are what the author typed.

A whole document

parseFull wraps the result in a complete HTML document, for a file that is opened by itself:

zig
test "markdown: parseFull gives a whole document" {
    const html = try spider.zmd.parseFull(std.testing.allocator, "# Notes", .{});
    defer std.testing.allocator.free(html);
    try std.testing.expect(std.mem.startsWith(u8, html, "<!DOCTYPE html>"));
    try std.testing.expect(std.mem.indexOf(u8, html, "<h1>Notes</h1>") != null);
}