From 1eaf6a9d883a47444d0d68f4ab88bfba62c321d0 Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Thu, 28 May 2026 23:27:10 +0200 Subject: feat: add ziglang-docs-epub tool --- src/epub.zig | 129 ++++++++++++++++++++++++ src/fetch.zig | 30 ++++++ src/html.zig | 309 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.zig | 103 ++++++++++++++++++++ src/main.zig | 106 ++++++++++++++++++++ src/zip.zig | 192 ++++++++++++++++++++++++++++++++++++ 6 files changed, 869 insertions(+) create mode 100644 src/epub.zig create mode 100644 src/fetch.zig create mode 100644 src/html.zig create mode 100644 src/index.zig create mode 100644 src/main.zig create mode 100644 src/zip.zig (limited to 'src') 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 = + \\ + \\ + \\ + \\ + \\ + \\ + \\ +; + +fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 { + return std.fmt.allocPrint(allocator, + \\ + \\ + \\ + \\ urn:ziglang-docs:{s} + \\ {s} ({s}) + \\ {s} + \\ {s} + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + , .{ opts.version, opts.title, opts.version, opts.lang, modified }); +} + +fn buildNav(allocator: std.mem.Allocator, opts: Options) ![]u8 { + return std.fmt.allocPrint(allocator, + \\ + \\ + \\ {s} ({s}) + \\ + \\ + \\ + \\ + \\ + , .{ 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 = "\nhi", + }); + 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