diff options
| -rw-r--r-- | .forgejo/workflows/build-epubs.yml | 54 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | LICENSE | 201 | ||||
| -rw-r--r-- | README.md | 52 | ||||
| -rw-r--r-- | build.zig | 33 | ||||
| -rw-r--r-- | build.zig.zon | 80 | ||||
| -rw-r--r-- | epubs/.gitkeep | 0 | ||||
| -rw-r--r-- | epubs/zig-0.1.1.epub | bin | 0 -> 201853 bytes | |||
| -rw-r--r-- | epubs/zig-0.10.0.epub | bin | 0 -> 1130432 bytes | |||
| -rw-r--r-- | epubs/zig-0.10.1.epub | bin | 0 -> 1133513 bytes | |||
| -rw-r--r-- | epubs/zig-0.11.0.epub | bin | 0 -> 1081055 bytes | |||
| -rw-r--r-- | epubs/zig-0.12.0.epub | bin | 0 -> 922795 bytes | |||
| -rw-r--r-- | epubs/zig-0.12.1.epub | bin | 0 -> 933356 bytes | |||
| -rw-r--r-- | epubs/zig-0.13.0.epub | bin | 0 -> 933399 bytes | |||
| -rw-r--r-- | epubs/zig-0.14.0.epub | bin | 0 -> 964357 bytes | |||
| -rw-r--r-- | epubs/zig-0.14.1.epub | bin | 0 -> 967595 bytes | |||
| -rw-r--r-- | epubs/zig-0.15.1.epub | bin | 0 -> 970943 bytes | |||
| -rw-r--r-- | epubs/zig-0.15.2.epub | bin | 0 -> 970121 bytes | |||
| -rw-r--r-- | epubs/zig-0.16.0.epub | bin | 0 -> 983739 bytes | |||
| -rw-r--r-- | epubs/zig-0.2.0.epub | bin | 0 -> 238448 bytes | |||
| -rw-r--r-- | epubs/zig-0.3.0.epub | bin | 0 -> 525326 bytes | |||
| -rw-r--r-- | epubs/zig-0.4.0.epub | bin | 0 -> 656333 bytes | |||
| -rw-r--r-- | epubs/zig-0.5.0.epub | bin | 0 -> 719277 bytes | |||
| -rw-r--r-- | epubs/zig-0.6.0.epub | bin | 0 -> 709449 bytes | |||
| -rw-r--r-- | epubs/zig-0.7.0.epub | bin | 0 -> 763214 bytes | |||
| -rw-r--r-- | epubs/zig-0.7.1.epub | bin | 0 -> 763304 bytes | |||
| -rw-r--r-- | epubs/zig-0.8.0.epub | bin | 0 -> 799052 bytes | |||
| -rw-r--r-- | epubs/zig-0.8.1.epub | bin | 0 -> 802163 bytes | |||
| -rw-r--r-- | epubs/zig-0.9.0.epub | bin | 0 -> 1113158 bytes | |||
| -rw-r--r-- | epubs/zig-0.9.1.epub | bin | 0 -> 1113237 bytes | |||
| -rw-r--r-- | epubs/zig-master.epub | bin | 0 -> 967090 bytes | |||
| -rw-r--r-- | src/epub.zig | 129 | ||||
| -rw-r--r-- | src/fetch.zig | 30 | ||||
| -rw-r--r-- | src/html.zig | 309 | ||||
| -rw-r--r-- | src/index.zig | 103 | ||||
| -rw-r--r-- | src/main.zig | 106 | ||||
| -rw-r--r-- | src/zip.zig | 192 |
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/ @@ -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 Binary files differnew file mode 100644 index 0000000..42c91ec --- /dev/null +++ b/epubs/zig-0.1.1.epub diff --git a/epubs/zig-0.10.0.epub b/epubs/zig-0.10.0.epub Binary files differnew file mode 100644 index 0000000..49a331d --- /dev/null +++ b/epubs/zig-0.10.0.epub diff --git a/epubs/zig-0.10.1.epub b/epubs/zig-0.10.1.epub Binary files differnew file mode 100644 index 0000000..b153576 --- /dev/null +++ b/epubs/zig-0.10.1.epub diff --git a/epubs/zig-0.11.0.epub b/epubs/zig-0.11.0.epub Binary files differnew file mode 100644 index 0000000..7aecf9c --- /dev/null +++ b/epubs/zig-0.11.0.epub diff --git a/epubs/zig-0.12.0.epub b/epubs/zig-0.12.0.epub Binary files differnew file mode 100644 index 0000000..c72d0d1 --- /dev/null +++ b/epubs/zig-0.12.0.epub diff --git a/epubs/zig-0.12.1.epub b/epubs/zig-0.12.1.epub Binary files differnew file mode 100644 index 0000000..d1be901 --- /dev/null +++ b/epubs/zig-0.12.1.epub diff --git a/epubs/zig-0.13.0.epub b/epubs/zig-0.13.0.epub Binary files differnew file mode 100644 index 0000000..bfd1991 --- /dev/null +++ b/epubs/zig-0.13.0.epub diff --git a/epubs/zig-0.14.0.epub b/epubs/zig-0.14.0.epub Binary files differnew file mode 100644 index 0000000..53a9137 --- /dev/null +++ b/epubs/zig-0.14.0.epub diff --git a/epubs/zig-0.14.1.epub b/epubs/zig-0.14.1.epub Binary files differnew file mode 100644 index 0000000..a533700 --- /dev/null +++ b/epubs/zig-0.14.1.epub diff --git a/epubs/zig-0.15.1.epub b/epubs/zig-0.15.1.epub Binary files differnew file mode 100644 index 0000000..68bb208 --- /dev/null +++ b/epubs/zig-0.15.1.epub diff --git a/epubs/zig-0.15.2.epub b/epubs/zig-0.15.2.epub Binary files differnew file mode 100644 index 0000000..bce9cd8 --- /dev/null +++ b/epubs/zig-0.15.2.epub diff --git a/epubs/zig-0.16.0.epub b/epubs/zig-0.16.0.epub Binary files differnew file mode 100644 index 0000000..154ea4b --- /dev/null +++ b/epubs/zig-0.16.0.epub diff --git a/epubs/zig-0.2.0.epub b/epubs/zig-0.2.0.epub Binary files differnew file mode 100644 index 0000000..34f1342 --- /dev/null +++ b/epubs/zig-0.2.0.epub diff --git a/epubs/zig-0.3.0.epub b/epubs/zig-0.3.0.epub Binary files differnew file mode 100644 index 0000000..f34192d --- /dev/null +++ b/epubs/zig-0.3.0.epub diff --git a/epubs/zig-0.4.0.epub b/epubs/zig-0.4.0.epub Binary files differnew file mode 100644 index 0000000..1d9afa6 --- /dev/null +++ b/epubs/zig-0.4.0.epub diff --git a/epubs/zig-0.5.0.epub b/epubs/zig-0.5.0.epub Binary files differnew file mode 100644 index 0000000..2b6ed40 --- /dev/null +++ b/epubs/zig-0.5.0.epub diff --git a/epubs/zig-0.6.0.epub b/epubs/zig-0.6.0.epub Binary files differnew file mode 100644 index 0000000..78ade26 --- /dev/null +++ b/epubs/zig-0.6.0.epub diff --git a/epubs/zig-0.7.0.epub b/epubs/zig-0.7.0.epub Binary files differnew file mode 100644 index 0000000..81e36ff --- /dev/null +++ b/epubs/zig-0.7.0.epub diff --git a/epubs/zig-0.7.1.epub b/epubs/zig-0.7.1.epub Binary files differnew file mode 100644 index 0000000..02ccf63 --- /dev/null +++ b/epubs/zig-0.7.1.epub diff --git a/epubs/zig-0.8.0.epub b/epubs/zig-0.8.0.epub Binary files differnew file mode 100644 index 0000000..8105508 --- /dev/null +++ b/epubs/zig-0.8.0.epub diff --git a/epubs/zig-0.8.1.epub b/epubs/zig-0.8.1.epub Binary files differnew file mode 100644 index 0000000..43d73f5 --- /dev/null +++ b/epubs/zig-0.8.1.epub diff --git a/epubs/zig-0.9.0.epub b/epubs/zig-0.9.0.epub Binary files differnew file mode 100644 index 0000000..47ae0b9 --- /dev/null +++ b/epubs/zig-0.9.0.epub diff --git a/epubs/zig-0.9.1.epub b/epubs/zig-0.9.1.epub Binary files differnew file mode 100644 index 0000000..212a0fd --- /dev/null +++ b/epubs/zig-0.9.1.epub diff --git a/epubs/zig-master.epub b/epubs/zig-master.epub Binary files differnew file mode 100644 index 0000000..4b59c34 --- /dev/null +++ b/epubs/zig-master.epub 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. `—`) to numeric references, +//! since XML predefines only `& < > " '`, +//! 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 `&` 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("&"); + i = amp + 1; + continue; + } + i = amp + tok.len + 2; + } else { + // Bare '&' not forming an entity reference. + try out.writer.writeAll("&"); + 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—b & c d R&D <x> — &unknownent;</body></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "—") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "—") != null); // mdash -> numeric + try std.testing.expect(std.mem.indexOf(u8, out, " ") != null); // nbsp -> numeric + try std.testing.expect(std.mem.indexOf(u8, out, "R&D") != null); // stray & escaped + try std.testing.expect(std.mem.indexOf(u8, out, "&unknownent;") != null); // unknown name escaped + try std.testing.expect(std.mem.indexOf(u8, out, "& c") != null); // existing & kept (not double-escaped) + try std.testing.expect(std.mem.indexOf(u8, out, "<x>") != 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); +} |
