@sdelsad/commodity-desk-daily 1.0.18 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/STYLE.md +439 -0
- package/__pycache__/build_page.cpython-314.pyc +0 -0
- package/__pycache__/chart.cpython-314.pyc +0 -0
- package/build_email.py +773 -0
- package/build_page.py +1237 -0
- package/build_post.py +291 -0
- package/chart.py +563 -0
- package/conversions.md +156 -0
- package/curriculum.md +294 -0
- package/ep01.md +144 -0
- package/ep01.mp3 +0 -0
- package/ep01.script.txt +71 -0
- package/ep02.md +141 -0
- package/ep02.script.txt +70 -0
- package/ep03.md +163 -0
- package/ep03.script.txt +61 -0
- package/ep04.md +192 -0
- package/ep04.mp3 +0 -0
- package/ep04.script.txt +69 -0
- package/feed.xml +3 -15
- package/fetch_context.py +123 -0
- package/generate_audio.py +120 -0
- package/package.json +1 -1
- package/publish_episode.py +367 -0
- package/setup.sh +23 -0
package/build_page.py
ADDED
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Soft Commodity Trading — episode page builder.
|
|
4
|
+
|
|
5
|
+
Turns an episode's markdown notes into a standalone HTML page: editorial
|
|
6
|
+
layout, working audio player, light/dark toggle, a contents rail, episode
|
|
7
|
+
navigation, the cumulative glossary, and one reveal per quiz answer so Q1
|
|
8
|
+
can be checked without spoiling Q7.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 build_page.py --notes ep03.md --number 3 \
|
|
12
|
+
--title "Futures Plumbing and the Shape of the Curve" \
|
|
13
|
+
--dek "One sentence for under the headline." \
|
|
14
|
+
--audio-url "https://.../ep03.mp3" --duration 620 \
|
|
15
|
+
--date "Wednesday 12 August 2026" --out ep03.html
|
|
16
|
+
|
|
17
|
+
The markdown is rendered generically, so the page follows whatever structure
|
|
18
|
+
the notes have. Everything from the "SOLUTIONS" heading up to the next H2 is
|
|
19
|
+
folded, one <details> per answer.
|
|
20
|
+
|
|
21
|
+
Two files are picked up automatically when they sit in the working directory
|
|
22
|
+
(both optional — the page degrades cleanly without them):
|
|
23
|
+
|
|
24
|
+
covered.md the running episode log, written by publish_episode.py.
|
|
25
|
+
Gives the page its previous/next links and its archive.
|
|
26
|
+
glossary.md the cumulative glossary, also written at publish time.
|
|
27
|
+
Rendered on the page in a collapsed, filterable section.
|
|
28
|
+
|
|
29
|
+
Override or disable them with --covered / --glossary.
|
|
30
|
+
|
|
31
|
+
Zero runtime dependencies: standard library only, and the page it writes has
|
|
32
|
+
no CDN, no web font and no external script.
|
|
33
|
+
"""
|
|
34
|
+
import argparse
|
|
35
|
+
import html
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import chart as chartlib
|
|
43
|
+
except ImportError: # chart.py sits next to us
|
|
44
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
45
|
+
try:
|
|
46
|
+
import chart as chartlib
|
|
47
|
+
except ImportError:
|
|
48
|
+
chartlib = None
|
|
49
|
+
|
|
50
|
+
CHART_SPECS = [] # every chart the page rendered, in order
|
|
51
|
+
HEADINGS = [] # (level, id, text) for the contents rail
|
|
52
|
+
|
|
53
|
+
SHOW = "Soft Commodity Trading"
|
|
54
|
+
SLUG = "commodity-desk-daily" # frozen: npm package, bucket folder, URLs
|
|
55
|
+
BUCKET = "https://storage.googleapis.com/podcast-audio-2647223968"
|
|
56
|
+
FEED_URL = f"{BUCKET}/{SLUG}/feed.xml"
|
|
57
|
+
SITE_URL = f"{BUCKET}/index.html"
|
|
58
|
+
COVER_URL = ("https://cdn.jsdelivr.net/npm/@sdelsad/"
|
|
59
|
+
"commodity-desk-daily@1.0.15/cover.jpg")
|
|
60
|
+
AUTHOR = "Sébastien Delsad"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def page_url(number):
|
|
64
|
+
return f"{BUCKET}/{SLUG}/ep{number:02d}.html"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------ markdown ------
|
|
68
|
+
|
|
69
|
+
def inline(text):
|
|
70
|
+
"""Inline markdown → HTML, on already-escaped text."""
|
|
71
|
+
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
72
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
73
|
+
text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", text)
|
|
74
|
+
text = re.sub(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", r"<em>\1</em>", text)
|
|
75
|
+
text = re.sub(r"\[(.+?)\]\((.+?)\)", r'<a href="\2">\1</a>', text)
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
FENCE = "```"
|
|
80
|
+
|
|
81
|
+
_SLUGS = {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def slug(text, prefix=""):
|
|
85
|
+
"""A stable, readable id for a heading."""
|
|
86
|
+
base = re.sub(r"<[^>]+>", "", text)
|
|
87
|
+
base = html.unescape(base).lower()
|
|
88
|
+
base = re.sub(r"[^a-z0-9]+", "-", base).strip("-") or "section"
|
|
89
|
+
base = prefix + base[:48].rstrip("-")
|
|
90
|
+
n = _SLUGS.get(base, 0) + 1
|
|
91
|
+
_SLUGS[base] = n
|
|
92
|
+
return base if n == 1 else f"{base}-{n}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------- tables -------
|
|
96
|
+
|
|
97
|
+
# "$4.77¾ /bu", "−0.6%", "+21½¢", "1,653", "12.5"
|
|
98
|
+
NUMERIC = re.compile(
|
|
99
|
+
r"^[~≈]?[+\-−±]?\s*[$€£¥]?\s*\d[\d,]*(?:\.\d+)?[¼½¾⅓⅔⅛⅜⅝⅞]?"
|
|
100
|
+
r"\s*(?:/?\s*[A-Za-z%¢$€£]{1,5})?\s*$")
|
|
101
|
+
SIGNED = re.compile(r"^([+\-−±])\s*(.+)$")
|
|
102
|
+
BLANKS = {"", "—", "–", "-", "n/a", "na"}
|
|
103
|
+
MOVE_HEADS = {"change", "chg", "move", "moves", "δ", "delta", "d/d", "w/w",
|
|
104
|
+
"m/m", "y/y", "net", "net change", "+/-", "on the day", "session"}
|
|
105
|
+
FLAT_WORDS = ("unchanged", "unch", "flat", "steady", "nil", "level")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clean(cell):
|
|
109
|
+
return re.sub(r"<[^>]+>", "", cell).strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_numeric(cell):
|
|
113
|
+
c = _clean(cell)
|
|
114
|
+
return bool(c) and c.lower() not in BLANKS and bool(NUMERIC.match(c))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def classify_move(txt):
|
|
118
|
+
"""(display text, 'up' | 'down' | 'flat' | None) for one price move.
|
|
119
|
+
|
|
120
|
+
The sign is made explicit here rather than left to colour alone, so the
|
|
121
|
+
table still reads correctly in monochrome, in print and for a reader who
|
|
122
|
+
cannot distinguish the two hues. build_email.py imports this, so the page
|
|
123
|
+
and the e-mail never disagree about which way a market went.
|
|
124
|
+
"""
|
|
125
|
+
txt = (txt or "").strip()
|
|
126
|
+
low = txt.lower()
|
|
127
|
+
if not txt or low in BLANKS:
|
|
128
|
+
return txt, None
|
|
129
|
+
if any(w in low for w in FLAT_WORDS):
|
|
130
|
+
return txt, "flat"
|
|
131
|
+
m = SIGNED.match(txt)
|
|
132
|
+
if m:
|
|
133
|
+
sign, rest = m.group(1), m.group(2)
|
|
134
|
+
if sign == "±":
|
|
135
|
+
return txt, "flat"
|
|
136
|
+
if sign in "-−":
|
|
137
|
+
return "−" + rest, "down" # hyphen-minus → a real minus
|
|
138
|
+
return txt, "up"
|
|
139
|
+
if re.match(r"^[$€£¥]?\s*0(\.0+)?\s*[%¢]?$", txt):
|
|
140
|
+
return txt, "flat"
|
|
141
|
+
if NUMERIC.match(txt):
|
|
142
|
+
return "+" + txt, "up" # a rise must never read as a level
|
|
143
|
+
return txt, None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def move_cell(cell_html):
|
|
147
|
+
"""Wrap a move in a span carrying up/down/flat."""
|
|
148
|
+
txt = _clean(cell_html)
|
|
149
|
+
shown, direction = classify_move(txt)
|
|
150
|
+
if direction is None:
|
|
151
|
+
return cell_html
|
|
152
|
+
if shown != txt:
|
|
153
|
+
cell_html = cell_html.replace(txt, shown, 1)
|
|
154
|
+
return f'<span class="mv {direction}">{cell_html}</span>'
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def table_html(head, rows):
|
|
158
|
+
"""A table that survives a 390px screen: it scrolls inside its own box,
|
|
159
|
+
figures are tabular and right-aligned, and moves carry a sign and a hue."""
|
|
160
|
+
ncol = max([len(head)] + [len(r) for r in rows]) if rows else len(head)
|
|
161
|
+
numeric, moves = [], []
|
|
162
|
+
for c in range(ncol):
|
|
163
|
+
col = [r[c] for r in rows if c < len(r)]
|
|
164
|
+
filled = [x for x in col if _clean(x).lower() not in BLANKS]
|
|
165
|
+
title = _clean(head[c]).lower().strip(" .:") if c < len(head) else ""
|
|
166
|
+
hit = sum(1 for x in filled if is_numeric(x))
|
|
167
|
+
numeric.append(bool(filled) and hit >= max(1, int(len(filled) * 0.6)))
|
|
168
|
+
signed = sum(1 for x in filled if SIGNED.match(_clean(x))
|
|
169
|
+
or any(w in _clean(x).lower() for w in FLAT_WORDS))
|
|
170
|
+
moves.append(title in MOVE_HEADS
|
|
171
|
+
or (bool(filled) and signed == len(filled)))
|
|
172
|
+
|
|
173
|
+
th = []
|
|
174
|
+
for c, cell in enumerate(head):
|
|
175
|
+
cls = ' class="num"' if (numeric[c] or moves[c]) else ""
|
|
176
|
+
th.append(f"<th{cls}>{inline(html.escape(cell))}</th>")
|
|
177
|
+
body = []
|
|
178
|
+
for r in rows:
|
|
179
|
+
tds = []
|
|
180
|
+
for c, cell in enumerate(r):
|
|
181
|
+
rendered = inline(html.escape(cell))
|
|
182
|
+
klass = []
|
|
183
|
+
if c < ncol and (numeric[c] or moves[c]):
|
|
184
|
+
klass.append("num")
|
|
185
|
+
if c < ncol and moves[c]:
|
|
186
|
+
rendered = move_cell(rendered)
|
|
187
|
+
cls = f' class="{" ".join(klass)}"' if klass else ""
|
|
188
|
+
tds.append(f"<td{cls}>{rendered}</td>")
|
|
189
|
+
body.append("<tr>" + "".join(tds) + "</tr>")
|
|
190
|
+
return ('<div class="tablewrap" tabindex="0" role="region" '
|
|
191
|
+
'aria-label="Table, scrolls sideways">'
|
|
192
|
+
f'<table><thead><tr>{"".join(th)}</tr></thead>'
|
|
193
|
+
f'<tbody>{"".join(body)}</tbody></table></div>')
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------- render -------
|
|
197
|
+
|
|
198
|
+
def render(md):
|
|
199
|
+
"""A deliberately small markdown renderer: headings, lists, tables,
|
|
200
|
+
blockquotes, rules, paragraphs. Enough for the show's notes, with no
|
|
201
|
+
third-party dependency to install every morning."""
|
|
202
|
+
out, lines, i = [], md.split("\n"), 0
|
|
203
|
+
while i < len(lines):
|
|
204
|
+
raw = lines[i]
|
|
205
|
+
line = raw.rstrip()
|
|
206
|
+
stripped = line.strip()
|
|
207
|
+
|
|
208
|
+
if not stripped:
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if stripped.startswith(FENCE + "chart"):
|
|
213
|
+
# a ```chart fenced block: JSON in, an <svg> figure out
|
|
214
|
+
body, i = [], i + 1
|
|
215
|
+
while i < len(lines) and not lines[i].strip().startswith(FENCE):
|
|
216
|
+
body.append(lines[i])
|
|
217
|
+
i += 1
|
|
218
|
+
i += 1 # skip the closing fence
|
|
219
|
+
try:
|
|
220
|
+
spec = json.loads("\n".join(body))
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
out.append(f'<p class="charterr">Chart skipped: {html.escape(str(exc))}</p>')
|
|
223
|
+
continue
|
|
224
|
+
CHART_SPECS.append(spec)
|
|
225
|
+
if chartlib:
|
|
226
|
+
out.append(chartlib.figure_html(spec))
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if stripped.startswith("<"): # pass through raw HTML
|
|
230
|
+
out.append(stripped)
|
|
231
|
+
i += 1
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
if re.match(r"^(-{3,}|\*{3,})$", stripped):
|
|
235
|
+
out.append("<hr>")
|
|
236
|
+
i += 1
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
|
240
|
+
if m:
|
|
241
|
+
level = len(m.group(1))
|
|
242
|
+
text = inline(html.escape(m.group(2)))
|
|
243
|
+
if level in (2, 3):
|
|
244
|
+
hid = slug(m.group(2))
|
|
245
|
+
HEADINGS.append((level, hid, re.sub(r"<[^>]+>", "", text)))
|
|
246
|
+
anchor = (f'<a class="anchor" href="#{hid}" aria-label="Link to '
|
|
247
|
+
f'this section">#</a>')
|
|
248
|
+
out.append(f'<h{level} id="{hid}">{text}{anchor}</h{level}>')
|
|
249
|
+
else:
|
|
250
|
+
out.append(f"<h{level}>{text}</h{level}>")
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if stripped.startswith("|") and i + 1 < len(lines) and \
|
|
255
|
+
re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
|
256
|
+
head = [c.strip() for c in stripped.strip("|").split("|")]
|
|
257
|
+
i += 2
|
|
258
|
+
rows = []
|
|
259
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
260
|
+
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
|
261
|
+
i += 1
|
|
262
|
+
out.append(table_html(head, rows))
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
if re.match(r"^[-*]\s+", stripped):
|
|
266
|
+
items = []
|
|
267
|
+
while i < len(lines) and re.match(r"^[-*]\s+", lines[i].strip()):
|
|
268
|
+
items.append(inline(html.escape(re.sub(r"^[-*]\s+", "", lines[i].strip()))))
|
|
269
|
+
i += 1
|
|
270
|
+
out.append("<ul>" + "".join(f"<li>{it}</li>" for it in items) + "</ul>")
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
if re.match(r"^\d+[.)]\s+", stripped):
|
|
274
|
+
items = []
|
|
275
|
+
while i < len(lines) and re.match(r"^\d+[.)]\s+", lines[i].strip()):
|
|
276
|
+
items.append(inline(html.escape(re.sub(r"^\d+[.)]\s+", "", lines[i].strip()))))
|
|
277
|
+
i += 1
|
|
278
|
+
out.append("<ol>" + "".join(f"<li>{it}</li>" for it in items) + "</ol>")
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
if stripped.startswith(">"):
|
|
282
|
+
quote = []
|
|
283
|
+
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
284
|
+
quote.append(inline(html.escape(lines[i].strip().lstrip("> ").rstrip())))
|
|
285
|
+
i += 1
|
|
286
|
+
out.append("<blockquote>" + "<br>".join(quote) + "</blockquote>")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
para = []
|
|
290
|
+
while i < len(lines) and lines[i].strip() and \
|
|
291
|
+
not re.match(r"^(#{1,6}\s|[-*]\s|\d+[.)]\s|\||>|<|-{3,})", lines[i].strip()):
|
|
292
|
+
para.append(lines[i].strip())
|
|
293
|
+
i += 1
|
|
294
|
+
if para:
|
|
295
|
+
out.append("<p>" + inline(html.escape(" ".join(para))) + "</p>")
|
|
296
|
+
else:
|
|
297
|
+
i += 1
|
|
298
|
+
return "\n".join(out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ----------------------------------------------------- quiz and answers -----
|
|
302
|
+
|
|
303
|
+
# Episodes have used "**Q1.**", "**Q1 — A title.**" and "**S1.**" over the
|
|
304
|
+
# months. Match the family, not one spelling of it.
|
|
305
|
+
END = r"[.)::—–-]"
|
|
306
|
+
QUESTION_MARK = re.compile(rf"<p>(<strong>\s*Q(\d+)\s*(?={END}|\s|</strong>))")
|
|
307
|
+
ANSWER_SPLIT = re.compile(rf"(?=<p><strong>\s*[AS]\d+\s*(?:{END}|</strong>))")
|
|
308
|
+
ANSWER_HEAD = re.compile(
|
|
309
|
+
rf"^(<p>)<strong>\s*[AS](\d+)\s*{END}?\s*(.*?)</strong>\s*", re.DOTALL)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def number_questions(body_html):
|
|
313
|
+
"""Give every quiz question an id, so an answer can link back to it."""
|
|
314
|
+
return QUESTION_MARK.sub(
|
|
315
|
+
lambda m: f'<p id="q{m.group(2)}" class="qq">{m.group(1)}', body_html)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def split_answer(part):
|
|
319
|
+
"""(number, html) for one answer, with its "A1." marker removed — the
|
|
320
|
+
reveal button already says which question it belongs to."""
|
|
321
|
+
m = ANSWER_HEAD.match(part)
|
|
322
|
+
if not m:
|
|
323
|
+
return None, part
|
|
324
|
+
rest = m.group(3).strip()
|
|
325
|
+
head = m.group(1) + (f"<strong>{rest}</strong> " if rest else "")
|
|
326
|
+
return m.group(2), head + part[m.end():]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def find_section(body_html, word):
|
|
330
|
+
"""Locate an <h2> by its text, and return (start, end_of_heading, end)."""
|
|
331
|
+
for m in re.finditer(r"<h2\b[^>]*>.*?</h2>", body_html, re.DOTALL):
|
|
332
|
+
if word.lower() in re.sub(r"<[^>]+>", "", m.group(0)).lower():
|
|
333
|
+
rest = body_html[m.end():]
|
|
334
|
+
nxt = re.search(r"<h2\b", rest)
|
|
335
|
+
end = m.end() + (nxt.start() if nxt else len(rest))
|
|
336
|
+
return m.start(), m.end(), end
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def fold_solutions(body_html):
|
|
341
|
+
"""Fold the solutions one answer at a time.
|
|
342
|
+
|
|
343
|
+
A single <details> for all seven answers means checking Q1 burns Q2 to Q7.
|
|
344
|
+
Each answer gets its own reveal, and a link back to its question.
|
|
345
|
+
"""
|
|
346
|
+
found = find_section(body_html, "solutions")
|
|
347
|
+
if not found:
|
|
348
|
+
return body_html
|
|
349
|
+
start, head_end, end = found
|
|
350
|
+
inner = body_html[head_end:end]
|
|
351
|
+
inner = re.sub(r"<p>\s*(<br>)?\s*</p>", "", inner)
|
|
352
|
+
|
|
353
|
+
parts = ANSWER_SPLIT.split(inner)
|
|
354
|
+
numbered = [split_answer(p) for p in parts]
|
|
355
|
+
lead = "".join(p for n, p in numbered if n is None)
|
|
356
|
+
answers = [(n, p) for n, p in numbered if n is not None]
|
|
357
|
+
if not answers:
|
|
358
|
+
folded = ('<details class="soln"><summary>Reveal the solutions</summary>'
|
|
359
|
+
f'<div class="solnbody">{inner}</div></details>')
|
|
360
|
+
return body_html[:head_end] + folded + body_html[end:]
|
|
361
|
+
|
|
362
|
+
blocks = [lead] if lead.strip() else []
|
|
363
|
+
del parts, numbered
|
|
364
|
+
blocks.append('<p class="secnote">One reveal per question — check your '
|
|
365
|
+
'answer to Q1 without spoiling the rest.</p>'
|
|
366
|
+
'<div class="solnbar"><button type="button" class="ghost" '
|
|
367
|
+
'data-solnall="open">Reveal all</button>'
|
|
368
|
+
'<button type="button" class="ghost" data-solnall="close">'
|
|
369
|
+
'Hide all</button></div>')
|
|
370
|
+
for n, part in answers:
|
|
371
|
+
blocks.append(
|
|
372
|
+
f'<details class="soln" id="a{n}">'
|
|
373
|
+
f'<summary><span class="qn">Q{n}</span>'
|
|
374
|
+
f'<span class="sl">Reveal the answer</span></summary>'
|
|
375
|
+
f'<div class="solnbody">{part}'
|
|
376
|
+
f'<p class="backq"><a href="#q{n}">↑ Back to question {n}</a></p>'
|
|
377
|
+
f"</div></details>")
|
|
378
|
+
return body_html[:head_end] + "\n".join(blocks) + body_html[end:]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def tidy_rules(body_html):
|
|
382
|
+
"""A rule immediately above a section heading double-rules it: the heading
|
|
383
|
+
already carries one. Drop the rule and keep the break."""
|
|
384
|
+
return re.sub(r"<hr>\s*(?=<h2\b)", "", body_html)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def strip_spoiler_banner(md):
|
|
388
|
+
"""The email needs a scroll warning; the page has a button instead.
|
|
389
|
+
|
|
390
|
+
Normalise whatever spoiler heading the notes used into a plain
|
|
391
|
+
"## Solutions" so the folding step below can find it reliably.
|
|
392
|
+
"""
|
|
393
|
+
md = re.sub(r"^#{1,6}\s*.*SOLUTIONS.*$", "## Solutions", md,
|
|
394
|
+
flags=re.MULTILINE | re.IGNORECASE)
|
|
395
|
+
md = re.sub(r"^\s*(▼|▲).*$", "", md, flags=re.MULTILINE)
|
|
396
|
+
md = re.sub(r"^\s*(<br\s*/?>\s*)+$", "", md, flags=re.MULTILINE)
|
|
397
|
+
# the e-mail pads its spoiler gap with lines; on the page they would
|
|
398
|
+
# print as the literal text " "
|
|
399
|
+
md = re.sub(r"^\s*( \s*)+$", "", md, flags=re.MULTILINE)
|
|
400
|
+
md = md.replace("<br><br>", "")
|
|
401
|
+
return md
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------- continuity artefacts -----
|
|
405
|
+
|
|
406
|
+
SEARCH_NEAR = "" # the --notes path, so siblings can be found
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def find_file(explicit, *names):
|
|
410
|
+
"""Explicit path wins; otherwise look in the cwd and next to the notes."""
|
|
411
|
+
if explicit:
|
|
412
|
+
return explicit if os.path.exists(explicit) else None
|
|
413
|
+
near = os.path.dirname(os.path.abspath(SEARCH_NEAR)) if SEARCH_NEAR else ""
|
|
414
|
+
for name in names:
|
|
415
|
+
for candidate in (name, os.path.join(near, name) if near else ""):
|
|
416
|
+
if candidate and os.path.exists(candidate):
|
|
417
|
+
return candidate
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def load_covered(path):
|
|
422
|
+
"""Episode numbers and titles out of covered.md, for navigation."""
|
|
423
|
+
eps = {}
|
|
424
|
+
if not path:
|
|
425
|
+
return eps
|
|
426
|
+
try:
|
|
427
|
+
text = open(path, encoding="utf-8").read()
|
|
428
|
+
except OSError:
|
|
429
|
+
return eps
|
|
430
|
+
for line in text.splitlines():
|
|
431
|
+
m = re.match(r"^-\s+\*\*Ep (\d+)\*\*[^—-]*[—-]\s*\*(.+?)\*", line.strip())
|
|
432
|
+
if m:
|
|
433
|
+
eps[int(m.group(1))] = m.group(2).strip()
|
|
434
|
+
return eps
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def load_glossary(path):
|
|
438
|
+
"""(term, definition, episode) out of glossary.md."""
|
|
439
|
+
terms = []
|
|
440
|
+
if not path:
|
|
441
|
+
return terms
|
|
442
|
+
try:
|
|
443
|
+
text = open(path, encoding="utf-8").read()
|
|
444
|
+
except OSError:
|
|
445
|
+
return terms
|
|
446
|
+
for line in text.splitlines():
|
|
447
|
+
m = re.match(r"^-\s+\*\*(.+?)\*\*\s+—\s+(.+?)(?:\s+_\(ep (\d+)\)_)?$",
|
|
448
|
+
line.strip())
|
|
449
|
+
if m:
|
|
450
|
+
terms.append((m.group(1).strip(), m.group(2).strip(), m.group(3) or ""))
|
|
451
|
+
return terms
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def glossary_html(terms, number):
|
|
455
|
+
"""The glossary as it stood on the day this episode aired.
|
|
456
|
+
|
|
457
|
+
An episode page carries the vocabulary the show had introduced *by then* —
|
|
458
|
+
episode 1 shows episode 1's terms, episode 5 shows one to five. A page
|
|
459
|
+
dated Monday listing a word first used on Friday reads as a leak, and it
|
|
460
|
+
is no help to someone working through the series in order. Chips filter
|
|
461
|
+
the list down to any single episode.
|
|
462
|
+
"""
|
|
463
|
+
if not terms:
|
|
464
|
+
return ""
|
|
465
|
+
upto = [(t, d, ep) for t, d, ep in terms
|
|
466
|
+
if not ep or int(ep) <= number]
|
|
467
|
+
if not upto:
|
|
468
|
+
return ""
|
|
469
|
+
rows, counts = [], {}
|
|
470
|
+
for term, definition, ep in upto:
|
|
471
|
+
counts[ep] = counts.get(ep, 0) + 1
|
|
472
|
+
tag = (f'<span class="gep{" now" if ep == str(number) else ""}">ep {ep}</span>'
|
|
473
|
+
if ep else "")
|
|
474
|
+
rows.append(f'<div class="gterm" data-ep="{ep}"><dt>{html.escape(term)}</dt>'
|
|
475
|
+
f'<dd>{inline(html.escape(definition))} {tag}</dd></div>')
|
|
476
|
+
|
|
477
|
+
# episode one has nothing to filter between: "All 37 / Ep 1 37" is noise
|
|
478
|
+
seen_eps = sorted(int(e) for e in counts if e)
|
|
479
|
+
chips = []
|
|
480
|
+
if len(seen_eps) > 1:
|
|
481
|
+
chips.append(f'<button type="button" class="gchip on" data-gep="all">'
|
|
482
|
+
f'All<span class="gn">{len(upto)}</span></button>')
|
|
483
|
+
for n in seen_eps:
|
|
484
|
+
chips.append(f'<button type="button" class="gchip" data-gep="{n}">'
|
|
485
|
+
f'Ep {n}<span class="gn">{counts[str(n)]}</span></button>')
|
|
486
|
+
|
|
487
|
+
hid = slug("Glossary")
|
|
488
|
+
HEADINGS.append((2, hid, "Glossary"))
|
|
489
|
+
return ('<section class="glossec">'
|
|
490
|
+
f'<h2 id="{hid}">Glossary<a class="anchor" href="#{hid}" '
|
|
491
|
+
f'aria-label="Link to this section">#</a></h2>'
|
|
492
|
+
'<p class="secnote">Every unit, convention and desk expression the '
|
|
493
|
+
f'show had introduced by episode {number}. Nothing said in the '
|
|
494
|
+
'audio should ever be unrecoverable.</p>'
|
|
495
|
+
f'<details class="gloss"><summary>Open the glossary'
|
|
496
|
+
f'<span class="sl">{len(upto)} terms</span></summary>'
|
|
497
|
+
'<div class="glossbody">'
|
|
498
|
+
'<label class="gsearch"><span class="vh">Search the glossary</span>'
|
|
499
|
+
'<input type="search" id="gfilter" placeholder="Search terms…" '
|
|
500
|
+
'autocomplete="off"></label>'
|
|
501
|
+
+ (f'<div class="gchips" role="group" aria-label="Filter by episode">'
|
|
502
|
+
f'{"".join(chips)}</div>' if chips else "") +
|
|
503
|
+
f'<dl id="glist">{"".join(rows)}</dl>'
|
|
504
|
+
'<p class="gnone" hidden>No term matches that.</p>'
|
|
505
|
+
"</div></details></section>")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def epnav_html(eps, number):
|
|
509
|
+
"""Previous / next / archive. Every page was an orphan before this."""
|
|
510
|
+
prev_n = max([n for n in eps if n < number], default=None)
|
|
511
|
+
next_n = min([n for n in eps if n > number], default=None)
|
|
512
|
+
if prev_n is None and number > 1:
|
|
513
|
+
prev_n = number - 1
|
|
514
|
+
bits = ['<nav class="epnav" aria-label="Episodes">']
|
|
515
|
+
if prev_n:
|
|
516
|
+
title = html.escape(eps.get(prev_n, f"Episode {prev_n}"))
|
|
517
|
+
bits.append(f'<a class="epprev" href="ep{prev_n:02d}.html">'
|
|
518
|
+
f'<span class="dir">← Previous</span>'
|
|
519
|
+
f'<span class="ept">{title}</span>'
|
|
520
|
+
f'<span class="epn">Episode {prev_n}</span></a>')
|
|
521
|
+
if next_n:
|
|
522
|
+
title = html.escape(eps.get(next_n, f"Episode {next_n}"))
|
|
523
|
+
bits.append(f'<a class="epnext" href="ep{next_n:02d}.html">'
|
|
524
|
+
f'<span class="dir">Next →</span>'
|
|
525
|
+
f'<span class="ept">{title}</span>'
|
|
526
|
+
f'<span class="epn">Episode {next_n}</span></a>')
|
|
527
|
+
bits.append("</nav>")
|
|
528
|
+
|
|
529
|
+
if eps:
|
|
530
|
+
items = []
|
|
531
|
+
for n in sorted(eps):
|
|
532
|
+
here = ' class="here" aria-current="page"' if n == number else ""
|
|
533
|
+
items.append(f'<li{here}><a href="ep{n:02d}.html">'
|
|
534
|
+
f'<b>{n:02d}</b> {html.escape(eps[n])}</a></li>')
|
|
535
|
+
bits.append('<details class="archive"><summary>Jump to an episode'
|
|
536
|
+
f'<span class="sl">{len(eps)} so far</span></summary>'
|
|
537
|
+
f'<ol class="arclist">{"".join(items)}</ol></details>')
|
|
538
|
+
return "\n".join(bits)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --------------------------------------------------------- head metadata ----
|
|
542
|
+
|
|
543
|
+
MONTHS = {m: i for i, m in enumerate(
|
|
544
|
+
["january", "february", "march", "april", "may", "june", "july",
|
|
545
|
+
"august", "september", "october", "november", "december"], 1)}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def iso_date(text):
|
|
549
|
+
"""'Friday 14 August 2026' → '2026-08-14'. Returns '' if unparseable."""
|
|
550
|
+
if not text:
|
|
551
|
+
return ""
|
|
552
|
+
if re.match(r"^\d{4}-\d{2}-\d{2}$", text.strip()):
|
|
553
|
+
return text.strip()
|
|
554
|
+
m = re.search(r"(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})", text)
|
|
555
|
+
if not m:
|
|
556
|
+
m = re.search(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})", text)
|
|
557
|
+
if not m:
|
|
558
|
+
return ""
|
|
559
|
+
day, month, year = m.group(2), m.group(1), m.group(3)
|
|
560
|
+
else:
|
|
561
|
+
day, month, year = m.group(1), m.group(2), m.group(3)
|
|
562
|
+
mo = MONTHS.get(month.lower())
|
|
563
|
+
if not mo:
|
|
564
|
+
return ""
|
|
565
|
+
return f"{int(year):04d}-{mo:02d}-{int(day):02d}"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def toc_html():
|
|
569
|
+
if len(HEADINGS) < 3:
|
|
570
|
+
return ""
|
|
571
|
+
items = []
|
|
572
|
+
for level, hid, text in HEADINGS:
|
|
573
|
+
cls = "t2" if level == 2 else "t3"
|
|
574
|
+
items.append(f'<li class="{cls}"><a href="#{hid}">{text}</a></li>')
|
|
575
|
+
return ('<nav class="toc" id="toc" aria-label="Contents">'
|
|
576
|
+
'<p class="tochead">Contents</p>'
|
|
577
|
+
f'<ol>{"".join(items)}</ol></nav>')
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def jsonld(args, dek, iso, number):
|
|
581
|
+
duration = f"PT{args.duration // 60}M{args.duration % 60:02d}S"
|
|
582
|
+
data = {
|
|
583
|
+
"@context": "https://schema.org",
|
|
584
|
+
"@type": "PodcastEpisode",
|
|
585
|
+
"url": page_url(number),
|
|
586
|
+
"name": f"Ep {number} — {args.title}",
|
|
587
|
+
"episodeNumber": number,
|
|
588
|
+
"duration": duration,
|
|
589
|
+
"description": dek or f"Episode {number} of {SHOW}.",
|
|
590
|
+
"image": COVER_URL,
|
|
591
|
+
"inLanguage": "en",
|
|
592
|
+
"author": {"@type": "Person", "name": AUTHOR},
|
|
593
|
+
"associatedMedia": {
|
|
594
|
+
"@type": "MediaObject",
|
|
595
|
+
"contentUrl": args.audio_url,
|
|
596
|
+
"encodingFormat": "audio/mpeg",
|
|
597
|
+
},
|
|
598
|
+
"partOfSeries": {
|
|
599
|
+
"@type": "PodcastSeries",
|
|
600
|
+
"name": SHOW,
|
|
601
|
+
"url": SITE_URL,
|
|
602
|
+
"image": COVER_URL,
|
|
603
|
+
"webFeed": FEED_URL,
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
if iso:
|
|
607
|
+
data["datePublished"] = iso
|
|
608
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------- template -----
|
|
612
|
+
|
|
613
|
+
CSS = """
|
|
614
|
+
:root{
|
|
615
|
+
--ink:#16110c; --ink-soft:#4a4238; --line:#e3ddd2; --paper:#faf7f1;
|
|
616
|
+
--accent:#1d4032; --gold:#a8813c; --spoiler:#8a2f2f;
|
|
617
|
+
--tint:rgba(128,110,70,.07); --tint-2:rgba(128,110,70,.045);
|
|
618
|
+
--up:#215c44; --down:#8a2f2f; --shadow:rgba(22,17,12,.14);
|
|
619
|
+
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
620
|
+
--serif:Georgia,"Iowan Old Style","Times New Roman",serif;
|
|
621
|
+
--col:680px;
|
|
622
|
+
}
|
|
623
|
+
html[data-theme="dark"]{
|
|
624
|
+
--ink:#ece6dc; --ink-soft:#a49c90; --line:#2f2a24; --paper:#14110e;
|
|
625
|
+
--accent:#7fae95; --gold:#c9a45c; --spoiler:#d98a8a;
|
|
626
|
+
--tint:rgba(200,180,130,.06); --tint-2:rgba(200,180,130,.035);
|
|
627
|
+
--up:#7fae95; --down:#d98a8a; --shadow:rgba(0,0,0,.55);
|
|
628
|
+
color-scheme:dark;
|
|
629
|
+
}
|
|
630
|
+
*{box-sizing:border-box}
|
|
631
|
+
html{scroll-behavior:smooth;scroll-padding-top:64px}
|
|
632
|
+
html,body{transition:background-color .25s ease,color .25s ease}
|
|
633
|
+
body{margin:0;background:var(--paper);color:var(--ink);
|
|
634
|
+
font:18px/1.72 var(--serif);-webkit-font-smoothing:antialiased;
|
|
635
|
+
text-rendering:optimizeLegibility}
|
|
636
|
+
.wrap{max-width:var(--col);margin:0 auto;padding:0 24px 40px}
|
|
637
|
+
.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);
|
|
638
|
+
white-space:nowrap}
|
|
639
|
+
|
|
640
|
+
/* ---------- reading progress ---------- */
|
|
641
|
+
#bar{position:fixed;top:0;left:0;height:2px;width:0;z-index:30;
|
|
642
|
+
background:var(--gold);transition:width .12s linear}
|
|
643
|
+
|
|
644
|
+
/* ---------- masthead ---------- */
|
|
645
|
+
header{padding:52px 0 30px}
|
|
646
|
+
.masthead{display:flex;justify-content:space-between;align-items:baseline;gap:10px 16px;
|
|
647
|
+
flex-wrap:wrap;
|
|
648
|
+
padding-bottom:12px;border-bottom:1px solid var(--ink);
|
|
649
|
+
font:600 13px/1 var(--sans);letter-spacing:.13em;text-transform:uppercase}
|
|
650
|
+
.masthead a{color:inherit;text-decoration:none}
|
|
651
|
+
.masthead .epno{color:var(--gold);letter-spacing:.1em}
|
|
652
|
+
.mastright{display:flex;align-items:baseline;gap:16px;white-space:nowrap}
|
|
653
|
+
/* the show name is a link too, but it reads as a title — this is the one that
|
|
654
|
+
looks clickable, at both ends of the page */
|
|
655
|
+
.homelink{color:var(--ink-soft);border-bottom:1px solid var(--line);
|
|
656
|
+
padding-bottom:2px;transition:color .2s,border-color .2s}
|
|
657
|
+
.homelink:hover{color:var(--accent);border-color:var(--accent)}
|
|
658
|
+
.tohome{margin:0 0 8px;font:14px/1.6 var(--sans)}
|
|
659
|
+
.tohome a{color:var(--ink-soft);text-decoration:none;
|
|
660
|
+
border-bottom:1px solid var(--line);padding-bottom:2px;
|
|
661
|
+
transition:color .2s,border-color .2s}
|
|
662
|
+
.tohome a:hover{color:var(--accent);border-color:var(--accent)}
|
|
663
|
+
h1{font-size:clamp(31px,5.6vw,44px);line-height:1.12;margin:26px 0 0;
|
|
664
|
+
letter-spacing:-.017em;font-weight:400;text-wrap:balance}
|
|
665
|
+
.dek{color:var(--ink-soft);font-size:20px;line-height:1.5;margin:16px 0 0;
|
|
666
|
+
max-width:34em;text-wrap:pretty}
|
|
667
|
+
.meta{margin-top:22px;font:12.5px/1.6 var(--sans);color:var(--ink-soft);
|
|
668
|
+
letter-spacing:.05em;text-transform:uppercase}
|
|
669
|
+
.meta b{font-weight:600;color:var(--ink)}
|
|
670
|
+
|
|
671
|
+
/* ---------- listen ---------- */
|
|
672
|
+
.listen{margin-top:26px;border:1px solid var(--line);border-radius:12px;
|
|
673
|
+
background:var(--tint-2);padding:16px 18px 14px}
|
|
674
|
+
audio{width:100%;height:38px;display:block}
|
|
675
|
+
.listenrow{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;
|
|
676
|
+
margin-top:12px;font:12.5px/1 var(--sans);color:var(--ink-soft)}
|
|
677
|
+
.listenrow a{color:var(--ink-soft);text-decoration:none;border-bottom:1px solid var(--line)}
|
|
678
|
+
.listenrow a:hover{color:var(--accent);border-color:var(--accent)}
|
|
679
|
+
.rates{display:flex;gap:6px;margin-right:auto}
|
|
680
|
+
.rate{background:none;border:1px solid var(--line);color:var(--ink-soft);
|
|
681
|
+
border-radius:99px;padding:4px 10px;cursor:pointer;
|
|
682
|
+
font:600 11.5px/1 var(--sans);letter-spacing:.04em;transition:all .2s}
|
|
683
|
+
.rate:hover{border-color:var(--gold);color:var(--ink)}
|
|
684
|
+
.rate[aria-pressed="true"]{background:var(--ink);color:var(--paper);border-color:var(--ink)}
|
|
685
|
+
|
|
686
|
+
/* ---------- contents ---------- */
|
|
687
|
+
.toc{margin:34px 0 6px;padding:16px 0 4px;border-top:1px solid var(--line);
|
|
688
|
+
border-bottom:1px solid var(--line)}
|
|
689
|
+
.tochead{font:600 11px/1 var(--sans);letter-spacing:.18em;text-transform:uppercase;
|
|
690
|
+
color:var(--gold);margin:0 0 12px}
|
|
691
|
+
.toc ol{list-style:none;margin:0 0 12px;padding:0;
|
|
692
|
+
font:14.5px/1.5 var(--sans);columns:2;column-gap:26px}
|
|
693
|
+
.toc li{margin:0 0 7px;break-inside:avoid}
|
|
694
|
+
.toc li.t3{padding-left:12px;font-size:13.5px}
|
|
695
|
+
.toc a{color:var(--ink-soft);text-decoration:none;border-bottom:1px solid transparent}
|
|
696
|
+
.toc a:hover{color:var(--ink);border-color:var(--gold)}
|
|
697
|
+
.toc li.on>a{color:var(--ink);font-weight:600}
|
|
698
|
+
|
|
699
|
+
/* ---------- body ---------- */
|
|
700
|
+
main{padding-top:8px}
|
|
701
|
+
h2{font:400 27px/1.25 var(--serif);color:var(--gold);margin:56px 0 18px;
|
|
702
|
+
font-variant-caps:all-small-caps;letter-spacing:.045em;
|
|
703
|
+
padding-bottom:9px;border-bottom:1px solid var(--line)}
|
|
704
|
+
h3{font-size:23px;line-height:1.3;margin:38px 0 10px;letter-spacing:-.012em;
|
|
705
|
+
font-weight:400}
|
|
706
|
+
h4{font:600 17px/1.4 var(--sans);margin:26px 0 6px;letter-spacing:-.005em}
|
|
707
|
+
p{margin:0 0 18px}
|
|
708
|
+
h2+p,h3+p,h4+p{margin-top:0}
|
|
709
|
+
ul,ol{margin:0 0 18px;padding-left:24px}
|
|
710
|
+
li{margin:0 0 9px}
|
|
711
|
+
li::marker{color:var(--gold)}
|
|
712
|
+
strong{font-weight:700}
|
|
713
|
+
a{color:var(--accent);text-underline-offset:2px;text-decoration-thickness:1px}
|
|
714
|
+
.anchor{float:right;margin-left:12px;color:var(--line);text-decoration:none;
|
|
715
|
+
font:400 16px/1 var(--sans);opacity:0;transition:opacity .2s}
|
|
716
|
+
h2:hover .anchor,h3:hover .anchor,.anchor:focus{opacity:1;color:var(--gold)}
|
|
717
|
+
.secnote{font:14.5px/1.6 var(--sans);color:var(--ink-soft);margin:-4px 0 18px}
|
|
718
|
+
code{background:var(--tint);padding:1px 5px;border-radius:3px;
|
|
719
|
+
font:15px/1 ui-monospace,Menlo,Consolas,monospace}
|
|
720
|
+
blockquote{border-left:2px solid var(--gold);margin:26px 0;padding:6px 0 6px 20px;
|
|
721
|
+
font-style:italic;color:var(--ink-soft);font-size:17.5px}
|
|
722
|
+
blockquote strong{font-style:normal;color:var(--ink);font-weight:600}
|
|
723
|
+
hr{border:none;border-top:1px solid var(--line);margin:40px 0}
|
|
724
|
+
.charterr{color:var(--spoiler);font:14px/1.5 var(--sans)}
|
|
725
|
+
|
|
726
|
+
/* ---------- tables ---------- */
|
|
727
|
+
.tablewrap{margin:26px 0;overflow-x:auto;overscroll-behavior-x:contain;
|
|
728
|
+
background:
|
|
729
|
+
linear-gradient(to right,var(--paper) 34%,rgba(255,255,255,0)) left center,
|
|
730
|
+
linear-gradient(to left,var(--paper) 34%,rgba(255,255,255,0)) right center,
|
|
731
|
+
radial-gradient(farthest-side at 0 50%,var(--shadow),rgba(255,255,255,0)) left center,
|
|
732
|
+
radial-gradient(farthest-side at 100% 50%,var(--shadow),rgba(255,255,255,0)) right center;
|
|
733
|
+
background-repeat:no-repeat;
|
|
734
|
+
background-size:44px 100%,44px 100%,13px 100%,13px 100%;
|
|
735
|
+
background-attachment:local,local,scroll,scroll}
|
|
736
|
+
.tablewrap:focus-visible{outline:2px solid var(--gold);outline-offset:3px}
|
|
737
|
+
table{width:100%;border-collapse:collapse;font:15.5px/1.5 var(--sans);
|
|
738
|
+
font-variant-numeric:tabular-nums lining-nums}
|
|
739
|
+
th{text-align:left;font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;
|
|
740
|
+
color:var(--ink-soft);border-bottom:1px solid var(--ink);padding:0 14px 9px 0;
|
|
741
|
+
font-weight:600;white-space:nowrap}
|
|
742
|
+
td{padding:10px 14px 10px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
|
743
|
+
th:last-child,td:last-child{padding-right:0}
|
|
744
|
+
th.num,td.num{text-align:right;white-space:nowrap}
|
|
745
|
+
tbody tr:last-child td{border-bottom:none}
|
|
746
|
+
tr td:first-child{font-weight:600;padding-right:20px;min-width:9em}
|
|
747
|
+
.mv{font-weight:600;font-variant-numeric:tabular-nums}
|
|
748
|
+
.mv.up{color:var(--up)} .mv.down{color:var(--down)} .mv.flat{color:var(--ink-soft)}
|
|
749
|
+
|
|
750
|
+
/* ---------- quiz and solutions ---------- */
|
|
751
|
+
.qq{scroll-margin-top:70px}
|
|
752
|
+
.solnbar{display:flex;gap:8px;margin:0 0 16px}
|
|
753
|
+
.ghost{background:none;border:1px solid var(--line);color:var(--ink-soft);
|
|
754
|
+
border-radius:99px;padding:6px 14px;cursor:pointer;
|
|
755
|
+
font:600 11.5px/1 var(--sans);letter-spacing:.06em;text-transform:uppercase;
|
|
756
|
+
transition:all .2s}
|
|
757
|
+
.ghost:hover{border-color:var(--gold);color:var(--ink)}
|
|
758
|
+
details.soln{margin:0 0 10px;scroll-margin-top:70px}
|
|
759
|
+
details.soln>summary{cursor:pointer;list-style:none;display:flex;align-items:center;
|
|
760
|
+
gap:12px;border:1px solid var(--line);border-left:3px solid var(--spoiler);
|
|
761
|
+
border-radius:7px;padding:11px 16px;transition:all .2s;background:var(--tint-2)}
|
|
762
|
+
summary::-webkit-details-marker{display:none}
|
|
763
|
+
details.soln>summary:hover{border-color:var(--spoiler);background:var(--tint)}
|
|
764
|
+
.qn{font:700 13px/1 var(--sans);letter-spacing:.08em;color:var(--spoiler);
|
|
765
|
+
text-transform:uppercase}
|
|
766
|
+
.sl{font:500 12.5px/1 var(--sans);color:var(--ink-soft);letter-spacing:.05em}
|
|
767
|
+
details[open]>summary .sl{opacity:.6}
|
|
768
|
+
.solnbody{border-left:2px solid var(--accent);margin:14px 0 22px 3px;
|
|
769
|
+
padding:2px 0 2px 20px;font-size:17.5px}
|
|
770
|
+
.backq{font:12.5px/1 var(--sans);margin:0}
|
|
771
|
+
.backq a{color:var(--ink-soft);text-decoration:none}
|
|
772
|
+
.backq a:hover{color:var(--accent)}
|
|
773
|
+
|
|
774
|
+
/* ---------- glossary ---------- */
|
|
775
|
+
details.gloss>summary,details.archive>summary{cursor:pointer;list-style:none;
|
|
776
|
+
display:flex;align-items:baseline;justify-content:space-between;gap:14px;
|
|
777
|
+
border:1px solid var(--line);border-radius:8px;padding:13px 18px;
|
|
778
|
+
font:600 14px/1 var(--sans);letter-spacing:.03em;transition:all .2s;
|
|
779
|
+
background:var(--tint-2)}
|
|
780
|
+
details.gloss>summary:hover,details.archive>summary:hover{border-color:var(--gold)}
|
|
781
|
+
.glossbody{padding:22px 2px 6px}
|
|
782
|
+
.gsearch input{width:100%;padding:11px 14px;border:1px solid var(--line);
|
|
783
|
+
border-radius:8px;background:var(--paper);color:var(--ink);
|
|
784
|
+
font:15px/1 var(--sans)}
|
|
785
|
+
.gsearch input:focus{outline:none;border-color:var(--gold)}
|
|
786
|
+
.gchips{display:flex;flex-wrap:wrap;gap:7px;margin:14px 0 0}
|
|
787
|
+
.gchip{background:none;border:1px solid var(--line);color:var(--ink-soft);
|
|
788
|
+
border-radius:99px;padding:6px 12px;cursor:pointer;display:flex;align-items:center;
|
|
789
|
+
gap:6px;font:600 12px/1 var(--sans);letter-spacing:.04em;transition:all .2s}
|
|
790
|
+
.gchip:hover{border-color:var(--gold);color:var(--ink)}
|
|
791
|
+
.gchip.on{background:var(--ink);color:var(--paper);border-color:var(--ink)}
|
|
792
|
+
.gn{font-weight:400;opacity:.65;font-variant-numeric:tabular-nums}
|
|
793
|
+
dl{margin:20px 0 0}
|
|
794
|
+
.gterm{padding:11px 0;border-top:1px solid var(--line)}
|
|
795
|
+
.gterm dt{font:600 15.5px/1.4 var(--sans)}
|
|
796
|
+
.gterm dd{margin:3px 0 0;font-size:16.5px;color:var(--ink-soft)}
|
|
797
|
+
.gep{font:600 10.5px/1 var(--sans);letter-spacing:.09em;text-transform:uppercase;
|
|
798
|
+
color:var(--ink-soft);border:1px solid var(--line);border-radius:99px;
|
|
799
|
+
padding:3px 7px;white-space:nowrap;margin-left:4px}
|
|
800
|
+
.gep.now{color:var(--gold);border-color:var(--gold)}
|
|
801
|
+
.gnone{font:15px/1.5 var(--sans);color:var(--ink-soft);padding:16px 0}
|
|
802
|
+
|
|
803
|
+
/* ---------- episode navigation ---------- */
|
|
804
|
+
.epnav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:56px 0 20px;
|
|
805
|
+
padding-top:26px;border-top:1px solid var(--ink)}
|
|
806
|
+
.epnav a{display:flex;flex-direction:column;gap:5px;text-decoration:none;
|
|
807
|
+
border:1px solid var(--line);border-radius:10px;padding:16px 18px;
|
|
808
|
+
color:var(--ink);transition:all .25s}
|
|
809
|
+
.epnav a:hover{border-color:var(--gold);background:var(--tint-2)}
|
|
810
|
+
.epnav .epnext{text-align:right}
|
|
811
|
+
.epnav .dir{font:600 11px/1 var(--sans);letter-spacing:.14em;text-transform:uppercase;
|
|
812
|
+
color:var(--gold)}
|
|
813
|
+
.epnav .ept{font:19px/1.3 var(--serif)}
|
|
814
|
+
.epnav .epn{font:12px/1 var(--sans);color:var(--ink-soft);letter-spacing:.05em}
|
|
815
|
+
.epnav a:only-child{grid-column:1/-1;max-width:60%}
|
|
816
|
+
.epnav .epnext:only-child{margin-left:auto}
|
|
817
|
+
details.archive{margin:0 0 12px}
|
|
818
|
+
.arclist{list-style:none;margin:18px 0 6px;padding:0;font:15px/1.5 var(--sans)}
|
|
819
|
+
.arclist li{margin:0;border-top:1px solid var(--line)}
|
|
820
|
+
.arclist a{display:flex;gap:14px;padding:11px 4px;text-decoration:none;
|
|
821
|
+
color:var(--ink);transition:background .2s}
|
|
822
|
+
.arclist a:hover{background:var(--tint-2)}
|
|
823
|
+
.arclist b{color:var(--gold);font-variant-numeric:tabular-nums;font-weight:600}
|
|
824
|
+
.arclist li.here a{color:var(--ink-soft)}
|
|
825
|
+
.arclist li.here b:after{content:" · you are here";font-weight:400;
|
|
826
|
+
font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
|
827
|
+
|
|
828
|
+
/* ---------- theme button + footer ---------- */
|
|
829
|
+
.themebtn{position:fixed;top:14px;right:14px;z-index:20;background:var(--paper);
|
|
830
|
+
color:var(--ink-soft);border:1px solid var(--line);border-radius:99px;
|
|
831
|
+
padding:8px 14px;cursor:pointer;font:500 12px/1 var(--sans);letter-spacing:.06em;
|
|
832
|
+
display:flex;align-items:center;gap:7px;box-shadow:0 1px 4px var(--shadow);
|
|
833
|
+
transition:all .2s}
|
|
834
|
+
.themebtn:hover{color:var(--ink);border-color:var(--gold)}
|
|
835
|
+
.themebtn svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;
|
|
836
|
+
stroke-linecap:round;stroke-linejoin:round}
|
|
837
|
+
html[data-theme="dark"] .themebtn{box-shadow:none}
|
|
838
|
+
.themebtn .moon{display:block} .themebtn .sun{display:none}
|
|
839
|
+
html[data-theme="dark"] .themebtn .moon{display:none}
|
|
840
|
+
html[data-theme="dark"] .themebtn .sun{display:block}
|
|
841
|
+
.themebtn .lbl:after{content:"Dark"}
|
|
842
|
+
html[data-theme="dark"] .themebtn .lbl:after{content:"Light"}
|
|
843
|
+
footer{margin-top:40px;padding:24px 0 70px;border-top:1px solid var(--line);
|
|
844
|
+
font:14px/1.7 var(--sans);color:var(--ink-soft);
|
|
845
|
+
display:flex;flex-wrap:wrap;gap:8px 22px;justify-content:space-between}
|
|
846
|
+
footer a{color:var(--ink-soft)}
|
|
847
|
+
footer a:hover{color:var(--accent)}
|
|
848
|
+
footer .sig b{color:var(--ink);font-weight:600}
|
|
849
|
+
|
|
850
|
+
/* ---------- wide screens: the rail, and the breakout ---------- */
|
|
851
|
+
@media (min-width:1240px){
|
|
852
|
+
.toc{position:fixed;top:96px;left:max(24px,calc(50% - 596px));width:216px;
|
|
853
|
+
max-height:74vh;overflow-y:auto;margin:0;padding:0;border:none;
|
|
854
|
+
border-left:1px solid var(--line);padding-left:18px;z-index:5}
|
|
855
|
+
.toc ol{columns:1;font-size:14px}
|
|
856
|
+
.toc li{margin-bottom:9px}
|
|
857
|
+
/* charts break out of the measure; tables stay aligned with the prose,
|
|
858
|
+
because a table that starts left of the paragraph above it reads as a
|
|
859
|
+
layout fault rather than as emphasis */
|
|
860
|
+
.chartfig{margin-left:-38px;margin-right:-38px;
|
|
861
|
+
padding-left:26px;padding-right:26px}
|
|
862
|
+
}
|
|
863
|
+
@media (min-width:1240px) and (max-height:620px){ .toc{display:none} }
|
|
864
|
+
|
|
865
|
+
/* ---------- phones ---------- */
|
|
866
|
+
@media (max-width:640px){
|
|
867
|
+
body{font-size:17px;line-height:1.68}
|
|
868
|
+
.wrap{padding:0 18px 32px}
|
|
869
|
+
header{padding-top:34px}
|
|
870
|
+
.dek{font-size:18px}
|
|
871
|
+
h2{font-size:24px;margin-top:44px}
|
|
872
|
+
h3{font-size:20.5px}
|
|
873
|
+
/* a phone gets the sections, not every subhead: eleven rows of contents
|
|
874
|
+
is a screenful of scrolling before the article even starts */
|
|
875
|
+
.toc li.t3{display:none}
|
|
876
|
+
.toc ol{columns:2;column-gap:18px;font-size:14px}
|
|
877
|
+
.themebtn{top:9px;right:9px;padding:7px 11px}
|
|
878
|
+
table{font-size:14.5px}
|
|
879
|
+
th{font-size:10.5px;padding-right:12px}
|
|
880
|
+
td{padding-right:12px}
|
|
881
|
+
tr td:first-child{min-width:7.5em;padding-right:14px}
|
|
882
|
+
.epnav{grid-template-columns:1fr}
|
|
883
|
+
.epnav .epnext{text-align:left}
|
|
884
|
+
.epnav a:only-child{max-width:none}
|
|
885
|
+
.solnbody{font-size:17px;padding-left:15px}
|
|
886
|
+
/* charts earn the full width of a phone */
|
|
887
|
+
.chartfig{margin-left:-18px;margin-right:-18px;border-radius:0;
|
|
888
|
+
border-left:none;border-right:none;padding:16px 14px 12px}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/* ---------- print ---------- */
|
|
892
|
+
@page{margin:16mm 14mm}
|
|
893
|
+
@media print{
|
|
894
|
+
:root{--ink:#000;--ink-soft:#333;--line:#bbb;--paper:#fff;--tint:#f4f2ec;
|
|
895
|
+
--tint-2:#f8f7f3;--gold:#7a5c24;--accent:#123024;--up:#123024;
|
|
896
|
+
--down:#7a1f1f;--shadow:transparent}
|
|
897
|
+
html[data-theme="dark"]{--ink:#000;--ink-soft:#333;--line:#bbb;--paper:#fff;
|
|
898
|
+
--tint:#f4f2ec;--tint-2:#f8f7f3;--gold:#7a5c24;--accent:#123024}
|
|
899
|
+
body{font-size:11pt;line-height:1.5;background:#fff}
|
|
900
|
+
.wrap{max-width:none;padding:0}
|
|
901
|
+
#bar,.themebtn,.toc,.listen,.solnbar,.gsearch,.epnav,.anchor,.backq,
|
|
902
|
+
.homelink,.tohome{display:none!important}
|
|
903
|
+
header{padding-top:0}
|
|
904
|
+
.masthead{border-color:#000}
|
|
905
|
+
h1{font-size:23pt;margin-top:14pt}
|
|
906
|
+
.dek{font-size:12pt}
|
|
907
|
+
h2{font-size:15pt;margin:22pt 0 8pt;break-after:avoid}
|
|
908
|
+
h3{font-size:13pt;margin:14pt 0 5pt;break-after:avoid}
|
|
909
|
+
p,li{orphans:3;widows:3}
|
|
910
|
+
.tablewrap{overflow:visible;background:none;break-inside:avoid}
|
|
911
|
+
.chartfig{break-inside:avoid;border-color:#ccc;background:none}
|
|
912
|
+
details{break-inside:avoid;margin:0}
|
|
913
|
+
/* the answers print open, labelled by question, without a button */
|
|
914
|
+
details.soln>summary{display:flex;border:none;background:none;
|
|
915
|
+
padding:10pt 0 2pt;border-top:1px solid #ddd}
|
|
916
|
+
summary .sl{display:none}
|
|
917
|
+
.solnbody{border-color:#999;margin:2pt 0 8pt;padding-left:0}
|
|
918
|
+
/* the cumulative glossary is a reference, not part of the article:
|
|
919
|
+
eighty-odd terms would add pages to every printout */
|
|
920
|
+
.glossec,details.archive{display:none}
|
|
921
|
+
a{color:#000;text-decoration:none}
|
|
922
|
+
footer{border-color:#000}
|
|
923
|
+
}
|
|
924
|
+
@media (prefers-reduced-motion:reduce){
|
|
925
|
+
html{scroll-behavior:auto}
|
|
926
|
+
*{animation-duration:.001ms!important;transition-duration:.001ms!important}
|
|
927
|
+
}
|
|
928
|
+
"""
|
|
929
|
+
|
|
930
|
+
JS = """
|
|
931
|
+
(function(){
|
|
932
|
+
var doc = document.documentElement;
|
|
933
|
+
|
|
934
|
+
/* theme */
|
|
935
|
+
var btn = document.querySelector('.themebtn');
|
|
936
|
+
if(btn) btn.addEventListener('click', function(){
|
|
937
|
+
var next = doc.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
938
|
+
doc.setAttribute('data-theme', next);
|
|
939
|
+
try{ localStorage.setItem('cdd-theme', next); }catch(e){}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
/* reading progress */
|
|
943
|
+
var bar = document.getElementById('bar'), ticking = false;
|
|
944
|
+
function progress(){
|
|
945
|
+
var h = doc.scrollHeight - innerHeight;
|
|
946
|
+
bar.style.width = (h > 0 ? Math.min(100, Math.max(0, scrollY / h * 100)) : 0) + '%';
|
|
947
|
+
ticking = false;
|
|
948
|
+
}
|
|
949
|
+
if(bar){
|
|
950
|
+
addEventListener('scroll', function(){
|
|
951
|
+
if(!ticking){ ticking = true; requestAnimationFrame(progress); }
|
|
952
|
+
}, {passive:true});
|
|
953
|
+
addEventListener('resize', progress, {passive:true});
|
|
954
|
+
progress();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* contents: mark the section being read */
|
|
958
|
+
var links = [].slice.call(document.querySelectorAll('.toc a'));
|
|
959
|
+
if(links.length && 'IntersectionObserver' in window){
|
|
960
|
+
var map = {};
|
|
961
|
+
links.forEach(function(a){
|
|
962
|
+
var el = document.getElementById(decodeURIComponent(a.hash.slice(1)));
|
|
963
|
+
if(el) map[el.id] = a.parentNode;
|
|
964
|
+
});
|
|
965
|
+
var seen = [];
|
|
966
|
+
var io = new IntersectionObserver(function(entries){
|
|
967
|
+
entries.forEach(function(en){
|
|
968
|
+
var id = en.target.id, at = seen.indexOf(id);
|
|
969
|
+
if(en.isIntersecting){ if(at < 0) seen.push(id); }
|
|
970
|
+
else if(at >= 0){ seen.splice(at, 1); }
|
|
971
|
+
});
|
|
972
|
+
links.forEach(function(a){ a.parentNode.classList.remove('on'); });
|
|
973
|
+
if(seen.length && map[seen[0]]) map[seen[0]].classList.add('on');
|
|
974
|
+
}, {rootMargin:'-64px 0px -70% 0px'});
|
|
975
|
+
Object.keys(map).forEach(function(id){
|
|
976
|
+
var el = document.getElementById(id); if(el) io.observe(el);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/* playback speed */
|
|
981
|
+
var audio = document.querySelector('audio');
|
|
982
|
+
[].forEach.call(document.querySelectorAll('.rate'), function(b){
|
|
983
|
+
b.addEventListener('click', function(){
|
|
984
|
+
if(!audio) return;
|
|
985
|
+
audio.playbackRate = parseFloat(b.dataset.rate);
|
|
986
|
+
[].forEach.call(document.querySelectorAll('.rate'), function(o){
|
|
987
|
+
o.setAttribute('aria-pressed', String(o === b));
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
/* solutions: reveal all / hide all */
|
|
993
|
+
[].forEach.call(document.querySelectorAll('[data-solnall]'), function(b){
|
|
994
|
+
b.addEventListener('click', function(){
|
|
995
|
+
var open = b.dataset.solnall === 'open';
|
|
996
|
+
[].forEach.call(document.querySelectorAll('details.soln'), function(d){
|
|
997
|
+
d.open = open;
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
/* an answer linked to directly should already be open */
|
|
1003
|
+
function openTarget(){
|
|
1004
|
+
var id = location.hash.slice(1);
|
|
1005
|
+
if(!id) return;
|
|
1006
|
+
var el = document.getElementById(decodeURIComponent(id));
|
|
1007
|
+
while(el){
|
|
1008
|
+
if(el.tagName === 'DETAILS') el.open = true;
|
|
1009
|
+
el = el.parentElement;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
addEventListener('hashchange', openTarget); openTarget();
|
|
1013
|
+
|
|
1014
|
+
/* glossary: an episode chip and a search box, which compose */
|
|
1015
|
+
var gf = document.getElementById('gfilter'), list = document.getElementById('glist');
|
|
1016
|
+
if(list){
|
|
1017
|
+
var rows = [].slice.call(list.querySelectorAll('.gterm'));
|
|
1018
|
+
var chips = [].slice.call(document.querySelectorAll('.gchip'));
|
|
1019
|
+
var none = document.querySelector('.gnone');
|
|
1020
|
+
var pick = 'all';
|
|
1021
|
+
function apply(){
|
|
1022
|
+
var q = gf ? gf.value.trim().toLowerCase() : '', hits = 0;
|
|
1023
|
+
rows.forEach(function(r){
|
|
1024
|
+
var on = (pick === 'all' || r.dataset.ep === pick) &&
|
|
1025
|
+
(!q || r.textContent.toLowerCase().indexOf(q) > -1);
|
|
1026
|
+
r.hidden = !on; if(on) hits++;
|
|
1027
|
+
});
|
|
1028
|
+
if(none) none.hidden = hits > 0;
|
|
1029
|
+
}
|
|
1030
|
+
if(gf) gf.addEventListener('input', apply);
|
|
1031
|
+
chips.forEach(function(c){
|
|
1032
|
+
c.addEventListener('click', function(){
|
|
1033
|
+
pick = c.dataset.gep;
|
|
1034
|
+
chips.forEach(function(o){ o.classList.toggle('on', o === c); });
|
|
1035
|
+
apply();
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/* printing: paper has no disclosure triangles */
|
|
1041
|
+
var reopen = [];
|
|
1042
|
+
addEventListener('beforeprint', function(){
|
|
1043
|
+
reopen = [].filter.call(document.querySelectorAll('details'), function(d){
|
|
1044
|
+
return !d.open;
|
|
1045
|
+
});
|
|
1046
|
+
reopen.forEach(function(d){ d.open = true; });
|
|
1047
|
+
});
|
|
1048
|
+
addEventListener('afterprint', function(){
|
|
1049
|
+
reopen.forEach(function(d){ d.open = false; });
|
|
1050
|
+
reopen = [];
|
|
1051
|
+
});
|
|
1052
|
+
})();
|
|
1053
|
+
"""
|
|
1054
|
+
|
|
1055
|
+
HEAD_JS = """
|
|
1056
|
+
try{
|
|
1057
|
+
var s = localStorage.getItem('cdd-theme');
|
|
1058
|
+
if(s){ document.documentElement.setAttribute('data-theme', s); }
|
|
1059
|
+
else if(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches){
|
|
1060
|
+
document.documentElement.setAttribute('data-theme','dark');
|
|
1061
|
+
}
|
|
1062
|
+
}catch(e){}
|
|
1063
|
+
"""
|
|
1064
|
+
|
|
1065
|
+
TEMPLATE = """<!DOCTYPE html>
|
|
1066
|
+
<html lang="en" data-theme="light">
|
|
1067
|
+
<head>
|
|
1068
|
+
<meta charset="utf-8">
|
|
1069
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1070
|
+
<title>Ep {number} — {title} · {show}</title>
|
|
1071
|
+
<meta name="description" content="{dek_attr}">
|
|
1072
|
+
<meta name="author" content="{author}">
|
|
1073
|
+
<link rel="canonical" href="{page}">
|
|
1074
|
+
<meta property="og:type" content="article">
|
|
1075
|
+
<meta property="og:site_name" content="{show}">
|
|
1076
|
+
<meta property="og:title" content="Ep {number} — {title}">
|
|
1077
|
+
<meta property="og:description" content="{dek_attr}">
|
|
1078
|
+
<meta property="og:url" content="{page}">
|
|
1079
|
+
<meta property="og:image" content="{cover}">
|
|
1080
|
+
<meta property="og:image:alt" content="{show} cover art">
|
|
1081
|
+
<meta property="og:audio" content="{audio}">
|
|
1082
|
+
<meta property="og:audio:type" content="audio/mpeg">
|
|
1083
|
+
<meta property="article:published_time" content="{iso}">
|
|
1084
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
1085
|
+
<meta name="twitter:title" content="Ep {number} — {title}">
|
|
1086
|
+
<meta name="twitter:description" content="{dek_attr}">
|
|
1087
|
+
<meta name="twitter:image" content="{cover}">
|
|
1088
|
+
<meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)">
|
|
1089
|
+
<meta name="theme-color" content="#14110e" media="(prefers-color-scheme: dark)">
|
|
1090
|
+
<link rel="alternate" type="application/rss+xml" title="{show}" href="{feed}">
|
|
1091
|
+
<link rel="icon" href="{favicon}">
|
|
1092
|
+
<script type="application/ld+json">
|
|
1093
|
+
{jsonld}
|
|
1094
|
+
</script>
|
|
1095
|
+
<style>{css}</style>
|
|
1096
|
+
<script>{headjs}</script>
|
|
1097
|
+
</head>
|
|
1098
|
+
<body>
|
|
1099
|
+
<div id="bar" aria-hidden="true"></div>
|
|
1100
|
+
<button class="themebtn" type="button" aria-label="Switch colour theme">
|
|
1101
|
+
<svg class="moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
1102
|
+
<svg class="sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
1103
|
+
<span class="lbl"></span>
|
|
1104
|
+
</button>
|
|
1105
|
+
<div class="wrap">
|
|
1106
|
+
<header>
|
|
1107
|
+
<div class="masthead">
|
|
1108
|
+
<a href="{site}">{show}</a>
|
|
1109
|
+
<span class="mastright">
|
|
1110
|
+
<a class="homelink" href="{site}">All episodes ↗</a>
|
|
1111
|
+
<span class="epno">Ep {number:02d}</span>
|
|
1112
|
+
</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
<h1>{title}</h1>
|
|
1115
|
+
{dekblock}
|
|
1116
|
+
<div class="meta">{date}{datesep}<b>{duration}</b></div>
|
|
1117
|
+
<div class="listen">
|
|
1118
|
+
<audio controls preload="none" src="{audio}"></audio>
|
|
1119
|
+
<div class="listenrow">
|
|
1120
|
+
<span class="rates" role="group" aria-label="Playback speed">
|
|
1121
|
+
<button type="button" class="rate" data-rate="1" aria-pressed="true">1×</button>
|
|
1122
|
+
<button type="button" class="rate" data-rate="1.25" aria-pressed="false">1.25×</button>
|
|
1123
|
+
<button type="button" class="rate" data-rate="1.5" aria-pressed="false">1.5×</button>
|
|
1124
|
+
</span>
|
|
1125
|
+
<a href="{audio}" download>Download</a>
|
|
1126
|
+
<a href="{feed}">Subscribe · RSS</a>
|
|
1127
|
+
</div>
|
|
1128
|
+
</div>
|
|
1129
|
+
{toc}
|
|
1130
|
+
</header>
|
|
1131
|
+
<main>
|
|
1132
|
+
{body}
|
|
1133
|
+
{glossary}
|
|
1134
|
+
</main>
|
|
1135
|
+
{epnav}
|
|
1136
|
+
<p class="tohome"><a href="{site}">← All episodes on {show}</a></p>
|
|
1137
|
+
<footer>
|
|
1138
|
+
<span class="sig"><b>{show}</b> — a daily briefing on physical commodity trading.</span>
|
|
1139
|
+
<span><a href="{site}">The show</a> · <a href="{feed}">RSS</a></span>
|
|
1140
|
+
</footer>
|
|
1141
|
+
</div>
|
|
1142
|
+
<script>{js}</script>
|
|
1143
|
+
</body>
|
|
1144
|
+
</html>
|
|
1145
|
+
"""
|
|
1146
|
+
|
|
1147
|
+
FAVICON = ("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
|
|
1148
|
+
"viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' "
|
|
1149
|
+
"fill='%231d4032'/%3E%3Ctext x='32' y='44' font-family='Georgia,serif' "
|
|
1150
|
+
"font-size='34' fill='%23c9a45c' text-anchor='middle'%3ES%3C/text%3E%3C/svg%3E")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def main():
|
|
1154
|
+
global SEARCH_NEAR
|
|
1155
|
+
ap = argparse.ArgumentParser()
|
|
1156
|
+
ap.add_argument("--notes", required=True)
|
|
1157
|
+
ap.add_argument("--number", type=int, required=True)
|
|
1158
|
+
ap.add_argument("--title", required=True)
|
|
1159
|
+
ap.add_argument("--dek", default="")
|
|
1160
|
+
ap.add_argument("--audio-url", required=True)
|
|
1161
|
+
ap.add_argument("--duration", type=int, required=True, help="seconds")
|
|
1162
|
+
ap.add_argument("--date", default="")
|
|
1163
|
+
ap.add_argument("--out", required=True)
|
|
1164
|
+
ap.add_argument("--charts-prefix",
|
|
1165
|
+
help="also write each chart as a PNG for the e-mail, "
|
|
1166
|
+
"named <prefix>1.png, <prefix>2.png, ...")
|
|
1167
|
+
ap.add_argument("--covered", default="",
|
|
1168
|
+
help="covered.md, for previous/next links and the archive. "
|
|
1169
|
+
"Found automatically in the working directory; pass "
|
|
1170
|
+
"'none' to leave the page without episode navigation.")
|
|
1171
|
+
ap.add_argument("--glossary", default="",
|
|
1172
|
+
help="glossary.md, rendered on the page in a collapsed "
|
|
1173
|
+
"section. Found automatically; 'none' to omit it.")
|
|
1174
|
+
args = ap.parse_args()
|
|
1175
|
+
|
|
1176
|
+
SEARCH_NEAR = args.notes
|
|
1177
|
+
md = open(args.notes, encoding="utf-8").read()
|
|
1178
|
+
# Drop the notes' own H1/H2 title block: the page has a proper header.
|
|
1179
|
+
md = re.sub(r"\A\s*#\s+.*?\n(##\s+.*?\n)?", "", md, count=1)
|
|
1180
|
+
md = re.sub(r"^\*A daily 10-minute briefing.*$", "", md, flags=re.MULTILINE)
|
|
1181
|
+
# the notes rule off under their title block; the page's masthead does that
|
|
1182
|
+
md = re.sub(r"\A\s*(-{3,}|\*{3,})\s*$", "", md, count=1, flags=re.MULTILINE)
|
|
1183
|
+
body = tidy_rules(fold_solutions(number_questions(render(strip_spoiler_banner(md)))))
|
|
1184
|
+
|
|
1185
|
+
covered = {} if args.covered == "none" else load_covered(
|
|
1186
|
+
find_file(args.covered, "covered.md"))
|
|
1187
|
+
terms = [] if args.glossary == "none" else load_glossary(
|
|
1188
|
+
find_file(args.glossary, "glossary.md", "glossary_final.md"))
|
|
1189
|
+
|
|
1190
|
+
mins, secs = divmod(args.duration, 60)
|
|
1191
|
+
dek = args.dek.strip()
|
|
1192
|
+
iso = iso_date(args.date)
|
|
1193
|
+
# the glossary adds a heading, so it has to exist before the contents rail
|
|
1194
|
+
glossary_block = glossary_html(terms, args.number)
|
|
1195
|
+
toc_block = toc_html()
|
|
1196
|
+
page = TEMPLATE.format(
|
|
1197
|
+
number=args.number,
|
|
1198
|
+
show=SHOW,
|
|
1199
|
+
title=html.escape(args.title),
|
|
1200
|
+
dek_attr=html.escape(dek or f"Episode {args.number} of {SHOW}.", quote=True),
|
|
1201
|
+
dekblock=f'<p class="dek">{html.escape(dek)}</p>' if dek else "",
|
|
1202
|
+
date=html.escape(args.date),
|
|
1203
|
+
datesep=" · " if args.date else "",
|
|
1204
|
+
duration=f"{mins} min {secs:02d}",
|
|
1205
|
+
audio=html.escape(args.audio_url, quote=True),
|
|
1206
|
+
feed=FEED_URL,
|
|
1207
|
+
site=SITE_URL,
|
|
1208
|
+
page=page_url(args.number),
|
|
1209
|
+
cover=COVER_URL,
|
|
1210
|
+
favicon=FAVICON,
|
|
1211
|
+
author=html.escape(AUTHOR, quote=True),
|
|
1212
|
+
iso=iso,
|
|
1213
|
+
jsonld=jsonld(args, dek, iso, args.number),
|
|
1214
|
+
css=CSS + (chartlib.CHART_CSS if chartlib else ""),
|
|
1215
|
+
js=JS,
|
|
1216
|
+
headjs=HEAD_JS,
|
|
1217
|
+
toc=toc_block,
|
|
1218
|
+
glossary=glossary_block,
|
|
1219
|
+
epnav=epnav_html(covered, args.number),
|
|
1220
|
+
body=body,
|
|
1221
|
+
)
|
|
1222
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
1223
|
+
fh.write(page)
|
|
1224
|
+
print(f"[page] {args.out} | {len(page):,} bytes")
|
|
1225
|
+
|
|
1226
|
+
if CHART_SPECS and args.charts_prefix and chartlib:
|
|
1227
|
+
for n, spec in enumerate(CHART_SPECS, 1):
|
|
1228
|
+
path = f"{args.charts_prefix}{n}.png"
|
|
1229
|
+
if chartlib.to_png(spec, path):
|
|
1230
|
+
print(f"CHART_PNG={path}")
|
|
1231
|
+
elif CHART_SPECS:
|
|
1232
|
+
print(f"[page] {len(CHART_SPECS)} chart(s) rendered inline")
|
|
1233
|
+
return 0
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
if __name__ == "__main__":
|
|
1237
|
+
sys.exit(main())
|