aboutsummaryrefslogtreecommitdiffstats
path: root/src/html.zig
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-05-28 23:27:10 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-05-28 23:27:10 +0200
commit1eaf6a9d883a47444d0d68f4ab88bfba62c321d0 (patch)
treea4ff9ee96580c8173620b1419537308367ebd399 /src/html.zig
downloadziglang-docs-epub-1eaf6a9d883a47444d0d68f4ab88bfba62c321d0.tar.gz
ziglang-docs-epub-1eaf6a9d883a47444d0d68f4ab88bfba62c321d0.zip
feat: add ziglang-docs-epub tool
Diffstat (limited to 'src/html.zig')
-rw-r--r--src/html.zig309
1 files changed, 309 insertions, 0 deletions
diff --git a/src/html.zig b/src/html.zig
new file mode 100644
index 0000000..56c5d91
--- /dev/null
+++ b/src/html.zig
@@ -0,0 +1,309 @@
+//! Transform the Zig docs single-page HTML5 into well-formed XHTML5 for EPUB.
+//!
+//! The page is self-contained (embedded <style>, inline base64 icons, in-page TOC,
+//! entity-escaped code). The only well-formedness work needed is:
+//! 1. replace the leading `<!DOCTYPE html>` with an XML declaration,
+//! 2. ensure the <html> element carries the XHTML namespace,
+//! 3. strip <script>...</script> blocks (non-functional in EPUB, aids validation),
+//! 4. normalize HTML named entities (e.g. `&mdash;`) to numeric references,
+//! since XML predefines only `&amp; &lt; &gt; &quot; &apos;`,
+//! 5. self-close void elements (`<meta>` -> `<meta/>`, etc.).
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+const xml_decl = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
+const xhtml_ns = "http://www.w3.org/1999/xhtml";
+
+/// HTML void elements: never have children, must be self-closed in XHTML.
+const void_elements = [_][]const u8{
+ "meta", "link", "br", "hr", "img", "col", "input", "source", "area", "base", "wbr", "track", "param",
+};
+
+/// Transform `page` (HTML5) into well-formed XHTML5. Caller owns the result.
+pub fn toXhtml(allocator: std.mem.Allocator, page: []const u8) ![]u8 {
+ assert(page.len > 0);
+
+ const no_doctype = stripDoctype(page);
+ const no_scripts = try stripScripts(allocator, no_doctype);
+ defer allocator.free(no_scripts);
+
+ const escaped = try escapeEntities(allocator, no_scripts);
+ defer allocator.free(escaped);
+
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ defer out.deinit();
+ try out.writer.writeAll(xml_decl);
+ try writeWithFixups(&out.writer, escaped);
+
+ const result = try out.toOwnedSlice();
+ assert(std.mem.startsWith(u8, result, "<?xml"));
+ return result;
+}
+
+/// Drop a leading `<!doctype html>` (case-insensitive), preserving the rest.
+fn stripDoctype(page: []const u8) []const u8 {
+ const start = std.mem.indexOfNone(u8, page, " \t\r\n") orelse 0;
+ const rest = page[start..];
+ if (rest.len >= 9 and std.ascii.eqlIgnoreCase(rest[0..9], "<!doctype")) {
+ const end = std.mem.indexOfScalar(u8, rest, '>') orelse return rest;
+ return rest[end + 1 ..];
+ }
+ return page;
+}
+
+/// Remove every `<script ...>...</script>` block. Caller owns the result.
+fn stripScripts(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ defer out.deinit();
+
+ var i: usize = 0;
+ var guard: usize = 0;
+ while (i < html.len) {
+ guard += 1;
+ assert(guard <= html.len + 1);
+
+ const open = findTagOpen(html, i, "script") orelse {
+ try out.writer.writeAll(html[i..]);
+ break;
+ };
+ try out.writer.writeAll(html[i..open]);
+
+ const close = findClose(html, open, "</script>") orelse {
+ // Unterminated script: drop the remainder rather than emit broken markup.
+ break;
+ };
+ i = close;
+ }
+ return out.toOwnedSlice();
+}
+
+/// XML predefines only these five entities; everything else must be numeric.
+const xml_entities = [_][]const u8{ "amp", "lt", "gt", "quot", "apos" };
+
+/// Common HTML named entities → Unicode code point. Not exhaustive; any name not
+/// listed (and not XML-predefined or numeric) has its `&` escaped to `&amp;` so
+/// output stays well-formed regardless of input.
+const named_entities = std.StaticStringMap(u21).initComptime(.{
+ .{ "nbsp", 0x00A0 }, .{ "copy", 0x00A9 }, .{ "reg", 0x00AE }, .{ "trade", 0x2122 },
+ .{ "mdash", 0x2014 }, .{ "ndash", 0x2013 }, .{ "hellip", 0x2026 }, .{ "deg", 0x00B0 },
+ .{ "plusmn", 0x00B1 }, .{ "times", 0x00D7 }, .{ "divide", 0x00F7 }, .{ "micro", 0x00B5 },
+ .{ "middot", 0x00B7 }, .{ "bull", 0x2022 }, .{ "dagger", 0x2020 }, .{ "sect", 0x00A7 },
+ .{ "para", 0x00B6 }, .{ "laquo", 0x00AB }, .{ "raquo", 0x00BB }, .{ "lsquo", 0x2018 },
+ .{ "rsquo", 0x2019 }, .{ "ldquo", 0x201C }, .{ "rdquo", 0x201D }, .{ "larr", 0x2190 },
+ .{ "rarr", 0x2192 }, .{ "uarr", 0x2191 }, .{ "darr", 0x2193 }, .{ "harr", 0x2194 },
+ .{ "le", 0x2264 }, .{ "ge", 0x2265 }, .{ "ne", 0x2260 }, .{ "infin", 0x221E },
+ .{ "sum", 0x2211 }, .{ "prod", 0x220F }, .{ "radic", 0x221A }, .{ "asymp", 0x2248 },
+ .{ "equiv", 0x2261 }, .{ "frac12", 0x00BD }, .{ "frac14", 0x00BC }, .{ "frac34", 0x00BE },
+ .{ "euro", 0x20AC }, .{ "pound", 0x00A3 }, .{ "yen", 0x00A5 }, .{ "cent", 0x00A2 },
+});
+
+/// Longest entity name we will look ahead for (e.g. "frac34").
+const max_entity_name = 8;
+
+/// Rewrite `&...;` tokens so the output is XML well-formed. Caller owns the result.
+fn escapeEntities(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ defer out.deinit();
+
+ var i: usize = 0;
+ while (i < html.len) {
+ const amp = std.mem.indexOfScalarPos(u8, html, i, '&') orelse {
+ try out.writer.writeAll(html[i..]);
+ break;
+ };
+ try out.writer.writeAll(html[i..amp]);
+
+ if (entityToken(html, amp)) |tok| {
+ if (isKeptEntity(tok)) {
+ try out.writer.writeAll(html[amp .. amp + tok.len + 2]); // include & and ;
+ } else if (named_entities.get(tok)) |cp| {
+ try out.writer.print("&#{d};", .{cp});
+ } else {
+ try out.writer.writeAll("&amp;");
+ i = amp + 1;
+ continue;
+ }
+ i = amp + tok.len + 2;
+ } else {
+ // Bare '&' not forming an entity reference.
+ try out.writer.writeAll("&amp;");
+ i = amp + 1;
+ }
+ }
+ return out.toOwnedSlice();
+}
+
+/// If an `&...;` reference starts at `amp`, return the name between `&` and `;`.
+fn entityToken(html: []const u8, amp: usize) ?[]const u8 {
+ assert(html[amp] == '&');
+ const max = @min(html.len, amp + 2 + max_entity_name);
+ const semi = std.mem.indexOfScalarPos(u8, html[0..max], amp + 1, ';') orelse return null;
+ const tok = html[amp + 1 .. semi];
+ if (tok.len == 0) return null;
+ return tok;
+}
+
+/// True for entities XML keeps verbatim: the predefined five and numeric refs.
+fn isKeptEntity(tok: []const u8) bool {
+ if (tok[0] == '#') {
+ const digits = tok[1..];
+ if (digits.len == 0) return false;
+ const hex = digits[0] == 'x' or digits[0] == 'X';
+ const rest = if (hex) digits[1..] else digits;
+ if (rest.len == 0) return false;
+ for (rest) |c| {
+ const ok = if (hex) std.ascii.isHex(c) else std.ascii.isDigit(c);
+ if (!ok) return false;
+ }
+ return true;
+ }
+ for (xml_entities) |e| {
+ if (std.mem.eql(u8, tok, e)) return true;
+ }
+ return false;
+}
+
+/// Copy `html` to `w`, adding the XHTML namespace to <html> and self-closing
+/// void elements as they are encountered.
+fn writeWithFixups(w: *std.Io.Writer, html: []const u8) !void {
+ var i: usize = 0;
+ while (i < html.len) {
+ const lt = std.mem.indexOfScalarPos(u8, html, i, '<') orelse {
+ try w.writeAll(html[i..]);
+ return;
+ };
+ try w.writeAll(html[i..lt]);
+
+ const gt = std.mem.indexOfScalarPos(u8, html, lt, '>') orelse {
+ try w.writeAll(html[lt..]);
+ return;
+ };
+ const tag = html[lt .. gt + 1]; // includes < and >
+ try writeTag(w, tag);
+ i = gt + 1;
+ }
+}
+
+/// Emit a single tag (`<...>`), applying namespace/void-element fixups.
+fn writeTag(w: *std.Io.Writer, tag: []const u8) !void {
+ assert(tag.len >= 2);
+ assert(tag[0] == '<' and tag[tag.len - 1] == '>');
+
+ const name = tagName(tag);
+ const is_closing = tag.len >= 2 and tag[1] == '/';
+
+ if (!is_closing and std.ascii.eqlIgnoreCase(name, "html") and std.mem.indexOf(u8, tag, "xmlns") == null) {
+ try w.print("<html xmlns=\"{s}\"", .{xhtml_ns});
+ try w.writeAll(tag[1 + name.len ..]);
+ return;
+ }
+
+ if (isVoid(name) and !std.mem.endsWith(u8, tag, "/>")) {
+ try w.writeAll(tag[0 .. tag.len - 1]);
+ try w.writeAll("/>");
+ return;
+ }
+
+ try w.writeAll(tag);
+}
+
+/// Extract the element name from a tag slice (without `<`, `>`, `/`, attrs).
+fn tagName(tag: []const u8) []const u8 {
+ var s: usize = 1; // skip '<'
+ if (s < tag.len and tag[s] == '/') s += 1;
+ var e = s;
+ while (e < tag.len and isNameChar(tag[e])) e += 1;
+ return tag[s..e];
+}
+
+fn isNameChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or c == '-' or c == ':';
+}
+
+fn isVoid(name: []const u8) bool {
+ for (void_elements) |v| {
+ if (std.ascii.eqlIgnoreCase(name, v)) return true;
+ }
+ return false;
+}
+
+/// Find the next `<name` opening tag at or after `from`, returning the `<` index.
+fn findTagOpen(html: []const u8, from: usize, name: []const u8) ?usize {
+ var i = from;
+ while (std.mem.indexOfScalarPos(u8, html, i, '<')) |lt| {
+ const after = lt + 1;
+ if (after + name.len <= html.len and
+ std.ascii.eqlIgnoreCase(html[after .. after + name.len], name))
+ {
+ const next = html[after + name.len];
+ if (next == '>' or next == ' ' or next == '\t' or next == '\n' or next == '/') return lt;
+ }
+ i = lt + 1;
+ }
+ return null;
+}
+
+/// Find the index just past a closing tag (e.g. `</script>`) at or after `from`.
+fn findClose(html: []const u8, from: usize, close: []const u8) ?usize {
+ const idx = std.ascii.indexOfIgnoreCasePos(html, from, close) orelse return null;
+ return idx + close.len;
+}
+
+test "strips doctype and adds xml declaration" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<!DOCTYPE html>\n<html><body>x</body></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.startsWith(u8, out, "<?xml"));
+ try std.testing.expect(std.mem.indexOf(u8, out, "<!DOCTYPE") == null);
+}
+
+test "adds xhtml namespace to html element" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html lang=\"en\"><head></head></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "xmlns=\"http://www.w3.org/1999/xhtml\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "lang=\"en\"") != null);
+ // The closing tag must be left intact (not rewritten with a namespace).
+ try std.testing.expect(std.mem.endsWith(u8, out, "</html>"));
+ try std.testing.expect(std.mem.indexOf(u8, out, "xhtml\"l>") == null);
+}
+
+test "self-closes void elements" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><head><meta charset=\"utf-8\"><br></head></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"/>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "<br/>") != null);
+}
+
+test "strips script blocks" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><body>a<script>var x = 1 < 2;</script>b</body></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "<script") == null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "var x") == null);
+ try std.testing.expect(std.mem.indexOf(u8, out, ">a") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "b<") != null);
+}
+
+test "normalizes named entities and escapes stray ampersands" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><body>a&mdash;b &amp; c&nbsp;d R&D &lt;x&gt; &#8212; &unknownent;</body></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "&mdash;") == null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "&#8212;") != null); // mdash -> numeric
+ try std.testing.expect(std.mem.indexOf(u8, out, "&#160;") != null); // nbsp -> numeric
+ try std.testing.expect(std.mem.indexOf(u8, out, "R&amp;D") != null); // stray & escaped
+ try std.testing.expect(std.mem.indexOf(u8, out, "&amp;unknownent;") != null); // unknown name escaped
+ try std.testing.expect(std.mem.indexOf(u8, out, "&amp; c") != null); // existing &amp; kept (not double-escaped)
+ try std.testing.expect(std.mem.indexOf(u8, out, "&lt;x&gt;") != null);
+}
+
+test "leaves already self-closed void elements unchanged" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><head><meta charset=\"utf-8\"/></head></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"//>") == null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"/>") != null);
+}