Skip to content

Publish plain text only; add dictionary cross-references - #4

Merged
Llewellynvdm merged 4 commits into
mainfrom
claude/study-builder-api-refactor-gt1i2j
Aug 11, 2026
Merged

Publish plain text only; add dictionary cross-references#4
Llewellynvdm merged 4 commits into
mainfrom
claude/study-builder-api-refactor-gt1i2j

Conversation

@Llewellynvdm

@Llewellynvdm Llewellynvdm commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

This change removes HTML from the public API entirely, publishing only plain text. It also adds cross-reference linking within dictionaries, allowing entries to point to related words. The builder now performs two passes over dictionary entries to assign stable identifiers and resolve links.

Key Changes

  • Plain text only: Removed the bleach HTML sanitizer dependency. The strip_markup() function now extracts readable text from source markup without republishing any HTML. All schemas and documents have been updated to remove html fields.

  • Dictionary cross-references: Added link_candidates() function to extract cross-reference targets from source markup (SWORD links, markup targets, and Strong's "see" references). Dictionary entries now include see_also and backlinks arrays to navigate the word graph.

  • Two-pass dictionary writing: DictionaryWriter now stages entries in a first pass to assign stable identifiers and collect targets, then resolves links in a second pass before writing. This allows forward and reverse links to be computed once all keys are known.

  • Dictionary index: Added a new index.json document listing all entries sorted by accent-insensitive search term, enabling efficient client-side dictionary search.

  • Composed documents: Added write_composed_json() to create bulk documents that embed their parts byte-for-byte, allowing offline clients to fetch a single file while online clients use individual documents.

  • Repository naming: Updated references from v1_commentaries and v1_dictionaries to commentaries and dictionaries, with the version living in the v1/ folder rather than the repository name.

  • Schema updates: Added new schemas for dictionary index, commentary books index, and whole-module documents. Updated existing schemas to reflect plain text only and new cross-reference fields.

  • Utility functions: Added search_key() for accent-insensitive search term folding, hash_tree() to digest all generated documents, and removed SHA sidecar generation.

Implementation Details

  • The _Staged dataclass holds entry metadata during the two-pass dictionary build process.
  • _MarkupStripper uses Python's HTMLParser to extract text while suppressing script/style content and normalizing whitespace around block elements.
  • Search keys use Unicode NFKD decomposition and combining character removal for accent folding.
  • Link resolution filters out scripture references (OSIS-like patterns) to keep the word graph self-contained.
  • Commentary and dictionary writers now return tuples of (record, metadata) instead of a single summary dict.

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.
The single example server block was a starting point, not a deployment. This
replaces it with the origin configuration, a deploy engine, and a live verifier
for both hosts, so bringing the API up is a known procedure rather than an
exercise left to the reader.

docs/nginx/ holds the real configuration, split so the two hosts differ only in
server_name, root, and certificate paths, and the locations they share live in
one snippet. It sets two cache tiers (discovery documents are short-lived, the
corpus is not), serves the precompressed variants the deploy writes, restricts
the API to safe methods, answers preflight cheaply, and returns JSON for 404,
405, and 429 rather than an HTML error page a client has to special-case. It
also carries nosniff, a default-src 'none' policy, cross-origin CORP, and HSTS,
and exposes ETag to browsers so their own revalidation works.

scripts/deploy_static_api.sh pulls, verifies the whole tree against hashes.json,
compresses only what changed, syncs one version directory at a time, and
reloads. Verification runs before the sync, so a build that fails its digests
never reaches the live root and the previous one keeps serving. It can require
a valid GPG signature, which ties the origin to the build key.

The deploy pulls into a persistent checkout and syncs with rsync rather than
cloning, because nginx derives ETags from mtime and size: rewriting every file
each month would change every ETag and force every client to re-download a
corpus that had not changed. Git and rsync write only what differs, and the
compressed variants are stamped with their document's mtime for the same
reason. The live root now holds only version directories, so no repository
metadata reaches the origin at all.

scripts/verify_live_api.sh reads the catalog and follows it, asserting every
promise the API makes: paths resolve, both cache tiers are right, compressed
variants are served, revalidation returns 304, the CORS and security headers
are present, and failures are JSON. Nothing is hardcoded to a module, so it
works against any build and is safe to run from a monitor.

tests/nginx_config_check.sh wires it together in CI: generate a real tree, put
it in a repository, deploy it, serve it through the shipped configuration, and
run the verifier against it. That covers what the Python tests cannot reach.

docs/deployment.md documents the server layout, the caching model and why ETags
survive a deploy, the atomicity trade-off, CDN and real-IP handling, the
security posture, rollback, and monitoring.
Standing the configuration up against a real distribution nginx rather than a
purpose-built test config exposed four things that would each have broken a
first deployment.

A stock nginx.conf already declares 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 error — so the http-context drop-in
refused to start on a stock Ubuntu box. conf.d/ now carries only the shared
memory zones and the log format, which genuinely cannot live in a server block;
everything else moved to snippets/getbible-api-server.conf, where it overrides
the http-level settings instead of colliding with them.

The access log was named .json, outside the stock /etc/logrotate.d/nginx glob
of /var/log/nginx/*.log, so it would never have rotated and would eventually
have filled the disk. It is now .access.log.

"http2 on" needs nginx 1.25.1, but Ubuntu 24.04 LTS ships 1.24, so a first
nginx -t would have failed on the LTS release most likely to be hosting this.
brotli_static and IPv6 listeners have the same shape of problem. Rather than
leave three version footguns in a file people copy by hand,
scripts/install_nginx_config.sh detects each one, adapts the shipped files,
stages them, and runs nginx -t before installing. It also refuses to proceed
when a certificate is missing, naming the certbot command to run, because nginx
otherwise fails with a filename and no indication of what to do.

The ACME challenge now resolves from the stock /var/www/html rather than a
dedicated webroot nothing serves yet, which removes the chicken-and-egg between
issuing the first certificate and installing the configuration that serves the
challenge for renewals.

Dotfiles return 404 instead of deny: the caller sees the same JSON 404 as any
unknown path, which confirms nothing, and a scanned origin no longer fills its
error log with lines that would bury real failures.

tests/nginx_config_check.sh now drives the real installer against the
distribution's own nginx.conf and serves both hosts, which is what caught all
of the above; the previous hand-rolled test config could not have. It also
asserts the access log stays inside the logrotate glob. CI runs it as root and
shellchecks the tests as well as the scripts.

Also: deploy_static_api.sh reports an unreachable --verify-url instead of
exiting bare, and docs/deployment.md covers the installer, the configuration
split and why it exists, certificate issuance and renewal, and log rotation.
Deploy verification only rejected an unexpected .json, so a stray .html or .js
committed into a version directory would have passed and been served from the
API's own hostname. hashes.json is the complete list of what the builder
publishes, so anything else in the tree is unaccounted for: every file is now
checked, with only the .gz and .br variants this script writes exempted.

Also: --help printed a line of the script itself, because the header comment
block had outgrown the fixed line range it was extracted with; it is now bounded
by the block. And document that --require-signature verifies against the
deploying user's GPG keyring, so the build key has to be imported and trusted on
each origin first or every deploy fails closed.
@Llewellynvdm
Llewellynvdm merged commit fd5e699 into main Aug 11, 2026
7 checks passed
@Llewellynvdm
Llewellynvdm deleted the claude/study-builder-api-refactor-gt1i2j branch August 11, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant