//! 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("
");
// 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]) "" else "");
}
while (quotes > b.depth()) : (quotes -= 1) try w.writeAll("");
while (quotes < b.depth()) : (quotes += 1) try w.writeAll(
\\
);
while (depth < level) : (depth += 1) {
open[depth] = b.numbered();
try w.writeAll(if (open[depth]) "" else "
" else "");
}
try w.splatBytesAll("
", quotes);
try w.writeAll("
");
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("");
}
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("
");
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("");
}
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 \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(
"" ++
"
- first
- second
" ++
"
" ++
"
" ++
"Said
this
" ++
"" ++
"Before
",
try toHtml(arena_state.allocator(), sample),
);
}