Build site.

build(options)

Build the site.

Source code in src/build.py
30
31
32
33
34
35
36
37
38
39
40
41
42
def build(options):
    """Build the site."""
    config = _load_configuration(options)
    env = Environment(loader=FileSystemLoader(config["templates"]))
    section_slugs, slides, others = _find_files(config)

    _build_page(config, env, None, config["src"] / HOME_PAGE)
    for slug in section_slugs:
        _build_page(config, env, slug, config["order"][slug]["filepath"])
    for filepath in slides:
        _build_page(config, env, None, filepath)
    for filepath in others:
        _build_other(config, filepath)

_build_page(config, env, slug, src_path)

Handle a Markdown file.

Source code in src/build.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def _build_page(config, env, slug, src_path):
    """Handle a Markdown file."""
    content = src_path.read_text()
    with_links = f"{content}\n\n{config['links']}"
    raw_html = markdown(with_links, extensions=MARKDOWN_EXTENSIONS)

    template_name = TEMPLATE_SLIDES if src_path.name == "slides.md" else TEMPLATE_PAGE
    template = env.get_template(template_name)
    context = _make_context(config, slug)
    rendered_html = template.render(content=raw_html, **context)

    doc = BeautifulSoup(rendered_html, "html.parser")
    dst_path = _make_output_path(config, src_path, suffix=".html")
    for func in [
        _patch_terms_defined,  # must be before _patch_glossary_links
        _patch_bibliography_links,
        _patch_figure_numbers,
        _patch_glossary_links,
        _patch_pre_code_classes,
        _patch_table_numbers,
        _patch_title,
        _patch_root_links,  # must be last
    ]:
        func(config, dst_path, doc)

    dst_path.write_text(str(doc))

_build_other(config, src_path)

Handle non-Markdown file.

Source code in src/build.py
73
74
75
76
def _build_other(config, src_path):
    """Handle non-Markdown file."""
    dst_path = _make_output_path(config, src_path)
    dst_path.write_bytes(src_path.read_bytes())

_fill_element_numbers(dst_path, doc, prefix, known, text)

Fill in cross-reference numbers.

Source code in src/build.py
79
80
81
82
83
84
85
86
87
88
89
90
def _fill_element_numbers(dst_path, doc, prefix, known, text):
    """Fill in cross-reference numbers."""
    for node in doc.select("a[href]"):
        if not node["href"].startswith(prefix):
            continue

        key = node["href"].lstrip("#")
        if key not in known:
            util.warn(f"unknown cross-reference {key} in {dst_path}")
            continue

        node.string = f"{text} {known[key]}"

_find_files(config)

Find section files and other files.

Source code in src/build.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def _find_files(config):
    """Find section files and other files."""
    order = config["order"]
    slugs = set(order.keys())

    slides = {f for f in config["src"].glob("*/slides.md")}

    excludes = {config["src"] / HOME_PAGE}
    excludes |= {value["filepath"] for value in config["order"].values()}
    excludes |= slides

    others = {
        f
        for f in config["src"].glob("*/**")
        if _is_interesting_file(config, excludes, f)
    }
    return slugs, slides, others

Convert '@/something/' to 'something'.

Source code in src/build.py
112
113
114
115
def _get_slug_from_link(raw):
    """Convert '@/something/' to 'something'."""
    assert raw.startswith("@/") and raw.endswith("/")
    return raw[2:-1]

_is_interesting_file(config, excludes, filepath)

Is this file worth copying over?

Source code in src/build.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def _is_interesting_file(config, excludes, filepath):
    """Is this file worth copying over?"""
    if not filepath.is_file():
        return False

    if filepath in excludes:
        return False

    relative = filepath.relative_to(config["src"])
    if str(relative).startswith("."):
        return False
    if filepath.samefile(config["config"]):
        return False
    if filepath.is_relative_to(config["dst"]):
        return False
    if filepath.is_relative_to(config["extras"]):
        return False
    if filepath.is_relative_to(config["templates"]):
        return False

    for s in config["skips"]:
        if filepath.match(s):
            return False
        if s.endswith("/**") and relative.is_relative_to(s.replace("/**", "")):
            return False

    return True

_load_configuration(options)

Load configuration and combine with options.

Source code in src/build.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def _load_configuration(options):
    """Load configuration and combine with options."""
    config_path = options.src / options.config
    config = tomli.loads(config_path.read_text())

    links = util.load_links(options.src)
    glossary = _load_glossary(options.src)

    order = _load_order(options.src)

    return {
        "config": config_path,
        "dst": options.dst,
        "extras": options.src / util.EXTRAS_DIR,
        "glossary": glossary,
        "links": links,
        "order": order,
        "src": options.src,
        "templates": options.src / TEMPLATE_DIR,
        "verbose": options.verbose,
        **config.get("tool", {}).get("mccole", {}),
    }

