From 6549d8af81fd4f874582f3fc5e075ec041540cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:29:37 +0000 Subject: [PATCH 1/4] Reshape the v1 study API around whole books, words, and plain text The generated API could only be read one chapter at a time, duplicated every entry as both text and HTML, and silently discarded book introductions. This reshapes all three, keeping output under v1/ so the version stays in the folder rather than the repository name. Commentaries gain book and whole-commentary documents. The three 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, so one client parser handles all three. Composed documents stream from the documents they contain rather than being assembled in memory. Book introductions (chapter 0) and chapter introductions (verse 0) are published again, at {book}/0.json and as the first entry of their chapter. The README had promised this while the writer dropped them. Dictionaries replace keys.json and the 256 SHA-256 shards with a single sorted index.json. A client cannot know which SHA-256 shard holds a word without already hashing it, so the shards enabled no search at all. Records carry an accent-insensitive search term and drop the url that was always derivable from the id. Entries now carry the dictionary's own link graph as see_also and backlinks, resolved over two passes because forward and reverse links are only knowable once every key has an identifier. Whole-dictionary documents are published for offline clients. The html member is gone from every document and both schemas. It duplicated text almost everywhere, and with no markup republished the builder needs no HTML sanitizer, so bleach is dropped and the API carries nothing a consumer must sanitize before rendering. Entries whose stripped text is empty fall back to text derived from the rendered form instead of vanishing. Also: unify references to the object form in dictionaries as well as commentaries; slim the catalogs and hoist url templates to the envelope; guard module identifiers against colliding with a root document; retire the per-file .sha sidecars, halving the file count, in favour of hashes.json, which doubles as the manifest of builder-owned paths; serve the schemas beside the data so every $id resolves; and retarget output at getbible/commentaries and getbible/dictionaries. --- .github/workflows/build.yml | 4 +- AGENTS.md | 37 ++- README.md | 113 +++++++-- docs/nginx.conf | 35 ++- docs/target-repositories.md | 9 +- pyproject.toml | 1 - requirements.txt | 1 - schemas/commentary-book.schema.json | 20 ++ schemas/commentary-books.schema.json | 41 ++++ schemas/commentary-chapter.schema.json | 59 +++-- schemas/commentary.schema.json | 19 ++ schemas/dictionary-entry.schema.json | 35 ++- schemas/dictionary-index.schema.json | 42 ++++ schemas/dictionary.schema.json | 19 ++ scripts/validate_build.py | 105 +++++++-- src/study_builder/cli.py | 4 +- src/study_builder/commentaries.py | 246 ++++++++++++------- src/study_builder/content.py | 97 ++++---- src/study_builder/dictionaries.py | 311 ++++++++++++++++++++----- src/study_builder/pipeline.py | 96 +++++--- src/study_builder/util.py | 56 ++++- tests/test_cli.py | 4 +- tests/test_commentaries.py | 292 +++++++++++------------ tests/test_content.py | 37 +-- tests/test_dictionaries.py | 153 ++++++++++-- tests/test_pipeline.py | 67 ++++++ tests/test_pipeline_build.py | 156 +++++++++++++ tests/test_util.py | 33 ++- 28 files changed, 1592 insertions(+), 500 deletions(-) create mode 100644 schemas/commentary-book.schema.json create mode 100644 schemas/commentary-books.schema.json create mode 100644 schemas/commentary.schema.json create mode 100644 schemas/dictionary-index.schema.json create mode 100644 schemas/dictionary.schema.json create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_pipeline_build.py 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/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..d676aa8 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,9 +215,9 @@ 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 @@ -232,8 +295,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/nginx.conf b/docs/nginx.conf index bf1d4e8..a6a1491 100644 --- a/docs/nginx.conf +++ b/docs/nginx.conf @@ -1,30 +1,45 @@ # Add the relevant server block to each API virtual host. Set root to the -# checkout of v1_commentaries or v1_dictionaries respectively. +# checkout of getbible/commentaries or getbible/dictionaries respectively. +# +# The builder owns the v1/ directory of each checkout, so the public URL keeps an +# explicit version segment and a future v2/ can be served beside it unchanged. +# +# Generate the precompressed variants at deploy time rather than committing them; +# binary blobs delta poorly in Git and would grow both repositories every month: +# +# find v1 -name '*.json' -exec brotli -kf {} \; -exec gzip -kf9 {} \; server { - listen 443 ssl http2; + listen 443 ssl; + http2 on; server_name commentaries.getbible.net; - root /var/www/getbible/v1_commentaries; + root /var/www/getbible/commentaries; etag on; gzip on; - gzip_types application/json text/plain; + gzip_types application/json; + gzip_static on; + # brotli_static on; # requires ngx_brotli + # Chapter, book, and whole-commentary documents change only when the source + # module changes, so they are cached hard between monthly builds. 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; + add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always; } - location ~* \.sha$ { + # A regular expression location wins over the prefix above, so the three + # discovery documents stay short-lived while everything else does not. + location ~* ^/v1/(commentaries|build|hashes)\.json$ { 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; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always; } } # Duplicate the block for dictionaries.getbible.net and set: -# root /var/www/getbible/v1_dictionaries; - +# root /var/www/getbible/dictionaries; +# and match (dictionaries|build|hashes)\.json for the discovery documents. diff --git a/docs/target-repositories.md b/docs/target-repositories.md index 4eae3d6..18da63b 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`: 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/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/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"(?PA 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='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 "