aboutsummaryrefslogtreecommitdiffstats
path: root/src/html.zig
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-05-28 23:54:24 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-05-28 23:54:24 +0200
commit330a0c896970d99320529a47f186c831a6ecc4a0 (patch)
tree3dc8b3ac700a6e2715f0f81b96e7957320f6352e /src/html.zig
parentb2d7d90add48e5cd3b644949b9b401fafc6eafbc (diff)
downloadziglang-docs-epub-330a0c896970d99320529a47f186c831a6ecc4a0.tar.gz
ziglang-docs-epub-330a0c896970d99320529a47f186c831a6ecc4a0.zip
feat: add table of content instead of sidebar
Diffstat (limited to 'src/html.zig')
-rw-r--r--src/html.zig305
1 files changed, 292 insertions, 13 deletions
diff --git a/src/html.zig b/src/html.zig
index 56c5d91..ec9ecfd 100644
--- a/src/html.zig
+++ b/src/html.zig
@@ -1,13 +1,17 @@
//! 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,
+//! entity-escaped code). The transform:
+//! 1. replaces the leading `<!DOCTYPE html>` with an XML declaration,
+//! 2. ensures the <html> element carries the XHTML namespace,
+//! 3. strips <script>...</script> blocks (non-functional in EPUB, aids validation),
+//! 4. normalizes 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.).
+//! 5. removes invalid control characters,
+//! 6. extracts the in-page table of contents for use as the EPUB navigation,
+//! 7. removes the `#navigation` sidebar (it is `position: fixed`, so it bleeds
+//! onto every page, and the version-switcher dropdown is dead in an EPUB),
+//! 8. self-closes void elements (`<meta>` -> `<meta/>`, etc.).
const std = @import("std");
const assert = std.debug.assert;
@@ -15,30 +19,155 @@ 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";
+/// File name of the content document the TOC links point into.
+const content_href = "index.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 {
+/// Result of transforming a docs page: the content document plus, when present,
+/// the table-of-contents list extracted for the EPUB navigation document.
+pub const Document = struct {
+ allocator: std.mem.Allocator,
+ /// Well-formed XHTML5 content, with the fixed sidebar removed.
+ content: []u8,
+ /// `<ol>...</ol>` of TOC entries (links rewritten to `index.xhtml#...`), or
+ /// null if the page had no recognizable table of contents.
+ toc: ?[]u8,
+
+ pub fn deinit(self: *Document) void {
+ self.allocator.free(self.content);
+ if (self.toc) |t| self.allocator.free(t);
+ }
+};
+
+/// Transform `page` (HTML5) into an EPUB-ready `Document`. Caller owns it.
+pub fn transform(allocator: std.mem.Allocator, page: []const u8) !Document {
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);
+ const clean = try stripControlChars(allocator, no_scripts);
+ defer allocator.free(clean);
+
+ const escaped = try escapeEntities(allocator, clean);
defer allocator.free(escaped);
+ const toc = try extractToc(allocator, escaped);
+ errdefer if (toc) |t| allocator.free(t);
+
+ const no_sidebar = try removeNavigation(allocator, escaped);
+ defer allocator.free(no_sidebar);
+
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
try out.writer.writeAll(xml_decl);
- try writeWithFixups(&out.writer, escaped);
+ try writeWithFixups(&out.writer, no_sidebar);
+
+ const content = try out.toOwnedSlice();
+ assert(std.mem.startsWith(u8, content, "<?xml"));
+ return .{ .allocator = allocator, .content = content, .toc = toc };
+}
+
+/// Convenience wrapper returning only the content document. Caller owns it.
+pub fn toXhtml(allocator: std.mem.Allocator, page: []const u8) ![]u8 {
+ const doc = try transform(allocator, page);
+ defer if (doc.toc) |t| allocator.free(t);
+ return doc.content;
+}
+
+/// Extract the in-page table of contents (the `<ul>` inside the
+/// `table-of-contents` nav) as an EPUB `<ol>` with links rewritten to point into
+/// the content document. Returns null if no such list is found. Caller owns it.
+fn extractToc(allocator: std.mem.Allocator, html: []const u8) !?[]u8 {
+ const marker = "aria-labelledby=\"table-of-contents\"";
+ const m = std.mem.indexOf(u8, html, marker) orelse return null;
+ const ul_open = findTagOpen(html, m, "ul") orelse return null;
+ const ul_end = matchingClose(html, ul_open, "ul") orelse return null;
+ return try rewriteToc(allocator, html[ul_open..ul_end]);
+}
+
+/// Rewrite an extracted TOC list: `<ul>` → `<ol>` (EPUB nav requires ordered
+/// lists) and `href="#X"` → `href="index.xhtml#X"`. Caller owns the result.
+fn rewriteToc(allocator: std.mem.Allocator, list: []const u8) ![]u8 {
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ defer out.deinit();
+
+ var i: usize = 0;
+ while (i < list.len) {
+ const lt = std.mem.indexOfScalarPos(u8, list, i, '<') orelse {
+ try out.writer.writeAll(list[i..]);
+ break;
+ };
+ try out.writer.writeAll(list[i..lt]);
+ const gt = tagEnd(list, lt) orelse return error.MalformedToc;
+ const tag = list[lt .. gt + 1];
+ try writeTocTag(&out.writer, tag);
+ i = gt + 1;
+ }
+ return out.toOwnedSlice();
+}
+
+fn writeTocTag(w: *std.Io.Writer, tag: []const u8) !void {
+ const closing = tag[1] == '/';
+ const name = tagName(tag);
+
+ if (std.ascii.eqlIgnoreCase(name, "ul")) {
+ try w.writeAll(if (closing) "</ol" else "<ol");
+ try w.writeAll(tag[1 + @as(usize, if (closing) 1 else 0) + name.len ..]);
+ return;
+ }
+ if (!closing and std.ascii.eqlIgnoreCase(name, "a")) {
+ if (std.mem.indexOf(u8, tag, "href=\"#")) |p| {
+ try w.writeAll(tag[0 .. p + 6]); // through the opening quote
+ try w.writeAll(content_href);
+ try w.writeAll(tag[p + 6 ..]); // from '#' onward
+ return;
+ }
+ }
+ try w.writeAll(tag);
+}
+
+/// Remove the `<div id="navigation">…</div>` sidebar. If absent, returns an
+/// unmodified copy. Caller owns the result.
+fn removeNavigation(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ const m = std.mem.indexOf(u8, html, "id=\"navigation\"") orelse
+ return allocator.dupe(u8, html);
+ const div_open = std.mem.lastIndexOfScalar(u8, html[0..m], '<') orelse
+ return allocator.dupe(u8, html);
+ const div_end = matchingClose(html, div_open, "div") orelse
+ return allocator.dupe(u8, html);
- const result = try out.toOwnedSlice();
- assert(std.mem.startsWith(u8, result, "<?xml"));
- return result;
+ var out = try allocator.alloc(u8, html.len - (div_end - div_open));
+ @memcpy(out[0..div_open], html[0..div_open]);
+ @memcpy(out[div_open..], html[div_end..]);
+ return out;
+}
+
+/// Index just past the `</name>` that closes the element opened at `open_lt`,
+/// accounting for nested elements of the same name. Null if unbalanced.
+fn matchingClose(html: []const u8, open_lt: usize, name: []const u8) ?usize {
+ var i = open_lt;
+ var depth: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, html, i, '<')) |lt| {
+ const gt = tagEnd(html, lt) orelse return null;
+ const tag = html[lt .. gt + 1];
+ if (std.ascii.eqlIgnoreCase(tagName(tag), name)) {
+ if (tag[1] == '/') {
+ if (depth == 0) return null;
+ depth -= 1;
+ if (depth == 0) return gt + 1;
+ } else if (!std.mem.endsWith(u8, tag, "/>")) {
+ depth += 1;
+ }
+ }
+ i = gt + 1;
+ }
+ return null;
}
/// Drop a leading `<!doctype html>` (case-insensitive), preserving the rest.
@@ -78,6 +207,93 @@ fn stripScripts(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
return out.toOwnedSlice();
}
+/// Maximum element nesting depth we will validate (NASA Power-of-10: bounded).
+const max_depth = 256;
+
+/// Lightweight check that `xhtml` is well-formed: every element tag is balanced
+/// and properly nested. This is not a full XML parser, but it catches the
+/// structural breakage (unclosed/mis-nested tags) present in some older Zig docs
+/// pages, letting the caller skip versions that would not be valid EPUB3.
+///
+/// Relies on our transform guarantees: scripts stripped, void elements
+/// self-closed, code samples entity-escaped (so no stray `<` in text).
+pub fn isWellFormed(xhtml: []const u8) bool {
+ var stack: [max_depth][]const u8 = undefined;
+ var depth: usize = 0;
+
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, xhtml, i, '<')) |lt| {
+ // Markup we skip wholesale: processing instructions, comments, declarations.
+ if (startsAt(xhtml, lt, "<?")) {
+ i = (std.mem.indexOfPos(u8, xhtml, lt, "?>") orelse return false) + 2;
+ continue;
+ }
+ if (startsAt(xhtml, lt, "<!--")) {
+ i = (std.mem.indexOfPos(u8, xhtml, lt, "-->") orelse return false) + 3;
+ continue;
+ }
+ if (startsAt(xhtml, lt, "<!")) {
+ i = (tagEnd(xhtml, lt) orelse return false) + 1;
+ continue;
+ }
+
+ const gt = tagEnd(xhtml, lt) orelse return false;
+ const tag = xhtml[lt .. gt + 1];
+ i = gt + 1;
+
+ const name = tagName(tag);
+ if (name.len == 0) return false;
+
+ if (tag[1] == '/') {
+ if (depth == 0 or !std.mem.eql(u8, stack[depth - 1], name)) return false;
+ depth -= 1;
+ } else if (std.mem.endsWith(u8, tag, "/>") or isVoid(name)) {
+ // Self-contained element; nothing to balance.
+ } else {
+ if (depth >= max_depth) return false;
+ stack[depth] = name;
+ depth += 1;
+ }
+ }
+ return depth == 0;
+}
+
+fn startsAt(s: []const u8, at: usize, prefix: []const u8) bool {
+ return at + prefix.len <= s.len and std.mem.eql(u8, s[at .. at + prefix.len], prefix);
+}
+
+/// Index of the `>` closing the tag opened at `lt`, ignoring `>` inside quotes.
+fn tagEnd(s: []const u8, lt: usize) ?usize {
+ var i = lt + 1;
+ var quote: u8 = 0;
+ while (i < s.len) : (i += 1) {
+ const c = s[i];
+ if (quote != 0) {
+ if (c == quote) quote = 0;
+ } else switch (c) {
+ '"', '\'' => quote = c,
+ '>' => return i,
+ else => {},
+ }
+ }
+ return null;
+}
+
+/// Remove characters XML 1.0 forbids: C0 controls except tab/newline/return.
+/// Some older docs embed raw terminal escape codes (e.g. `\x1b[0K`) in captured
+/// code samples, which are invalid in any XML document. Caller owns the result.
+fn stripControlChars(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ try out.ensureTotalCapacity(allocator, html.len);
+
+ for (html) |c| {
+ const forbidden = c < 0x20 and c != '\t' and c != '\n' and c != '\r';
+ if (!forbidden) out.appendAssumeCapacity(c);
+ }
+ return out.toOwnedSlice(allocator);
+}
+
/// XML predefines only these five entities; everything else must be numeric.
const xml_entities = [_][]const u8{ "amp", "lt", "gt", "quot", "apos" };
@@ -300,6 +516,69 @@ test "normalizes named entities and escapes stray ampersands" {
try std.testing.expect(std.mem.indexOf(u8, out, "&lt;x&gt;") != null);
}
+test "strips XML-invalid control characters but keeps whitespace" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><body>a\x1b[0Kb\tc\nd</body></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOfScalar(u8, out, 0x1b) == null); // ESC removed
+ try std.testing.expect(std.mem.indexOf(u8, out, "a[0Kb") != null); // only the control byte dropped
+ try std.testing.expect(std.mem.indexOf(u8, out, "c\nd") != null); // tab/newline preserved
+}
+
+test "transform extracts TOC and removes the navigation sidebar" {
+ const gpa = std.testing.allocator;
+ const page =
+ "<!DOCTYPE html><html><body><h1>Title</h1>" ++
+ "<div id=\"main-wrapper\">" ++
+ "<div id=\"navigation\">" ++
+ "<nav aria-labelledby=\"zig-version\"><h2 id=\"zig-version\">0.x</h2></nav>" ++
+ "<nav aria-labelledby=\"table-of-contents\"><h2 id=\"table-of-contents\">Table of Contents</h2>" ++
+ "<ul><li><a id=\"toc-Intro\" href=\"#Intro\">Intro</a>" ++
+ "<ul><li><a href=\"#Sub\">Sub</a></li></ul></li></ul>" ++
+ "</nav></div>" ++
+ "<main id=\"contents\"><h2 id=\"Intro\">Intro</h2><p>body</p></main>" ++
+ "</div></body></html>";
+
+ var doc = try transform(gpa, page);
+ defer doc.deinit();
+
+ // Sidebar (and its version dropdown) is gone from the content.
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "id=\"navigation\"") == null);
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "zig-version") == null);
+ // Content itself is preserved.
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "<main id=\"contents\">") != null);
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "<h2 id=\"Intro\">") != null);
+ try std.testing.expect(isWellFormed(doc.content));
+
+ // TOC extracted, ul->ol, links rewritten into the content document.
+ try std.testing.expect(doc.toc != null);
+ const toc = doc.toc.?;
+ try std.testing.expect(std.mem.indexOf(u8, toc, "<ol>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "<ul>") == null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "href=\"index.xhtml#Intro\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "href=\"index.xhtml#Sub\"") != null);
+}
+
+test "transform yields null TOC when no table of contents is present" {
+ const gpa = std.testing.allocator;
+ var doc = try transform(gpa, "<html><body><p>no toc here</p></body></html>");
+ defer doc.deinit();
+ try std.testing.expect(doc.toc == null);
+}
+
+test "isWellFormed accepts balanced markup and rejects mismatches" {
+ const decl = "<?xml version=\"1.0\"?>";
+ // Balanced, with self-closed void, comment, and a '>' inside an attribute.
+ try std.testing.expect(isWellFormed(decl ++ "<html><head><meta charset=\"u\"/></head>" ++
+ "<body><!-- c --><p title=\"a &gt; b\">x<br/></p></body></html>"));
+ // Unclosed <p> before </body>.
+ try std.testing.expect(!isWellFormed("<html><body><p>x</body></html>"));
+ // Stray closing tag.
+ try std.testing.expect(!isWellFormed("<html><body></span></body></html>"));
+ // Unbalanced at end of document.
+ try std.testing.expect(!isWellFormed("<html><body><div></body>"));
+}
+
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>");