_load_glossary(src_path)

Load glossary keys and terms.

Source code in src/build.py
171
172
173
174
175
176
def _load_glossary(src_path):
    """Load glossary keys and terms."""
    md = (src_path / GLOSSARY_PATH).read_text()
    html = markdown(md, extensions=MARKDOWN_EXTENSIONS)
    doc = BeautifulSoup(html, "html.parser")
    return {node["id"]: node.decode_contents() for node in doc.select("span[id]")}

_load_order(src_path)

Determine section order from README.md.

Source code in src/build.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def _load_order(src_path):
    """Determine section order from README.md."""
    md = (src_path / HOME_PAGE).read_text()
    html = markdown(md, extensions=MARKDOWN_EXTENSIONS)
    doc = BeautifulSoup(html, "html.parser")
    lessons = _load_order_section(doc, "lessons", lambda i: str(i + 1))
    appendices = _load_order_section(doc, "appendices", lambda i: chr(ord("A") + i))
    combined = {**lessons, **appendices}

    flattened = list(combined.keys())
    for i, slug in enumerate(flattened):
        combined[slug]["previous"] = flattened[i - 1] if i > 0 else None
        combined[slug]["next"] = flattened[i + 1] if i < (len(flattened) - 1) else None
        combined[slug]["filepath"] = src_path / REVERSE_FILES.get(
            slug, Path(slug) / "index.md"
        )

    return combined

_load_order_section(doc, selector, labeller)

Load a section of the table of contents from README.md DOM.

Source code in src/build.py
199
200
201
202
203
204
205
206
207
208
209
def _load_order_section(doc, selector, labeller):
    """Load a section of the table of contents from README.md DOM."""
    div = f"div#{selector}"
    return {
        _get_slug_from_link(node["href"]): {
            "number": labeller(i),
            "kind": selector,
            "title": node.decode_contents(),
        }
        for i, node in enumerate(doc.select(div)[0].select("a[href]"))
    }

_make_context(config, slug)

Make rendering context for a particular file.

Source code in src/build.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def _make_context(config, slug):
    """Make rendering context for a particular file."""
    order = config["order"]
    context = {
        "lessons": [
            (slug, entry["title"])
            for slug, entry in order.items()
            if entry["kind"] == "lessons"
        ],
        "appendices": [
            (slug, entry["title"])
            for slug, entry in order.items()
            if entry["kind"] == "appendices"
        ],
    }

    if slug is None:
        prev_link = None
        prev_title = None
        next_link = None
        next_title = None
    else:
        entry = order[slug]
        prev_link = entry["previous"]
        prev_title = None if prev_link is None else order[prev_link]["title"]
        next_link = entry["next"]
        next_title = None if next_link is None else order[next_link]["title"]

    return {"prev": (prev_link, prev_title), "next": (next_link, next_title), **context}

_make_element_numbers(config, dst_path, doc, outer_tag, inner_tag, prefix, text)

Build element numbers, inserting along the way.

Source code in src/build.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def _make_element_numbers(config, dst_path, doc, outer_tag, inner_tag, prefix, text):
    """Build element numbers, inserting along the way."""
    known = {}
    for i, node in enumerate(doc.select(outer_tag)):
        num = i + 1

        if "id" not in node.attrs:
            util.warn(f"{outer_tag} {num} in {dst_path} has no ID")
            continue

        if not node["id"].startswith(prefix):
            util.warn(
                f"{outer_tag} {num} ID {node['id']} in {dst_path} does not start with '{prefix}'"
            )
            continue

        all_inner = node.select(inner_tag)
        if len(all_inner) != 1:
            util.warn(
                f"{outer_tag} {num} ID {node['id']} in {dst_path} has missing/too many {inner_tag}"
            )
            continue

        inner = all_inner[0]
        known[node["id"]] = num
        inner.insert(0, f"{text} {num}: ")

    return known

_make_output_path(config, src_path, suffix=None)

Generate output file path.

Source code in src/build.py
273
274
275
276
277
278
279
280
281
282
def _make_output_path(config, src_path, suffix=None):
    """Generate output file path."""
    if src_path.name in STANDARD_FILES:
        dst_path = config["dst"] / STANDARD_FILES[src_path.name] / "index.md"
    else:
        dst_path = config["dst"] / src_path.relative_to(config["src"])
    if suffix is not None:
        dst_path = dst_path.with_suffix(suffix)
    dst_path.parent.mkdir(parents=True, exist_ok=True)
    return dst_path

