aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.forgejo/workflows/build-epubs.yml54
-rw-r--r--.gitignore2
-rw-r--r--LICENSE201
-rw-r--r--README.md52
-rw-r--r--build.zig33
-rw-r--r--build.zig.zon80
-rw-r--r--epubs/.gitkeep0
-rw-r--r--epubs/zig-0.1.1.epubbin0 -> 201853 bytes
-rw-r--r--epubs/zig-0.10.0.epubbin0 -> 1130432 bytes
-rw-r--r--epubs/zig-0.10.1.epubbin0 -> 1133513 bytes
-rw-r--r--epubs/zig-0.11.0.epubbin0 -> 1081055 bytes
-rw-r--r--epubs/zig-0.12.0.epubbin0 -> 922795 bytes
-rw-r--r--epubs/zig-0.12.1.epubbin0 -> 933356 bytes
-rw-r--r--epubs/zig-0.13.0.epubbin0 -> 933399 bytes
-rw-r--r--epubs/zig-0.14.0.epubbin0 -> 964357 bytes
-rw-r--r--epubs/zig-0.14.1.epubbin0 -> 967595 bytes
-rw-r--r--epubs/zig-0.15.1.epubbin0 -> 970943 bytes
-rw-r--r--epubs/zig-0.15.2.epubbin0 -> 970121 bytes
-rw-r--r--epubs/zig-0.16.0.epubbin0 -> 983739 bytes
-rw-r--r--epubs/zig-0.2.0.epubbin0 -> 238448 bytes
-rw-r--r--epubs/zig-0.3.0.epubbin0 -> 525326 bytes
-rw-r--r--epubs/zig-0.4.0.epubbin0 -> 656333 bytes
-rw-r--r--epubs/zig-0.5.0.epubbin0 -> 719277 bytes
-rw-r--r--epubs/zig-0.6.0.epubbin0 -> 709449 bytes
-rw-r--r--epubs/zig-0.7.0.epubbin0 -> 763214 bytes
-rw-r--r--epubs/zig-0.7.1.epubbin0 -> 763304 bytes
-rw-r--r--epubs/zig-0.8.0.epubbin0 -> 799052 bytes
-rw-r--r--epubs/zig-0.8.1.epubbin0 -> 802163 bytes
-rw-r--r--epubs/zig-0.9.0.epubbin0 -> 1113158 bytes
-rw-r--r--epubs/zig-0.9.1.epubbin0 -> 1113237 bytes
-rw-r--r--epubs/zig-master.epubbin0 -> 967090 bytes
-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
37 files changed, 1291 insertions, 0 deletions
diff --git a/.forgejo/workflows/build-epubs.yml b/.forgejo/workflows/build-epubs.yml
new file mode 100644
index 0000000..676d3c2
--- /dev/null
+++ b/.forgejo/workflows/build-epubs.yml
@@ -0,0 +1,54 @@
+name: build-epubs
+
+on:
+ schedule:
+ - cron: "0 6 * * *" # daily, to catch master/new releases
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: docker
+ container:
+ image: docker.io/debian:trixie-slim
+ steps:
+ - name: Install prerequisites
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
+
+ - uses: actions/checkout@v4
+
+ - name: Install Zig 0.15.2
+ run: |
+ set -eux
+ arch="$(uname -m)"
+ tarball="zig-${arch}-linux-0.15.2.tar.xz"
+ curl -fSL "https://ziglang.org/download/0.15.2/${tarball}" -o /tmp/zig.tar.xz
+ mkdir -p /opt/zig
+ tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1
+ echo "/opt/zig" >> "$GITHUB_PATH"
+
+ - name: Build EPUBs
+ run: zig build run
+
+ - name: Commit updated EPUBs
+ run: |
+ set -eux
+ git config user.name "ziggy"
+ git config user.email "ziggy@noreply.codeberg.org"
+ git add epubs/
+ if git diff --cached --quiet; then
+ echo "No EPUB changes."
+ exit 0
+ fi
+ git commit -m "build: update epubs [skip ci]"
+ # Uses the runner token by default; set CI_PUSH_TOKEN if it lacks push rights.
+ if [ -n "${CI_PUSH_TOKEN:-}" ]; then
+ git push "https://x-access-token:${CI_PUSH_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" HEAD:main
+ else
+ git push origin HEAD:main
+ fi
+ env:
+ CI_PUSH_TOKEN: ${{ secrets.CI_PUSH_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dca1103
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+zig-out/
+.zig-cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..249e8cd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or Derivative
+ Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 Simen A. W. Olsen
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a5ea18
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# ziglang-docs-epub
+
+Turns the [Zig language reference](https://ziglang.org/documentation/) into an
+**EPUB**, one per Zig version. CI runs it on a schedule and commits the resulting
+`.epub` files back into [`epubs/`](epubs/).
+
+## Why
+
+The reference ships as a single self-contained HTML page. This tool repackages it
+as a standards-compliant EPUB3 so you can read it offline in any e-reader.
+
+## How it works
+
+Four decoupled concerns, each a small module that does one thing:
+
+| Module | Responsibility |
+| ------------- | --------------------------------------------------------- |
+| `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 |
+| `zip.zig` | minimal, reproducible ZIP writer (stored entries) |
+
+No external runtime dependencies — Zig's standard library provides HTTP and
+CRC32. EPUB entries are stored uncompressed (Zig 0.15.2's std deflate
+*compressor* is unfinished), which keeps the writer trivial and fully
+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.
+
+## Usage
+
+Requires **Zig 0.15.2**.
+
+```sh
+zig build run # build every version that has docs
+zig build run -- 0.15.2 # build only specific version(s)
+zig build test # run unit tests
+```
+
+EPUBs land in `epubs/zig-<version>.epub`.
+
+## Continuous integration
+
+`.forgejo/workflows/build-epubs.yml` runs on [Codeberg](https://codeberg.org)
+(Forgejo Actions): daily, on push to `main`, and on manual dispatch. It installs
+Zig, runs the tool, and commits any changed EPUBs with `[skip ci]`.
+
+## License
+
+Licensed under the [Apache License 2.0](LICENSE).
+
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..4456d5e
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,33 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const exe = b.addExecutable(.{
+ .name = "ziglang-docs-epub",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ b.installArtifact(exe);
+
+ const run = b.addRunArtifact(exe);
+ run.step.dependOn(b.getInstallStep());
+ if (b.args) |args| run.addArgs(args);
+ const run_step = b.step("run", "Build EPUBs (optionally pass version filters)");
+ run_step.dependOn(&run.step);
+
+ const tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ const run_tests = b.addRunArtifact(tests);
+ const test_step = b.step("test", "Run unit tests");
+ test_step.dependOn(&run_tests.step);
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..f37216e
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,80 @@
+.{
+ // This is the default name used by packages depending on this one. For
+ // example, when a user runs `zig fetch --save <url>`, this field is used
+ // as the key in the `dependencies` table. Although the user can choose a
+ // different name, most users will stick with this provided value.
+ //
+ // It is redundant to include "zig" in this name because it is already
+ // within the Zig package namespace.
+ .name = .ziglang_docs_epub,
+ // This is a [Semantic Version](https://semver.org/).
+ // In a future version of Zig it will be used for package deduplication.
+ .version = "0.0.0",
+ // Together with name, this represents a globally unique package
+ // identifier. This field is generated by the Zig toolchain when the
+ // package is first created, and then *never changes*. This allows
+ // unambiguous detection of one package being an updated version of
+ // another.
+ //
+ // When forking a Zig project, this id should be regenerated (delete the
+ // field and run `zig build`) if the upstream project is still maintained.
+ // Otherwise, the fork is *hostile*, attempting to take control over the
+ // original project's identity. Thus it is recommended to leave the comment
+ // on the following line intact, so that it shows up in code reviews that
+ // modify the field.
+ .fingerprint = 0xbbe24946ce75b1f, // Changing this has security and trust implications.
+ // Tracks the earliest Zig version that the package considers to be a
+ // supported use case.
+ .minimum_zig_version = "0.15.2",
+ // This field is optional.
+ // Each dependency must either provide a `url` and `hash`, or a `path`.
+ // `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
+ // Once all dependencies are fetched, `zig build` no longer requires
+ // internet connectivity.
+ .dependencies = .{
+ // See `zig fetch --save <url>` for a command-line interface for adding dependencies.
+ //.example = .{
+ // // When updating this field to a new URL, be sure to delete the corresponding
+ // // `hash`, otherwise you are communicating that you expect to find the old hash at
+ // // the new URL. If the contents of a URL change this will result in a hash mismatch
+ // // which will prevent zig from using it.
+ // .url = "https://example.com/foo.tar.gz",
+ //
+ // // This is computed from the file contents of the directory of files that is
+ // // obtained after fetching `url` and applying the inclusion rules given by
+ // // `paths`.
+ // //
+ // // This field is the source of truth; packages do not come from a `url`; they
+ // // come from a `hash`. `url` is just one of many possible mirrors for how to
+ // // obtain a package matching this `hash`.
+ // //
+ // // Uses the [multihash](https://multiformats.io/multihash/) format.
+ // .hash = "...",
+ //
+ // // When this is provided, the package is found in a directory relative to the
+ // // build root. In this case the package's hash is irrelevant and therefore not
+ // // computed. This field and `url` are mutually exclusive.
+ // .path = "foo",
+ //
+ // // When this is set to `true`, a package is declared to be lazily
+ // // fetched. This makes the dependency only get fetched if it is
+ // // actually used.
+ // .lazy = false,
+ //},
+ },
+ // Specifies the set of files and directories that are included in this package.
+ // Only files and directories listed here are included in the `hash` that
+ // is computed for this package. Only files listed here will remain on disk
+ // when using the zig package manager. As a rule of thumb, one should list
+ // files required for compilation plus any license(s).
+ // Paths are relative to the build root. Use the empty string (`""`) to refer to
+ // the build root itself.
+ // A directory listed here means that all files within, recursively, are included.
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "README.md",
+ "LICENSE",
+ },
+}
diff --git a/epubs/.gitkeep b/epubs/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/epubs/.gitkeep
diff --git a/epubs/zig-0.1.1.epub b/epubs/zig-0.1.1.epub
new file mode 100644
index 0000000..42c91ec
--- /dev/null
+++ b/epubs/zig-0.1.1.epub
Binary files differ
diff --git a/epubs/zig-0.10.0.epub b/epubs/zig-0.10.0.epub
new file mode 100644
index 0000000..49a331d
--- /dev/null
+++ b/epubs/zig-0.10.0.epub
Binary files differ
diff --git a/epubs/zig-0.10.1.epub b/epubs/zig-0.10.1.epub
new file mode 100644
index 0000000..b153576
--- /dev/null
+++ b/epubs/zig-0.10.1.epub
Binary files differ
diff --git a/epubs/zig-0.11.0.epub b/epubs/zig-0.11.0.epub
new file mode 100644
index 0000000..7aecf9c
--- /dev/null
+++ 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
new file mode 100644
index 0000000..c72d0d1
--- /dev/null
+++ 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
new file mode 100644
index 0000000..d1be901
--- /dev/null
+++ 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
new file mode 100644
index 0000000..bfd1991
--- /dev/null
+++ 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
new file mode 100644
index 0000000..53a9137
--- /dev/null
+++ 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
new file mode 100644
index 0000000..a533700
--- /dev/null
+++ 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
new file mode 100644
index 0000000..68bb208
--- /dev/null
+++ 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
new file mode 100644
index 0000000..bce9cd8
--- /dev/null
+++ 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
new file mode 100644
index 0000000..154ea4b
--- /dev/null
+++ 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
new file mode 100644
index 0000000..34f1342
--- /dev/null
+++ b/epubs/zig-0.2.0.epub
Binary files differ
diff --git a/epubs/zig-0.3.0.epub b/epubs/zig-0.3.0.epub
new file mode 100644
index 0000000..f34192d
--- /dev/null
+++ b/epubs/zig-0.3.0.epub
Binary files differ
diff --git a/epubs/zig-0.4.0.epub b/epubs/zig-0.4.0.epub
new file mode 100644
index 0000000..1d9afa6
--- /dev/null
+++ b/epubs/zig-0.4.0.epub
Binary files differ
diff --git a/epubs/zig-0.5.0.epub b/epubs/zig-0.5.0.epub
new file mode 100644
index 0000000..2b6ed40
--- /dev/null
+++ b/epubs/zig-0.5.0.epub
Binary files differ
diff --git a/epubs/zig-0.6.0.epub b/epubs/zig-0.6.0.epub
new file mode 100644
index 0000000..78ade26
--- /dev/null
+++ b/epubs/zig-0.6.0.epub
Binary files differ
diff --git a/epubs/zig-0.7.0.epub b/epubs/zig-0.7.0.epub
new file mode 100644
index 0000000..81e36ff
--- /dev/null
+++ b/epubs/zig-0.7.0.epub
Binary files differ
diff --git a/epubs/zig-0.7.1.epub b/epubs/zig-0.7.1.epub
new file mode 100644
index 0000000..02ccf63
--- /dev/null
+++ b/epubs/zig-0.7.1.epub
Binary files differ
diff --git a/epubs/zig-0.8.0.epub b/epubs/zig-0.8.0.epub
new file mode 100644
index 0000000..8105508
--- /dev/null
+++ b/epubs/zig-0.8.0.epub
Binary files differ
diff --git a/epubs/zig-0.8.1.epub b/epubs/zig-0.8.1.epub
new file mode 100644
index 0000000..43d73f5
--- /dev/null
+++ b/epubs/zig-0.8.1.epub
Binary files differ
diff --git a/epubs/zig-0.9.0.epub b/epubs/zig-0.9.0.epub
new file mode 100644
index 0000000..47ae0b9
--- /dev/null
+++ 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
new file mode 100644
index 0000000..212a0fd
--- /dev/null
+++ b/epubs/zig-0.9.1.epub
Binary files differ
diff --git a/epubs/zig-master.epub b/epubs/zig-master.epub
new file mode 100644
index 0000000..4b59c34
--- /dev/null
+++ b/epubs/zig-master.epub
Binary files differ
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);
+}