Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339//! Compose's body as siv's rich text editor holds it, Portable Text, and//! as a message carries it: plain text and HTML. A block is a line of the//! text, or lines with no empty one between, broken inside it; a quote's//! lines start with a ">" for each level, and a list item's with "- " or//! its number. So the text a draft is started from, or read back as,//! comes back to the same blocks.
const std = @import("std");const json = std.json;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const Io = std.Io;const Writer = Io.Writer;
const Span = struct { text: []const u8 = "", marks: []const []const u8 = &.{},};
const MarkDef = struct { _key: []const u8 = "", _type: []const u8 = "", href: []const u8 = "",};
const Block = struct { _type: []const u8 = "block", style: []const u8 = "normal", listItem: ?[]const u8 = null, level: u8 = 1, children: []const Span = &.{}, markDefs: []const MarkDef = &.{},
fn quoted(b: Block) bool { return mem.eql(u8, b.style, "blockquote"); }
/// How deep a quote it is: its level, or 0 when it is none. fn depth(b: Block) u8 { return if (b.quoted()) @max(b.level, 1) else 0; }
fn numbered(b: Block) bool { return mem.eql(u8, b.listItem.?, "number"); }};
/// `text` as Portable Text: a block a run of lines with no empty one/// between, those starting with ">" a quote as deep as their marks.pub fn fromText(w: *Writer, text: []const u8) !void { try w.writeByte('['); var lines = mem.splitScalar(u8, text, '\n'); var i: u32 = 0; var next: ?[]const u8 = lines.next(); while (next) |first| : (i += 1) { const depth = quoteDepth(first); if (i > 0) try w.writeByte(','); try w.print("{{\"_type\":\"block\",\"_key\":\"l{d}\",\"style\":\"{s}\",\"markDefs\":[],", .{ i, if (depth > 0) "blockquote" else "normal", }); if (depth > 1) try w.print("\"level\":{d},", .{depth}); try w.print("\"children\":[{{\"_type\":\"span\",\"_key\":\"l{d}s\",\"marks\":[],\"text\":\"", .{i}); var line = first; while (true) { try json.Stringify.encodeJsonStringChars(unquote(line), .{}, w); next = lines.next(); // An empty line is a block of its own, as is what follows it. if (mem.trimEnd(u8, line, "\r").len == 0) break; const more = next orelse break; if (mem.trimEnd(u8, more, "\r").len == 0 or quoteDepth(more) != depth) break; try w.writeAll("\\n"); line = more; } try w.writeAll("\"}]}"); } try w.writeByte(']');}
/// The most a block's quote goes, as siv's editor holds it.const max_depth = 8;
/// How many quote marks start `line`: ">> " and "> > " are two.fn quoteDepth(line: []const u8) u8 { var depth: u8 = 0; var rest = line; while (rest.len > 0 and rest[0] == '>') { depth += 1; rest = rest[1..]; if (rest.len > 1 and rest[0] == ' ' and rest[1] == '>') rest = rest[1..]; } return @min(depth, max_depth);}
/// A line less its carriage return and its quote marks, with the space/// after the last.fn unquote(raw: []const u8) []const u8 { var line = mem.trimEnd(u8, raw, "\r"); var marks = quoteDepth(line); while (marks > 0 and line.len > 0) { if (line[0] == '>') marks -= 1; line = line[1..]; } return if (mem.startsWith(u8, line, " ")) line[1..] else line;}
/// The document as text, a line a block: a quote's lines after "> ", a/// list item's after its marker, and a link's address after its words/// when they are not it.pub fn toText(arena: Allocator, document: []const u8) ![]const u8 { const blocks = try parse(arena, document); var out: Writer.Allocating = .init(arena); const w = &out.writer; // Each level's count, and whether its list is numbered. var numbers: [9]u32 = @splat(0); var kinds: [9]bool = @splat(false); for (blocks, 0..) |b, i| { if (i > 0) try w.writeByte('\n'); try w.splatByteAll('>', b.depth()); if (b.listItem) |_| { const level = @min(b.level, numbers.len - 1); for (1..level) |_| try w.writeAll(" "); if (kinds[level] != b.numbered()) numbers[level] = 0; kinds[level] = b.numbered(); numbers[level] += 1; @memset(numbers[level + 1 ..], 0); if (b.numbered()) try w.print("{d}. ", .{numbers[level]}) else try w.writeAll("- "); } else @memset(&numbers, 0); // Lines broken inside a block keep the block's quote: "> " before // what they say, ">" alone when empty. var line_start = true; for (b.children) |s| { var parts = mem.splitScalar(u8, s.text, '\n'); var first = true; while (parts.next()) |part| : (first = false) { if (!first) { try w.writeByte('\n'); try w.splatByteAll('>', b.depth()); line_start = true; } if (part.len == 0) continue; if (b.quoted() and line_start) try w.writeByte(' '); line_start = false; try w.writeAll(part); } if (link(b, s)) |href| if (!mem.eql(u8, href, s.text)) try w.print(" <{s}>", .{href}); } } return out.written();}
/// The document as HTML, as mail clients write it: a div a line, an empty/// one holding a break, quotes and lists around their blocks, and marks/// and links around their spans.pub fn toHtml(arena: Allocator, document: []const u8) ![]const u8 { const blocks = try parse(arena, document); var out: Writer.Allocating = .init(arena); const w = &out.writer; try w.writeAll("<div dir=\"auto\">"); // The lists open, innermost last, and how deep the quotes are. var open: [9]bool = undefined; var depth: u8 = 0; var quotes: u8 = 0; for (blocks) |b| { const level: u8 = if (b.listItem != null) @min(b.level, open.len) else 0; while (depth > level or (depth > 0 and depth == level and open[depth - 1] != b.numbered())) { depth -= 1; try w.writeAll(if (open[depth]) "</ol>" else "</ul>"); } while (quotes > b.depth()) : (quotes -= 1) try w.writeAll("</blockquote>"); while (quotes < b.depth()) : (quotes += 1) try w.writeAll( \\<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex"> ); while (depth < level) : (depth += 1) { open[depth] = b.numbered(); try w.writeAll(if (open[depth]) "<ol>" else "<ul>"); } try w.writeAll(if (level > 0) "<li>" else "<div>"); if (plainLength(b) == 0) try w.writeAll("<br>"); for (b.children) |s| try span(w, b, s); try w.writeAll(if (level > 0) "</li>" else "</div>"); } while (depth > 0) { depth -= 1; try w.writeAll(if (open[depth]) "</ol>" else "</ul>"); } try w.splatBytesAll("</blockquote>", quotes); try w.writeAll("</div>"); return out.written();}
/// Whether the document says more than its text does: a mark, a link, or/// a list. A quote alone its text says, with "> ".pub fn formatted(arena: Allocator, document: []const u8) !bool { for (try parse(arena, document)) |b| { if (b.listItem != null) return true; for (b.children) |s| if (s.marks.len > 0) return true; } return false;}
/// The text blocks of `document`; others, such as code, have no place in/// mail compose writes.fn parse(arena: Allocator, document: []const u8) ![]const Block { const all = try json.parseFromSliceLeaky([]const Block, arena, document, .{ .ignore_unknown_fields = true }); var blocks: std.ArrayList(Block) = try .initCapacity(arena, all.len); for (all) |b| if (mem.eql(u8, b._type, "block")) blocks.appendAssumeCapacity(b); return blocks.items;}
fn plainLength(b: Block) usize { var n: usize = 0; for (b.children) |s| n += s.text.len; return n;}
/// Where a span links to, if a mark of it is a link's.fn link(b: Block, s: Span) ?[]const u8 { for (s.marks) |m| for (b.markDefs) |d| { if (mem.eql(u8, d._key, m) and mem.eql(u8, d._type, "link")) return d.href; }; return null;}
const tags = std.StaticStringMap([]const u8).initComptime(.{ .{ "strong", "strong" }, .{ "em", "em" }, .{ "underline", "u" }, .{ "strike-through", "s" }, .{ "code", "code" },});
fn span(w: *Writer, b: Block, s: Span) !void { const href = link(b, s); if (href) |h| { try w.writeAll("<a href=\""); try escape(w, h); try w.writeAll("\">"); } for (s.marks) |m| if (tags.get(m)) |t| try w.print("<{s}>", .{t}); var parts = mem.splitScalar(u8, s.text, '\n'); var first = true; while (parts.next()) |part| : (first = false) { if (!first) try w.writeAll("<br>"); try escape(w, part); } var i = s.marks.len; while (i > 0) { i -= 1; if (tags.get(s.marks[i])) |t| try w.print("</{s}>", .{t}); } if (href != null) try w.writeAll("</a>");}
fn escape(w: *Writer, text: []const u8) !void { for (text) |c| try switch (c) { '&' => w.writeAll("&"), '<' => w.writeAll("<"), '>' => w.writeAll(">"), '"' => w.writeAll("""), else => w.writeByte(c), };}
test "text goes to blocks and back: runs of lines a block, a quote as deep as its marks" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = "\n\n-- \nTim \"T\"\n\nOn Thu, Ana wrote:\n> Lunch?\n>\n> Soon\n>\n" ++ ">> Earlier\n>>> First\n> Later\n"; var out: Writer.Allocating = .init(arena); try fromText(&out.writer, text); const blocks = try parse(arena, out.written()); try testing.expectEqual(10, blocks.len); try testing.expectEqualStrings("-- \nTim \"T\"", blocks[2].children[0].text); const depths = [_]u8{ 0, 0, 0, 0, 0, 1, 2, 3, 1, 0 }; for (blocks, depths) |b, d| try testing.expectEqual(d, b.depth()); try testing.expectEqualStrings("Lunch?\n\nSoon\n", blocks[5].children[0].text); try testing.expectEqualStrings("Earlier", blocks[6].children[0].text); try testing.expectEqualStrings(text, try toText(arena, out.written()));}
test "quote marks with spaces between are as deep as without, and a '>' in the text is text" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var out: Writer.Allocating = .init(arena); try fromText(&out.writer, "> > a > b\n>> c"); const blocks = try parse(arena, out.written()); try testing.expectEqual(1, blocks.len); try testing.expectEqual(2, blocks[0].depth()); try testing.expectEqualStrings("a > b\nc", blocks[0].children[0].text); try testing.expectEqualStrings(">> a > b\n>> c", try toText(arena, out.written()));}
const sample = \\[{"_type":"block","style":"normal", \\ "markDefs":[{"_key":"k1","_type":"link","href":"https://example.org/?a=1&b=2"}], \\ "children":[{"_type":"span","text":"Hi ","marks":[]}, \\ {"_type":"span","text":"Ana","marks":["strong","em"]}, \\ {"_type":"span","text":", see ","marks":[]},{"_type":"span","text":"the plan","marks":["k1"]}]}, \\ {"_type":"block","style":"normal","listItem":"bullet","level":1, \\ "children":[{"_type":"span","text":"one","marks":[]}]}, \\ {"_type":"block","style":"normal","listItem":"bullet","level":2, \\ "children":[{"_type":"span","text":"two < three","marks":[]}]}, \\ {"_type":"block","style":"normal","listItem":"number","level":1, \\ "children":[{"_type":"span","text":"first","marks":[]}]}, \\ {"_type":"block","style":"normal","listItem":"number","level":1, \\ "children":[{"_type":"span","text":"second","marks":[]}]}, \\ {"_type":"block","style":"normal","children":[]}, \\ {"_type":"code","code":"left out"}, \\ {"_type":"block","style":"blockquote","children":[{"_type":"span","text":"Said\nthis","marks":[]}]}, \\ {"_type":"block","style":"blockquote","level":2,"children":[{"_type":"span","text":"Before","marks":[]}]}];
test "a document as text: list markers and numbers, a link's address, quotes on each line" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try testing.expectEqualStrings( "Hi Ana, see the plan <https://example.org/?a=1&b=2>\n- one\n - two < three\n" ++ "1. first\n2. second\n\n> Said\n> this\n>> Before", try toText(arena_state.allocator(), sample), );}
test "a document as HTML: nested lists closed in order, marks nested, text and addresses escaped" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try testing.expectEqualStrings( "<div dir=\"auto\"><div>Hi <strong><em>Ana</em></strong>, see " ++ "<a href=\"https://example.org/?a=1&b=2\">the plan</a></div>" ++ "<ul><li>one</li><ul><li>two < three</li></ul></ul><ol><li>first</li><li>second</li></ol>" ++ "<div><br></div>" ++ "<blockquote style=\"margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex\">" ++ "<div>Said<br>this</div>" ++ "<blockquote style=\"margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex\">" ++ "<div>Before</div></blockquote></blockquote></div>", try toHtml(arena_state.allocator(), sample), );}