aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/epub.zig129
-rw-r--r--src/fetch.zig30
-rw-r--r--src/html.zig309
-rw-r--r--src/index.zig103
-rw-r--r--src/main.zig106
-rw-r--r--src/zip.zig192
6 files changed, 869 insertions, 0 deletions
diff --git a/src/epub.zig b/src/epub.zig
new file mode 100644
index 0000000..0bdf563
--- /dev/null
+++ b/src/epub.zig
@@ -0,0 +1,129 @@
+//! Package transformed XHTML content into a valid EPUB3 file (a ZIP with a
+//! specific structure). The `mimetype` entry must be first and stored uncompressed.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const zip = @import("zip.zig");
+
+/// Fixed modification timestamp for reproducible output.
+const modified = "2021-01-01T00:00:00Z";
+
+pub const Options = struct {
+ title: []const u8,
+ /// 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).
+ xhtml: []const u8,
+};
+
+/// Build an EPUB3 archive from `opts`. Caller owns the returned bytes.
+pub fn build(allocator: std.mem.Allocator, opts: Options) ![]u8 {
+ assert(opts.version.len > 0);
+ assert(opts.xhtml.len > 0);
+
+ var w = zip.Writer.init(allocator);
+ defer w.deinit();
+
+ // 1. mimetype — MUST be the first entry and stored uncompressed.
+ try w.addStored("mimetype", "application/epub+zip");
+
+ // 2. container.xml — points the reader at the package document.
+ try w.addStored("META-INF/container.xml", container_xml);
+
+ // 3. content.opf — package metadata, manifest, spine.
+ const opf = try buildOpf(allocator, opts);
+ defer allocator.free(opf);
+ try w.addStored("OEBPS/content.opf", opf);
+
+ // 4. nav.xhtml — minimal EPUB3 navigation document.
+ const nav = try buildNav(allocator, opts);
+ defer allocator.free(nav);
+ try w.addStored("OEBPS/nav.xhtml", nav);
+
+ // 5. index.xhtml — the transformed reference content.
+ try w.addStored("OEBPS/index.xhtml", opts.xhtml);
+
+ const bytes = try w.finish();
+ assert(std.mem.startsWith(u8, bytes, "PK"));
+ return bytes;
+}
+
+const container_xml =
+ \\<?xml version="1.0" encoding="utf-8"?>
+ \\<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+ \\ <rootfiles>
+ \\ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
+ \\ </rootfiles>
+ \\</container>
+ \\
+;
+
+fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
+ return std.fmt.allocPrint(allocator,
+ \\<?xml version="1.0" encoding="utf-8"?>
+ \\<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
+ \\ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
+ \\ <dc:identifier id="pub-id">urn:ziglang-docs:{s}</dc:identifier>
+ \\ <dc:title>{s} ({s})</dc:title>
+ \\ <dc:language>{s}</dc:language>
+ \\ <meta property="dcterms:modified">{s}</meta>
+ \\ </metadata>
+ \\ <manifest>
+ \\ <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
+ \\ <item id="index" href="index.xhtml" media-type="application/xhtml+xml"/>
+ \\ </manifest>
+ \\ <spine>
+ \\ <itemref idref="index"/>
+ \\ </spine>
+ \\</package>
+ \\
+ , .{ opts.version, opts.title, opts.version, opts.lang, modified });
+}
+
+fn buildNav(allocator: std.mem.Allocator, opts: Options) ![]u8 {
+ 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>
+ \\ </nav>
+ \\ </body>
+ \\</html>
+ \\
+ , .{ opts.lang, opts.title, opts.version, opts.title, opts.version });
+}
+
+test "epub starts with PK and mimetype, and round-trips" {
+ const gpa = std.testing.allocator;
+ const bytes = try build(gpa, .{
+ .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>",
+ });
+ defer gpa.free(bytes);
+
+ try std.testing.expect(std.mem.startsWith(u8, bytes, "PK"));
+ // mimetype must be the first entry, stored, with its content right after the
+ // local header + filename (offset 30 + len("mimetype") == 38).
+ try std.testing.expectEqualStrings("mimetype", bytes[30..38]);
+ try std.testing.expectEqualStrings("application/epub+zip", bytes[38..58]);
+
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(.{ .sub_path = "b.epub", .data = bytes });
+ var file = try tmp.dir.openFile("b.epub", .{});
+ defer file.close();
+ var buf: [4096]u8 = undefined;
+ var fr = file.reader(&buf);
+ try std.zip.extract(tmp.dir, &fr, .{});
+
+ 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);
+}
diff --git a/src/fetch.zig b/src/fetch.zig
new file mode 100644
index 0000000..706a435
--- /dev/null
+++ b/src/fetch.zig
@@ -0,0 +1,30 @@
+//! Thin HTTP GET helper over std.http.Client.
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+/// Largest response body we accept (the docs page is ~1 MB; allow generous slack).
+pub const max_body = 32 * 1024 * 1024;
+
+pub const Error = error{ HttpStatus, BodyTooLarge } || std.mem.Allocator.Error;
+
+/// GET `url` and return the response body. Caller owns the returned slice.
+/// Returns an error on any non-200 status so callers can skip that version.
+pub fn get(allocator: std.mem.Allocator, client: *std.http.Client, url: []const u8) ![]u8 {
+ assert(url.len > 0);
+
+ var body: std.Io.Writer.Allocating = .init(allocator);
+ defer body.deinit();
+
+ const result = try client.fetch(.{
+ .location = .{ .url = url },
+ .response_writer = &body.writer,
+ });
+
+ if (result.status != .ok) return Error.HttpStatus;
+ if (body.writer.end > max_body) return Error.BodyTooLarge;
+
+ const out = try body.toOwnedSlice();
+ assert(out.len > 0);
+ return out;
+}
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);
+}
diff --git a/src/index.zig b/src/index.zig
new file mode 100644
index 0000000..7eb3e4b
--- /dev/null
+++ b/src/index.zig
@@ -0,0 +1,103 @@
+//! Discover Zig versions and their docs URLs from ziglang.org/download/index.json.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const fetch = @import("fetch.zig");
+
+pub const index_url = "https://ziglang.org/download/index.json";
+
+/// Upper bound on versions we will process (NASA Power-of-10: bounded loops).
+pub const max_versions = 64;
+
+pub const Version = struct {
+ /// Release name as it appears in index.json (e.g. "master", "0.15.2").
+ name: []const u8,
+ /// Absolute URL of the single-page language reference.
+ docs_url: []const u8,
+};
+
+/// Owns the slice of versions and all their backing strings.
+pub const Versions = struct {
+ allocator: std.mem.Allocator,
+ items: []Version,
+
+ pub fn deinit(self: *Versions) void {
+ for (self.items) |v| {
+ self.allocator.free(v.name);
+ self.allocator.free(v.docs_url);
+ }
+ self.allocator.free(self.items);
+ }
+};
+
+/// Fetch and parse index.json, collecting every entry that has a `docs` URL.
+pub fn fetchAll(allocator: std.mem.Allocator, client: *std.http.Client) !Versions {
+ const json = try fetch.get(allocator, client, index_url);
+ defer allocator.free(json);
+ return parse(allocator, json);
+}
+
+/// Parse index.json bytes into owned versions. Split out for testability.
+pub fn parse(allocator: std.mem.Allocator, json: []const u8) !Versions {
+ assert(json.len > 0);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
+ defer parsed.deinit();
+
+ const root = switch (parsed.value) {
+ .object => |o| o,
+ else => return error.MalformedIndex,
+ };
+
+ var list: std.ArrayList(Version) = .empty;
+ errdefer {
+ for (list.items) |v| {
+ allocator.free(v.name);
+ allocator.free(v.docs_url);
+ }
+ list.deinit(allocator);
+ }
+
+ var it = root.iterator();
+ while (it.next()) |entry| {
+ if (list.items.len >= max_versions) break;
+
+ const obj = switch (entry.value_ptr.*) {
+ .object => |o| o,
+ else => continue,
+ };
+ const docs = switch (obj.get("docs") orelse continue) {
+ .string => |s| s,
+ else => continue,
+ };
+ if (docs.len == 0) continue;
+
+ const name = try allocator.dupe(u8, entry.key_ptr.*);
+ errdefer allocator.free(name);
+ const url = try allocator.dupe(u8, docs);
+ try list.append(allocator, .{ .name = name, .docs_url = url });
+ }
+
+ return .{ .allocator = allocator, .items = try list.toOwnedSlice(allocator) };
+}
+
+test "parses versions with docs urls and skips entries without" {
+ const gpa = std.testing.allocator;
+ const json =
+ \\{
+ \\ "master": { "version": "0.16.0-dev", "docs": "https://ziglang.org/documentation/master/" },
+ \\ "0.15.2": { "docs": "https://ziglang.org/documentation/0.15.2/" },
+ \\ "0.1.0": { "notes": "no docs here" }
+ \\}
+ ;
+ var versions = try parse(gpa, json);
+ defer versions.deinit();
+
+ try std.testing.expectEqual(@as(usize, 2), versions.items.len);
+ var saw_master = false;
+ for (versions.items) |v| {
+ try std.testing.expect(v.docs_url.len > 0);
+ if (std.mem.eql(u8, v.name, "master")) saw_master = true;
+ }
+ try std.testing.expect(saw_master);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..a6bbe31
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,106 @@
+//! ziglang-docs-epub: turn the Zig language reference into one EPUB per version.
+//!
+//! Usage:
+//! ziglang-docs-epub [version...]
+//! no args build every version in index.json that has a docs URL
+//! version build only the named version(s), e.g. `0.15.2 master`
+//!
+//! Output is written to the `epubs/` directory as `zig-<version>.epub`.
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+const fetch = @import("fetch.zig");
+const index = @import("index.zig");
+const html = @import("html.zig");
+const epub = @import("epub.zig");
+
+const out_dir = "epubs";
+const title = "Zig Language Reference";
+/// A valid EPUB is always larger than this; used to sanity-check output.
+const min_epub_size = 256;
+
+pub fn main() !void {
+ var gpa_state: std.heap.GeneralPurposeAllocator(.{}) = .init;
+ defer _ = gpa_state.deinit();
+ const gpa = gpa_state.allocator();
+
+ const args = try std.process.argsAlloc(gpa);
+ defer std.process.argsFree(gpa, args);
+ const filters = args[1..]; // empty == build all
+
+ var client: std.http.Client = .{ .allocator = gpa };
+ defer client.deinit();
+
+ var versions = index.fetchAll(gpa, &client) catch |err| {
+ std.log.err("failed to fetch version index: {s}", .{@errorName(err)});
+ return err;
+ };
+ defer versions.deinit();
+
+ var dir = try std.fs.cwd().makeOpenPath(out_dir, .{});
+ defer dir.close();
+
+ var built: usize = 0;
+ var skipped: usize = 0;
+
+ assert(versions.items.len <= index.max_versions);
+ for (versions.items) |v| {
+ if (!wanted(filters, v.name)) continue;
+
+ buildVersion(gpa, &client, dir, v) catch |err| {
+ std.log.warn("skip {s}: {s}", .{ v.name, @errorName(err) });
+ skipped += 1;
+ continue;
+ };
+ built += 1;
+ }
+
+ std.log.info("done: {d} built, {d} skipped", .{ built, skipped });
+ if (built == 0) std.log.warn("no epubs were produced", .{});
+}
+
+/// Fetch, transform, package and write the EPUB for a single version.
+fn buildVersion(
+ gpa: std.mem.Allocator,
+ client: *std.http.Client,
+ dir: std.fs.Dir,
+ v: index.Version,
+) !void {
+ 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);
+
+ const bytes = try epub.build(gpa, .{ .title = title, .version = v.name, .xhtml = xhtml });
+ defer gpa.free(bytes);
+
+ assert(std.mem.startsWith(u8, bytes, "PK"));
+ assert(bytes.len > min_epub_size);
+
+ const name = try std.fmt.allocPrint(gpa, "zig-{s}.epub", .{v.name});
+ defer gpa.free(name);
+ try dir.writeFile(.{ .sub_path = name, .data = bytes });
+
+ std.log.info("built {s} ({d} bytes)", .{ name, bytes.len });
+}
+
+/// True if `name` should be built given the CLI `filters` (empty == all).
+fn wanted(filters: []const []const u8, name: []const u8) bool {
+ if (filters.len == 0) return true;
+ for (filters) |f| {
+ if (std.mem.eql(u8, f, name)) return true;
+ }
+ return false;
+}
+
+test "wanted matches filters or accepts all when empty" {
+ try std.testing.expect(wanted(&.{}, "0.15.2"));
+ try std.testing.expect(wanted(&.{"0.15.2"}, "0.15.2"));
+ try std.testing.expect(!wanted(&.{"master"}, "0.15.2"));
+}
+
+test {
+ std.testing.refAllDecls(@This());
+}
diff --git a/src/zip.zig b/src/zip.zig
new file mode 100644
index 0000000..9268106
--- /dev/null
+++ b/src/zip.zig
@@ -0,0 +1,192 @@
+//! Minimal ZIP writer: stored (uncompressed) entries, central directory, EOCD.
+//!
+//! Entries are stored uncompressed (method 0). Zig 0.15.2's std deflate
+//! *compressor* is unfinished (`std.compress.flate.Compress` panics / loops),
+//! so we avoid it entirely; stored entries are fully ZIP/EPUB compliant. The
+//! cost is larger files, acceptable for docs committed to the repo.
+//!
+//! Output is byte-for-byte reproducible: a fixed DOS timestamp is used for every
+//! entry (no wall clock), so identical inputs produce identical archives and git
+//! only sees a diff when the underlying docs actually change.
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+const Crc32 = std.hash.crc.Crc32;
+
+/// Upper bound on entries in a single archive (NASA Power-of-10: bounded loops).
+pub const max_entries = 64;
+
+/// Fixed DOS date/time for reproducibility (2021-01-01 00:00:00).
+const dos_time: u16 = 0;
+const dos_date: u16 = (41 << 9) | (1 << 5) | 1; // (year-1980)<<9 | month<<5 | day
+
+const sig_local = std.zip.local_file_header_sig;
+const sig_central = std.zip.central_file_header_sig;
+const sig_end = std.zip.end_record_sig;
+
+const method_store: u16 = @intFromEnum(std.zip.CompressionMethod.store);
+
+const Entry = struct {
+ name: []const u8,
+ crc: u32,
+ comp_size: u32,
+ uncomp_size: u32,
+ method: u16,
+ offset: u32,
+};
+
+pub const Writer = struct {
+ allocator: std.mem.Allocator,
+ bytes: std.ArrayList(u8),
+ entries: std.ArrayList(Entry),
+
+ pub fn init(allocator: std.mem.Allocator) Writer {
+ return .{
+ .allocator = allocator,
+ .bytes = .empty,
+ .entries = .empty,
+ };
+ }
+
+ pub fn deinit(w: *Writer) void {
+ w.bytes.deinit(w.allocator);
+ w.entries.deinit(w.allocator);
+ }
+
+ /// Add an entry stored uncompressed (method 0).
+ pub fn addStored(w: *Writer, name: []const u8, data: []const u8) !void {
+ assert(name.len > 0);
+ assert(w.entries.items.len < max_entries);
+ const crc = Crc32.hash(data);
+ try w.writeLocal(name, crc, @intCast(data.len), @intCast(data.len), method_store);
+ try w.bytes.appendSlice(w.allocator, data);
+ }
+
+ /// Append central directory + end-of-central-directory; return archive bytes.
+ /// Caller owns the returned slice.
+ pub fn finish(w: *Writer) ![]u8 {
+ assert(w.entries.items.len > 0);
+ const cd_offset: u32 = @intCast(w.bytes.items.len);
+
+ for (w.entries.items) |e| {
+ try w.appendBytes(&sig_central);
+ try w.appendInt(u16, 20); // version made by
+ try w.appendInt(u16, 20); // version needed
+ try w.appendInt(u16, 0); // flags
+ try w.appendInt(u16, e.method);
+ try w.appendInt(u16, dos_time);
+ try w.appendInt(u16, dos_date);
+ try w.appendInt(u32, e.crc);
+ try w.appendInt(u32, e.comp_size);
+ try w.appendInt(u32, e.uncomp_size);
+ try w.appendInt(u16, @intCast(e.name.len));
+ try w.appendInt(u16, 0); // extra len
+ try w.appendInt(u16, 0); // comment len
+ try w.appendInt(u16, 0); // disk number start
+ try w.appendInt(u16, 0); // internal attrs
+ try w.appendInt(u32, 0); // external attrs
+ try w.appendInt(u32, e.offset);
+ try w.appendBytes(e.name);
+ }
+
+ const cd_size: u32 = @as(u32, @intCast(w.bytes.items.len)) - cd_offset;
+ const count: u16 = @intCast(w.entries.items.len);
+
+ try w.appendBytes(&sig_end);
+ try w.appendInt(u16, 0); // disk number
+ try w.appendInt(u16, 0); // cd start disk
+ try w.appendInt(u16, count); // records this disk
+ try w.appendInt(u16, count); // total records
+ try w.appendInt(u32, cd_size);
+ try w.appendInt(u32, cd_offset);
+ try w.appendInt(u16, 0); // comment len
+
+ return w.bytes.toOwnedSlice(w.allocator);
+ }
+
+ fn writeLocal(w: *Writer, name: []const u8, crc: u32, comp_size: u32, uncomp_size: u32, method: u16) !void {
+ const offset: u32 = @intCast(w.bytes.items.len);
+ assert(w.entries.items.len == 0 or offset > w.entries.items[w.entries.items.len - 1].offset);
+
+ try w.appendBytes(&sig_local);
+ try w.appendInt(u16, 20); // version needed
+ try w.appendInt(u16, 0); // flags
+ try w.appendInt(u16, method);
+ try w.appendInt(u16, dos_time);
+ try w.appendInt(u16, dos_date);
+ try w.appendInt(u32, crc);
+ try w.appendInt(u32, comp_size);
+ try w.appendInt(u32, uncomp_size);
+ try w.appendInt(u16, @intCast(name.len));
+ try w.appendInt(u16, 0); // extra len
+ try w.appendBytes(name);
+
+ try w.entries.append(w.allocator, .{
+ .name = name,
+ .crc = crc,
+ .comp_size = comp_size,
+ .uncomp_size = uncomp_size,
+ .method = method,
+ .offset = offset,
+ });
+ }
+
+ fn appendBytes(w: *Writer, data: []const u8) !void {
+ try w.bytes.appendSlice(w.allocator, data);
+ }
+
+ fn appendInt(w: *Writer, comptime T: type, value: T) !void {
+ var buf: [@sizeOf(T)]u8 = undefined;
+ std.mem.writeInt(T, &buf, value, .little);
+ try w.bytes.appendSlice(w.allocator, &buf);
+ }
+};
+
+/// Write `archive` to a temp dir, extract it with the std.zip reader, and return
+/// the extracted contents of `name`. Exercises a full ZIP round-trip.
+fn extractOne(gpa: std.mem.Allocator, dir: std.fs.Dir, archive: []const u8, name: []const u8) ![]u8 {
+ try dir.writeFile(.{ .sub_path = "out.zip", .data = archive });
+ var file = try dir.openFile("out.zip", .{});
+ defer file.close();
+ var buf: [4096]u8 = undefined;
+ var fr = file.reader(&buf);
+ try std.zip.extract(dir, &fr, .{});
+ return dir.readFileAlloc(gpa, name, 1 << 20);
+}
+
+test "stored entry round-trips via std.zip reader" {
+ const gpa = std.testing.allocator;
+ var w = Writer.init(gpa);
+ defer w.deinit();
+
+ const payload = "hello, epub";
+ try w.addStored("a.txt", payload);
+ const archive = try w.finish();
+ defer gpa.free(archive);
+
+ try std.testing.expect(std.mem.startsWith(u8, archive, "PK"));
+
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const got = try extractOne(gpa, tmp.dir, archive, "a.txt");
+ defer gpa.free(got);
+ try std.testing.expectEqualStrings(payload, got);
+}
+
+test "multiple entries round-trip in nested paths" {
+ const gpa = std.testing.allocator;
+ var w = Writer.init(gpa);
+ defer w.deinit();
+
+ try w.addStored("first.txt", "one");
+ try w.addStored("dir/second.txt", "A" ** 1000);
+ const archive = try w.finish();
+ defer gpa.free(archive);
+
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const got = try extractOne(gpa, tmp.dir, archive, "dir/second.txt");
+ defer gpa.free(got);
+ try std.testing.expectEqualStrings("A" ** 1000, got);
+}