Files
bible/render_books.py
T
2026-09-04 08:08:32 -04:00

286 lines
11 KiB
Python

#!/usr/bin/env python3
"""Render bible.json into one semantic HTML fragment per book.
Reads the JSON produced by extract_bible.py and writes books/GEN.html and so
on, plus an index. These are fragments, not documents: a <title> and one root
element. The document around them is assets/shell.html, which is static and the
same for every page, so chrome changes never require running this. The markup
carries structure and no presentation: every USFM distinction survives as a
class or data attribute, so the whole corpus can be restyled from the
stylesheet without regenerating it.
This is the only build step left, and books/ is all it owns: everything
hand-written lives in assets/ and is served straight from there. Run it when
bible.json changes, which is to say almost never.
Grouping worth knowing about: consecutive poetry lines are wrapped in a
<div class="poetry"> that a blank-line block closes, so stanzas are real
elements rather than spacing; consecutive list items become one <ul>.
"""
from __future__ import annotations
import argparse
import html
import json
import os
import sys
# Inline node type -> (tag, class). ``cite`` and ``dfn`` carry real meaning
# here: a book name cited in a note, and a glossary term at its definition.
INLINE_TAGS = {
"wj": ("span", "wj"), # words of Jesus
"keyword": ("dfn", "k"), # glossary headword
"bookref": ("cite", "bk"), # book title cited in a note
"fq": ("span", "fq"), # the words a footnote is about
"fqa": ("span", "fqa"), # an alternative rendering
"fl": ("span", "fl"), # footnote label
"fine": ("span", "fine"),
"xt": ("span", "xt"), # cross-reference target
}
# Block type -> tag. Headings sit at h3 so the outline never skips a level:
# h1 book, h2 chapter, h3 section.
BLOCK_TAGS = {
"paragraph": "p",
"line": "p",
"heading": "h3",
"title": "p",
"speaker": "p",
"item": "li",
}
def esc(text):
return html.escape(text, quote=False)
def verse_id(chapter, label):
"""Page-unique verse anchor, e.g. v23_1 (or v11_15-16 for a range)."""
return "v%d_%s" % (chapter, label)
def ref_href(node, book, chapter):
"""Link an intra-corpus reference; whole books are single pages."""
target_book = node.get("book") or book
target_ch = node.get("chapter", chapter)
verse = node.get("verse")
base = "" if target_book == book else "%s.html" % target_book
if verse:
return "%s#%s" % (base, verse_id(target_ch, verse))
return "%s#ch%d" % (base, target_ch)
def render_inline(nodes, book, chapter, out):
for node in nodes:
kind = node["t"]
if kind == "text":
out.append(esc(node["v"]))
elif kind == "verse":
# Trailing space keeps the number off the text before any CSS
# lands; style .v with padding and it stays correct either way.
out.append('<span class="v" id="%s">%s</span> ' % (
verse_id(chapter, node["label"]), esc(node["label"])))
elif kind == "noteref":
n = node["id"].replace("FN", "")
out.append('<a class="noteref" id="fnref%s_%s" href="#fn%s_%s">'
"<sup>%s</sup></a>" % (chapter, n, chapter, n,
esc(node.get("marker") or "*")))
elif kind == "br":
out.append("<br>")
elif kind == "ref":
out.append('<a class="ref" href="%s">' % ref_href(node, book, chapter))
render_inline(node.get("c", []), book, chapter, out)
out.append("</a>")
elif kind in INLINE_TAGS:
tag, cls = INLINE_TAGS[kind]
out.append('<%s class="%s">' % (tag, cls))
render_inline(node.get("c", []), book, chapter, out)
out.append("</%s>" % tag)
else: # unknown node type: keep the words, drop the wrapper
render_inline(node.get("c", []), book, chapter, out)
def render_block(block, book, chapter, out):
btype = block["type"]
tag = BLOCK_TAGS.get(btype, "p")
classes = [block["style"]]
attrs = ""
if "indent" in block:
attrs += ' data-indent="%d"' % block["indent"]
if btype == "line":
classes.append("line")
out.append(" <%s class=\"%s\"%s>" % (tag, " ".join(classes), attrs))
render_inline(block.get("content", []), book, chapter, out)
out.append("</%s>\n" % tag)
def render_notes(notes, chapter, book, out):
if not notes:
return
out.append(' <section class="footnotes">\n')
out.append(' <h4 class="footnotes-title">Notes</h4>\n')
out.append(" <ol>\n")
for note in notes:
n = (note["id"] or "").replace("FN", "")
out.append(' <li class="note" id="fn%s_%s" data-kind="%s">'
% (chapter, n, note["kind"]))
out.append('<span class="marker">%s</span> ' % esc(note.get("marker") or "*"))
if note.get("ref"):
out.append('<a class="backref" href="#fnref%s_%s">%s</a> '
% (chapter, n, esc(note["ref"])))
out.append('<span class="ft">')
render_inline(note["content"], book, chapter, out)
out.append("</span></li>\n")
out.append(" </ol>\n </section>\n")
def render_chapter(chapter, book, out):
num = chapter["number"]
out.append(' <section class="chapter" id="ch%d" data-chapter="%d">\n'
% (num, num))
out.append(' <h2 class="chapter-number">%d</h2>\n' % num)
group = None # "poetry" | "list" | None
def close_group():
nonlocal group
if group == "poetry":
out.append(" </div>\n")
elif group == "list":
out.append(" </ul>\n")
group = None
for block in chapter["blocks"]:
btype = block["type"]
if btype == "break":
close_group() # a blank line ends the stanza
continue
want = {"line": "poetry", "item": "list"}.get(btype)
if want != group:
close_group()
if want == "poetry":
out.append(' <div class="poetry">\n')
elif want == "list":
out.append(' <ul class="list">\n')
group = want
render_block(block, book, num, out)
close_group()
render_notes(chapter["notes"], num, book, out)
out.append(" </section>\n")
def render_book(book, translation, out_dir):
code, name = book["code"], book["name"]
out = []
title = "%s — %s" % (name, translation["name"])
# A fragment: the title the shell will use, then one root element. The
# class on it is what identifies the kind of page to CSS.
out.append("<title>%s</title>\n" % esc(title))
out.append('<article class="book" data-template="book" id="%s" data-book="%s" '
'data-name="%s" data-section="%s">\n'
% (code, code, esc(name), book["section"]))
# Header: the full multi-line title if the source had one.
out.append(' <header class="book-header">\n')
out.append(" <h1>")
if book["titles"]:
# Separated by a space, not run together. The spans record what USFM
# called them -- mt2 and mt3 were lines above and below the main title
# -- but they are set as one line here, and without the space between
# them Genesis reads "The First Book of Moses,Commonly CalledGenesis".
# The markup still says what each part is, so stacking them again is a
# stylesheet away.
out.append(" ".join('<span class="%s">%s</span>'
% (part["style"], esc(part["text"]))
for part in book["titles"]))
else:
out.append(esc(name))
out.append("</h1>\n")
out.append(' <nav class="chapter-nav" aria-label="Chapters" '
'hx-boost="false">\n <ul>\n')
for chapter in book["chapters"]:
out.append(' <li><a href="#ch%d">%d</a></li>\n'
% (chapter["number"], chapter["number"]))
out.append(" </ul>\n </nav>\n")
out.append(' <p class="home"><a href="index.html">All books</a></p>\n')
out.append(" </header>\n")
out.append(' <div class="chapters">\n')
for chapter in book["chapters"]:
render_chapter(chapter, code, out)
out.append(" </div>\n")
out.append(' <footer class="colophon"><p>%s. %s.</p></footer>\n'
% (esc(translation["name"]), esc(translation["license"])))
out.append("</article>\n")
path = os.path.join(out_dir, "%s.html" % code)
with open(path, "w", encoding="utf-8") as fh:
fh.write("".join(out))
return path
SECTION_LABELS = [
("ot", "Old Testament"),
("nt", "New Testament"),
("deuterocanon", "Deuterocanon"),
("other", "Other"),
]
def render_index(books, translation, out_dir):
out = ["<title>%s</title>\n" % esc(translation["name"]),
'<div class="index" data-template="index">\n']
for key, label in SECTION_LABELS:
group = [b for b in books if b["section"] == key]
if not group:
continue
out.append(' <section class="book-group" data-section="%s">\n' % key)
out.append(" <h2>%s</h2>\n <ul>\n" % esc(label))
for book in group:
out.append(' <li><a href="%s.html">%s</a> '
'<span class="count">%d</span></li>\n'
% (book["code"], esc(book["name"]), len(book["chapters"])))
out.append(" </ul>\n </section>\n")
out.append(' <footer class="colophon"><p>%s. %s.</p></footer>\n'
% (esc(translation["name"]), esc(translation["license"])))
out.append("</div>\n")
path = os.path.join(out_dir, "index.html")
with open(path, "w", encoding="utf-8") as fh:
fh.write("".join(out))
return path
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-i", "--input", default="bible.json",
help="JSON from extract_bible.py (default: bible.json)")
ap.add_argument("-o", "--out", default="books",
help="output directory (default: books)")
args = ap.parse_args(argv)
with open(args.input, encoding="utf-8") as fh:
data = json.load(fh)
os.makedirs(args.out, exist_ok=True)
total = 0
for book in data["books"]:
path = render_book(book, data["translation"], args.out)
total += os.path.getsize(path)
render_index(data["books"], data["translation"], args.out)
print("books %d" % len(data["books"]), file=sys.stderr)
print("html %.1f MB total, %.0f KB average"
% (total / 1e6, total / len(data["books"]) / 1e3), file=sys.stderr)
print("wrote %s/" % args.out, file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())