diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-05-28 23:54:24 +0200 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-05-28 23:54:24 +0200 |
| commit | 330a0c896970d99320529a47f186c831a6ecc4a0 (patch) | |
| tree | 3dc8b3ac700a6e2715f0f81b96e7957320f6352e /src/html.zig | |
| parent | b2d7d90add48e5cd3b644949b9b401fafc6eafbc (diff) | |
| download | ziglang-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.zig | 305 |
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. `—`) 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. `—`) to numeric references, //! since XML predefines only `& < > " '`, -//! 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, "<x>") != 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 > 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>"); |