_make_root_prefix(config, path)

Create prefix to root for path.

Source code in src/build.py
285
286
287
288
289
290
def _make_root_prefix(config, path):
    """Create prefix to root for path."""
    relative = path.relative_to(config["dst"])
    depth = len(relative.parents) - 1
    assert depth >= 0
    return "./" if (depth == 0) else "../" * depth

Convert b: bibliography links.

Source code in src/build.py
293
294
295
def _patch_bibliography_links(config, dst_path, doc):
    """Convert b: bibliography links."""
    _patch_special_link(config, dst_path, doc, "b:", "bibliography", True)

_patch_figure_numbers(config, dst_path, doc)

Insert figure numbers.

Source code in src/build.py
298
299
300
301
302
303
def _patch_figure_numbers(config, dst_path, doc):
    """Insert figure numbers."""
    known = _make_element_numbers(
        config, dst_path, doc, "figure", "figcaption", "f:", "Figure"
    )
    _fill_element_numbers(dst_path, doc, "#f:", known, "Figure")

Convert g: glossary links.

Source code in src/build.py
306
307
308
def _patch_glossary_links(config, dst_path, doc):
    """Convert g: glossary links."""
    _patch_special_link(config, dst_path, doc, "g:", "glossary", False)

_patch_pre_code_classes(config, dst_path, doc)

Add language classes to

 elements.

Source code in src/build.py
311
312
313
314
315
def _patch_pre_code_classes(config, dst_path, doc):
    """Add language classes to <pre> elements."""
    for node in doc.select("pre>code"):
        cls = node.get("class", [])
        node.parent["class"] = node.parent.get("class", []) + cls

Convert @ links to relative path to root.

Source code in src/build.py
318
319
320
321
322
323
324
325
326
327
328
329
330
def _patch_root_links(config, dst_path, doc):
    """Convert @ links to relative path to root."""
    prefix = _make_root_prefix(config, dst_path)
    targets = (
        ("a[href]", "href"),
        ("img[src]", "src"),
        ("link[href]", "href"),
        ("script[src]", "src"),
    )
    for selector, attr in targets:
        for node in doc.select(selector):
            if node[attr].startswith("@/"):
                node[attr] = node[attr].replace("@/", prefix)

Patch specially-prefixed links.

Source code in src/build.py
333
334
335
336
337
338
339
340
341
342
def _patch_special_link(config, dst_path, doc, prefix, stem, change_text):
    """Patch specially-prefixed links."""
    for node in doc.select("a[href]"):
        if not node["href"].startswith(prefix):
            continue
        assert node["href"].count(":") == 1
        key = node["href"].split(":")[1]
        if change_text:
            node.string = key
        node["href"] = _make_root_prefix(config, dst_path) + f"{stem}/#{key}"

_patch_table_numbers(config, dst_path, doc)

Insert figure numbers.

Source code in src/build.py
345
346
347
348
349
350
def _patch_table_numbers(config, dst_path, doc):
    """Insert figure numbers."""
    known = _make_element_numbers(
        config, dst_path, doc, "table", "caption", "t:", "Table"
    )
    _fill_element_numbers(dst_path, doc, "#t:", known, "Table")

_patch_terms_defined(config, dst_path, doc)

Insert terms defined where requested.

Source code in src/build.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def _patch_terms_defined(config, dst_path, doc):
    """Insert terms defined where requested."""
    paragraphs = doc.select("p#terms")
    if not paragraphs:
        return
    if len(paragraphs) > 1:
        util.warn(f"{dst_path} has multiple p#terms")
        return
    para = paragraphs[0]

    keys = {
        node["href"] for node in doc.select("a[href]") if node["href"].startswith("g:")
    }
    if not keys:
        para.decompose()
        return

    entries = [(key, config["glossary"].get(key, "UNDEFINED")) for key in keys]
    entries.sort(key=lambda item: item[1])
    para.append("Terms defined: ")
    for i, (key, term) in enumerate(entries):
        tag = doc.new_tag("a", attrs={"class": "term-defined"}, href=key)
        tag.string = term
        if i > 0:
            para.append(", ")
        para.append(tag)

_patch_title(config, dst_path, doc)

Make sure the HTML title element is set.

Source code in src/build.py
381
382
383
384
385
386
387
388
389
def _patch_title(config, dst_path, doc):
    """Make sure the HTML title element is set."""
    if doc.title is None:
        util.warn(f"{dst_path} does not have <title> element")
        return
    try:
        doc.title.string = doc.h1.get_text()
    except Exception:
        util.warn(f"{dst_path} lacks H1 heading")