diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 65f0fd0..b6aa546 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -88,8 +88,8 @@ jobs:
RESOURCE: ${{ github.event_name == 'schedule' && 'all' || inputs.resource }}
REFRESH: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh || 'false' }}
PUBLISH: ${{ steps.publication.outputs.enabled }}
- STUDY_BUILDER_COMMENTARIES_REPO: ${{ secrets.GETBIBLE_COMMENTARIES_REPO || 'git@github.com:getbible/v1_commentaries.git' }}
- STUDY_BUILDER_DICTIONARIES_REPO: ${{ secrets.GETBIBLE_DICTIONARIES_REPO || 'git@github.com:getbible/v1_dictionaries.git' }}
+ STUDY_BUILDER_COMMENTARIES_REPO: ${{ secrets.GETBIBLE_COMMENTARIES_REPO || 'git@github.com:getbible/commentaries.git' }}
+ STUDY_BUILDER_DICTIONARIES_REPO: ${{ secrets.GETBIBLE_DICTIONARIES_REPO || 'git@github.com:getbible/dictionaries.git' }}
STUDY_BUILDER_SIGN_COMMITS: ${{ steps.publication.outputs.enabled }}
shell: bash
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01af92a..4395ddc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,3 +33,12 @@ jobs:
study-builder --help
study-builder engine --help
study-builder build --help
+ - name: Lint deployment scripts
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq shellcheck
+ shellcheck --severity=style scripts/*.sh tests/*.sh
+ - name: Install, deploy, and serve the origin configuration
+ run: |
+ sudo apt-get install -y -qq nginx
+ sudo --preserve-env=PATH bash tests/nginx_config_check.sh
diff --git a/AGENTS.md b/AGENTS.md
index 7b88670..cb3c5f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,9 @@ publication workflow.
- Runtime: Python 3.12.
- Extractor: the separately released `getbiblesword` executable, pinned in
`conf/getbiblesword.json` and invoked only as a subprocess.
-- Outputs: static JSON trees for `v1_commentaries` and `v1_dictionaries`.
+- Outputs: static JSON trees under `v1/` in `getbible/commentaries` and
+ `getbible/dictionaries`. The version lives in the folder, not the repository
+ name, so a future `v2/` can be published beside it.
This repository does not build or link the CrossWire SWORD C++ engine. Changes to
that engine belong in `getbible/getbiblesword`. Do not reintroduce a local C++
@@ -32,21 +34,42 @@ Never use a `utf8` convenience field as the authoritative value. Decode `base64`
verify it, then create the public text projection. Unknown additive fields must be
retained in the internal source record. Validated entries remain disk-backed and
writers stream them; do not restore whole-module entry or commentary collections in
-memory. A missing footer, failed digest, failed artifact, unsupported major contract,
-or extractor error blocks all publication.
+memory. Composed documents are streamed from the documents they embed, never built
+up as one object. A missing footer, failed digest, failed artifact, unsupported major
+contract, or extractor error blocks all publication.
## API stability
-Commentary files remain addressable by GetBible book number and chapter. Dictionary
-Strong's keys remain compatible with Bible API v3 (`G3056`, `H0430`). Any breaking
-path or document change requires a new API version; do not silently mutate v1.
+The published API is plain text. No document may reintroduce an `html` member, and
+the builder must not grow an HTML sanitizer; the value of the text-only contract is
+that no consumer has to sanitize a response.
+
+Commentary files remain addressable by GetBible book number and chapter. Chapter `0`
+is a book introduction and verse `0` a chapter introduction; neither may be dropped.
+Book and whole-commentary documents embed their parts byte-for-byte, so
+`book.chapters[n]` must stay identical to the chapter document served on its own —
+`scripts/validate_build.py` asserts this and it is the property clients rely on.
+
+Dictionary Strong's keys remain compatible with Bible API v3 (`G3056`, `H0430`).
Repeated dictionary keys retain the unsuffixed direct path for their first
definition; later definitions use deterministic `--2`, `--3`, and subsequent
-suffixes and must all remain discoverable through `keys.json`.
+suffixes and must all remain discoverable through `index.json`, which stays sorted
+by its `search` term. Cross-references between words resolve only to keys that
+exist in the same dictionary.
+
+A module identifier may never collide with a document at the `v1/` root; see
+`RESERVED_MODULE_IDS`. Any breaking path or document change requires a new API
+version; do not silently mutate v1.
Generated repositories are replace-only outputs. A partial `--module` build may be
used for tests but must never be pushed.
+## Commits
+
+Commits in this repository are authored in the maintainer's name. Do not add a
+`Co-Authored-By` trailer, a session link, an assistant name, or any other
+tool attribution to a commit message, tag, or pull request.
+
## Verification
Run before publishing changes:
diff --git a/README.md b/README.md
index 40bb3df..23841de 100644
--- a/README.md
+++ b/README.md
@@ -5,21 +5,24 @@
`v1_study_builder` converts policy-approved CrossWire SWORD commentary and
dictionary modules into two independently deployable static JSON APIs:
-- `https://commentaries.getbible.net/v1/` from `getbible/v1_commentaries`
-- `https://dictionaries.getbible.net/v1/` from `getbible/v1_dictionaries`
+- `https://commentaries.getbible.net/v1/` from `getbible/commentaries`
+- `https://dictionaries.getbible.net/v1/` from `getbible/dictionaries`
The Bible API v3 builder remains unchanged. Study Builder deliberately uses the
same book numbers, chapters, verses, and Strong's keys so a client can move from a
Bible response to commentary or dictionary data with a direct path lookup.
+Every document is plain text. Nothing in either API publishes HTML, so a consuming
+application never has to sanitize a response before rendering it.
+
## Repository boundaries
| Repository | Responsibility | Runtime |
| --- | --- | --- |
| `getbible/getbiblesword` | Official SWORD C++ extraction and deterministic NDJSON | Released Linux executable |
| `getbible/v1_study_builder` | Download policy, strict contract validation, normalization, schemas, and publication | Python 3.12 at build time |
-| `getbible/v1_commentaries` | Generated commentary JSON under `v1/` | Nginx/CDN only |
-| `getbible/v1_dictionaries` | Generated dictionary JSON under `v1/` | Nginx/CDN only |
+| `getbible/commentaries` | Generated commentary JSON under `v1/` | Nginx/CDN only |
+| `getbible/dictionaries` | Generated dictionary JSON under `v1/` | Nginx/CDN only |
Study Builder does not contain C++, link `libsword`, use a Python SWORD binding, or
parse a module's binary driver format. `getbiblesword` is a separately versioned
@@ -65,30 +68,42 @@ and independently checks all of the rules that protect publication:
- exact stream SHA-256 over every line before the footer, including LF;
- exact footer record/entry/artifact/byte counts and `success: true`.
-Raw bytes remain authoritative. The adapter derives safe text/HTML for the public
-API only after verification and retains the original contract records internally.
+Raw bytes remain authoritative. The adapter derives the public plain text only
+after verification and retains the original contract records internally.
Validated entries are held in a compressed, disk-backed spool. Commentary entries
are then normalized into disk-backed chapter buckets and emitted in canonical
GetBible book/chapter order; this supports source modules whose versification orders
canonical or deuterocanonical books differently. Dictionary definitions are written
-one at a time. This keeps memory bounded for large modules without weakening the
-contract or the all-or-nothing publication rule. Any missing footer, checksum
-failure, failed diagnostic, extractor error, or classification mismatch stops the
-complete build before publication.
+one at a time. Book, whole-commentary, and whole-dictionary documents are streamed
+from the documents they contain rather than assembled in memory. This keeps memory
+bounded for large modules without weakening the contract or the all-or-nothing
+publication rule. Any missing footer, checksum failure, failed diagnostic,
+extractor error, or classification mismatch stops the complete build before
+publication.
## Commentary API
```text
GET https://commentaries.getbible.net/v1/commentaries.json
+GET https://commentaries.getbible.net/v1/{commentary}.json
GET https://commentaries.getbible.net/v1/{commentary}/metadata.json
GET https://commentaries.getbible.net/v1/{commentary}/books.json
GET https://commentaries.getbible.net/v1/{commentary}/{book}.json
GET https://commentaries.getbible.net/v1/{commentary}/{book}/{chapter}.json
```
-The chapter path is the primary high-volume endpoint. `book` is the GetBible API
-v3 numeric identifier: Genesis is `1`, Matthew `40`, and Revelation `66`. Each
-entry contains its natural Bible coordinate:
+`book` is the GetBible API v3 numeric identifier: Genesis is `1`, Daniel `27`,
+Matthew `40`, and Revelation `66`. Deuterocanonical books continue to `83`.
+
+The three content levels are self-similar. A chapter document is one member of a
+book document, which is one member of a whole-commentary document, embedded
+byte-for-byte. One client parser therefore handles all three:
+
+```text
+{commentary}/{book}/{chapter}.json one chapter, the high-volume endpoint
+{commentary}/{book}.json every chapter of that book
+{commentary}.json every book of that commentary
+```
```json
{
@@ -106,24 +121,41 @@ entry contains its natural Bible coordinate:
"name": "John 1:1",
"anchor": {"book": 43, "chapter": 1, "verse": 1, "osis": "John.1.1"},
"text": "...",
- "html": "
...
"
+ "references": [{"osis": "Gen.1.1", "book": 1, "chapter": 1, "verse": 1}]
}
]
}
```
-Book and chapter introductions use chapter or verse `0`; they are not discarded.
+Introductions are published, not discarded. A book introduction is chapter `0`,
+so Clarke's introduction to Daniel is `clarke/27/0.json`. A chapter introduction
+is verse `0`, and appears as the first entry of its own chapter document.
+
+`books.json` reports which books and chapters a commentary covers, and
+`metadata.json` reports its licence, counts, and the byte size of the
+whole-commentary document so a client can decide before requesting it.
## Dictionary API
```text
GET https://dictionaries.getbible.net/v1/dictionaries.json
+GET https://dictionaries.getbible.net/v1/{dictionary}.json
GET https://dictionaries.getbible.net/v1/{dictionary}/metadata.json
-GET https://dictionaries.getbible.net/v1/{dictionary}/keys.json
+GET https://dictionaries.getbible.net/v1/{dictionary}/index.json
GET https://dictionaries.getbible.net/v1/{dictionary}/{entry}.json
-GET https://dictionaries.getbible.net/v1/{dictionary}/indexes/{sha256-prefix}.json
```
+Searching a dictionary takes two requests. `index.json` lists every word once,
+sorted by an accent-insensitive lowercase `search` term, so a client can fetch it
+once and then search, prefix-match, or binary-search entirely in memory:
+
+```json
+{"id": "k-KADESH", "key": "KADESH", "search": "kadesh"}
+```
+
+The record's `id` is the path of the word itself — `{entry}.json` — so a hit in
+the index resolves to exactly one document with no further lookup.
+
Strong's paths match Bible API v3 tokens directly:
```text
@@ -133,17 +165,48 @@ H0430 -> https://dictionaries.getbible.net/v1/strongshebrew/H0430.json
Greek keys use `G` plus the unpadded number; Hebrew keys use `H0` plus the
unpadded number. Other dictionary keys receive deterministic, path-safe IDs.
-`keys.json` maps source keys and aliases, while 256 SHA-256-prefix shards provide
-smaller lookup indexes for constrained clients.
+
+Each word document carries the dictionary's own link graph, so a client can
+navigate in either direction without rebuilding an index:
+
+```json
+{
+ "schema": "getbible-dictionary-entry-v1",
+ "dictionary": "easton",
+ "id": "k-KADESH",
+ "key": "KADESH",
+ "occurrence": 1,
+ "aliases": ["KADESH"],
+ "text": "Holy; a place in the wilderness of Zin.",
+ "see_also": [{"id": "k-MERIBAH", "key": "MERIBAH"}],
+ "backlinks": [{"id": "k-ZIN", "key": "ZIN"}],
+ "references": [{"osis": "Num.20.1", "book": 4, "chapter": 20, "verse": 1}]
+}
+```
+
+`see_also` lists the words this entry points at and `backlinks` the words that
+point back. Only targets that resolve to a real key in the same dictionary are
+published. Scripture references stay in `references`, in the same shape the
+commentary API uses.
Some SWORD dictionaries legitimately contain more than one definition for the
same public key. The first definition keeps the canonical direct path, and later
definitions receive deterministic `--2`, `--3`, and subsequent suffixes. For
example, Easton's repeated `KADESH` records are available as `k-KADESH.json` and
-`k-KADESH--2.json`. Every definition appears in `keys.json` with an `occurrence`
+`k-KADESH--2.json`. Every definition appears in `index.json` with an `occurrence`
value. Dictionary metadata reports both the total `entry_count` and the distinct
`unique_key_count`.
+`{dictionary}.json` is the complete dictionary in index order, for offline
+clients that would otherwise request every word individually.
+
+## Integrity and schemas
+
+Each API root publishes `hashes.json`, a SHA-256 digest of every other generated
+document, which is also the manifest of the paths a build owns. The JSON Schemas
+for every document type are served beside the data under `v1/schema/`, so each
+schema `$id` resolves to the document that defines it.
+
## Build flow
```mermaid
@@ -152,15 +215,37 @@ flowchart TD
B --> C["NDJSON v1 subprocess stream"]
C --> D["Independent stream + artifact validator"]
D --> E["Python API adapter + JSON Schema"]
- E --> F["Atomic static v1 trees + hash sidecars"]
- F --> G["v1_commentaries, when publication secrets exist"]
- F --> H["v1_dictionaries, when publication secrets exist"]
+ E --> F["Atomic static v1 trees + SHA-256 manifest"]
+ F --> G["commentaries, when publication secrets exist"]
+ F --> H["dictionaries, when publication secrets exist"]
```
The static output is the system of record. Nginx and a CDN can serve direct
lookups without an application process, database connection pool, or request
throttling bottleneck.
+## Deployment
+
+A production origin is a pull, a verify, a compress, and a sync:
+
+```bash
+scripts/deploy_static_api.sh \
+ --repo git@github.com:getbible/commentaries.git \
+ --root /var/www/getbible/commentaries \
+ --require-signature
+
+scripts/verify_live_api.sh https://commentaries.getbible.net
+```
+
+The whole tree is checked against `hashes.json` before it reaches the live root,
+so a failed build leaves the previous one serving.
+
+`docs/nginx/` holds the origin configuration for both hosts; install it with
+`scripts/install_nginx_config.sh`, which adapts it to the host's nginx version,
+brotli availability, and IPv6 support rather than leaving those as footguns.
+`docs/deployment.md` describes the server layout, the caching model, the CDN and
+security posture, rollback, and monitoring.
+
## Local development
```bash
@@ -232,8 +317,8 @@ Publication secret set:
| `GETBIBLE_SSH_KEY` | SSH private key with write access to both outputs |
| `GETBIBLE_SSH_PUB` | Matching public key |
-The default output remotes are `getbible/v1_commentaries` and
-`getbible/v1_dictionaries`. Optional `GETBIBLE_COMMENTARIES_REPO` and
+The default output remotes are `getbible/commentaries` and
+`getbible/dictionaries`. Optional `GETBIBLE_COMMENTARIES_REPO` and
`GETBIBLE_DICTIONARIES_REPO` secrets may select staging remotes.
## Redistribution policy
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..bba9a4c
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,330 @@
+# Deploying the study APIs
+
+Two origins, each serving one generated repository as static JSON. There is no
+application process, no database, and no request-time logic — a request is a
+file read, which is why the whole design leans on the filesystem and the CDN
+rather than on code.
+
+| Origin | Serves | Live root |
+| --- | --- | --- |
+| `commentaries.getbible.net` | `getbible/commentaries` | `/var/www/getbible/commentaries` |
+| `dictionaries.getbible.net` | `getbible/dictionaries` | `/var/www/getbible/dictionaries` |
+
+Everything below applies identically to both. Where a command names one, run the
+same command with the other's repository and root.
+
+## What the builder guarantees
+
+The deploy depends on four properties. If you change the builder, keep them, or
+the deployment stops being safe:
+
+1. **Output is byte-stable.** A module that has not changed rebuilds to an
+ identical file. Content documents carry no timestamp — only `build.json` and
+ the catalog do — so an unchanged corpus produces an unchanged tree.
+2. **`v1/hashes.json` describes the whole tree.** It holds a SHA-256 for every
+ other document, so it is both the integrity manifest and the list of paths
+ the builder owns.
+3. **The builder owns only version directories.** Anything else in the
+ publication repository — README, licence, workflows — is the repository's
+ own and is never served.
+4. **Every document is plain text JSON.** No HTML is published anywhere, which
+ is why the origin can send `Content-Security-Policy: default-src 'none'` and
+ why no consumer has to sanitize a response.
+
+## Server layout
+
+```
+/var/lib/getbible/commentaries persistent Git checkout (working copy)
+/var/www/getbible/commentaries live root nginx serves — version dirs only
+/etc/nginx/conf.d/getbible-api-http.conf zones and log format (http only)
+/etc/nginx/snippets/getbible-api-server.conf tuning, TLS, compression
+/etc/nginx/snippets/getbible-api-headers.conf CORS and security headers
+/etc/nginx/snippets/getbible-api-v1.conf the v1 locations
+/etc/nginx/sites-available/commentaries.getbible.net.conf
+```
+
+The live root is **not** a Git checkout. The checkout stays in `/var/lib`, and
+only the version directories are copied across. That keeps repository metadata
+off the origin entirely rather than relying on a rule to hide it.
+
+## First-time setup
+
+Requires nginx (1.25.1+ preferred; older is adapted automatically), git,
+rsync, python3, gzip, and ideally brotli.
+
+```bash
+apt-get install -y nginx git rsync python3 certbot brotli
+# Brotli for nginx is a separate module; without it only .gz is served, which
+# still works and is roughly 15-20% larger on JSON.
+apt-get install -y libnginx-mod-http-brotli # where packaged
+
+install -d -m 755 /var/lib/getbible
+```
+
+Issue the certificates first — nginx refuses to start when a referenced
+certificate is missing. The stock nginx site already serves `/var/www/html` on
+port 80, so this works before anything below is installed, and the installed
+configuration keeps serving the same webroot afterwards so renewals need no
+further changes:
+
+```bash
+certbot certonly --webroot -w /var/www/html \
+ -d commentaries.getbible.net -d dictionaries.getbible.net
+```
+
+Then install the origin configuration:
+
+```bash
+scripts/install_nginx_config.sh --reload
+```
+
+Do not copy `docs/nginx/` into place by hand. Three things legitimately differ
+between hosts, and guessing wrong on any of them stops nginx from starting, so
+the installer detects them instead:
+
+- **`http2 on` is nginx 1.25.1+.** On older nginx — Ubuntu 24.04 LTS still ships
+ 1.24 — it is folded back into the `listen` line.
+- **`brotli_static` needs `ngx_brotli`**, which many distributions do not
+ package. It is enabled only when the module is actually present.
+- **IPv6 listeners fail outright** on a host without IPv6.
+
+The installer stages the adapted files, refuses to proceed if a certificate is
+missing (naming the exact certbot command), and runs `nginx -t` before
+reloading. `--dry-run` shows what it would install and leaves the adapted files
+for inspection.
+
+Reload nginx automatically after each renewal:
+
+```bash
+cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
+#!/bin/sh
+systemctl reload nginx
+HOOK
+chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
+```
+
+## Deploying
+
+```bash
+scripts/deploy_static_api.sh \
+ --repo git@github.com:getbible/commentaries.git \
+ --root /var/www/getbible/commentaries \
+ --require-signature \
+ --verify-url https://commentaries.getbible.net/v1/commentaries.json
+```
+
+The script does five things, and the order is the point — nothing reaches the
+live root until it has been proven good:
+
+1. **Pull.** `git reset --hard` into the persistent checkout. Git rewrites only
+ files whose content changed, so every unchanged document keeps its mtime.
+2. **Verify.** Every digest in `hashes.json` is recomputed, and any `.json` in
+ the tree that the manifest does not list is an error. A build that fails here
+ never reaches the live root; the previous deploy keeps serving.
+3. **Compress.** `.gz` and `.br` are written beside each document, but only
+ where missing or stale, and each variant is stamped with its document's
+ mtime. A monthly rebuild that changes two modules recompresses two modules.
+4. **Sync.** `rsync --delete --delay-updates`, one version directory at a time.
+ Scoping the delete to a single version means no failure of this script can
+ remove anything outside a version directory.
+5. **Reload.** `nginx -t` then reload. New workers start with an empty
+ open-file cache, which is what makes the new documents visible at once.
+
+`--require-signature` refuses any commit without a valid GPG signature. The
+build workflow signs its commits when the publication secrets are present, so on
+a production origin this should always be on: it means the origin serves only
+what the build key signed.
+
+It verifies against the deploying user's own GPG keyring, so import and trust
+the build key once on each origin before enabling it — otherwise every deploy
+fails closed with an unsigned-commit error:
+
+```bash
+sudo -u deploy gpg --import getbible-build-key.asc
+sudo -u deploy gpg --lsign-key
+```
+
+Verification covers every file, not only the JSON documents: anything present in
+a version directory that `hashes.json` does not list fails the deploy. A stray
+`.html` or `.js` would otherwise be served from the API's own hostname.
+
+Use `--dry-run` to see the exact change set without touching the live root.
+
+### Automating it
+
+```ini
+# /etc/systemd/system/getbible-commentaries-deploy.service
+[Unit]
+Description=Deploy the GetBible commentary API
+After=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/bin/deploy_static_api.sh \
+ --repo git@github.com:getbible/commentaries.git \
+ --root /var/www/getbible/commentaries \
+ --require-signature \
+ --verify-url https://commentaries.getbible.net/v1/commentaries.json
+```
+
+```ini
+# /etc/systemd/system/getbible-commentaries-deploy.timer
+[Unit]
+Description=Check for a new commentary API build
+
+[Timer]
+OnCalendar=*-*-* 05:30:00
+RandomizedDelaySec=30m
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+```
+
+The builder runs monthly, so a daily timer simply finds nothing to do most days
+— the pull is a no-op, verification passes, and rsync transfers nothing.
+
+### Why the configuration is split the way it is
+
+`conf.d/getbible-api-http.conf` holds only the shared memory zones and the log
+format, because those cannot live in a server block. Everything else is in
+`snippets/getbible-api-server.conf` and included per host.
+
+That is not tidiness. A distribution's stock `nginx.conf` already sets
+`sendfile`, `gzip`, `tcp_nopush`, `ssl_protocols`, and
+`ssl_prefer_server_ciphers` at http level, and nginx treats a second
+declaration in the same context as a fatal `directive is duplicate` error — so
+an http-context drop-in would refuse to start on a stock Ubuntu box. Server
+context directives override the http-level ones instead. Keep new settings in
+the server snippet unless they genuinely cannot go there.
+
+## The caching model
+
+Two tiers, because the documents have two very different change rates:
+
+| Documents | `max-age` | Why |
+| --- | --- | --- |
+| `{resource}.json`, `build.json`, `hashes.json` | 300s | Rewritten by every build |
+| Everything else | 86400s | Changes only when a source module changes |
+
+Both carry `stale-while-revalidate` and `stale-if-error`, so a slow or briefly
+unreachable origin degrades into serving slightly stale JSON rather than errors.
+
+Nothing is marked `immutable`, deliberately. Paths are not content-hashed, so a
+document's URL is stable across rebuilds; promising immutability would strand
+clients on an old copy after a correction.
+
+### Why ETags survive a deploy
+
+nginx derives a static file's ETag from its mtime and size. A deploy that
+rewrote every file — a fresh clone, or `cp -r` — would change every mtime and so
+every ETag, and several thousand clients would re-download a corpus that had not
+changed. Git and rsync both write only what actually differs and rsync preserves
+mtimes, so unchanged documents keep their ETag and revalidation stays a 304.
+
+This is why the script pulls into a persistent checkout instead of cloning, and
+why the compressed variants are stamped with `touch -r`.
+
+### Atomicity
+
+`rsync --delay-updates` stages the whole change set and swaps it in at the end,
+which narrows the window where a client could see a mix of old and new documents
+to a fast sequence of renames. It does not eliminate it.
+
+That is a deliberate trade. Eliminating it entirely means deploying to a new
+release directory and swapping a symlink — which changes every inode on every
+deploy and throws away the ETag stability above. For a read-only corpus where
+old and new documents are each individually valid, and where a build lands
+monthly, stable ETags are worth far more than a perfectly atomic switch.
+
+## Behind a CDN
+
+Put a CDN in front of both origins. The two cache tiers are already what a CDN
+wants, and the bulk documents in particular should be served from the edge.
+
+Restore the real client IP before the rate limits apply, or they will limit the
+CDN rather than the client — the commented `set_real_ip_from` block in each
+server file is where that goes. Refresh the address ranges from the CDN's
+published list on a schedule; do not freeze them into the config.
+
+If the origin sits only behind a CDN, consider raising the `limit_req` rate:
+the limits shipped here assume the origin is directly reachable.
+
+## Security posture
+
+- **Read-only.** Anything other than `GET`, `HEAD`, or `OPTIONS` gets a JSON
+ 405. `client_max_body_size` is 1k — nothing is ever uploaded.
+- **No markup anywhere.** `default-src 'none'`, `sandbox`, and `nosniff` mean a
+ response that somehow was not JSON is inert in a browser.
+- **No repository metadata on the origin.** The live root holds only version
+ directories; the dotfile deny rule is a second line, not the first.
+- **Signed input.** `--require-signature` ties the origin to the build key.
+- **Verified input.** The whole tree is checked against `hashes.json` before and
+ after it goes live.
+- **No version disclosure.** `server_tokens off`.
+- **Abuse limits.** Per-IP request and connection limits, with a tighter
+ connection cap and a rate cap on the bulk documents so one offline sync cannot
+ monopolise the origin's upstream.
+
+## Verifying a live origin
+
+```bash
+scripts/verify_live_api.sh https://commentaries.getbible.net
+scripts/verify_live_api.sh https://dictionaries.getbible.net
+```
+
+The script reads the catalog and follows it, so it works against any build
+without being told which modules exist. It asserts every promise the API makes:
+paths resolve, both cache tiers are right, precompressed variants are served,
+revalidation returns 304, CORS and the security headers are present, and
+failures come back as JSON. It exits non-zero on the first broken promise, so it
+is safe to run from a monitor.
+
+## Rolling back
+
+The live root records what it is serving:
+
+```bash
+cat /var/www/getbible/commentaries/.revision
+```
+
+Generated repositories are replace-only, so the clean rollback is to revert the
+bad commit in the publication repository and deploy again:
+
+```bash
+git -C /var/lib/getbible/commentaries revert --no-edit
+git -C /var/lib/getbible/commentaries push origin main
+scripts/deploy_static_api.sh --root /var/www/getbible/commentaries
+```
+
+That keeps the history honest about what was served and when, which a symlink
+flip does not.
+
+A failed deploy needs no rollback: verification runs before the sync, so the
+previous build is still live and untouched.
+
+## Monitoring
+
+Watch these, in rough order of how much they matter:
+
+- `verify_live_api.sh` exit status, on a schedule.
+- The deploy unit's exit status. A non-zero exit means the origin is serving a
+ build older than the newest published one.
+- `hashes.json` age versus the builder's monthly schedule — a stale one means
+ builds have stopped, which is invisible from the API itself.
+- 5xx rate and `p99` request time from the JSON access log.
+- 429 rate. A rising 429 rate usually means a client is ignoring
+ `Cache-Control`, not that the limits are too tight.
+- Certificate expiry.
+- Access log growth. The logs are named `*.access.log` so the stock
+ `/etc/logrotate.d/nginx` glob (`/var/log/nginx/*.log`) rotates them; renaming
+ them outside that glob silently fills the disk.
+
+## Adding a v2 later
+
+The version lives in the directory, not the repository name, so a future
+`v2/` is published beside `v1/` in the same repository. The deploy script
+already discovers and syncs every `v[0-9]*` directory it finds and retires ones
+that disappear. Serving it needs a copy of `snippets/getbible-api-v1.conf` with
+the paths changed, included alongside the existing one. `v1` keeps working
+untouched, which is the whole reason the version segment is there.
diff --git a/docs/nginx.conf b/docs/nginx.conf
deleted file mode 100644
index bf1d4e8..0000000
--- a/docs/nginx.conf
+++ /dev/null
@@ -1,30 +0,0 @@
-# Add the relevant server block to each API virtual host. Set root to the
-# checkout of v1_commentaries or v1_dictionaries respectively.
-
-server {
- listen 443 ssl http2;
- server_name commentaries.getbible.net;
- root /var/www/getbible/v1_commentaries;
-
- etag on;
- gzip on;
- gzip_types application/json text/plain;
-
- location /v1/ {
- try_files $uri =404;
- add_header Access-Control-Allow-Origin "*" always;
- add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
- add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always;
- }
-
- location ~* \.sha$ {
- try_files $uri =404;
- default_type text/plain;
- add_header Access-Control-Allow-Origin "*" always;
- add_header Cache-Control "public, max-age=31536000, immutable" always;
- }
-}
-
-# Duplicate the block for dictionaries.getbible.net and set:
-# root /var/www/getbible/v1_dictionaries;
-
diff --git a/docs/nginx/commentaries.getbible.net.conf b/docs/nginx/commentaries.getbible.net.conf
new file mode 100644
index 0000000..ccd787d
--- /dev/null
+++ b/docs/nginx/commentaries.getbible.net.conf
@@ -0,0 +1,74 @@
+# GetBible commentary API — https://commentaries.getbible.net/
+#
+# Install as /etc/nginx/sites-available/commentaries.getbible.net.conf and link
+# it into sites-enabled. Requires conf.d/getbible-api-http.conf and both files
+# in snippets/. Requires nginx 1.25.1+ for "http2 on"; on older nginx use
+# "listen 443 ssl http2;" and drop the separate directive.
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name commentaries.getbible.net;
+
+ # Served from the stock nginx document root so the very first certificate
+ # can be issued before this file is installed, and so renewals keep working
+ # afterwards without a second webroot.
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/html;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ # HTTP/3 needs a QUIC-capable build; enable both lines together with the
+ # Alt-Svc header below.
+ # listen 443 quic reuseport;
+ # listen [::]:443 quic reuseport;
+ # http3 on;
+
+ server_name commentaries.getbible.net;
+
+ # The live root holds only the version directories, deployed by
+ # scripts/deploy_static_api.sh. It is not a Git checkout.
+ root /var/www/getbible/commentaries;
+
+ ssl_certificate /etc/letsencrypt/live/commentaries.getbible.net/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/commentaries.getbible.net/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/commentaries.getbible.net/chain.pem;
+
+ access_log /var/log/nginx/commentaries.getbible.net.access.log getbible buffer=64k flush=5s;
+ error_log /var/log/nginx/commentaries.getbible.net.error.log warn;
+
+ # add_header Alt-Svc 'h3=":443"; ma=86400' always; # with HTTP/3 above
+
+ # Behind Cloudflare or another CDN, restore the client IP before the rate
+ # limits in conf.d/getbible-api-http.conf are applied to it. Refresh the
+ # ranges from the CDN's published list; do not hardcode them permanently.
+ # include /etc/nginx/conf.d/cloudflare-real-ip.conf;
+ # real_ip_header CF-Connecting-IP;
+ # real_ip_recursive on;
+
+ include snippets/getbible-api-server.conf;
+ include snippets/getbible-api-headers.conf;
+
+ # Read-only API. Answer preflight cheaply and reject everything unsafe.
+ if ($request_method = OPTIONS) {
+ return 204;
+ }
+ if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
+ return 405 '{"error":"method_not_allowed","allow":"GET, HEAD, OPTIONS"}';
+ }
+
+ include snippets/getbible-api-v1.conf;
+
+ location / {
+ return 404;
+ }
+}
diff --git a/docs/nginx/dictionaries.getbible.net.conf b/docs/nginx/dictionaries.getbible.net.conf
new file mode 100644
index 0000000..b32dfa5
--- /dev/null
+++ b/docs/nginx/dictionaries.getbible.net.conf
@@ -0,0 +1,69 @@
+# GetBible dictionary API — https://dictionaries.getbible.net/
+#
+# Install as /etc/nginx/sites-available/dictionaries.getbible.net.conf and link
+# it into sites-enabled. Requires conf.d/getbible-api-http.conf and both files
+# in snippets/. Requires nginx 1.25.1+ for "http2 on"; on older nginx use
+# "listen 443 ssl http2;" and drop the separate directive.
+#
+# This file is the commentary host with three values changed: server_name, root,
+# and the certificate and log paths. The locations themselves are shared.
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name dictionaries.getbible.net;
+
+ # Served from the stock nginx document root so the very first certificate
+ # can be issued before this file is installed, and so renewals keep working
+ # afterwards without a second webroot.
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/html;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ # listen 443 quic reuseport;
+ # listen [::]:443 quic reuseport;
+ # http3 on;
+
+ server_name dictionaries.getbible.net;
+
+ root /var/www/getbible/dictionaries;
+
+ ssl_certificate /etc/letsencrypt/live/dictionaries.getbible.net/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/dictionaries.getbible.net/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/dictionaries.getbible.net/chain.pem;
+
+ access_log /var/log/nginx/dictionaries.getbible.net.access.log getbible buffer=64k flush=5s;
+ error_log /var/log/nginx/dictionaries.getbible.net.error.log warn;
+
+ # add_header Alt-Svc 'h3=":443"; ma=86400' always; # with HTTP/3 above
+
+ # include /etc/nginx/conf.d/cloudflare-real-ip.conf;
+ # real_ip_header CF-Connecting-IP;
+ # real_ip_recursive on;
+
+ include snippets/getbible-api-server.conf;
+ include snippets/getbible-api-headers.conf;
+
+ if ($request_method = OPTIONS) {
+ return 204;
+ }
+ if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
+ return 405 '{"error":"method_not_allowed","allow":"GET, HEAD, OPTIONS"}';
+ }
+
+ include snippets/getbible-api-v1.conf;
+
+ location / {
+ return 404;
+ }
+}
diff --git a/docs/nginx/getbible-api-http.conf b/docs/nginx/getbible-api-http.conf
new file mode 100644
index 0000000..5b9312c
--- /dev/null
+++ b/docs/nginx/getbible-api-http.conf
@@ -0,0 +1,45 @@
+# GetBible study API — http-context settings.
+#
+# Install as /etc/nginx/conf.d/getbible-api-http.conf.
+#
+# This file holds only what CANNOT live in a server block: shared memory zones
+# and a log format. Everything else the API needs is in
+# snippets/getbible-api-server.conf, which each host includes.
+#
+# That split is deliberate. A distribution's stock nginx.conf already sets
+# directives like sendfile, gzip, and ssl_protocols at http level, and nginx
+# treats a second declaration in the same context as a fatal "directive is
+# duplicate" error. Server-context directives simply override the http-level
+# ones instead, so the API configuration drops into any distribution without
+# having to edit its nginx.conf first.
+#
+# Names here are unique to this API, so nothing in this file can collide.
+
+# ---------------------------------------------------------------------------
+# Abuse limits
+# ---------------------------------------------------------------------------
+# These protect the origin when it is reachable directly. Behind a CDN they
+# limit the CDN unless the real client IP is restored first — see the
+# set_real_ip_from block in the server files.
+
+limit_req_zone $binary_remote_addr zone=getbible_api:16m rate=40r/s;
+limit_conn_zone $binary_remote_addr zone=getbible_conn:16m;
+limit_conn_zone $binary_remote_addr zone=getbible_bulk:16m;
+
+# ---------------------------------------------------------------------------
+# Logging
+# ---------------------------------------------------------------------------
+
+log_format getbible escape=json '{'
+ '"time":"$time_iso8601",'
+ '"host":"$host",'
+ '"remote_addr":"$remote_addr",'
+ '"method":"$request_method",'
+ '"uri":"$uri",'
+ '"status":$status,'
+ '"bytes_sent":$body_bytes_sent,'
+ '"request_time":$request_time,'
+ '"referer":"$http_referer",'
+ '"user_agent":"$http_user_agent",'
+ '"encoding":"$http_accept_encoding"'
+'}';
diff --git a/docs/nginx/snippets/getbible-api-headers.conf b/docs/nginx/snippets/getbible-api-headers.conf
new file mode 100644
index 0000000..2d2e767
--- /dev/null
+++ b/docs/nginx/snippets/getbible-api-headers.conf
@@ -0,0 +1,24 @@
+# GetBible study API — response headers shared by every location.
+#
+# nginx replaces rather than merges add_header sets: a location that declares
+# any add_header of its own loses every inherited one. So this file is included
+# at server level AND in each location that adds a Cache-Control of its own.
+# Do not add a header in a location without including this file beside it.
+
+add_header Access-Control-Allow-Origin "*" always;
+add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
+add_header Access-Control-Allow-Headers "Accept, Accept-Encoding, If-None-Match, If-Modified-Since, Range" always;
+add_header Access-Control-Expose-Headers "ETag, Content-Length, Content-Range" always;
+add_header Access-Control-Max-Age "86400" always;
+
+# The API is public, read-only, and returns no markup. default-src 'none' makes
+# any response that is somehow not JSON inert in a browser, and cross-origin
+# CORP keeps the API usable from cross-origin-isolated pages.
+add_header X-Content-Type-Options "nosniff" always;
+add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; sandbox" always;
+add_header Referrer-Policy "no-referrer" always;
+add_header Cross-Origin-Resource-Policy "cross-origin" always;
+
+# Add includeSubDomains and preload only once every getbible.net host is HTTPS
+# and you have accepted that the choice is effectively irreversible.
+add_header Strict-Transport-Security "max-age=31536000" always;
diff --git a/docs/nginx/snippets/getbible-api-server.conf b/docs/nginx/snippets/getbible-api-server.conf
new file mode 100644
index 0000000..8b00ea0
--- /dev/null
+++ b/docs/nginx/snippets/getbible-api-server.conf
@@ -0,0 +1,84 @@
+# GetBible study API — server-context tuning, shared by both hosts.
+#
+# Every directive here is valid in a server block, so it overrides whatever the
+# distribution's nginx.conf sets at http level rather than colliding with it.
+# Keep it that way: moving any of this to http context reintroduces the
+# "directive is duplicate" failures that make the configuration distribution
+# specific. Requires conf.d/getbible-api-http.conf for the zones and log format.
+
+# ---------------------------------------------------------------------------
+# Static file serving
+# ---------------------------------------------------------------------------
+
+# A study API request is one small file read. Caching descriptors and stat()
+# results removes most of its syscall cost. Workers start with an empty cache,
+# so a deploy must reload nginx — deploy_static_api.sh does.
+open_file_cache max=200000 inactive=5m;
+open_file_cache_valid 2m;
+open_file_cache_min_uses 1;
+open_file_cache_errors on;
+
+sendfile on;
+sendfile_max_chunk 2m;
+tcp_nopush on;
+tcp_nodelay on;
+
+# Whole-commentary documents reach tens of megabytes. Threaded reads keep one
+# bulk transfer from blocking a worker that is serving chapter lookups.
+aio threads;
+directio 16m;
+output_buffers 2 512k;
+
+server_tokens off;
+
+# ---------------------------------------------------------------------------
+# Compression
+# ---------------------------------------------------------------------------
+# The deploy writes .gz and .br beside every .json, so compression normally
+# costs no request-time CPU at all. On-the-fly gzip stays on only as a safety
+# net for a document the deploy has not compressed yet.
+
+gzip on;
+gzip_vary on;
+gzip_proxied any;
+gzip_comp_level 5;
+gzip_min_length 1024;
+gzip_types application/json;
+
+# ---------------------------------------------------------------------------
+# TLS
+# ---------------------------------------------------------------------------
+# Mozilla "intermediate" profile. Certificates are per host, in the server files.
+
+ssl_protocols TLSv1.2 TLSv1.3;
+ssl_prefer_server_ciphers off;
+ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
+ssl_session_timeout 1d;
+ssl_session_tickets off;
+
+# Harmless to leave on: if the certificate carries no OCSP responder URL — Let's
+# Encrypt no longer issues one — nginx logs a single warning at startup and
+# serves without stapling. It still works for a CA that does publish one.
+ssl_stapling on;
+ssl_stapling_verify on;
+
+# Needed for OCSP stapling. Use the host's own resolver if it has one.
+resolver 1.1.1.1 9.9.9.9 valid=300s;
+resolver_timeout 5s;
+
+# ---------------------------------------------------------------------------
+# Request handling
+# ---------------------------------------------------------------------------
+
+limit_req_status 429;
+limit_conn_status 429;
+
+charset off;
+default_type application/json;
+etag on;
+
+# A bulk document is tens of megabytes; nothing here is ever uploaded.
+client_max_body_size 1k;
+client_body_timeout 10s;
+keepalive_timeout 65s;
+keepalive_requests 1000;
diff --git a/docs/nginx/snippets/getbible-api-v1.conf b/docs/nginx/snippets/getbible-api-v1.conf
new file mode 100644
index 0000000..6fbe1fb
--- /dev/null
+++ b/docs/nginx/snippets/getbible-api-v1.conf
@@ -0,0 +1,126 @@
+# GetBible study API — the v1 locations, identical for both hosts.
+#
+# The served root contains only the version directories the builder produces,
+# so v1/ is the whole API surface and anything else is a 404.
+#
+# nginx evaluates regular-expression locations in the order they appear here and
+# before the "/v1/" prefix location, so the order below is load-bearing:
+#
+# 1. dotfiles denied outright
+# 2. discovery docs short cache, they change on every build
+# 3. bulk documents long cache, throttled, tens of megabytes each
+# 4. everything else long cache — chapters, books, words, indexes, schemas
+
+# Refuse dotfiles before any other rule can serve one. The live root is built by
+# rsync and holds no repository metadata, but this costs nothing and closes the
+# hole permanently if someone ever points the root at a Git checkout.
+#
+# 404 rather than "deny all": the caller sees the same JSON 404 it would get for
+# any unknown path, which confirms nothing about what exists, and nginx does not
+# write an error-log line for every probe. A scanned origin would otherwise fill
+# its error log with noise that buries real failures.
+location ~ /\. {
+ access_log off;
+ log_not_found off;
+ return 404;
+}
+
+# commentaries.json / dictionaries.json / build.json / hashes.json.
+# Both resource names appear here so a single snippet serves both hosts; only
+# one of them exists in any given root.
+location ~ ^/v1/(commentaries|dictionaries|build|hashes)\.json$ {
+ limit_req zone=getbible_api burst=200 nodelay;
+ limit_conn getbible_conn 64;
+
+ gzip_static on;
+ # brotli_static on;
+
+ try_files $uri =404;
+
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400" always;
+}
+
+# Whole-commentary and whole-dictionary documents: /v1/clarke.json, /v1/easton.json.
+# One of these is worth thousands of chapter requests, so it is deliberately
+# cheap to fetch once and deliberately hard to fetch in a loop.
+location ~ ^/v1/[^/]+\.json$ {
+ limit_req zone=getbible_api burst=20 nodelay;
+ limit_conn getbible_bulk 2;
+
+ # Full speed for the first 10 MB, then throttled, so one offline sync cannot
+ # monopolise the origin's upstream.
+ limit_rate_after 10m;
+ limit_rate 5m;
+
+ gzip_static on;
+ # brotli_static on;
+
+ try_files $uri =404;
+
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400" always;
+}
+
+# Chapters, books, word entries, search indexes, metadata, and schemas.
+# These change only when a source module changes, which is monthly at most.
+location /v1/ {
+ limit_req zone=getbible_api burst=200 nodelay;
+ limit_conn getbible_conn 64;
+
+ gzip_static on;
+ # brotli_static on;
+
+ try_files $uri =404;
+
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400" always;
+}
+
+# Unversioned root: point a caller at the current version rather than 404.
+location = / {
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "public, max-age=3600" always;
+ default_type application/json;
+ return 200 '{"api":"getbible-study","versions":["v1"],"base_url":"$scheme://$host/v1/"}\n';
+}
+
+# Load balancer probe. Never logged, never cached, no headers needed.
+location = /health {
+ access_log off;
+ add_header Cache-Control "no-store" always;
+ default_type application/json;
+ return 200 '{"status":"ok"}\n';
+}
+
+# A JSON API must fail in JSON. Without this a missing chapter returns nginx's
+# HTML error page, which every client then has to special-case.
+error_page 403 404 = @not_found;
+
+location @not_found {
+ internal;
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "public, max-age=60" always;
+ default_type application/json;
+ return 404 '{"error":"not_found","message":"No such document in this API version.","base_url":"$scheme://$host/v1/"}\n';
+}
+
+error_page 429 = @rate_limited;
+
+location @rate_limited {
+ internal;
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "no-store" always;
+ default_type application/json;
+ return 429 '{"error":"rate_limited","message":"Too many requests. This API is cacheable; honour Cache-Control before retrying."}\n';
+}
+
+error_page 500 502 503 504 = @server_error;
+
+location @server_error {
+ internal;
+ include snippets/getbible-api-headers.conf;
+ add_header Cache-Control "no-store" always;
+ default_type application/json;
+ return 500 '{"error":"server_error"}\n';
+}
diff --git a/docs/target-repositories.md b/docs/target-repositories.md
index 4eae3d6..a1a55cc 100644
--- a/docs/target-repositories.md
+++ b/docs/target-repositories.md
@@ -1,13 +1,14 @@
# Target repository setup
-Create two empty repositories with `main` as their default branch:
+Create two repositories with `main` as their default branch:
-- `getbible/v1_commentaries`
-- `getbible/v1_dictionaries`
+- `getbible/commentaries`
+- `getbible/dictionaries`
Seed each repository with a README and commit it before the first builder run.
The builder owns only the `v1/` directory; repository documentation and server
-configuration outside that directory are preserved.
+configuration outside that directory are preserved, and a future `v2/` can be
+published beside it without disturbing v1 consumers.
Add these Actions secrets to `v1_study_builder`:
@@ -26,8 +27,13 @@ default target URLs are already compiled into the CLI. Set the optional
using forks, staging repositories, or non-GitHub remotes.
Protect `main` on both generated repositories against manual changes, while
-allowing the builder deploy key to push. Serve the repository checkout directly
-with Nginx; no application runtime is required.
+allowing the builder deploy key to push.
+
+Do not serve a checkout directly. `scripts/deploy_static_api.sh` pulls into a
+working copy, verifies it against `hashes.json`, and syncs only the version
+directories to the live root, so no repository metadata reaches the origin. See
+[deployment.md](deployment.md) for the full origin setup; no application runtime
+is required.
The production workflow always completes the requested build. It invokes the
publication steps only when all six Git author/signing/SSH values are present and
diff --git a/pyproject.toml b/pyproject.toml
index 6bfbbbe..40fb15b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,6 @@ requires-python = ">=3.12"
license = {text = "GPL-2.0-only"}
authors = [{name = "GetBible", email = "github@vdm.io"}]
dependencies = [
- "bleach>=6.2,<7",
"jsonschema>=4.23,<5",
]
diff --git a/requirements.txt b/requirements.txt
index 30b3c20..41a1657 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,2 @@
-bleach>=6.2,<7
jsonschema>=4.23,<5
diff --git a/schemas/commentary-book.schema.json b/schemas/commentary-book.schema.json
new file mode 100644
index 0000000..08dbe8d
--- /dev/null
+++ b/schemas/commentary-book.schema.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://commentaries.getbible.net/schema/v1/commentary-book.json",
+ "title": "GetBible Commentary Book v1",
+ "description": "Every chapter of one book of one commentary. Each member of chapters is byte-for-byte the document served at {commentary}/{book}/{chapter}.json.",
+ "type": "object",
+ "required": ["schema", "commentary", "language", "book", "name", "chapters"],
+ "properties": {
+ "schema": {"const": "getbible-commentary-book-v1"},
+ "commentary": {"type": "string", "minLength": 1},
+ "language": {"type": "string", "minLength": 2},
+ "book": {"type": "integer", "minimum": 1, "maximum": 83},
+ "name": {"type": "string", "minLength": 1},
+ "chapters": {
+ "type": "array",
+ "items": {"$ref": "https://commentaries.getbible.net/schema/v1/commentary-chapter.json"}
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/commentary-books.schema.json b/schemas/commentary-books.schema.json
new file mode 100644
index 0000000..482a74e
--- /dev/null
+++ b/schemas/commentary-books.schema.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://commentaries.getbible.net/schema/v1/commentary-books.json",
+ "title": "GetBible Commentary Books Index v1",
+ "description": "Which books and chapters one commentary covers. Chapter 0 is a book introduction.",
+ "type": "object",
+ "required": [
+ "schema",
+ "commentary",
+ "language",
+ "name",
+ "book_url_template",
+ "chapter_url_template",
+ "book_count",
+ "books"
+ ],
+ "properties": {
+ "schema": {"const": "getbible-commentary-books-v1"},
+ "commentary": {"type": "string", "minLength": 1},
+ "language": {"type": "string", "minLength": 2},
+ "name": {"type": "string", "minLength": 1},
+ "book_url_template": {"const": "{book}.json"},
+ "chapter_url_template": {"const": "{book}/{chapter}.json"},
+ "book_count": {"type": "integer", "minimum": 0},
+ "books": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["book", "name", "chapters", "entry_count"],
+ "properties": {
+ "book": {"type": "integer", "minimum": 1, "maximum": 83},
+ "name": {"type": "string", "minLength": 1},
+ "chapters": {"type": "array", "items": {"type": "integer", "minimum": 0}},
+ "entry_count": {"type": "integer", "minimum": 0}
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/commentary-chapter.schema.json b/schemas/commentary-chapter.schema.json
index eda1b6e..b63391b 100644
--- a/schemas/commentary-chapter.schema.json
+++ b/schemas/commentary-chapter.schema.json
@@ -2,6 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://commentaries.getbible.net/schema/v1/commentary-chapter.json",
"title": "GetBible Commentary Chapter v1",
+ "description": "One chapter of one commentary. Chapter 0 carries the book introduction and verse 0 carries a chapter introduction.",
"type": "object",
"required": ["schema", "commentary", "language", "book", "name", "chapter", "entries"],
"properties": {
@@ -10,35 +11,51 @@
"language": {"type": "string", "minLength": 2},
"book": {"type": "integer", "minimum": 1, "maximum": 83},
"name": {"type": "string", "minLength": 1},
- "chapter": {"type": "integer", "minimum": 1},
+ "chapter": {"type": "integer", "minimum": 0},
"entries": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/entry"}
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "entry": {
+ "type": "object",
+ "required": ["book", "chapter", "verse", "name", "anchor", "text"],
+ "properties": {
+ "book": {"type": "integer", "minimum": 1, "maximum": 83},
+ "chapter": {"type": "integer", "minimum": 0},
+ "verse": {"type": "integer", "minimum": 0},
+ "name": {"type": "string"},
+ "anchor": {
+ "type": "object",
+ "required": ["book", "chapter", "verse"],
+ "properties": {
+ "book": {"type": "integer", "minimum": 1, "maximum": 83},
+ "chapter": {"type": "integer", "minimum": 0},
+ "verse": {"type": "integer", "minimum": 0},
+ "osis": {"type": "string"}
+ },
+ "additionalProperties": false
+ },
+ "text": {"type": "string"},
+ "references": {"$ref": "#/$defs/references"}
+ },
+ "additionalProperties": false
+ },
+ "references": {
"type": "array",
"items": {
"type": "object",
- "required": ["book", "chapter", "verse", "name", "anchor", "text"],
+ "required": ["osis", "book", "chapter"],
"properties": {
+ "osis": {"type": "string", "minLength": 1},
"book": {"type": "integer", "minimum": 1, "maximum": 83},
- "chapter": {"type": "integer", "minimum": 1},
- "verse": {"type": "integer", "minimum": 0},
- "name": {"type": "string"},
- "anchor": {
- "type": "object",
- "required": ["book", "chapter", "verse"],
- "properties": {
- "book": {"type": "integer"},
- "chapter": {"type": "integer", "minimum": 1},
- "verse": {"type": "integer"},
- "osis": {"type": "string"}
- },
- "additionalProperties": false
- },
- "text": {"type": "string"},
- "html": {"type": "string"},
- "references": {"type": "array", "items": {"type": "object"}}
+ "chapter": {"type": "integer", "minimum": 0},
+ "verse": {"type": "integer", "minimum": 0}
},
"additionalProperties": false
}
}
- },
- "additionalProperties": false
+ }
}
diff --git a/schemas/commentary.schema.json b/schemas/commentary.schema.json
new file mode 100644
index 0000000..25f44cf
--- /dev/null
+++ b/schemas/commentary.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://commentaries.getbible.net/schema/v1/commentary.json",
+ "title": "GetBible Commentary v1",
+ "description": "One complete commentary. Each member of books is byte-for-byte the document served at {commentary}/{book}.json. This is a bulk document; metadata.json publishes its size in bytes.",
+ "type": "object",
+ "required": ["schema", "commentary", "language", "name", "books"],
+ "properties": {
+ "schema": {"const": "getbible-commentary-v1"},
+ "commentary": {"type": "string", "minLength": 1},
+ "language": {"type": "string", "minLength": 2},
+ "name": {"type": "string", "minLength": 1},
+ "books": {
+ "type": "array",
+ "items": {"$ref": "https://commentaries.getbible.net/schema/v1/commentary-book.json"}
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/dictionary-entry.schema.json b/schemas/dictionary-entry.schema.json
index aec1a25..50d1b15 100644
--- a/schemas/dictionary-entry.schema.json
+++ b/schemas/dictionary-entry.schema.json
@@ -2,6 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://dictionaries.getbible.net/schema/v1/dictionary-entry.json",
"title": "GetBible Dictionary Entry v1",
+ "description": "One word of one dictionary. see_also lists the words this entry points at; backlinks lists the words that point back.",
"type": "object",
"required": ["schema", "dictionary", "language", "id", "key", "occurrence", "aliases", "text"],
"properties": {
@@ -13,8 +14,36 @@
"occurrence": {"type": "integer", "minimum": 1},
"aliases": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"text": {"type": "string"},
- "html": {"type": "string"},
- "references": {"type": "array", "items": {"type": "string"}}
+ "see_also": {"$ref": "#/$defs/links"},
+ "backlinks": {"$ref": "#/$defs/links"},
+ "references": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["osis", "book", "chapter"],
+ "properties": {
+ "osis": {"type": "string", "minLength": 1},
+ "book": {"type": "integer", "minimum": 1, "maximum": 83},
+ "chapter": {"type": "integer", "minimum": 0},
+ "verse": {"type": "integer", "minimum": 0}
+ },
+ "additionalProperties": false
+ }
+ }
},
- "additionalProperties": false
+ "additionalProperties": false,
+ "$defs": {
+ "links": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "key"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1},
+ "key": {"type": "string", "minLength": 1}
+ },
+ "additionalProperties": false
+ }
+ }
+ }
}
diff --git a/schemas/dictionary-index.schema.json b/schemas/dictionary-index.schema.json
new file mode 100644
index 0000000..6dbab95
--- /dev/null
+++ b/schemas/dictionary-index.schema.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://dictionaries.getbible.net/schema/v1/dictionary-index.json",
+ "title": "GetBible Dictionary Index v1",
+ "description": "Every word in one dictionary, sorted by the accent-insensitive lowercase search term. One fetch is enough to search a dictionary in any direction; the word itself is then at entry_url_template with the record's id.",
+ "type": "object",
+ "required": [
+ "schema",
+ "dictionary",
+ "language",
+ "name",
+ "entry_url_template",
+ "entry_count",
+ "unique_key_count",
+ "entries"
+ ],
+ "properties": {
+ "schema": {"const": "getbible-dictionary-index-v1"},
+ "dictionary": {"type": "string", "minLength": 1},
+ "language": {"type": "string", "minLength": 2},
+ "name": {"type": "string", "minLength": 1},
+ "entry_url_template": {"const": "{entry}.json"},
+ "entry_count": {"type": "integer", "minimum": 0},
+ "unique_key_count": {"type": "integer", "minimum": 0},
+ "entries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "key", "search"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1},
+ "key": {"type": "string", "minLength": 1},
+ "search": {"type": "string", "minLength": 1},
+ "aliases": {"type": "array", "items": {"type": "string"}, "minItems": 1},
+ "occurrence": {"type": "integer", "minimum": 2}
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/schemas/dictionary.schema.json b/schemas/dictionary.schema.json
new file mode 100644
index 0000000..82dbd1e
--- /dev/null
+++ b/schemas/dictionary.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://dictionaries.getbible.net/schema/v1/dictionary.json",
+ "title": "GetBible Dictionary v1",
+ "description": "One complete dictionary in index order. Each member of entries is byte-for-byte the document served at {dictionary}/{entry}.json. This is a bulk document for offline clients; metadata.json publishes its size in bytes.",
+ "type": "object",
+ "required": ["schema", "dictionary", "language", "name", "entries"],
+ "properties": {
+ "schema": {"const": "getbible-dictionary-v1"},
+ "dictionary": {"type": "string", "minLength": 1},
+ "language": {"type": "string", "minLength": 2},
+ "name": {"type": "string", "minLength": 1},
+ "entries": {
+ "type": "array",
+ "items": {"$ref": "https://dictionaries.getbible.net/schema/v1/dictionary-entry.json"}
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/scripts/deploy_static_api.sh b/scripts/deploy_static_api.sh
new file mode 100755
index 0000000..d7bc27f
--- /dev/null
+++ b/scripts/deploy_static_api.sh
@@ -0,0 +1,327 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Deploy one generated GetBible study API onto a static origin.
+#
+# deploy_static_api.sh --repo git@github.com:getbible/commentaries.git \
+# --root /var/www/getbible/commentaries
+#
+# The deploy is a pull, a verify, a compress, and a sync — in that order, and it
+# stops at the first failure, so a bad build never reaches the live root.
+#
+# Two properties of the generated API make this safe and cheap, and the script
+# is built around them:
+#
+# 1. Output is byte-stable. A module that did not change rebuilds identically,
+# so `git reset --hard` rewrites only genuinely changed files and every
+# other file keeps its mtime. nginx derives ETags from mtime and size, so
+# unchanged documents keep their ETag across a deploy and clients keep
+# getting 304 instead of re-downloading the corpus every month.
+#
+# 2. hashes.json lists a SHA-256 for every other document in the tree. It is
+# both the integrity manifest and the list of paths the builder owns, so it
+# is what this script verifies before anything goes live.
+#
+# Requires: bash 4, git, rsync, python3, gzip. Uses brotli when present.
+
+set -euo pipefail
+
+REPO=""
+REF="main"
+ROOT=""
+WORK=""
+JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
+RELOAD=1
+DRY_RUN=0
+REQUIRE_SIGNATURE=0
+VERIFY_URL=""
+
+readonly PROGRAM="${0##*/}"
+
+die() {
+ printf '%s: %s\n' "$PROGRAM" "$*" >&2
+ exit 1
+}
+
+log() {
+ printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"
+}
+
+usage() {
+ # The header comment block, minus the shebang and licence line. Bounded by
+ # the block itself rather than a line range, which drifts when it is edited.
+ awk 'NR > 2 && /^#/ { sub(/^# ?/, ""); print; next } NR > 2 { exit }' "$0"
+ cat <<'USAGE'
+
+Options:
+ --repo URL Source repository (required unless --work already exists)
+ --ref NAME Branch or tag to deploy (default: main)
+ --root DIR Live document root nginx serves (required)
+ --work DIR Persistent checkout (default: /var/lib/getbible/)
+ --jobs N Parallel compression jobs (default: CPU count)
+ --require-signature Refuse to deploy a commit without a valid GPG signature
+ --verify-url URL After reload, assert this URL returns 200 and JSON
+ --no-reload Do not reload nginx
+ --dry-run Show what would change without touching the live root
+ -h, --help This message
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
+ --ref) REF="${2:?--ref needs a value}"; shift 2 ;;
+ --root) ROOT="${2:?--root needs a value}"; shift 2 ;;
+ --work) WORK="${2:?--work needs a value}"; shift 2 ;;
+ --jobs) JOBS="${2:?--jobs needs a value}"; shift 2 ;;
+ --verify-url) VERIFY_URL="${2:?--verify-url needs a value}"; shift 2 ;;
+ --require-signature) REQUIRE_SIGNATURE=1; shift ;;
+ --no-reload) RELOAD=0; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) die "unknown option: $1 (try --help)" ;;
+ esac
+done
+
+[[ -n "$ROOT" ]] || die "--root is required"
+[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || die "--jobs must be a positive integer"
+[[ -n "$WORK" ]] || WORK="/var/lib/getbible/$(basename "$ROOT")"
+
+for tool in git rsync python3 gzip; do
+ command -v "$tool" >/dev/null || die "$tool is required but not installed"
+done
+
+# ---------------------------------------------------------------------------
+# Only one deploy per root at a time.
+# ---------------------------------------------------------------------------
+mkdir -p "$(dirname "$WORK")"
+LOCK="${WORK}.lock"
+exec 9>"$LOCK"
+flock -n 9 || die "another deploy is already running for $ROOT"
+
+# ---------------------------------------------------------------------------
+# 1. Pull. Only changed files are rewritten, so mtimes and ETags survive.
+# ---------------------------------------------------------------------------
+if [[ ! -d "$WORK/.git" ]]; then
+ [[ -n "$REPO" ]] || die "$WORK is not a checkout and no --repo was given"
+ log "cloning $REPO into $WORK"
+ git clone --branch "$REF" --single-branch "$REPO" "$WORK"
+else
+ if [[ -n "$REPO" ]]; then
+ actual="$(git -C "$WORK" remote get-url origin)"
+ [[ "$actual" == "$REPO" ]] || die "$WORK points at $actual, expected $REPO"
+ fi
+ log "fetching $REF"
+ git -C "$WORK" fetch --prune origin "$REF"
+ git -C "$WORK" reset --hard "origin/$REF"
+ # Drop anything untracked except the compressed variants, which this script
+ # owns and prunes itself. Cleaning those away would force a full brotli pass
+ # over the whole corpus on every deploy.
+ git -C "$WORK" clean -fdx -e '*.json.gz' -e '*.json.br'
+fi
+
+REVISION="$(git -C "$WORK" rev-parse HEAD)"
+log "deploying $REVISION"
+
+if [[ "$REQUIRE_SIGNATURE" -eq 1 ]]; then
+ git -C "$WORK" verify-commit HEAD \
+ || die "commit $REVISION has no valid signature and --require-signature was given"
+ log "signature verified"
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Verify. hashes.json must describe the tree exactly before it goes live.
+# ---------------------------------------------------------------------------
+verify_tree() {
+ python3 - "$1" <<'PY'
+import hashlib
+import json
+import sys
+from pathlib import Path
+
+root = Path(sys.argv[1])
+manifest_path = root / "hashes.json"
+if not manifest_path.is_file():
+ sys.exit(f"no hashes.json under {root}")
+
+manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+if manifest.get("algorithm") != "sha256":
+ sys.exit(f"unsupported digest algorithm: {manifest.get('algorithm')!r}")
+
+files = manifest.get("files") or {}
+if not files:
+ sys.exit("hashes.json lists no files")
+
+problems = []
+for relative, expected in sorted(files.items()):
+ path = root / relative
+ if ".." in Path(relative).parts or path.is_symlink():
+ problems.append(f"unsafe manifest path: {relative}")
+ continue
+ if not path.is_file():
+ problems.append(f"missing: {relative}")
+ continue
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(block)
+ if digest.hexdigest() != expected:
+ problems.append(f"digest mismatch: {relative}")
+
+# Every file, not just every .json. The manifest is the complete list of what
+# the builder publishes, so anything else in the tree is unaccounted for and
+# must not reach the origin — an unexpected .html or .js would otherwise be
+# served from the API's own hostname.
+variants = (".json.gz", ".json.br")
+published = set()
+for path in root.rglob("*"):
+ if not path.is_file():
+ continue
+ relative = path.relative_to(root).as_posix()
+ if relative == "hashes.json" or relative.endswith(variants):
+ continue
+ published.add(relative)
+for extra in sorted(published - set(files)):
+ problems.append(f"not in manifest: {extra}")
+
+if problems:
+ for problem in problems[:20]:
+ print(problem, file=sys.stderr)
+ remaining = len(problems) - 20
+ if remaining > 0:
+ print(f"... and {remaining} more", file=sys.stderr)
+ sys.exit(f"{len(problems)} integrity problem(s) under {root}")
+
+print(f"verified {len(files)} documents")
+PY
+}
+
+shopt -s nullglob
+VERSIONS=("$WORK"/v[0-9]*/)
+shopt -u nullglob
+[[ ${#VERSIONS[@]} -gt 0 ]] || die "$WORK contains no version directory"
+
+for version in "${VERSIONS[@]}"; do
+ log "verifying ${version#"$WORK"/}"
+ verify_tree "$version"
+done
+
+# ---------------------------------------------------------------------------
+# 3. Compress. Only for documents whose variant is missing or stale, and the
+# variant carries the document's mtime so its ETag is stable too.
+# ---------------------------------------------------------------------------
+compress_one() {
+ local file="$1"
+ if [[ ! -f "$file.gz" || "$file" -nt "$file.gz" ]]; then
+ gzip -9 -c -- "$file" > "$file.gz.tmp"
+ mv -f -- "$file.gz.tmp" "$file.gz"
+ touch -r "$file" -- "$file.gz"
+ fi
+ if command -v brotli >/dev/null && [[ ! -f "$file.br" || "$file" -nt "$file.br" ]]; then
+ brotli -q 11 -c -- "$file" > "$file.br.tmp"
+ mv -f -- "$file.br.tmp" "$file.br"
+ touch -r "$file" -- "$file.br"
+ fi
+}
+export -f compress_one
+
+log "compressing with $JOBS job(s)"
+# shellcheck disable=SC2016 # $1 is the child shell's argument, not this one's
+find "$WORK" -path "$WORK/.git" -prune -o -type f -name '*.json' -print0 \
+ | xargs -0 -r -P "$JOBS" -I{} bash -c 'compress_one "$1"' _ {}
+
+# Drop variants whose document no longer exists, so a removed module cannot
+# leave a stale compressed copy that nginx would still serve.
+pruned=0
+while IFS= read -r -d '' variant; do
+ if [[ ! -f "${variant%.*}" ]]; then
+ rm -f -- "$variant"
+ pruned=$((pruned + 1))
+ fi
+done < <(find "$WORK" -path "$WORK/.git" -prune -o -type f \( -name '*.json.gz' -o -name '*.json.br' \) -print0)
+[[ "$pruned" -eq 0 ]] || log "pruned $pruned orphaned compressed file(s)"
+
+# ---------------------------------------------------------------------------
+# 4. Sync into the live root, one version directory at a time. Scoping --delete
+# to a single version means no failure mode of this script can remove
+# anything outside a version directory, and the live root ends up holding
+# only what is actually served — no repository metadata, no README.
+# ---------------------------------------------------------------------------
+RSYNC_FLAGS=(
+ --archive
+ --delete --delete-delay --delay-updates
+ "--chmod=D755,F644"
+ --omit-dir-times
+ --human-readable
+ --exclude='*.tmp'
+)
+
+if [[ "$DRY_RUN" -eq 1 ]]; then
+ log "dry run — changes that would be applied to $ROOT:"
+ for version in "${VERSIONS[@]}"; do
+ rsync "${RSYNC_FLAGS[@]}" --dry-run --itemize-changes \
+ "$version" "$ROOT/$(basename "$version")/"
+ done
+ exit 0
+fi
+
+mkdir -p "$ROOT"
+for version in "${VERSIONS[@]}"; do
+ name="$(basename "$version")"
+ log "syncing $name into $ROOT"
+ mkdir -p "$ROOT/$name"
+ rsync "${RSYNC_FLAGS[@]}" "$version" "$ROOT/$name/"
+done
+
+# Retire a version directory that the publication repository no longer carries.
+for existing in "$ROOT"/v[0-9]*/; do
+ [[ -d "$existing" ]] || continue
+ name="$(basename "$existing")"
+ if [[ ! -d "$WORK/$name" ]]; then
+ log "removing retired version $name"
+ rm -rf -- "${ROOT:?}/$name"
+ fi
+done
+
+for version in "${VERSIONS[@]}"; do
+ log "verifying live $(basename "$version")"
+ verify_tree "$ROOT/$(basename "$version")"
+done
+
+printf '%s\n' "$REVISION" > "$ROOT/.revision"
+
+# ---------------------------------------------------------------------------
+# 5. Reload. New workers start with an empty open-file cache, which is what
+# makes the newly deployed documents visible immediately.
+# ---------------------------------------------------------------------------
+if [[ "$RELOAD" -eq 1 ]]; then
+ if command -v nginx >/dev/null; then
+ nginx -t
+ if command -v systemctl >/dev/null && systemctl is-active --quiet nginx; then
+ systemctl reload nginx
+ else
+ nginx -s reload
+ fi
+ log "nginx reloaded"
+ else
+ log "nginx not found; skipping reload"
+ fi
+fi
+
+if [[ -n "$VERIFY_URL" ]]; then
+ command -v curl >/dev/null || die "--verify-url needs curl"
+ log "verifying $VERIFY_URL"
+ # No --fail: the status code is what is being checked, so it has to come
+ # back rather than turning into a bare non-zero exit with no explanation.
+ response="$(curl --silent --show-error --location --max-time 30 \
+ --output /dev/null --write-out '%{http_code} %{content_type}' "$VERIFY_URL" || true)"
+ status="${response%% *}"
+ content_type="${response#* }"
+ [[ -n "$status" && "$status" != "000" ]] || die "could not reach $VERIFY_URL"
+ [[ "$status" == "200" ]] || die "$VERIFY_URL returned HTTP $status"
+ [[ "$content_type" == application/json* ]] \
+ || die "$VERIFY_URL returned Content-Type ${content_type:-none}"
+ log "live check passed ($status $content_type)"
+fi
+
+log "deployed $REVISION to $ROOT"
diff --git a/scripts/install_nginx_config.sh b/scripts/install_nginx_config.sh
new file mode 100755
index 0000000..13ec82d
--- /dev/null
+++ b/scripts/install_nginx_config.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Install the GetBible study API origin configuration, adapted to this host.
+#
+# install_nginx_config.sh --root-dir /var/www/getbible
+# install_nginx_config.sh --host commentaries.getbible.net --reload
+#
+# The shipped configuration in docs/nginx/ targets current nginx. Three things
+# legitimately differ between hosts, and guessing wrong on any of them makes
+# nginx refuse to start, so they are detected rather than assumed:
+#
+# * "http2 on" is nginx 1.25.1+. Older nginx needs it folded into listen.
+# * brotli_static needs ngx_brotli, which many distributions do not package.
+# * IPv6 listeners fail outright on a host without IPv6.
+#
+# Nothing is written until every adapted file has passed nginx -t.
+#
+# Requires: bash 4, nginx.
+
+set -euo pipefail
+
+readonly PROGRAM="${0##*/}"
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../docs/nginx" && pwd)"
+readonly SOURCE_DIR
+
+PREFIX="/etc/nginx"
+ROOT_DIR="/var/www/getbible"
+HOSTS=()
+RELOAD=0
+DRY_RUN=0
+
+die() { printf '%s: %s\n' "$PROGRAM" "$*" >&2; exit 1; }
+log() { printf ' %s\n' "$*"; }
+
+usage() {
+ cat <<'USAGE'
+usage: install_nginx_config.sh [options]
+
+ --host NAME Install only this host (repeatable; default: both)
+ --prefix DIR nginx configuration prefix (default: /etc/nginx)
+ --root-dir DIR Parent of the live document roots (default: /var/www/getbible)
+ --reload Reload nginx after a successful install
+ --dry-run Show what would be installed, write nothing
+ -h, --help This message
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --host) HOSTS+=("${2:?--host needs a value}"); shift 2 ;;
+ --prefix) PREFIX="${2:?--prefix needs a value}"; shift 2 ;;
+ --root-dir) ROOT_DIR="${2:?--root-dir needs a value}"; shift 2 ;;
+ --reload) RELOAD=1; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) die "unknown option: $1 (try --help)" ;;
+ esac
+done
+
+command -v nginx >/dev/null || die "nginx is not installed"
+[[ ${#HOSTS[@]} -gt 0 ]] || HOSTS=(commentaries.getbible.net dictionaries.getbible.net)
+
+for host in "${HOSTS[@]}"; do
+ [[ -f "$SOURCE_DIR/$host.conf" ]] || die "no shipped configuration for $host"
+done
+
+# nginx refuses to start when a referenced certificate is missing, and the
+# resulting error names a file rather than the thing to do about it. Check first
+# and say the actual next step.
+if [[ "$DRY_RUN" -eq 0 ]]; then
+ missing=()
+ for host in "${HOSTS[@]}"; do
+ [[ -f "/etc/letsencrypt/live/$host/fullchain.pem" ]] || missing+=("$host")
+ done
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ printf '%s: no certificate for: %s\n\n' "$PROGRAM" "${missing[*]}" >&2
+ printf 'Issue them first. The stock nginx site already serves the ACME\n' >&2
+ printf 'webroot on port 80, so this works before anything here is installed:\n\n' >&2
+ printf ' certbot certonly --webroot -w /var/www/html%s\n\n' \
+ "$(printf ' \\\n -d %s' "${missing[@]}")" >&2
+ exit 1
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# Detect what this host actually supports.
+# ---------------------------------------------------------------------------
+VERSION="$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)"
+[[ -n "$VERSION" ]] || die "could not determine the nginx version"
+
+MODULES="$(nginx -V 2>&1)"
+adapt=()
+
+echo "Detected:"
+log "nginx $VERSION"
+
+if [[ "$(printf '%s\n1.25.1\n' "$VERSION" | sort -V | head -1)" != "1.25.1" ]]; then
+ log "HTTP/2: folding \"http2 on\" into listen (needs 1.25.1+)"
+ adapt+=(-e 's|^ http2 on;$| # http2 folded into listen below (nginx < 1.25.1)|'
+ -e 's|^ listen 443 ssl;$| listen 443 ssl http2;|'
+ -e 's|^ listen \[::\]:443 ssl;$| listen [::]:443 ssl http2;|')
+else
+ log "HTTP/2: native \"http2 on\""
+fi
+
+snippet_adapt=()
+if [[ "$MODULES" == *brotli* ]] || ls /usr/lib/nginx/modules/ngx_http_brotli_static_module.so >/dev/null 2>&1; then
+ log "Brotli: available, enabling brotli_static"
+ snippet_adapt+=(-e 's|^\( *\)# brotli_static on;$|\1brotli_static on;|')
+else
+ log "Brotli: not available, serving gzip only (roughly 15-20% larger on JSON)"
+fi
+
+if [[ ! -f /proc/net/if_inet6 ]]; then
+ log "IPv6: unavailable, commenting out the [::] listeners"
+ adapt+=(-e 's|^\( *\)listen \(.*\)\[::\]\(.*\)$|\1# listen \2[::]\3|')
+else
+ log "IPv6: available"
+fi
+
+if [[ "$ROOT_DIR" != "/var/www/getbible" ]]; then
+ log "Document roots: $ROOT_DIR"
+ adapt+=(-e "s|/var/www/getbible/|${ROOT_DIR%/}/|")
+fi
+
+# ---------------------------------------------------------------------------
+# Build the adapted set in a staging directory and test it before installing.
+# ---------------------------------------------------------------------------
+STAGE="$(mktemp -d)"
+trap 'rm -rf "$STAGE"' EXIT
+mkdir -p "$STAGE/conf.d" "$STAGE/snippets" "$STAGE/sites-available"
+
+cp "$SOURCE_DIR/getbible-api-http.conf" "$STAGE/conf.d/"
+for snippet in "$SOURCE_DIR"/snippets/*.conf; do
+ if [[ ${#snippet_adapt[@]} -gt 0 ]]; then
+ sed "${snippet_adapt[@]}" "$snippet" > "$STAGE/snippets/$(basename "$snippet")"
+ else
+ cp "$snippet" "$STAGE/snippets/"
+ fi
+done
+for host in "${HOSTS[@]}"; do
+ if [[ ${#adapt[@]} -gt 0 ]]; then
+ sed "${adapt[@]}" "$SOURCE_DIR/$host.conf" > "$STAGE/sites-available/$host.conf"
+ else
+ cp "$SOURCE_DIR/$host.conf" "$STAGE/sites-available/$host.conf"
+ fi
+done
+
+echo
+echo "Would install into $PREFIX:"
+for file in "$STAGE"/conf.d/* "$STAGE"/snippets/* "$STAGE"/sites-available/*; do
+ log "${file#"$STAGE"/}"
+done
+
+if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo
+ echo "Dry run; nothing written. Adapted files were left in:"
+ trap - EXIT
+ echo " $STAGE"
+ exit 0
+fi
+
+[[ -w "$PREFIX" ]] || die "$PREFIX is not writable; run as root"
+
+echo
+echo "Installing:"
+install -d -m 755 "$PREFIX/conf.d" "$PREFIX/snippets" "$PREFIX/sites-available" "$PREFIX/sites-enabled"
+install -m 644 "$STAGE/conf.d/getbible-api-http.conf" "$PREFIX/conf.d/"
+install -m 644 "$STAGE"/snippets/*.conf "$PREFIX/snippets/"
+for host in "${HOSTS[@]}"; do
+ install -m 644 "$STAGE/sites-available/$host.conf" "$PREFIX/sites-available/"
+ ln -sfn "$PREFIX/sites-available/$host.conf" "$PREFIX/sites-enabled/$host.conf"
+ install -d -m 755 "${ROOT_DIR%/}/${host%%.*}"
+ log "$host -> ${ROOT_DIR%/}/${host%%.*}"
+done
+
+echo
+echo "Testing:"
+if ! nginx -t; then
+ die "nginx rejected the installed configuration; it has NOT been reloaded"
+fi
+
+if [[ "$RELOAD" -eq 1 ]]; then
+ if command -v systemctl >/dev/null && systemctl is-active --quiet nginx; then
+ systemctl reload nginx
+ else
+ nginx -s reload
+ fi
+ echo " reloaded"
+else
+ echo " not reloaded; run 'systemctl reload nginx' when ready"
+fi
diff --git a/scripts/validate_build.py b/scripts/validate_build.py
index edacca0..adb7758 100644
--- a/scripts/validate_build.py
+++ b/scripts/validate_build.py
@@ -10,42 +10,104 @@
from study_builder.util import read_json, slug
-def validate_commentary(root: Path) -> dict[str, Any]:
+def _reject_markup(document: Any, where: str) -> None:
+ if isinstance(document, dict):
+ if "html" in document:
+ raise RuntimeError(f"{where} still publishes an html member")
+ for key, value in document.items():
+ _reject_markup(value, f"{where}.{key}")
+ elif isinstance(document, list):
+ for index, value in enumerate(document):
+ _reject_markup(value, f"{where}[{index}]")
+
+
+def _assert_composed(composed: list[Any], parts: list[Path], where: str) -> None:
+ """A composed document must contain its parts exactly as they are served alone."""
+ if len(composed) != len(parts):
+ raise RuntimeError(f"{where} holds {len(composed)} members for {len(parts)} documents")
+ for member, path in zip(composed, parts, strict=True):
+ if member != read_json(path):
+ raise RuntimeError(f"{where} does not match the document served at {path}")
+
+
+def validate_commentary(root: Path, complete_path: Path) -> dict[str, Any]:
metadata = read_json(root / "metadata.json")
books = read_json(root / "books.json")
if metadata.get("schema") != "getbible-commentary-metadata-v1":
raise RuntimeError("Unexpected commentary metadata schema")
- if int(metadata.get("entry_count", 0)) <= 0 or not books:
+ if books.get("schema") != "getbible-commentary-books-v1":
+ raise RuntimeError("Unexpected commentary books index schema")
+ if int(metadata.get("entry_count", 0)) <= 0 or not books.get("books"):
raise RuntimeError("Commentary produced no addressable entries")
- first_book = books[0]
- book_index = read_json(root / str(first_book["url"]))
- if not book_index.get("chapters"):
- raise RuntimeError("Commentary book index produced no chapters")
- chapter = read_json(root / str(book_index["chapters"][0]["url"]))
- if chapter.get("schema") != "getbible-commentary-chapter-v1":
- raise RuntimeError("Unexpected commentary chapter schema")
- if not chapter.get("entries"):
+
+ first_book = books["books"][0]
+ book_path = root / f"{first_book['book']}.json"
+ book = read_json(book_path)
+ if book.get("schema") != "getbible-commentary-book-v1" or not book.get("chapters"):
+ raise RuntimeError("Commentary book document produced no chapters")
+ chapter_paths = [
+ root / str(first_book["book"]) / f"{number}.json" for number in first_book["chapters"]
+ ]
+ _assert_composed(book["chapters"], chapter_paths, f"{book_path}.chapters")
+
+ chapter = read_json(chapter_paths[0])
+ if chapter.get("schema") != "getbible-commentary-chapter-v1" or not chapter.get("entries"):
raise RuntimeError("Commentary chapter produced no entries")
first = chapter["entries"][0]
if not all(name in first for name in ("book", "chapter", "verse", "anchor", "text")):
raise RuntimeError("Commentary entry is not linked to a Bible API coordinate")
- return {"entries": metadata["entry_count"], "books": metadata["book_count"]}
+ _reject_markup(chapter, "chapter")
+ complete = read_json(complete_path)
+ if complete.get("schema") != "getbible-commentary-v1":
+ raise RuntimeError("Unexpected whole-commentary schema")
+ book_paths = [root / f"{record['book']}.json" for record in books["books"]]
+ _assert_composed(complete["books"], book_paths, f"{complete_path}.books")
-def validate_dictionary(root: Path) -> dict[str, Any]:
+ return {
+ "books": metadata["book_count"],
+ "chapters": metadata["chapter_count"],
+ "entries": metadata["entry_count"],
+ "bytes": metadata["bytes"],
+ "introductions": sum(1 for record in books["books"] if 0 in record["chapters"]),
+ }
+
+
+def validate_dictionary(root: Path, complete_path: Path) -> dict[str, Any]:
metadata = read_json(root / "metadata.json")
- keys = read_json(root / "keys.json")
+ index = read_json(root / "index.json")
if metadata.get("schema") != "getbible-dictionary-metadata-v1":
raise RuntimeError("Unexpected dictionary metadata schema")
- if int(metadata.get("entry_count", 0)) <= 0 or not keys:
+ if index.get("schema") != "getbible-dictionary-index-v1":
+ raise RuntimeError("Unexpected dictionary index schema")
+ if int(metadata.get("entry_count", 0)) <= 0 or not index.get("entries"):
raise RuntimeError("Dictionary produced no addressable entries")
- first = keys[0]
- document = read_json(root / str(first["url"]))
+
+ terms = [record["search"] for record in index["entries"]]
+ if terms != sorted(terms):
+ raise RuntimeError("Dictionary index is not sorted by its search term")
+
+ entry_paths = [root / f"{record['id']}.json" for record in index["entries"]]
+ document = read_json(entry_paths[0])
if document.get("schema") != "getbible-dictionary-entry-v1":
raise RuntimeError("Unexpected dictionary entry schema")
if not all(name in document for name in ("dictionary", "id", "key", "aliases", "text")):
raise RuntimeError("Dictionary entry is missing its lookup contract")
- return {"entries": metadata["entry_count"], "strong_prefix": metadata["strong_prefix"]}
+ _reject_markup(document, "entry")
+
+ complete = read_json(complete_path)
+ if complete.get("schema") != "getbible-dictionary-v1":
+ raise RuntimeError("Unexpected whole-dictionary schema")
+ _assert_composed(complete["entries"], entry_paths, f"{complete_path}.entries")
+
+ linked = sum(1 for entry in complete["entries"] if entry.get("see_also"))
+ return {
+ "entries": metadata["entry_count"],
+ "unique_keys": metadata["unique_key_count"],
+ "strong_prefix": metadata["strong_prefix"],
+ "bytes": metadata["bytes"],
+ "entries_with_links": linked,
+ }
def main() -> int:
@@ -54,9 +116,14 @@ def main() -> int:
parser.add_argument("--module", required=True)
parser.add_argument("--dist-dir", type=Path, default=Path("dist"))
args = parser.parse_args()
- root = args.dist_dir / args.resource / "v1" / slug(args.module)
+ module_id = slug(args.module)
+ version_root = args.dist_dir / args.resource / "v1"
+ root = version_root / module_id
+ complete_path = version_root / f"{module_id}.json"
result = (
- validate_commentary(root) if args.resource == "commentaries" else validate_dictionary(root)
+ validate_commentary(root, complete_path)
+ if args.resource == "commentaries"
+ else validate_dictionary(root, complete_path)
)
print(json.dumps({"resource": args.resource, "module": args.module, **result}, indent=2))
return 0
diff --git a/scripts/verify_live_api.sh b/scripts/verify_live_api.sh
new file mode 100755
index 0000000..6775a0a
--- /dev/null
+++ b/scripts/verify_live_api.sh
@@ -0,0 +1,173 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Smoke-test a deployed GetBible study API origin.
+#
+# verify_live_api.sh https://commentaries.getbible.net
+# verify_live_api.sh https://dictionaries.getbible.net
+#
+# Everything checked here is a promise the API makes to its clients: the paths
+# resolve, the two cache tiers are right, precompressed variants are served,
+# revalidation returns 304, CORS and the security headers are present, and
+# failures come back as JSON rather than an HTML error page.
+#
+# Nothing is hardcoded to a module — the catalog is read and followed, so this
+# works against any build. Exits non-zero on the first broken promise.
+#
+# Requires: bash 4, curl, python3.
+
+set -uo pipefail
+
+readonly PROGRAM="${0##*/}"
+BASE="${1:-}"
+
+if [[ -z "$BASE" || "$BASE" == -h || "$BASE" == --help ]]; then
+ printf 'usage: %s https://commentaries.getbible.net [--insecure]\n' "$PROGRAM" >&2
+ exit 2
+fi
+BASE="${BASE%/}"
+shift || true
+
+CURL=(curl --silent --show-error --location --max-time 30)
+[[ "${1:-}" == "--insecure" ]] && CURL+=(--insecure)
+
+for tool in curl python3; do
+ command -v "$tool" >/dev/null || { printf '%s: %s is required\n' "$PROGRAM" "$tool" >&2; exit 2; }
+done
+
+pass=0
+fail=0
+
+check() {
+ local label="$1" expected="$2" actual="$3"
+ # HTTP/2 lowercases header names, so compare case-insensitively.
+ if [[ "${actual,,}" == *"${expected,,}"* ]]; then
+ printf ' ok %s\n' "$label"
+ pass=$((pass + 1))
+ else
+ printf ' FAIL %-46s expected %-24s got: %s\n' "$label" "$expected" "$actual"
+ fail=$((fail + 1))
+ fi
+}
+
+body() { "${CURL[@]}" "$1"; }
+headers() { "${CURL[@]}" --output /dev/null --dump-header - "$1"; }
+status() { "${CURL[@]}" --output /dev/null --write-out '%{http_code}' "$@"; }
+header() { headers "$1" | grep -i "^$2:" | tr -d '\r' | tail -1; }
+
+# Read one value out of a JSON document without needing jq on the server.
+pick() {
+ python3 -c '
+import json, sys
+document = json.loads(sys.stdin.read())
+for key in sys.argv[1:]:
+ document = document[int(key)] if isinstance(document, list) else document[key]
+print(document)
+' "$@"
+}
+
+echo "== $BASE =="
+
+# ---------------------------------------------------------------------------
+# Discover which resource this origin serves, then follow its own catalog.
+# ---------------------------------------------------------------------------
+RESOURCE=""
+for candidate in commentaries dictionaries; do
+ if [[ "$(status "$BASE/v1/$candidate.json")" == "200" ]]; then
+ RESOURCE="$candidate"
+ break
+ fi
+done
+[[ -n "$RESOURCE" ]] || { printf '%s: no catalog at %s/v1/\n' "$PROGRAM" "$BASE" >&2; exit 1; }
+echo " serving: $RESOURCE"
+
+CATALOG="$(body "$BASE/v1/$RESOURCE.json")"
+MODULE="$(printf '%s' "$CATALOG" | pick "$RESOURCE" 0 id)" || exit 1
+echo " sampling module: $MODULE"
+
+echo
+echo "-- documents --"
+check "catalog" "200" "$(status "$BASE/v1/$RESOURCE.json")"
+check "build stamp" "200" "$(status "$BASE/v1/build.json")"
+check "integrity manifest" "200" "$(status "$BASE/v1/hashes.json")"
+check "module metadata" "200" "$(status "$BASE/v1/$MODULE/metadata.json")"
+check "bulk document" "200" "$(status "$BASE/v1/$MODULE.json")"
+
+if [[ "$RESOURCE" == commentaries ]]; then
+ BOOKS="$(body "$BASE/v1/$MODULE/books.json")"
+ BOOK="$(printf '%s' "$BOOKS" | pick books 0 book)"
+ CHAPTER="$(printf '%s' "$BOOKS" | pick books 0 chapters 0)"
+ HOT="$BASE/v1/$MODULE/$BOOK/$CHAPTER.json"
+ check "books index" "200" "$(status "$BASE/v1/$MODULE/books.json")"
+ check "book document" "200" "$(status "$BASE/v1/$MODULE/$BOOK.json")"
+ check "chapter document ($MODULE $BOOK:$CHAPTER)" "200" "$(status "$HOT")"
+ check "schema published" "200" "$(status "$BASE/v1/schema/commentary-chapter.json")"
+else
+ INDEX="$(body "$BASE/v1/$MODULE/index.json")"
+ ENTRY="$(printf '%s' "$INDEX" | pick entries 0 id)"
+ HOT="$BASE/v1/$MODULE/$ENTRY.json"
+ check "search index" "200" "$(status "$BASE/v1/$MODULE/index.json")"
+ check "word document ($MODULE/$ENTRY)" "200" "$(status "$HOT")"
+ check "schema published" "200" "$(status "$BASE/v1/schema/dictionary-entry.json")"
+fi
+
+check "content type" "application/json" "$(header "$HOT" content-type)"
+
+echo
+echo "-- caching --"
+check "discovery is short-lived" "max-age=300" "$(header "$BASE/v1/$RESOURCE.json" cache-control)"
+check "content is long-lived" "max-age=86400" "$(header "$HOT" cache-control)"
+ETAG="$(header "$HOT" etag | cut -d' ' -f2)"
+check "etag present" '"' "$ETAG"
+check "revalidation returns 304" "304" \
+ "$("${CURL[@]}" --output /dev/null --write-out '%{http_code}' --header "If-None-Match: $ETAG" "$HOT")"
+
+echo
+echo "-- compression --"
+check "gzip variant served" "content-encoding: gzip" \
+ "$("${CURL[@]}" --output /dev/null --dump-header - --header 'Accept-Encoding: gzip' "$HOT" \
+ | grep -i '^content-encoding' | tr -d '\r')"
+check "varies on encoding" "accept-encoding" "$(header "$HOT" vary)"
+plain=$("${CURL[@]}" --output /dev/null --write-out '%{size_download}' --header 'Accept-Encoding: identity' "$HOT")
+small=$("${CURL[@]}" --output /dev/null --write-out '%{size_download}' --header 'Accept-Encoding: gzip, br' "$HOT")
+if (( small < plain )); then
+ printf ' ok compressed transfer is smaller (%s -> %s bytes)\n' "$plain" "$small"
+ pass=$((pass + 1))
+else
+ printf ' FAIL compressed transfer is smaller (%s -> %s bytes)\n' "$plain" "$small"
+ fail=$((fail + 1))
+fi
+
+echo
+echo "-- CORS and security --"
+HEAD="$(headers "$HOT")"
+check "allows any origin" "access-control-allow-origin: *" \
+ "$(echo "$HEAD" | grep -i '^access-control-allow-origin' | tr -d '\r')"
+check "exposes etag" "etag" \
+ "$(echo "$HEAD" | grep -i '^access-control-expose-headers' | tr -d '\r')"
+check "nosniff" "nosniff" "$(echo "$HEAD" | grep -i '^x-content-type-options' | tr -d '\r')"
+check "content policy" "default-src 'none'" "$(echo "$HEAD" | grep -i '^content-security-policy' | tr -d '\r')"
+check "resource policy" "cross-origin" "$(echo "$HEAD" | grep -i '^cross-origin-resource-policy' | tr -d '\r')"
+check "strict transport" "max-age=" "$(echo "$HEAD" | grep -i '^strict-transport-security' | tr -d '\r')"
+check "preflight" "204" "$(status --request OPTIONS "$HOT")"
+
+if echo "$HEAD" | grep -qiE '^server: nginx/[0-9]'; then
+ printf ' FAIL server version is disclosed (set server_tokens off)\n'
+ fail=$((fail + 1))
+else
+ printf ' ok server version withheld\n'
+ pass=$((pass + 1))
+fi
+
+echo
+echo "-- failure modes --"
+MISSING="$BASE/v1/$MODULE/does-not-exist-$$.json"
+check "missing document 404" "404" "$(status "$MISSING")"
+check "404 body is JSON" '"error"' "$(body "$MISSING")"
+check "404 content type" "application/json" "$(header "$MISSING" content-type)"
+check "writes rejected" "405" "$(status --request POST "$HOT")"
+check "repository metadata hidden" "404" "$(status "$BASE/.git/config")"
+
+echo
+printf '== %d passed, %d failed ==\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
diff --git a/src/study_builder/cli.py b/src/study_builder/cli.py
index 843a9cc..6f91e1f 100644
--- a/src/study_builder/cli.py
+++ b/src/study_builder/cli.py
@@ -55,14 +55,14 @@ def parser() -> argparse.ArgumentParser:
"--commentaries-repo",
default=os.environ.get(
"STUDY_BUILDER_COMMENTARIES_REPO",
- "git@github.com:getbible/v1_commentaries.git",
+ "git@github.com:getbible/commentaries.git",
),
)
build.add_argument(
"--dictionaries-repo",
default=os.environ.get(
"STUDY_BUILDER_DICTIONARIES_REPO",
- "git@github.com:getbible/v1_dictionaries.git",
+ "git@github.com:getbible/dictionaries.git",
),
)
build.add_argument("--commentaries-branch", default="main")
diff --git a/src/study_builder/commentaries.py b/src/study_builder/commentaries.py
index 8617c88..517e29a 100644
--- a/src/study_builder/commentaries.py
+++ b/src/study_builder/commentaries.py
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
from __future__ import annotations
from collections import defaultdict
@@ -11,80 +12,42 @@
from study_builder.chapter_spool import CommentaryChapterSpool
from study_builder.content import extract_osis_references, public_content
from study_builder.models import ModuleDescriptor, NativeExport
-from study_builder.util import read_json, slug, write_json
+from study_builder.util import read_json, slug, write_composed_json, write_json
+
+# Reserved inside a commentary directory; an entry may never claim these names.
+RESERVED_DOCUMENTS = {"metadata.json", "books.json"}
class CommentaryWriter:
- def __init__(self, root: Path, books: BookRegistry, schema_path: Path) -> None:
+ """Write the chapter, book, and whole-commentary documents for one module.
+
+ Chapter documents are the addressable unit. Book and commentary documents embed
+ them byte-for-byte, so `book.chapters[n]` is exactly the chapter document served
+ at its own path and a client needs only one parser for all three levels.
+ """
+
+ def __init__(self, root: Path, books: BookRegistry, schemas_dir: Path) -> None:
self.root = root
self.books = books
- self.schema = read_json(schema_path)
+ self.chapter_schema = read_json(schemas_dir / "commentary-chapter.schema.json")
- def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, Any]:
+ def write(self, module: ModuleDescriptor, exported: NativeExport) -> tuple[dict, dict]:
module_id = slug(module.name)
module_root = self.root / module_id
- chapter_indexes: dict[int, list[dict[str, Any]]] = defaultdict(list)
+ chapter_files: dict[int, list[Path]] = defaultdict(list)
+ chapter_counts: dict[int, list[tuple[int, int]]] = defaultdict(list)
entry_count = 0
with CommentaryChapterSpool() as chapters:
for source in exported.entries:
- verse = source.get("verse") or {}
- chapter = int(verse.get("chapter", 0) or 0)
- verse_number = int(verse.get("verse", 0) or 0)
- # The public commentary API is chapter-addressable. SWORD modules may
- # also expose book introductions with chapter zero; those records do
- # not have a chapter endpoint and intentionally remain unpublished.
- if chapter <= 0 or verse_number < 0:
- continue
- try:
- book = self.books.from_entry(source)
- except ValueError:
- continue
- content = public_content(source)
- if not content.get("text") and not content.get("html"):
- continue
- osis = str(verse.get("osis", ""))
- related = []
- for reference in extract_osis_references(
- str(source.get("raw", "")), str(source.get("html", ""))
- ):
- normalized = self.books.reference(reference)
- if normalized:
- related.append(normalized)
- anchor = {
- "book": book.number,
- "chapter": chapter,
- "verse": verse_number,
- }
- if osis:
- anchor["osis"] = osis
- label = book.name
- if chapter:
- label += f" {chapter}"
- if verse_number:
- label += f":{verse_number}"
- entry: dict[str, Any] = {
- "book": book.number,
- "chapter": chapter,
- "verse": verse_number,
- "name": label,
- "anchor": anchor,
- **content,
- }
- if related:
- entry["references"] = related
- chapters.append(entry)
+ entry = self._entry(source)
+ if entry is not None:
+ chapters.append(entry)
for book_number, chapter_number in chapters.coordinates():
- chapter_seen: set[tuple[int, str]] = set()
- chapter_entries: list[dict[str, Any]] = []
- for entry in chapters.entries(book_number, chapter_number):
- unique = (int(entry["verse"]), str(entry.get("text", "")))
- if unique in chapter_seen:
- continue
- chapter_seen.add(unique)
- chapter_entries.append(entry)
- chapter_entries.sort(key=lambda item: (item["verse"], item["name"]))
+ chapter_entries = self._chapter_entries(chapters, book_number, chapter_number)
+ if not chapter_entries:
+ continue
book = self.books.by_number[book_number]
document = {
"schema": "getbible-commentary-chapter-v1",
@@ -95,44 +58,159 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
"chapter": chapter_number,
"entries": chapter_entries,
}
- validate(document, self.schema)
- write_json(module_root / str(book_number) / f"{chapter_number}.json", document)
- chapter_indexes[book_number].append(
- {
- "chapter": chapter_number,
- "entry_count": len(chapter_entries),
- "url": f"{book_number}/{chapter_number}.json",
- }
- )
+ validate(document, self.chapter_schema)
+ path = module_root / str(book_number) / f"{chapter_number}.json"
+ write_json(path, document)
+ chapter_files[book_number].append(path)
+ chapter_counts[book_number].append((chapter_number, len(chapter_entries)))
entry_count += len(chapter_entries)
+ book_files: list[Path] = []
books_index: list[dict[str, Any]] = []
- for book_number in sorted(chapter_indexes):
+ for book_number in sorted(chapter_files):
book = self.books.by_number[book_number]
- chapter_index = chapter_indexes[book_number]
- chapter_numbers = [record["chapter"] for record in chapter_index]
- write_json(
- module_root / f"{book_number}.json",
+ path = module_root / f"{book_number}.json"
+ if path.name in RESERVED_DOCUMENTS:
+ raise RuntimeError(f"Book document collides with a reserved name: {path.name}")
+ write_composed_json(
+ path,
{
- "schema": "getbible-commentary-book-index-v1",
+ "schema": "getbible-commentary-book-v1",
"commentary": module_id,
"language": module.language,
"book": book_number,
"name": book.name,
- "chapters": chapter_index,
},
+ "chapters",
+ chapter_files[book_number],
)
+ book_files.append(path)
books_index.append(
{
"book": book_number,
"name": book.name,
- "chapters": chapter_numbers,
- "url": f"{book_number}.json",
- "chapter_url_template": f"{book_number}/{{chapter}}.json",
+ "chapters": [number for number, _ in chapter_counts[book_number]],
+ "entry_count": sum(count for _, count in chapter_counts[book_number]),
}
)
- metadata = {
+ write_json(
+ module_root / "books.json",
+ {
+ "schema": "getbible-commentary-books-v1",
+ "commentary": module_id,
+ "language": module.language,
+ "name": module.description,
+ "book_url_template": "{book}.json",
+ "chapter_url_template": "{book}/{chapter}.json",
+ "book_count": len(books_index),
+ "books": books_index,
+ },
+ )
+
+ complete = self.root / f"{module_id}.json"
+ write_composed_json(
+ complete,
+ {
+ "schema": "getbible-commentary-v1",
+ "commentary": module_id,
+ "language": module.language,
+ "name": module.description,
+ },
+ "books",
+ book_files,
+ )
+
+ chapter_count = sum(len(records) for records in chapter_counts.values())
+ metadata = self._metadata(
+ module, module_id, len(books_index), chapter_count, entry_count, complete.stat().st_size
+ )
+ write_json(module_root / "metadata.json", metadata)
+ record = {
+ "id": module_id,
+ "name": module.description,
+ "language": module.language,
+ "license": module.license,
+ "book_count": len(books_index),
+ "chapter_count": chapter_count,
+ "entry_count": entry_count,
+ "bytes": metadata["bytes"],
+ }
+ return record, metadata
+
+ def _entry(self, source: dict[str, Any]) -> dict[str, Any] | None:
+ verse = source.get("verse") or {}
+ chapter = int(verse.get("chapter", 0) or 0)
+ verse_number = int(verse.get("verse", 0) or 0)
+ # Chapter zero carries a book introduction and verse zero a chapter
+ # introduction. Both are published: chapter zero at {book}/0.json, and
+ # verse zero as the first entry of its chapter.
+ if chapter < 0 or verse_number < 0:
+ return None
+ try:
+ book = self.books.from_entry(source)
+ except ValueError:
+ return None
+ content = public_content(source)
+ if not content["text"]:
+ return None
+ label = book.name
+ if chapter:
+ label += f" {chapter}"
+ if verse_number:
+ label += f":{verse_number}"
+ anchor: dict[str, Any] = {
+ "book": book.number,
+ "chapter": chapter,
+ "verse": verse_number,
+ }
+ osis = str(verse.get("osis", ""))
+ if osis:
+ anchor["osis"] = osis
+ entry: dict[str, Any] = {
+ "book": book.number,
+ "chapter": chapter,
+ "verse": verse_number,
+ "name": label,
+ "anchor": anchor,
+ **content,
+ }
+ related = []
+ for reference in extract_osis_references(
+ str(source.get("raw", "")), str(source.get("html", ""))
+ ):
+ normalized = self.books.reference(reference)
+ if normalized:
+ related.append(normalized)
+ if related:
+ entry["references"] = related
+ return entry
+
+ @staticmethod
+ def _chapter_entries(
+ chapters: CommentaryChapterSpool, book_number: int, chapter_number: int
+ ) -> list[dict[str, Any]]:
+ seen: set[tuple[int, str]] = set()
+ collected: list[dict[str, Any]] = []
+ for entry in chapters.entries(book_number, chapter_number):
+ unique = (int(entry["verse"]), str(entry.get("text", "")))
+ if unique in seen:
+ continue
+ seen.add(unique)
+ collected.append(entry)
+ collected.sort(key=lambda item: (item["verse"], item["name"]))
+ return collected
+
+ @staticmethod
+ def _metadata(
+ module: ModuleDescriptor,
+ module_id: str,
+ book_count: int,
+ chapter_count: int,
+ entry_count: int,
+ complete_bytes: int,
+ ) -> dict[str, Any]:
+ return {
"schema": "getbible-commentary-metadata-v1",
"id": module_id,
"module": module.name,
@@ -143,9 +221,12 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
"driver": module.driver,
"source_type": module.first("sourcetype"),
"versification": module.first("versification", "KJV"),
+ "book_count": book_count,
+ "chapter_count": chapter_count,
"entry_count": entry_count,
- "book_count": len(books_index),
+ "bytes": complete_bytes,
"books_url": "books.json",
+ "book_url_template": "{book}.json",
"chapter_url_template": "{book}/{chapter}.json",
"source": "CrossWire SWORD",
"source_module_url": (
@@ -166,6 +247,3 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
"Converted to GetBible static JSON; wording is supplied by the source module."
),
}
- write_json(module_root / "metadata.json", metadata)
- write_json(module_root / "books.json", books_index)
- return metadata
diff --git a/src/study_builder/content.py b/src/study_builder/content.py
index db8d796..ee993d2 100644
--- a/src/study_builder/content.py
+++ b/src/study_builder/content.py
@@ -1,22 +1,21 @@
+# SPDX-License-Identifier: GPL-2.0-only
from __future__ import annotations
import html
import re
+from html.parser import HTMLParser
from typing import Any
-import bleach
-
-ALLOWED_TAGS = {
- "a",
- "b",
+# The public API publishes plain text only. Markup is never republished, so the
+# builder needs no HTML sanitizer and the generated API carries no markup that a
+# consuming application could inject into a page.
+_SUPPRESSED_TAGS = {"script", "style"}
+_BREAK_TAGS = {
"blockquote",
"br",
- "code",
"dd",
"div",
- "dl",
"dt",
- "em",
"h1",
"h2",
"h3",
@@ -24,28 +23,13 @@
"h5",
"h6",
"hr",
- "i",
"li",
"ol",
"p",
- "span",
- "strong",
- "sub",
- "sup",
"table",
- "tbody",
- "td",
- "th",
- "thead",
"tr",
- "u",
"ul",
}
-ALLOWED_ATTRIBUTES = {
- "a": ["href", "title"],
- "*": ["class", "dir", "lang", "title"],
-}
-ALLOWED_PROTOCOLS = {"http", "https", "mailto", "sword"}
_OSIS_REF = re.compile(
r"(?P[1-4]?[A-Za-z][A-Za-z0-9]+)\.(?P\d+)(?:\.(?P\d+))?"
@@ -53,19 +37,49 @@
_SWORD_URI = re.compile(r"sword://(?P[^\s\"'<>]+)", re.IGNORECASE)
-def clean_html(value: str) -> str:
- return bleach.clean(
- value,
- tags=ALLOWED_TAGS,
- attributes=ALLOWED_ATTRIBUTES,
- protocols=ALLOWED_PROTOCOLS,
- strip=True,
- strip_comments=True,
- ).strip()
+class _MarkupStripper(HTMLParser):
+ """Reduce source markup to readable text without republishing any of it."""
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self._parts: list[str] = []
+ self._suppressed = 0
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag in _SUPPRESSED_TAGS:
+ self._suppressed += 1
+ elif tag in _BREAK_TAGS:
+ self._parts.append("\n")
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag in _SUPPRESSED_TAGS:
+ self._suppressed = max(0, self._suppressed - 1)
+ elif tag in _BREAK_TAGS:
+ self._parts.append("\n")
+
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag in _BREAK_TAGS:
+ self._parts.append("\n")
+
+ def handle_data(self, data: str) -> None:
+ if not self._suppressed:
+ self._parts.append(data)
+
+ def text(self) -> str:
+ return "".join(self._parts)
+
+
+def strip_markup(value: str) -> str:
+ parser = _MarkupStripper()
+ parser.feed(value)
+ parser.close()
+ return parser.text()
-def clean_text(value: str) -> str:
- value = html.unescape(value).replace("\x00", "")
+def clean_text(value: str, *, unescape: bool = True) -> str:
+ if unescape:
+ value = html.unescape(value)
+ value = value.replace("\x00", "")
return "\n".join(line.rstrip() for line in value.strip().splitlines()).strip()
@@ -81,11 +95,12 @@ def extract_osis_references(*values: str) -> list[str]:
return sorted(references)
-def public_content(entry: dict[str, Any]) -> dict[str, Any]:
+def public_content(entry: dict[str, Any]) -> dict[str, str]:
+ """Project a validated contract entry onto the published text-only shape."""
text = clean_text(str(entry.get("plain", "")))
- rendered = clean_html(str(entry.get("html", "")))
- result: dict[str, Any] = {"text": text}
- visible_rendered = clean_text(bleach.clean(rendered, tags=set(), strip=True))
- if rendered and visible_rendered and rendered != text:
- result["html"] = rendered
- return result
+ if not text:
+ # A few modules leave the extractor's stripped field empty and carry the
+ # definition only in the rendered form. Deriving text keeps those entries
+ # addressable instead of dropping them when markup is not republished.
+ text = clean_text(strip_markup(str(entry.get("html", ""))), unescape=False)
+ return {"text": text}
diff --git a/src/study_builder/dictionaries.py b/src/study_builder/dictionaries.py
index 9bc2ec5..19ea69d 100644
--- a/src/study_builder/dictionaries.py
+++ b/src/study_builder/dictionaries.py
@@ -1,21 +1,38 @@
+# SPDX-License-Identifier: GPL-2.0-only
from __future__ import annotations
import base64
import hashlib
import re
+import unicodedata
from collections import defaultdict
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
-from urllib.parse import quote
+from urllib.parse import quote, unquote
from jsonschema import validate
+from study_builder.books import BookRegistry
from study_builder.content import extract_osis_references, public_content
from study_builder.models import ModuleDescriptor, NativeExport
-from study_builder.util import read_json, slug, write_json
+from study_builder.util import read_json, slug, write_composed_json, write_json
_STRONG_KEY = re.compile(r"^(?:strong:)?([GH])?0*(\d{1,5})(?:!.*)?$", re.IGNORECASE)
_SAFE_ENTRY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$")
+_OSIS_LIKE = re.compile(r"^[1-4]?[A-Za-z][A-Za-z0-9]+\.\d+(?:\.\d+)?$")
+_SWORD_LINK = re.compile(r"sword://(?P[^\s\"'<>]+)", re.IGNORECASE)
+_MARKUP_TARGET = re.compile(
+ r"<(?:ref|reference|a)\b[^>]*?\b(?:target|href|osisRef)\s*=\s*[\"'](?P[^\"']+)[\"']",
+ re.IGNORECASE,
+)
+_STRONG_SEE = re.compile(
+ r"\bsee\s+(?PGREEK|HEBREW)\s+for\s+0*(?P\d{1,5})\b", re.IGNORECASE
+)
+_SEARCH_NOISE = re.compile(r"[^\w\s-]", re.UNICODE)
+
+# Reserved inside a dictionary directory; an entry may never claim these names.
+RESERVED_DOCUMENTS = {"metadata.json", "index.json"}
def strong_prefix(module: ModuleDescriptor, metadata: dict[str, Any]) -> str | None:
@@ -51,27 +68,156 @@ def encoded_entry_id(key: str) -> str:
return "h-" + hashlib.sha256(key.encode("utf-8")).hexdigest()
+def search_key(key: str) -> str:
+ """Fold a source key to an accent-insensitive, lowercase search term."""
+ decomposed = unicodedata.normalize("NFKD", key)
+ folded = "".join(part for part in decomposed if not unicodedata.combining(part)).casefold()
+ return re.sub(r"\s+", " ", _SEARCH_NOISE.sub(" ", folded)).strip() or key.casefold()
+
+
+def link_candidates(*values: str) -> set[str]:
+ """Collect the raw cross-reference targets a dictionary entry points at.
+
+ Source markup and the extractor's rendered form spell the same link in different
+ ways, so both are scanned. Only targets that resolve to a key in the same
+ dictionary survive, which keeps the sweep self-limiting.
+ """
+ candidates: set[str] = set()
+ for value in values:
+ for match in _SWORD_LINK.finditer(value):
+ candidates.add(match.group("value").rsplit("/", 1)[-1])
+ for match in _MARKUP_TARGET.finditer(value):
+ target = match.group("value")
+ if target.lower().startswith("sword://"):
+ target = target[len("sword://") :]
+ candidates.add(target.rsplit("/", 1)[-1].split(":", 1)[-1])
+ for match in _STRONG_SEE.finditer(value):
+ language = "G" if match.group("language").upper() == "GREEK" else "H"
+ strong = canonical_strong(match.group("number"), language)
+ if strong:
+ candidates.add(strong)
+ resolved = set()
+ for candidate in candidates:
+ value = unquote(candidate).strip().strip("#")
+ # Scripture references belong in "references", not in the word link graph.
+ if value and not _OSIS_LIKE.fullmatch(value):
+ resolved.add(value)
+ return resolved
+
+
+@dataclass
+class _Staged:
+ entry_id: str
+ key: str
+ occurrence: int
+ aliases: list[str]
+ search: str
+ targets: set[str] = field(default_factory=set)
+ links: list[str] = field(default_factory=list)
+ backlinks: list[str] = field(default_factory=list)
+
+
class DictionaryWriter:
- def __init__(self, root: Path, schema_path: Path) -> None:
+ """Write the search index and per-word documents for one dictionary module.
+
+ The build reads the validated entry spool twice: the first pass assigns stable
+ identifiers and collects cross-reference targets, and the second writes each word
+ with its links resolved. Forward and reverse links are only knowable once every
+ key has an identifier, so a single pass cannot produce a navigable link graph.
+ """
+
+ def __init__(self, root: Path, books: BookRegistry, schemas_dir: Path) -> None:
self.root = root
- self.schema = read_json(schema_path)
+ self.books = books
+ self.schema = read_json(schemas_dir / "dictionary-entry.schema.json")
- def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, Any]:
+ def write(self, module: ModuleDescriptor, exported: NativeExport) -> tuple[dict, dict]:
module_id = slug(module.name)
module_root = self.root / module_id
prefix = strong_prefix(module, exported.metadata)
- key_index: list[dict[str, Any]] = []
- shards: dict[str, list[dict[str, Any]]] = defaultdict(list)
- used_ids: dict[str, str] = {}
- occurrences: dict[str, int] = defaultdict(int)
+ staged = self._stage(exported, prefix)
+ self._resolve_links(staged)
+
+ by_id = {item.entry_id: item for item in staged}
+ entry_files: dict[str, Path] = {}
+ position = 0
for source in exported.entries:
- key = str(source.get("key", "")).strip()
- if not key:
+ content = self._publishable(source)
+ if content is None:
continue
- content = public_content(source)
- if not content.get("text") and not content.get("html"):
+ item = staged[position]
+ position += 1
+ path = module_root / f"{item.entry_id}.json"
+ if path.name in RESERVED_DOCUMENTS:
+ raise RuntimeError(f"Entry document collides with a reserved name: {path.name}")
+ write_json(path, self._document(module, module_id, item, content, source, by_id))
+ entry_files[item.entry_id] = path
+ if position != len(staged):
+ raise RuntimeError(
+ f"Dictionary entries changed between passes: staged {len(staged)}, wrote {position}"
+ )
+
+ index = sorted(staged, key=lambda item: (item.search, item.key.casefold(), item.occurrence))
+ unique_keys = len({item.key.casefold() for item in staged})
+ write_json(
+ module_root / "index.json",
+ {
+ "schema": "getbible-dictionary-index-v1",
+ "dictionary": module_id,
+ "language": module.language,
+ "name": module.description,
+ "entry_url_template": "{entry}.json",
+ "entry_count": len(staged),
+ "unique_key_count": unique_keys,
+ "entries": [self._index_record(item) for item in index],
+ },
+ )
+
+ complete = self.root / f"{module_id}.json"
+ write_composed_json(
+ complete,
+ {
+ "schema": "getbible-dictionary-v1",
+ "dictionary": module_id,
+ "language": module.language,
+ "name": module.description,
+ },
+ "entries",
+ [entry_files[item.entry_id] for item in index],
+ )
+
+ metadata = self._metadata(
+ module, module_id, prefix, len(staged), unique_keys, complete.stat().st_size
+ )
+ write_json(module_root / "metadata.json", metadata)
+ record = {
+ "id": module_id,
+ "name": module.description,
+ "language": module.language,
+ "license": module.license,
+ "entry_count": len(staged),
+ "unique_key_count": unique_keys,
+ "strong_prefix": prefix,
+ "bytes": metadata["bytes"],
+ }
+ return record, metadata
+
+ @staticmethod
+ def _publishable(source: dict[str, Any]) -> dict[str, str] | None:
+ if not str(source.get("key", "")).strip():
+ return None
+ content = public_content(source)
+ return content if content["text"] else None
+
+ def _stage(self, exported: NativeExport, prefix: str | None) -> list[_Staged]:
+ staged: list[_Staged] = []
+ used_ids: dict[str, str] = {}
+ occurrences: dict[str, int] = defaultdict(int)
+ for source in exported.entries:
+ if self._publishable(source) is None:
continue
+ key = str(source["key"]).strip()
canonical = canonical_strong(key, prefix)
entry_id = canonical or encoded_entry_id(key)
collision_key = entry_id.casefold()
@@ -83,41 +229,100 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
if occurrence > 1:
entry_id = f"{entry_id}--{occurrence}"
used_ids[entry_id.casefold()] = key
- aliases = sorted({value for value in (key, canonical) if value})
- raw = str(source.get("raw", ""))
- rendered = str(source.get("html", ""))
- document: dict[str, Any] = {
- "schema": "getbible-dictionary-entry-v1",
- "dictionary": module_id,
- "language": module.language,
- "id": entry_id,
- "key": key,
- "occurrence": occurrence,
- "aliases": aliases,
- **content,
- }
- references = extract_osis_references(raw, rendered)
- if references:
- document["references"] = references
- validate(document, self.schema)
- write_json(module_root / f"{entry_id}.json", document)
- index_record = {
- "key": key,
- "id": entry_id,
- "occurrence": occurrence,
- "aliases": aliases,
- "url": f"{entry_id}.json",
- }
- key_index.append(index_record)
- shard = hashlib.sha256(key.casefold().encode("utf-8")).hexdigest()[:2]
- shards[shard].append(index_record)
-
- key_index.sort(key=lambda record: (record["key"].casefold(), record["occurrence"]))
- for shard, records in sorted(shards.items()):
- records.sort(key=lambda record: (record["key"].casefold(), record["occurrence"]))
- write_json(module_root / "indexes" / f"{shard}.json", records)
- write_json(module_root / "keys.json", key_index)
- metadata = {
+ staged.append(
+ _Staged(
+ entry_id=entry_id,
+ key=key,
+ occurrence=occurrence,
+ aliases=sorted({value for value in (key, canonical) if value}),
+ search=search_key(key),
+ targets=link_candidates(
+ str(source.get("raw", "")), str(source.get("html", ""))
+ ),
+ )
+ )
+ return staged
+
+ @staticmethod
+ def _resolve_links(staged: list[_Staged]) -> None:
+ """Turn raw targets into entry identifiers, then invert them into backlinks."""
+ lookup: dict[str, str] = {}
+ for item in staged:
+ for alias in (item.key, *item.aliases, item.search):
+ lookup.setdefault(alias.casefold(), item.entry_id)
+ incoming: dict[str, list[str]] = defaultdict(list)
+ for item in staged:
+ seen: set[str] = set()
+ for target in sorted(item.targets):
+ resolved = lookup.get(target.casefold()) or lookup.get(search_key(target))
+ if not resolved or resolved == item.entry_id or resolved in seen:
+ continue
+ seen.add(resolved)
+ item.links.append(resolved)
+ incoming[resolved].append(item.entry_id)
+ item.links.sort()
+ item.targets = set() # released once resolved; only the ids are published
+ for item in staged:
+ item.backlinks = sorted(set(incoming.get(item.entry_id, ())))
+
+ def _document(
+ self,
+ module: ModuleDescriptor,
+ module_id: str,
+ item: _Staged,
+ content: dict[str, str],
+ source: dict[str, Any],
+ by_id: dict[str, _Staged],
+ ) -> dict[str, Any]:
+ document: dict[str, Any] = {
+ "schema": "getbible-dictionary-entry-v1",
+ "dictionary": module_id,
+ "language": module.language,
+ "id": item.entry_id,
+ "key": item.key,
+ "occurrence": item.occurrence,
+ "aliases": item.aliases,
+ **content,
+ }
+ if item.links:
+ document["see_also"] = [
+ {"id": target, "key": by_id[target].key} for target in item.links
+ ]
+ if item.backlinks:
+ document["backlinks"] = [
+ {"id": target, "key": by_id[target].key} for target in item.backlinks
+ ]
+ references = []
+ for reference in extract_osis_references(
+ str(source.get("raw", "")), str(source.get("html", ""))
+ ):
+ normalized = self.books.reference(reference)
+ if normalized:
+ references.append(normalized)
+ if references:
+ document["references"] = references
+ validate(document, self.schema)
+ return document
+
+ @staticmethod
+ def _index_record(item: _Staged) -> dict[str, Any]:
+ record: dict[str, Any] = {"id": item.entry_id, "key": item.key, "search": item.search}
+ if item.aliases != [item.key]:
+ record["aliases"] = item.aliases
+ if item.occurrence > 1:
+ record["occurrence"] = item.occurrence
+ return record
+
+ @staticmethod
+ def _metadata(
+ module: ModuleDescriptor,
+ module_id: str,
+ prefix: str | None,
+ entry_count: int,
+ unique_key_count: int,
+ complete_bytes: int,
+ ) -> dict[str, Any]:
+ return {
"schema": "getbible-dictionary-metadata-v1",
"id": module_id,
"module": module.name,
@@ -127,12 +332,12 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
"license": module.license,
"driver": module.driver,
"source_type": module.first("sourcetype"),
- "entry_count": len(key_index),
- "unique_key_count": len({record["key"].casefold() for record in key_index}),
+ "entry_count": entry_count,
+ "unique_key_count": unique_key_count,
"strong_prefix": prefix,
- "keys_url": "keys.json",
+ "bytes": complete_bytes,
+ "index_url": "index.json",
"entry_url_template": "{entry}.json",
- "index_url_template": "indexes/{sha256_prefix}.json",
"source": "CrossWire SWORD",
"source_module_url": (
"https://www.crosswire.org/sword/modules/ModInfo.jsp?modName="
@@ -152,5 +357,3 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A
"Converted to GetBible static JSON; wording is supplied by the source module."
),
}
- write_json(module_root / "metadata.json", metadata)
- return metadata
diff --git a/src/study_builder/pipeline.py b/src/study_builder/pipeline.py
index adfdb2a..dc66f8d 100644
--- a/src/study_builder/pipeline.py
+++ b/src/study_builder/pipeline.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -18,16 +19,41 @@
from study_builder.native import SwordExporter
from study_builder.policy import ModulePolicy
from study_builder.util import (
+ hash_tree,
replace_tree,
reset_directory,
slug,
utc_now,
- write_hash_sidecars,
write_json,
)
LOG = logging.getLogger(__name__)
+# A module directory and its whole-module document both sit at the v1 root, so a
+# module identifier may never collide with a document the builder writes there.
+RESERVED_MODULE_IDS = frozenset({"build", "commentaries", "dictionaries", "hashes", "schema"})
+
+BASE_URLS: dict[str, str] = {
+ "commentaries": "https://commentaries.getbible.net/v1/",
+ "dictionaries": "https://dictionaries.getbible.net/v1/",
+}
+
+CATALOG_TEMPLATES: dict[str, dict[str, str]] = {
+ "commentaries": {
+ "metadata_url_template": "{commentary}/metadata.json",
+ "books_url_template": "{commentary}/books.json",
+ "commentary_url_template": "{commentary}.json",
+ "book_url_template": "{commentary}/{book}.json",
+ "chapter_url_template": "{commentary}/{book}/{chapter}.json",
+ },
+ "dictionaries": {
+ "metadata_url_template": "{dictionary}/metadata.json",
+ "index_url_template": "{dictionary}/index.json",
+ "dictionary_url_template": "{dictionary}.json",
+ "entry_url_template": "{dictionary}/{entry}.json",
+ },
+}
+
@dataclass(frozen=True)
class PipelineConfig:
@@ -47,8 +73,8 @@ class PipelineConfig:
pull: bool = False
push: bool = False
dry_run: bool = False
- commentaries_repo: str = "git@github.com:getbible/v1_commentaries.git"
- dictionaries_repo: str = "git@github.com:getbible/v1_dictionaries.git"
+ commentaries_repo: str = "git@github.com:getbible/commentaries.git"
+ dictionaries_repo: str = "git@github.com:getbible/dictionaries.git"
commentaries_branch: str = "main"
dictionaries_branch: str = "main"
@@ -76,12 +102,12 @@ def _repositories(self) -> dict[ResourceKind, GitRepository]:
return {
"commentaries": GitRepository(
self.config.commentaries_repo,
- self.config.work_dir / "repos" / "v1_commentaries",
+ self.config.work_dir / "repos" / "commentaries",
self.config.commentaries_branch,
),
"dictionaries": GitRepository(
self.config.dictionaries_repo,
- self.config.work_dir / "repos" / "v1_dictionaries",
+ self.config.work_dir / "repos" / "dictionaries",
self.config.dictionaries_branch,
),
}
@@ -107,7 +133,13 @@ def run(self) -> BuildReport:
raise RuntimeError("The policy did not approve any selected modules")
identifiers: dict[tuple[ResourceKind, str], str] = {}
for kind, module in approved:
- key = (kind, slug(module.name))
+ module_id = slug(module.name)
+ if module_id in RESERVED_MODULE_IDS:
+ raise RuntimeError(
+ f"Module {module.name!r} normalizes to the reserved identifier "
+ f"{module_id!r}, which would collide with a generated document"
+ )
+ key = (kind, module_id)
if key in identifiers and identifiers[key] != module.name:
raise RuntimeError(
f"Module identifiers collide after normalization: "
@@ -167,19 +199,15 @@ def run(self) -> BuildReport:
}
for item in exported.diagnostics
]
- if kind == "commentaries":
- writer = CommentaryWriter(
- generated_roots[kind],
- self.books,
- self.config.schemas_dir / "commentary-chapter.schema.json",
- )
- else:
- writer = DictionaryWriter(
- generated_roots[kind],
- self.config.schemas_dir / "dictionary-entry.schema.json",
+ writer = (
+ CommentaryWriter(generated_roots[kind], self.books, self.config.schemas_dir)
+ if kind == "commentaries"
+ else DictionaryWriter(
+ generated_roots[kind], self.books, self.config.schemas_dir
)
- summary = writer.write(module, exported)
- summaries[kind].append(summary)
+ )
+ record, _ = writer.write(module, exported)
+ summaries[kind].append(record)
report.built[kind].append(module.name)
except Exception as error:
LOG.exception("Failed to build %s", module.name)
@@ -198,19 +226,16 @@ def run(self) -> BuildReport:
generated_at = utc_now()
for kind in resources:
records = sorted(summaries[kind], key=lambda item: item["id"])
- catalog_name = kind
- base_url = (
- "https://commentaries.getbible.net/v1/"
- if kind == "commentaries"
- else "https://dictionaries.getbible.net/v1/"
- )
+ self._publish_schemas(generated_roots[kind], kind)
write_json(
- generated_roots[kind] / f"{catalog_name}.json",
+ generated_roots[kind] / f"{kind}.json",
{
"schema": f"getbible-{kind}-catalog-v1",
"version": 1,
"generated_at": generated_at,
- "base_url": base_url,
+ "base_url": BASE_URLS[kind],
+ **CATALOG_TEMPLATES[kind],
+ "module_count": len(records),
kind: records,
},
)
@@ -229,12 +254,16 @@ def run(self) -> BuildReport:
"module_count": len(records),
},
)
- hashes = write_hash_sidecars(generated_roots[kind])
+ # hashes.json covers every other generated document and is therefore
+ # also the manifest of the paths this build owns.
write_json(
generated_roots[kind] / "hashes.json",
- {"algorithm": "sha256", "files": hashes},
+ {
+ "schema": "getbible-hashes-v1",
+ "algorithm": "sha256",
+ "files": hash_tree(generated_roots[kind], exclude={"hashes.json"}),
+ },
)
- write_hash_sidecars(generated_roots[kind])
dist_root = self.config.dist_dir / kind / "v1"
replace_tree(generated_roots[kind], dist_root)
@@ -254,5 +283,14 @@ def run(self) -> BuildReport:
self._write_report(report)
return report
+ def _publish_schemas(self, root: Path, kind: ResourceKind) -> None:
+ """Serve the document schemas alongside the data so every $id resolves."""
+ prefix = "commentary" if kind == "commentaries" else "dictionary"
+ destination = root / "schema"
+ destination.mkdir(parents=True, exist_ok=True)
+ for source in sorted(self.config.schemas_dir.glob(f"{prefix}*.schema.json")):
+ name = source.name.removesuffix(".schema.json") + ".json"
+ shutil.copyfile(source, destination / name)
+
def _write_report(self, report: BuildReport) -> None:
write_json(self.config.work_dir / "reports" / "latest.json", report.as_dict())
diff --git a/src/study_builder/util.py b/src/study_builder/util.py
index fff27a9..09dc25f 100644
--- a/src/study_builder/util.py
+++ b/src/study_builder/util.py
@@ -6,9 +6,10 @@
import re
import shutil
import tempfile
+from collections.abc import Collection, Sequence
from datetime import UTC, datetime
from pathlib import Path
-from typing import Any
+from typing import IO, Any
_SAFE_SLUG = re.compile(r"[^a-z0-9._-]+")
@@ -52,18 +53,59 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()
-def write_hash_sidecars(root: Path) -> dict[str, str]:
+def hash_tree(root: Path, *, exclude: Collection[str] = ()) -> dict[str, str]:
+ """Digest every generated document. Also the manifest of builder-owned paths."""
+ excluded = set(exclude)
hashes: dict[str, str] = {}
for path in sorted(root.rglob("*.json")):
relative = path.relative_to(root).as_posix()
- data = path.read_bytes()
- sha1 = hashlib.sha1(data, usedforsecurity=False).hexdigest()
- sha256 = hashlib.sha256(data).hexdigest()
- path.with_suffix(path.suffix + ".sha").write_text(sha1 + "\n", encoding="ascii")
- hashes[relative] = sha256
+ if relative in excluded:
+ continue
+ hashes[relative] = sha256_file(path)
return hashes
+def _write_indented(handle: IO[str], source: Path, prefix: str) -> None:
+ first = True
+ with source.open(encoding="utf-8") as reader:
+ for raw in reader:
+ line = raw.rstrip("\n")
+ if not first:
+ handle.write("\n")
+ first = False
+ handle.write(prefix + line if line else "")
+
+
+def write_composed_json(
+ path: Path, header: dict[str, Any], member: str, sources: Sequence[Path]
+) -> None:
+ """Write an envelope whose array member embeds already-written documents.
+
+ The members are streamed from disk rather than held in memory, and each one is
+ embedded byte-for-byte. A composed document therefore contains its parts exactly
+ as they are served individually, so one client parser handles every zoom level.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if member in header:
+ raise ValueError(f"Composed member {member!r} is already present in the envelope")
+ with tempfile.NamedTemporaryFile(
+ "w", encoding="utf-8", dir=path.parent, delete=False, prefix=f".{path.name}."
+ ) as handle:
+ opening = stable_json(header).rstrip()
+ handle.write(opening[:-1].rstrip())
+ if header:
+ handle.write(",")
+ handle.write(f'\n "{member}": [')
+ for index, source in enumerate(sources):
+ handle.write(",\n" if index else "\n")
+ _write_indented(handle, source, " ")
+ if sources:
+ handle.write("\n ")
+ handle.write("]\n}\n")
+ temporary = Path(handle.name)
+ os.replace(temporary, path)
+
+
def replace_tree(source: Path, destination: Path) -> None:
"""Atomically publish a generated directory on the same filesystem."""
source = source.resolve()
diff --git a/tests/nginx_config_check.sh b/tests/nginx_config_check.sh
new file mode 100755
index 0000000..1120b7e
--- /dev/null
+++ b/tests/nginx_config_check.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# End-to-end check of everything the Python tests cannot reach: that the shipped
+# origin configuration installs onto a stock nginx, that deploy_static_api.sh
+# produces a tree nginx can serve, and that both hosts keep every promise
+# verify_live_api.sh asserts.
+#
+# It deliberately uses the distribution's own nginx.conf and the real installer
+# rather than a purpose-built test config. A hand-rolled nginx.conf would not
+# have caught, for example, that a stock nginx.conf already declares sendfile
+# and gzip at http level and that redeclaring them is a fatal error.
+#
+# Needs root: it installs into /etc/nginx and serves on :443.
+#
+# Requires: nginx, git, rsync, python3, openssl, curl, and the builder installed.
+
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORKSPACE="$(mktemp -d)"
+HOSTS=(commentaries.getbible.net dictionaries.getbible.net)
+HOSTS_MARKER="# getbible-api-check"
+
+[[ "$EUID" -eq 0 ]] || { echo "must run as root (it installs into /etc/nginx)" >&2; exit 2; }
+for tool in nginx git rsync python3 openssl curl; do
+ command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 2; }
+done
+
+cleanup() {
+ nginx -s quit 2>/dev/null || true
+ sed -i "/$HOSTS_MARKER/d" /etc/hosts 2>/dev/null || true
+ rm -rf "$WORKSPACE"
+}
+trap cleanup EXIT
+
+# ---------------------------------------------------------------------------
+# 1. Generate real API trees and publish each as a repository.
+# ---------------------------------------------------------------------------
+echo "== generating API trees =="
+python3 "$REPO/tests/support/build_sample_tree.py" "$WORKSPACE/build"
+
+for host in "${HOSTS[@]}"; do
+ resource="${host%%.*}"
+ repository="$WORKSPACE/repo-$resource"
+ git init -q -b main "$repository"
+ git -C "$repository" config user.name "nginx config check"
+ git -C "$repository" config user.email "check@example.invalid"
+ cp -a "$WORKSPACE/build/dist/$resource/v1" "$repository/v1"
+ printf '# published %s\n' "$resource" > "$repository/README.md"
+ git -C "$repository" add -A
+ git -C "$repository" commit -qm "Sample $resource build"
+
+ echo "== deploying $resource =="
+ "$REPO/scripts/deploy_static_api.sh" \
+ --repo "$repository" \
+ --root "/var/www/getbible/$resource" \
+ --work "$WORKSPACE/checkout-$resource" \
+ --no-reload
+
+ [[ -d "/var/www/getbible/$resource/v1" ]] || { echo "no v1 for $resource" >&2; exit 1; }
+ if [[ -e "/var/www/getbible/$resource/README.md" ]]; then
+ echo "deploy leaked a non-version file into the live root" >&2
+ exit 1
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 2. Install the configuration exactly the way an operator would.
+# ---------------------------------------------------------------------------
+for host in "${HOSTS[@]}"; do
+ install -d -m 755 "/etc/letsencrypt/live/$host"
+ openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
+ -keyout "/etc/letsencrypt/live/$host/privkey.pem" \
+ -out "/etc/letsencrypt/live/$host/fullchain.pem" \
+ -subj "/CN=$host" -addext "subjectAltName=DNS:$host" 2>/dev/null
+ cp "/etc/letsencrypt/live/$host/fullchain.pem" "/etc/letsencrypt/live/$host/chain.pem"
+ printf '127.0.0.1 %s %s\n' "$host" "$HOSTS_MARKER" >> /etc/hosts
+done
+
+# The stock default site binds :80 and :443 as default_server, which collides
+# with nothing here — but it also binds [::] unconditionally, so it fails on a
+# build host without IPv6. Drop it so the check tests this configuration only.
+rm -f /etc/nginx/sites-enabled/default
+
+echo "== installing =="
+"$REPO/scripts/install_nginx_config.sh"
+
+# ---------------------------------------------------------------------------
+# 3. Serve both hosts and hold each to its own promises.
+# ---------------------------------------------------------------------------
+echo "== serving =="
+# Start from empty logs so the assertion below sees only this run.
+for host in "${HOSTS[@]}"; do
+ : > "/var/log/nginx/$host.error.log"
+ : > "/var/log/nginx/$host.access.log"
+done
+nginx
+sleep 1
+
+export no_proxy='*' NO_PROXY='*'
+for host in "${HOSTS[@]}"; do
+ echo
+ "$REPO/scripts/verify_live_api.sh" "https://$host" --insecure
+done
+
+echo
+echo "== error logs =="
+found=0
+for host in "${HOSTS[@]}"; do
+ log="/var/log/nginx/$host.error.log"
+ if [[ -f "$log" ]] && grep -Ei '\[(emerg|alert|crit|error)\]' "$log" | grep -v ssl_stapling; then
+ found=1
+ fi
+done
+if [[ "$found" -eq 1 ]]; then
+ echo "nginx logged errors while serving" >&2
+ exit 1
+fi
+echo " clean"
+
+# ---------------------------------------------------------------------------
+# 4. The access log must be rotated by the stock logrotate glob, or it grows
+# without bound until it fills the disk.
+# ---------------------------------------------------------------------------
+if [[ -f /etc/logrotate.d/nginx ]]; then
+ for host in "${HOSTS[@]}"; do
+ access="/var/log/nginx/$host.access.log"
+ [[ -f "$access" ]] || { echo "no access log for $host" >&2; exit 1; }
+ if ! grep -q '/var/log/nginx/\*\.log' /etc/logrotate.d/nginx; then
+ echo " logrotate glob changed; re-check the access log name" >&2
+ elif [[ "$access" != *.log ]]; then
+ echo "access log $access is outside the logrotate glob" >&2
+ exit 1
+ fi
+ done
+ echo "== access logs are within the logrotate glob =="
+fi
diff --git a/tests/support/build_sample_tree.py b/tests/support/build_sample_tree.py
new file mode 100755
index 0000000..75890a3
--- /dev/null
+++ b/tests/support/build_sample_tree.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only
+"""Generate a complete API tree without CrossWire or the extractor.
+
+Used by tests/nginx_config_check.sh to give the origin configuration something
+real to serve. It drives the actual pipeline, so the tree it produces — catalog,
+hashes.json, published schemas, and all — is exactly what a build publishes.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+REPOSITORY = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPOSITORY / "src"))
+sys.path.insert(0, str(REPOSITORY / "tests"))
+
+from test_pipeline_build import ( # noqa: E402
+ COMMENTARY,
+ DICTIONARY,
+ StubExporter,
+ StubInstaller,
+)
+
+from study_builder import pipeline as pipeline_module # noqa: E402
+from study_builder.pipeline import BuildPipeline, PipelineConfig # noqa: E402
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(f"usage: {sys.argv[0]} TARGET_DIRECTORY", file=sys.stderr)
+ return 2
+ target = Path(sys.argv[1])
+
+ pipeline_module.ModuleInstaller = StubInstaller
+ pipeline_module.SwordExporter = StubExporter
+ pipeline_module.GetBibleSwordManager.ensure = lambda self, path=None: Path("/stub")
+ BuildPipeline._catalog = lambda self: [COMMENTARY, DICTIONARY]
+
+ report = BuildPipeline(
+ PipelineConfig(
+ root=REPOSITORY,
+ work_dir=target / "work",
+ dist_dir=target / "dist",
+ policy_path=REPOSITORY / "conf/module_policy.json",
+ books_path=REPOSITORY / "conf/book_registry.json",
+ schemas_dir=REPOSITORY / "schemas",
+ engine_manifest_path=REPOSITORY / "conf/getbiblesword.json",
+ engine_schema_path=REPOSITORY / "schemas/getbiblesword-ndjson-v1.schema.json",
+ )
+ ).run()
+ print(f"built {report.built}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 59d06ac..957df1c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -6,7 +6,7 @@ def test_build_cli_defaults_to_both_resources() -> None:
assert args.resource == "all"
assert args.dry_run
assert args.engine is None
- assert args.commentaries_repo.endswith("getbible/v1_commentaries.git")
- assert args.dictionaries_repo.endswith("getbible/v1_dictionaries.git")
+ assert args.commentaries_repo.endswith("getbible/commentaries.git")
+ assert args.dictionaries_repo.endswith("getbible/dictionaries.git")
assert args.commentaries_branch == "main"
assert args.dictionaries_branch == "main"
diff --git a/tests/test_commentaries.py b/tests/test_commentaries.py
index 2e2e76d..e4f533a 100644
--- a/tests/test_commentaries.py
+++ b/tests/test_commentaries.py
@@ -8,11 +8,11 @@
class OnePassEntries:
def __init__(self, entries):
self.entries = entries
- self.iterated = False
+ self.passes = 0
def __iter__(self):
- assert not self.iterated, "commentary entries were loaded or traversed more than once"
- self.iterated = True
+ self.passes += 1
+ assert self.passes <= 1, "commentary entries were loaded or traversed more than once"
yield from self.entries
def __len__(self):
@@ -22,179 +22,171 @@ def __getitem__(self, index):
return self.entries[index]
-def test_commentary_matches_v3_book_chapter_verse_contract(
- tmp_path, project_root, commentary_module
-) -> None:
- export = NativeExport(
- metadata={"record_type": "module"},
- entries=[
- {
- "record_type": "entry",
- "key": "Genesis 1:1",
- "raw": 'John 1:1',
- "plain": "A comment on creation.",
- "html": "A comment on creation.
",
- "verse": {
- "osis": "Gen.1.1",
- "testament": 1,
- "book": 1,
- "chapter": 1,
- "verse": 1,
- },
- }
- ],
- )
+def entry(osis, book, chapter, verse, text, *, raw=None, html=""):
+ return {
+ "record_type": "entry",
+ "key": osis,
+ "raw": raw if raw is not None else text,
+ "plain": text,
+ "html": html,
+ "verse": {
+ "osis": osis,
+ "testament": 1 if book <= 39 else 2,
+ "book": book if book <= 39 else book - 39,
+ "chapter": chapter,
+ "verse": verse,
+ },
+ }
+
+
+def write(tmp_path, project_root, module, entries):
writer = CommentaryWriter(
tmp_path,
BookRegistry(project_root / "conf/book_registry.json"),
- project_root / "schemas/commentary-chapter.schema.json",
+ project_root / "schemas",
)
- summary = writer.write(commentary_module, export)
- chapter_path = tmp_path / "testcom/1/1.json"
- chapter = json.loads(chapter_path.read_text(encoding="utf-8"))
- assert summary["chapter_url_template"] == "{book}/{chapter}.json"
- assert (chapter["book"], chapter["chapter"]) == (1, 1)
- assert chapter["entries"][0]["verse"] == 1
- assert chapter["entries"][0]["anchor"]["osis"] == "Gen.1.1"
- assert chapter["entries"][0]["references"][0]["book"] == 43
+ return writer.write(module, NativeExport(metadata={"record_type": "module"}, entries=entries))
-def test_commentary_consumes_source_entries_once(tmp_path, project_root, commentary_module) -> None:
- entries = OnePassEntries(
+def test_commentary_matches_v3_book_chapter_verse_contract(
+ tmp_path, project_root, commentary_module
+) -> None:
+ record, metadata = write(
+ tmp_path,
+ project_root,
+ commentary_module,
[
- {
- "key": "Genesis 1:1",
- "raw": "First chapter",
- "plain": "First chapter",
- "html": "",
- "verse": {
- "osis": "Gen.1.1",
- "testament": 1,
- "book": 1,
- "chapter": 1,
- "verse": 1,
- },
- },
- {
- "key": "Genesis 2:1",
- "raw": "Second chapter",
- "plain": "Second chapter",
- "html": "",
- "verse": {
- "osis": "Gen.2.1",
- "testament": 1,
- "book": 1,
- "chapter": 2,
- "verse": 1,
- },
- },
- ]
+ entry(
+ "John.1.1",
+ 43,
+ 1,
+ 1,
+ "A comment on creation.",
+ raw='John 1:1',
+ html="A comment on creation.
",
+ )
+ ],
)
- export = NativeExport(metadata={}, entries=entries)
+ chapter = json.loads((tmp_path / "testcom/43/1.json").read_text(encoding="utf-8"))
+ assert metadata["chapter_url_template"] == "{book}/{chapter}.json"
+ assert (chapter["book"], chapter["chapter"]) == (43, 1)
+ assert chapter["entries"][0]["verse"] == 1
+ assert chapter["entries"][0]["anchor"]["osis"] == "John.1.1"
+ assert chapter["entries"][0]["references"][0]["book"] == 43
+ assert record["entry_count"] == 1
- summary = CommentaryWriter(
- tmp_path,
- BookRegistry(project_root / "conf/book_registry.json"),
- project_root / "schemas/commentary-chapter.schema.json",
- ).write(commentary_module, export)
- assert entries.iterated
- assert summary["entry_count"] == 2
- assert (tmp_path / "testcom/1/1.json").is_file()
- assert (tmp_path / "testcom/1/2.json").is_file()
+def test_commentary_publishes_text_without_markup(
+ tmp_path, project_root, commentary_module
+) -> None:
+ write(
+ tmp_path,
+ project_root,
+ commentary_module,
+ [entry("Gen.1.1", 1, 1, 1, "Plain words.", html="Plain words.
")],
+ )
+ chapter = json.loads((tmp_path / "testcom/1/1.json").read_text(encoding="utf-8"))
+ published = chapter["entries"][0]
+ assert published["text"] == "Plain words."
+ assert "html" not in published
+ assert "html" not in (tmp_path / "testcom/1/1.json").read_text(encoding="utf-8")
-def test_commentary_skips_entries_without_a_scripture_chapter(
+def test_commentary_publishes_book_and_chapter_introductions(
tmp_path, project_root, commentary_module
) -> None:
- export = NativeExport(
- metadata={},
- entries=[
- {
- "key": "Genesis",
- "raw": "Book introduction",
- "plain": "Book introduction",
- "html": "",
- "verse": {
- "osis": "Gen",
- "testament": 1,
- "book": 1,
- "chapter": 0,
- "verse": 0,
- },
- },
- {
- "key": "Genesis 1",
- "raw": "Chapter introduction",
- "plain": "Chapter introduction",
- "html": "",
- "verse": {
- "osis": "Gen.1",
- "testament": 1,
- "book": 1,
- "chapter": 1,
- "verse": 0,
- },
- },
+ write(
+ tmp_path,
+ project_root,
+ commentary_module,
+ [
+ entry("Dan.0.0", 27, 0, 0, "About the book of Daniel."),
+ entry("Dan.1.0", 27, 1, 0, "About chapter one."),
+ entry("Dan.1.1", 27, 1, 1, "On the first verse."),
],
)
+ introduction = json.loads((tmp_path / "testcom/27/0.json").read_text(encoding="utf-8"))
+ assert introduction["chapter"] == 0
+ assert introduction["entries"][0]["name"] == "Daniel"
+ assert introduction["entries"][0]["text"] == "About the book of Daniel."
- summary = CommentaryWriter(
- tmp_path,
- BookRegistry(project_root / "conf/book_registry.json"),
- project_root / "schemas/commentary-chapter.schema.json",
- ).write(commentary_module, export)
+ chapter = json.loads((tmp_path / "testcom/27/1.json").read_text(encoding="utf-8"))
+ assert [item["verse"] for item in chapter["entries"]] == [0, 1]
+ assert chapter["entries"][0]["name"] == "Daniel 1"
- chapter = json.loads((tmp_path / "testcom/1/1.json").read_text(encoding="utf-8"))
- assert summary["entry_count"] == 1
- assert not (tmp_path / "testcom/1/0.json").exists()
- assert [entry["name"] for entry in chapter["entries"]] == ["Genesis 1"]
+ books = json.loads((tmp_path / "testcom/books.json").read_text(encoding="utf-8"))
+ assert books["books"][0]["chapters"] == [0, 1]
-def test_commentary_canonicalizes_interleaved_source_chapters(
+def test_book_and_commentary_documents_embed_their_parts_verbatim(
tmp_path, project_root, commentary_module
) -> None:
- def source(osis: str, testament: int, book: int, chapter: int, verse: int, text: str):
- return {
- "key": osis,
- "raw": text,
- "plain": text,
- "html": "",
- "verse": {
- "osis": osis,
- "testament": testament,
- "book": book,
- "chapter": chapter,
- "verse": verse,
- },
- }
+ write(
+ tmp_path,
+ project_root,
+ commentary_module,
+ [
+ entry("Gen.1.1", 1, 1, 1, "First."),
+ entry("Gen.2.1", 1, 2, 1, "Second."),
+ entry("John.1.1", 43, 1, 1, "Third."),
+ ],
+ )
+ chapters = [
+ json.loads((tmp_path / f"testcom/1/{number}.json").read_text(encoding="utf-8"))
+ for number in (1, 2)
+ ]
+ book = json.loads((tmp_path / "testcom/1.json").read_text(encoding="utf-8"))
+ assert book["schema"] == "getbible-commentary-book-v1"
+ assert book["chapters"] == chapters
+
+ complete = json.loads((tmp_path / "testcom.json").read_text(encoding="utf-8"))
+ books = [
+ json.loads((tmp_path / f"testcom/{number}.json").read_text(encoding="utf-8"))
+ for number in (1, 43)
+ ]
+ assert complete["schema"] == "getbible-commentary-v1"
+ assert complete["books"] == books
+
+
+def test_commentary_reports_counts_and_bulk_size(tmp_path, project_root, commentary_module) -> None:
+ record, metadata = write(
+ tmp_path,
+ project_root,
+ commentary_module,
+ [
+ entry("Gen.1.1", 1, 1, 1, "First."),
+ entry("Gen.2.1", 1, 2, 1, "Second."),
+ entry("John.1.1", 43, 1, 1, "Third."),
+ ],
+ )
+ assert (record["book_count"], record["chapter_count"], record["entry_count"]) == (2, 3, 3)
+ assert record["bytes"] == (tmp_path / "testcom.json").stat().st_size
+ assert metadata["bytes"] == record["bytes"]
+
+def test_commentary_consumes_source_entries_once(tmp_path, project_root, commentary_module) -> None:
entries = OnePassEntries(
[
- source("2Macc.7.1", 1, 48, 7, 1, "Maccabees first"),
- source("Job.2.1", 1, 18, 2, 1, "Job second chapter"),
- source("Job.1.2", 1, 18, 1, 2, "Job second verse"),
- source("2Macc.7.1", 1, 48, 7, 1, "Maccabees first"),
- source("Job.1.1", 1, 18, 1, 1, "Job first verse"),
+ entry("Gen.1.1", 1, 1, 1, "First chapter"),
+ entry("Gen.2.1", 1, 2, 1, "Second chapter"),
]
)
- export = NativeExport(metadata={}, entries=entries)
+ write(tmp_path, project_root, commentary_module, entries)
+ assert entries.passes == 1
+ assert (tmp_path / "testcom/1/1.json").is_file()
+ assert (tmp_path / "testcom/1/2.json").is_file()
- summary = CommentaryWriter(
- tmp_path,
- BookRegistry(project_root / "conf/book_registry.json"),
- project_root / "schemas/commentary-chapter.schema.json",
- ).write(commentary_module, export)
- books = json.loads((tmp_path / "testcom/books.json").read_text(encoding="utf-8"))
- job = json.loads((tmp_path / "testcom/18.json").read_text(encoding="utf-8"))
- job_one = json.loads((tmp_path / "testcom/18/1.json").read_text(encoding="utf-8"))
- maccabees_seven = json.loads((tmp_path / "testcom/81/7.json").read_text(encoding="utf-8"))
-
- assert entries.iterated
- assert summary["entry_count"] == 4
- assert [item["book"] for item in books] == [18, 81]
- assert [item["chapter"] for item in job["chapters"]] == [1, 2]
- assert [item["verse"] for item in job_one["entries"]] == [1, 2]
- assert list(job_one["entries"][0]) == ["book", "chapter", "verse", "name", "anchor", "text"]
- assert len(maccabees_seven["entries"]) == 1
+def test_commentary_skips_entries_without_a_bible_coordinate(
+ tmp_path, project_root, commentary_module
+) -> None:
+ record, _ = write(
+ tmp_path,
+ project_root,
+ commentary_module,
+ [
+ {"key": "Preface", "raw": "front matter", "plain": "front matter", "html": ""},
+ entry("Gen.1.1", 1, 1, 1, "Kept."),
+ ],
+ )
+ assert record["entry_count"] == 1
diff --git a/tests/test_content.py b/tests/test_content.py
index 5e0c19a..a6e81d2 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -1,21 +1,32 @@
-from study_builder.content import clean_html, extract_osis_references, public_content
+from study_builder.content import extract_osis_references, public_content, strip_markup
-def test_html_sanitizer_removes_scripts_and_unsafe_links() -> None:
- cleaned = clean_html(
- "Hello world
"
- 'bad'
+def test_markup_stripper_drops_scripts_and_keeps_readable_text() -> None:
+ text = strip_markup(
+ 'Safe text
'
+ 'link'
)
- assert "