This commit is contained in:
mark committed 2026-09-04 11:59:56 -04:00
1 parent 7127e3ff9d
commit 777996cb40
6 files changed
+311 -406

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@
'use strict';
// A fragment is a <title> and one root element. The root's data-template
// names the template that wraps it; render_books.py writes both.
// names the template that wraps it; compile_bible.py writes both.
var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
var TEMPLATE_RE = /data-template="([a-z0-9_-]+)"/i;
@@ -56,7 +56,7 @@
// A fragment becomes a whole document. page.njk is the fallback for one
// that names no template of its own.
//
// content is already escaped -- render_books.py escapes as it writes and
// content is already escaped -- compile_bible.py escapes as it writes and
// notice.njk comes out of nunjucks -- which is what the |safe on it in
// base.njk is for.
compose: function (fragment, mark) {
+1 -1
View File
@@ -73,7 +73,7 @@ const DOWNLOAD_AT_ONCE = 4;
// server puts together. VERSION covers the hand-written chrome in assets/,
// templates included, so editing the shell drops and refetches a few hundred
// kilobytes. The other covers the generated pages in books/, which change only
// when render_books.py runs -- so a change of chrome no longer costs every
// when compile_bible.py runs -- so a change of chrome no longer costs every
// device the whole corpus again.
const VERSION = 'dev';
const LIBRARY_VERSION = 'dev';
-1
View File
@@ -1 +0,0 @@
{"translation":{"id":"engwebu","name":"World English Bible Updated","license":"Public Domain","source":"eBible.org"},"books":[{"code":"FRT","name":"Preface","section":"other","titles":[{"style":"mt","level":1,"text":"Preface to the World English Bible"}],"chapters":[{"number":1,"blocks":[{"type":"heading","style":"is","content":[{"t":"text","v":"What is the Holy Bible?"}],"level":2},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The Holy Bible is a collection of books and letters written by many people who were inspired by the Holy Spirit of God. These books tell us how we can be saved from the evil of this world and gain eternal life that is truly worth living. Although the Holy Bible contains rules of conduct, it is not just a rule book. It reveals God’s heart—a Father’s heart, full of love and compassion. The Holy Bible tells you what you need to know and believe to be saved from sin and evil and how to live a life that is truly worth living, no matter what your current circumstances may be."}]},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The Holy Bible consists of two main sections: the Old Testament (including Psalms and Proverbs) and the New Testament (Matthew through Revelation). The Old Testament records God’s interaction with mankind before He sent His son to redeem us, while recording prophesy predicting that coming. The New Testament tells us of God’s Son and Anointed One, Jesus, and the wonderful salvation that He purchased for us."}]},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The same Holy Spirit who inspired the Holy Bible is living among us today, and He is happy to help you understand what He intended as you study His Word. Just ask Him, and He is more than happy to help you apply His message to your life."}]},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The Old Testament was originally written mostly in Hebrew. The New Testament was originally written mostly in the common street Greek (not the formal Greek used for official legal matters). The Holy Bible is translated into many languages, and being translated into many more, so that everyone may have an opportunity to hear the Good News about Jesus Christ."},{"t":"noteref","id":"FN1","marker":"*"}]},{"type":"heading","style":"is","content":[{"t":"text","v":"Why was the World English Bible translated?"}],"level":2},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"There are already many good translations of the Holy Bible into contemporary English. Unfortunately, almost all of them are restricted by copyright and copyright holder policy. This restricts publication and republication of God’s Word in many ways, such as in downloadable files on the Internet, use of extensive quotations in books, etc. The World English Bible was commissioned by God in response to prayer about this subject."}]},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"Because the World English Bible is in the Public Domain (not copyrighted), it can be freely copied, distributed, and redistributed without any payment of royalties. You don’t even have to ask permission to do so. You may publish the whole World English Bible in book form, bind it in leather, and sell it. You may incorporate it into your Bible study software. You may make and distribute audio recordings of it. You may broadcast it. All you have to do is maintain the integrity of God’s Word before God, and reserve the name “World English Bible” for faithful copies of this translation."}]},{"type":"heading","style":"is","content":[{"t":"text","v":"How was the World English Bible translated?"}],"level":2},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The World English Bible is an update of the American Standard Version (ASV) of the Holy Bible, published in 1901. A custom computer program updated the archaic words and word forms to contemporary equivalents, and then a team of volunteers proofread and updated the grammar. The New Testament was updated in places to conform to the Byzantine Majority Text reconstruction of the original Greek manuscripts, thus taking advantage of the superior access to manuscripts that we have now compared to when the original ASV was translated."}]},{"type":"heading","style":"is","content":[{"t":"text","v":"What is different about the World English Bible?"}],"level":2},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The style of the World English Bible, while fairly literally translated, is in informal, spoken English. The World English Bible is designed to sound good and be accurate when read aloud. It is not formal in its language, just as the original Greek of the New Testament was not formal. The WEB uses contractions rather freely."}]},{"type":"paragraph","style":"ip","content":[{"t":"text","v":"The World English Bible doesn’t capitalize pronouns pertaining to God. The original manuscripts made no such distinction. Hebrew has no such thing aLine truncated
-303
View File
@@ -1,303 +0,0 @@
#!/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())
+2 -2
View File
@@ -4,7 +4,7 @@
Stdlib only, so the container needs nothing but a Python base image.
It serves two directories as one origin: assets/, which is hand-written, and
books/, which render_books.py generates from bible.json. Nothing is copied
books/, which utils/compile_bible.py generates from source/. Nothing is copied
between them -- every file exists once, in the directory that owns it.
Beyond serving files it has three jobs.
@@ -115,7 +115,7 @@ def stamp_worker(roots):
The split is by root, which is the same split the project already makes:
the first root is the hand-written chrome -- the worker, the boot page, and
the css/, js/ and templates/ directories -- and the rest is what
render_books.py generates. They get separate versions because they change on completely
compile_bible.py generates. They get separate versions because they change on completely
different schedules -- editing the shell should not cost every installed
device the whole corpus again, which one shared version would.
+306 -97
View File
@@ -1,27 +1,59 @@
#!/usr/bin/env python3
"""Extract the eBible.org HTML Bible export into structured JSON.
"""Compile the eBible.org HTML export into the fragments the app serves.
The source is a flat directory of generated chapter files (``JHN03.htm``) plus
per-book and site index pages. Each chapter file is a flat sequence of
``<div class='...'>`` blocks whose class is the USFM paragraph marker (``p``,
``q2``, ``s``, ...), with verse numbers, footnote markers and character styles
as inline spans. Footnotes and cross references are collected in a trailing
``<div class="footnote">``.
One step, source to output. It reads a directory of eBible .htm files -- a flat
set of per-chapter pages, ``JHN03.htm``, plus a site index -- and writes
books/GEN.html and so on, plus books/index.html.
This script turns that into a JSON tree of blocks and inline nodes, so the
markup can be re-rendered from templates rather than scraped again. Navigation
lists and the copyright footer are dropped; footnotes are kept.
There used to be two scripts and a 15 MB bible.json between them. The JSON was
worth having when the corpus was going to be queried; nothing queries it, so
it was a second representation to keep in step for no one's benefit. The parse
tree now lives for as long as one process run.
The two halves are still clearly separated below, because they are genuinely
different jobs and the seam is where a second output format would attach:
READING eBible's generated markup is a flat sequence of
``<div class='...'>`` blocks whose class is the USFM paragraph
marker (``p``, ``q2``, ``s``, ...), with verse numbers, footnote
markers and character styles as inline spans. This half turns
that into a tree of blocks and inline nodes. Navigation and the
copyright footer are dropped; footnotes are kept.
WRITING That tree becomes one semantic fragment per book: a <title> and
one root element carrying data-template, not a document. The
shell around it 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 being
regenerated.
This is the only build step in the project, and books/ is all it owns.
Everything hand-written lives in assets/ and is served straight from there.
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>.
python3 utils/compile_bible.py source -o books
"""
from __future__ import annotations
import argparse
import json
import html
import os
import re
import sys
from html.parser import HTMLParser
# ==========================================================================
# READING -- eBible .htm into a tree of blocks and inline nodes
# ==========================================================================
# Chapter files are a three-character book code plus a zero-padded chapter
# number (three digits for Psalms).
CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$")
@@ -432,48 +464,6 @@ class IndexParser(HTMLParser):
self._link["name"] += data
def verse_index(blocks):
"""Flat {verse label: text} map, for search and direct verse lookup.
Verse markers are inline, so a verse's text runs from its marker to the
next one and may span several blocks (a poetry couplet, for instance).
"""
verses = {}
current = None
parts = []
def flush():
if current is not None:
text = re.sub(r"\s+", " ", "".join(parts)).strip()
if text:
verses[current] = (verses.get(current, "") + " " + text).strip()
def walk(nodes):
nonlocal current
for node in nodes:
if node["t"] == "verse":
flush()
parts.clear()
current = node["label"]
elif node["t"] == "noteref":
continue
elif node["t"] == "text":
parts.append(node["v"])
elif node["t"] == "br":
parts.append(" ")
elif "c" in node:
walk(node["c"])
for block in blocks:
content = block.get("content")
if not content:
continue
walk(content)
parts.append(" ") # blocks do not run words together
flush()
return verses
def parse_chapter(path):
parser = ChapterParser()
with open(path, encoding="utf-8-sig") as fh:
@@ -482,7 +472,7 @@ def parse_chapter(path):
return parser
def extract(source_dir, with_verse_text=True):
def extract(source_dir):
index_path = os.path.join(source_dir, "index.htm")
index = IndexParser()
with open(index_path, encoding="utf-8-sig") as fh:
@@ -523,8 +513,6 @@ def extract(source_dir, with_verse_text=True):
"blocks": parsed.blocks,
"notes": parsed.notes,
}
if with_verse_text:
chapter["verses"] = verse_index(parsed.blocks)
book["chapters"].append(chapter)
if book["chapters"]:
books.append(book)
@@ -540,64 +528,285 @@ def extract(source_dir, with_verse_text=True):
}, skipped
def write_split(data, out_dir):
"""One JSON per chapter plus an index, for chapter-at-a-time fetching."""
os.makedirs(out_dir, exist_ok=True)
index = {"translation": data["translation"], "books": []}
for book in data["books"]:
book_dir = os.path.join(out_dir, book["code"])
os.makedirs(book_dir, exist_ok=True)
for chapter in book["chapters"]:
payload = dict(chapter)
payload["book"] = book["code"]
path = os.path.join(book_dir, "%d.json" % chapter["number"])
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"))
index["books"].append({
"code": book["code"],
"name": book["name"],
"section": book["section"],
"titles": book["titles"],
"chapters": [c["number"] for c in book["chapters"]],
})
with open(os.path.join(out_dir, "index.json"), "w", encoding="utf-8") as fh:
json.dump(index, fh, ensure_ascii=False, indent=2)
# ==========================================================================
# WRITING -- that tree into one HTML fragment per book
# ==========================================================================
# 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
# ==========================================================================
# Entry point
# ==========================================================================
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("source", nargs="?", default="source",
help="directory of eBible .htm files (default: source)")
ap.add_argument("-o", "--output", default="bible.json",
help="output JSON file (default: bible.json)")
ap.add_argument("--split", metavar="DIR",
help="also write one JSON per chapter into DIR")
ap.add_argument("--no-verse-text", action="store_true",
help="omit the flat per-chapter verse text map")
ap.add_argument("--pretty", action="store_true",
help="indent the output JSON")
ap.add_argument("-o", "--out", default="books",
help="output directory (default: books)")
args = ap.parse_args(argv)
data, skipped = extract(args.source, with_verse_text=not args.no_verse_text)
data, skipped = extract(args.source)
os.makedirs(args.out, exist_ok=True)
dump = dict(indent=2) if args.pretty else dict(separators=(",", ":"))
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, **dump)
if args.split:
write_split(data, args.split)
total = 0
for book in data["books"]:
total += os.path.getsize(render_book(book, data["translation"], args.out))
render_index(data["books"], data["translation"], args.out)
chapters = sum(len(b["chapters"]) for b in data["books"])
notes = sum(len(c["notes"]) for b in data["books"] for c in b["chapters"])
blocks = sum(len(c["blocks"]) for b in data["books"] for c in b["chapters"])
size = os.path.getsize(args.output) / 1e6
print("read %s/" % args.source.rstrip("/"), file=sys.stderr)
print("books %d" % len(data["books"]), file=sys.stderr)
print("chapters %d" % chapters, file=sys.stderr)
print("blocks %d" % blocks, file=sys.stderr)
print("notes %d" % notes, file=sys.stderr)
print("skipped %d (contents pages)" % len(skipped), file=sys.stderr)
print("wrote %s (%.1f MB)" % (args.output, size), 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.rstrip("/"), file=sys.stderr)
return 0