304 lines
12 KiB
Python
304 lines
12 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 carrying data-template. The document around them is assembled from the
|
|
nunjucks templates in assets/templates/ -- by the service worker, or by
|
|
boot.html when there can never be one -- 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 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(caller(node.get("marker")))))
|
|
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)
|
|
|
|
|
|
# Footnote callers arrive from the source as * † ‡ § ✡. The star is the only
|
|
# one of them a browser may decide is an emoji: no serif font carries a glyph
|
|
# for U+2721, so the fallback chain reaches the colour emoji font and drops a
|
|
# purple tile into the middle of a verse. U+FE0E is the standardised way to ask
|
|
# for the text glyph of the same character -- the symbol, not the emoji. There
|
|
# is no separate text codepoint to switch to; this is the only lever.
|
|
TEXT_PRESENTATION = "\ufe0e"
|
|
EMOJI_CAPABLE = "\u2721"
|
|
|
|
|
|
def caller(marker):
|
|
"""A footnote marker, asked to render as text rather than as an emoji."""
|
|
return "".join(c + TEXT_PRESENTATION if c in EMOJI_CAPABLE else c
|
|
for c in (marker or "*"))
|
|
|
|
|
|
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(caller(note.get("marker"))))
|
|
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 whose
|
|
# data-template names the template that wraps it.
|
|
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")
|
|
# The title and nothing else. Both of the things that used to sit under it
|
|
# are in the navbar now, where they are in reach from anywhere in the book
|
|
# rather than only from the top of it: the chapter list became the
|
|
# reference picker, which reads its chapters and verses straight off this
|
|
# markup, and "All books" is what the navbar brand already does. In Psalms
|
|
# the chapter list alone was a hundred and fifty links, carried in the
|
|
# fragment and in every device's offline copy.
|
|
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">%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())
|