aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md23
-rw-r--r--epubs/zig-0.1.1.epubbin201853 -> 0 bytes
-rw-r--r--epubs/zig-0.10.0.epubbin1130432 -> 0 bytes
-rw-r--r--epubs/zig-0.10.1.epubbin1133513 -> 0 bytes
-rw-r--r--epubs/zig-0.11.0.epubbin1081055 -> 1083315 bytes
-rw-r--r--epubs/zig-0.12.0.epubbin922795 -> 925023 bytes
-rw-r--r--epubs/zig-0.12.1.epubbin933356 -> 935649 bytes
-rw-r--r--epubs/zig-0.13.0.epubbin933399 -> 935698 bytes
-rw-r--r--epubs/zig-0.14.0.epubbin964357 -> 966701 bytes
-rw-r--r--epubs/zig-0.14.1.epubbin967595 -> 970004 bytes
-rw-r--r--epubs/zig-0.15.1.epubbin970943 -> 973287 bytes
-rw-r--r--epubs/zig-0.15.2.epubbin970121 -> 972519 bytes
-rw-r--r--epubs/zig-0.16.0.epubbin983739 -> 986269 bytes
-rw-r--r--epubs/zig-0.2.0.epubbin238448 -> 0 bytes
-rw-r--r--epubs/zig-0.3.0.epubbin525326 -> 0 bytes
-rw-r--r--epubs/zig-0.4.0.epubbin656333 -> 0 bytes
-rw-r--r--epubs/zig-0.5.0.epubbin719277 -> 0 bytes
-rw-r--r--epubs/zig-0.6.0.epubbin709449 -> 0 bytes
-rw-r--r--epubs/zig-0.7.0.epubbin763214 -> 0 bytes
-rw-r--r--epubs/zig-0.7.1.epubbin763304 -> 0 bytes
-rw-r--r--epubs/zig-0.8.0.epubbin799052 -> 0 bytes
-rw-r--r--epubs/zig-0.8.1.epubbin802163 -> 0 bytes
-rw-r--r--epubs/zig-0.9.0.epubbin1113158 -> 1115228 bytes
-rw-r--r--epubs/zig-0.9.1.epubbin1113237 -> 1115381 bytes
-rw-r--r--epubs/zig-master.epubbin967090 -> 969372 bytes
-rw-r--r--src/epub.zig27
-rw-r--r--src/html.zig305
-rw-r--r--src/main.zig15
28 files changed, 346 insertions, 24 deletions
diff --git a/README.md b/README.md
index 0a5ea18..6252224 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,8 @@ Four decoupled concerns, each a small module that does one thing:
| ------------- | --------------------------------------------------------- |
| `index.zig` | discover versions + docs URLs from `download/index.json` |
| `fetch.zig` | HTTP GET via `std.http.Client` |
-| `html.zig` | transform the page HTML5 → well-formed XHTML5 |
-| `epub.zig` | assemble the EPUB3 container |
+| `html.zig` | HTML5 → XHTML5; extract the in-page TOC; drop the sidebar |
+| `epub.zig` | assemble the EPUB3 container (TOC navigation + content) |
| `zip.zig` | minimal, reproducible ZIP writer (stored entries) |
No external runtime dependencies — Zig's standard library provides HTTP and
@@ -28,6 +28,25 @@ spec-compliant at the cost of larger files. Output is **byte-for-byte
reproducible** (fixed ZIP timestamps), so CI only commits when the upstream docs
actually change.
+### Table of contents
+
+The docs page renders its table of contents in a `position: fixed` sidebar, which
+e-readers repaint on every page. The tool extracts that TOC into a proper EPUB3
+navigation document (`nav.xhtml`): it shows in the reader's built-in Contents
+menu, is the book's opening page, and each entry jumps to the matching section.
+The original fixed sidebar (and its dead version-switcher dropdown) is removed
+from the content so it no longer bleeds across pages.
+
+### Well-formedness gate
+
+Each transformed page is checked for well-formed, balanced XHTML before
+packaging; versions that fail are **skipped** (logged as `NotWellFormed`) rather
+than shipped as invalid EPUB3. A handful of older docs pages (currently 0.1.1–0.8.1
+and 0.10.x) contain malformed upstream HTML — unclosed `<p>`/`<li>` tags and
+outright typos — that can't be repaired without a full HTML5 tree-builder. Every
+EPUB this tool produces is therefore strictly well-formed; newer versions are
+picked up automatically as they are released.
+
## Usage
Requires **Zig 0.15.2**.
diff --git a/epubs/zig-0.1.1.epub b/epubs/zig-0.1.1.epub
deleted file mode 100644
index 42c91ec..0000000
--- a/epubs/zig-0.1.1.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.10.0.epub b/epubs/zig-0.10.0.epub
deleted file mode 100644
index 49a331d..0000000
--- a/epubs/zig-0.10.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.10.1.epub b/epubs/zig-0.10.1.epub
deleted file mode 100644
index b153576..0000000
--- a/epubs/zig-0.10.1.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.11.0.epub b/epubs/zig-0.11.0.epub
index 7aecf9c..1dfc0f0 100644
--- a/epubs/zig-0.11.0.epub
+++ b/epubs/zig-0.11.0.epub
Binary files differ
diff --git a/epubs/zig-0.12.0.epub b/epubs/zig-0.12.0.epub
index c72d0d1..05bb36e 100644
--- a/epubs/zig-0.12.0.epub
+++ b/epubs/zig-0.12.0.epub
Binary files differ
diff --git a/epubs/zig-0.12.1.epub b/epubs/zig-0.12.1.epub
index d1be901..1907d2f 100644
--- a/epubs/zig-0.12.1.epub
+++ b/epubs/zig-0.12.1.epub
Binary files differ
diff --git a/epubs/zig-0.13.0.epub b/epubs/zig-0.13.0.epub
index bfd1991..98cce35 100644
--- a/epubs/zig-0.13.0.epub
+++ b/epubs/zig-0.13.0.epub
Binary files differ
diff --git a/epubs/zig-0.14.0.epub b/epubs/zig-0.14.0.epub
index 53a9137..39d7ea3 100644
--- a/epubs/zig-0.14.0.epub
+++ b/epubs/zig-0.14.0.epub
Binary files differ
diff --git a/epubs/zig-0.14.1.epub b/epubs/zig-0.14.1.epub
index a533700..1f14838 100644
--- a/epubs/zig-0.14.1.epub
+++ b/epubs/zig-0.14.1.epub
Binary files differ
diff --git a/epubs/zig-0.15.1.epub b/epubs/zig-0.15.1.epub
index 68bb208..be73887 100644
--- a/epubs/zig-0.15.1.epub
+++ b/epubs/zig-0.15.1.epub
Binary files differ
diff --git a/epubs/zig-0.15.2.epub b/epubs/zig-0.15.2.epub
index bce9cd8..f4845af 100644
--- a/epubs/zig-0.15.2.epub
+++ b/epubs/zig-0.15.2.epub
Binary files differ
diff --git a/epubs/zig-0.16.0.epub b/epubs/zig-0.16.0.epub
index 154ea4b..6490d41 100644
--- a/epubs/zig-0.16.0.epub
+++ b/epubs/zig-0.16.0.epub
Binary files differ
diff --git a/epubs/zig-0.2.0.epub b/epubs/zig-0.2.0.epub
deleted file mode 100644
index 34f1342..0000000
--- a/epubs/zig-0.2.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.3.0.epub b/epubs/zig-0.3.0.epub
deleted file mode 100644
index f34192d..0000000
--- a/epubs/zig-0.3.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.4.0.epub b/epubs/zig-0.4.0.epub
deleted file mode 100644
index 1d9afa6..0000000
--- a/epubs/zig-0.4.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.5.0.epub b/epubs/zig-0.5.0.epub
deleted file mode 100644
index 2b6ed40..0000000
--- a/epubs/zig-0.5.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.6.0.epub b/epubs/zig-0.6.0.epub
deleted file mode 100644
index 78ade26..0000000
--- a/epubs/zig-0.6.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.7.0.epub b/epubs/zig-0.7.0.epub
deleted file mode 100644
index 81e36ff..0000000
--- a/epubs/zig-0.7.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.7.1.epub b/epubs/zig-0.7.1.epub
deleted file mode 100644
index 02ccf63..0000000
--- a/epubs/zig-0.7.1.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.8.0.epub b/epubs/zig-0.8.0.epub
deleted file mode 100644
index 8105508..0000000
--- a/epubs/zig-0.8.0.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.8.1.epub b/epubs/zig-0.8.1.epub
deleted file mode 100644
index 43d73f5..0000000
--- a/epubs/zig-0.8.1.epub
+++ /dev/null
Binary files differ
diff --git a/epubs/zig-0.9.0.epub b/epubs/zig-0.9.0.epub
index 47ae0b9..514d283 100644
--- a/epubs/zig-0.9.0.epub
+++ b/epubs/zig-0.9.0.epub
Binary files differ
diff --git a/epubs/zig-0.9.1.epub b/epubs/zig-0.9.1.epub
index 212a0fd..9914fdd 100644
--- a/epubs/zig-0.9.1.epub
+++ b/epubs/zig-0.9.1.epub
Binary files differ
diff --git a/epubs/zig-master.epub b/epubs/zig-master.epub
index 4b59c34..139d828 100644
--- a/epubs/zig-master.epub
+++ b/epubs/zig-master.epub
Binary files differ
diff --git a/src/epub.zig b/src/epub.zig
index 0bdf563..7e35089 100644
--- a/src/epub.zig
+++ b/src/epub.zig
@@ -13,8 +13,11 @@ pub const Options = struct {
/// Version name, used in the title and as the unique identifier basis.
version: []const u8,
lang: []const u8 = "en",
- /// Well-formed XHTML5 body (output of html.toXhtml).
+ /// Well-formed XHTML5 body (output of html.transform).
xhtml: []const u8,
+ /// `<ol>...</ol>` of TOC entries extracted from the page, or null. When
+ /// present it becomes the navigation document and the book's opening page.
+ toc: ?[]const u8 = null,
};
/// Build an EPUB3 archive from `opts`. Caller owns the returned bytes.
@@ -74,6 +77,7 @@ fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
\\ <item id="index" href="index.xhtml" media-type="application/xhtml+xml"/>
\\ </manifest>
\\ <spine>
+ \\ <itemref idref="nav"/>
\\ <itemref idref="index"/>
\\ </spine>
\\</package>
@@ -82,21 +86,23 @@ fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
}
fn buildNav(allocator: std.mem.Allocator, opts: Options) ![]u8 {
+ // Use the page's own table of contents when we extracted one; otherwise fall
+ // back to a single link to the whole document.
+ const list = opts.toc orelse
+ "<ol>\n <li><a href=\"index.xhtml\">Contents</a></li>\n </ol>";
return std.fmt.allocPrint(allocator,
\\<?xml version="1.0" encoding="utf-8"?>
\\<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="{s}">
\\ <head><title>{s} ({s})</title></head>
\\ <body>
\\ <nav epub:type="toc" id="toc">
- \\ <h1>Contents</h1>
- \\ <ol>
- \\ <li><a href="index.xhtml">{s} ({s})</a></li>
- \\ </ol>
+ \\ <h1>{s} ({s})</h1>
+ \\ {s}
\\ </nav>
\\ </body>
\\</html>
\\
- , .{ opts.lang, opts.title, opts.version, opts.title, opts.version });
+ , .{ opts.lang, opts.title, opts.version, opts.title, opts.version, list });
}
test "epub starts with PK and mimetype, and round-trips" {
@@ -105,6 +111,7 @@ test "epub starts with PK and mimetype, and round-trips" {
.title = "Zig Language Reference",
.version = "0.15.2",
.xhtml = "<?xml version=\"1.0\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>hi</body></html>",
+ .toc = "<ol><li><a href=\"index.xhtml#Intro\">Intro</a></li></ol>",
});
defer gpa.free(bytes);
@@ -126,4 +133,12 @@ test "epub starts with PK and mimetype, and round-trips" {
const opf = try tmp.dir.readFileAlloc(gpa, "OEBPS/content.opf", 1 << 16);
defer gpa.free(opf);
try std.testing.expect(std.mem.indexOf(u8, opf, "0.15.2") != null);
+ // nav must come before index in the spine (TOC is the opening page).
+ const nav_ref = std.mem.indexOf(u8, opf, "idref=\"nav\"").?;
+ const idx_ref = std.mem.indexOf(u8, opf, "idref=\"index\"").?;
+ try std.testing.expect(nav_ref < idx_ref);
+
+ const nav = try tmp.dir.readFileAlloc(gpa, "OEBPS/nav.xhtml", 1 << 16);
+ defer gpa.free(nav);
+ try std.testing.expect(std.mem.indexOf(u8, nav, "href=\"index.xhtml#Intro\"") != null);
}
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>");
diff --git a/src/main.zig b/src/main.zig
index a6bbe31..e9a9fa1 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -70,10 +70,19 @@ fn buildVersion(
const page = try fetch.get(gpa, client, v.docs_url);
defer gpa.free(page);
- const xhtml = try html.toXhtml(gpa, page);
- defer gpa.free(xhtml);
+ var doc = try html.transform(gpa, page);
+ defer doc.deinit();
- const bytes = try epub.build(gpa, .{ .title = title, .version = v.name, .xhtml = xhtml });
+ // Some older docs pages have malformed upstream HTML that can't be repaired
+ // without a full HTML5 tree-builder; skip them rather than emit invalid EPUB3.
+ if (!html.isWellFormed(doc.content)) return error.NotWellFormed;
+
+ const bytes = try epub.build(gpa, .{
+ .title = title,
+ .version = v.name,
+ .xhtml = doc.content,
+ .toc = doc.toc,
+ });
defer gpa.free(bytes);
assert(std.mem.startsWith(u8, bytes, "PK"));