pgn-postmortem 0.1.0__py3-none-any.whl

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.
pgn_postmortem/site.py ADDED
@@ -0,0 +1,1544 @@
1
+ """The static site: one Wikipedia-style article per game, and an index of the
2
+ games by year.
3
+
4
+ The site is HTML and one stylesheet, with a small script inlined in every
5
+ page for the optional reading history (below), written to an output
6
+ directory::
7
+
8
+ index.html the games by year, each linking to its article
9
+ games/<date>-<id>.html one article per game, named like the game's PGN file
10
+ quiz.html the player's own critical moments, worst first
11
+ (below), when the site has a player
12
+ assets/style.css the layout, the colours (light or dark, as the OS
13
+ says) and the one piece set every diagram uses
14
+
15
+ Every link within the site is relative and names a file (``../index.html``,
16
+ not ``../``), so the site works from a web server, from a folder copied to a
17
+ phone, and from ``file://``. The only absolute links are the two lichess
18
+ links of an article (below), which open in a new tab, and lichess is reached
19
+ only when the reader taps one. Nothing is loaded from the network. The only
20
+ JavaScript is the optional reading history (below), inlined in every page;
21
+ without it the pages read the same.
22
+
23
+ An article has an infobox (the players, the event, the result, the final
24
+ position), a lead paragraph, the moves with notes, a diagram and a question at
25
+ each critical moment, a conclusion and the game's PGN. All prose comes from
26
+ templates (the LLM is F-2 in ``ROADMAP.md``).
27
+
28
+ **The lichess links** (ROADMAP.md, F-10). Under the final position, the
29
+ infobox links "Open this game on lichess" to lichess's analysis board with
30
+ the game's moves, ``https://lichess.org/analysis/pgn/<moves>``: the mainline
31
+ in SAN without move numbers and without the check and mate signs (a ``+`` in
32
+ the path reads as a space), separated by spaces, URL-encoded
33
+ (``lichess_game_url``). A game that does not start from the standard position
34
+ (a ``FEN`` header that sets up another one) or has no moves has no game link.
35
+ Inside each critical moment's hidden answer, "Analyze this position on
36
+ lichess" links to ``https://lichess.org/analysis/<FEN>``, the question's
37
+ position (before the move), with the FEN's spaces written as ``_``
38
+ (``lichess_position_url``); it sits in the answer because an engine on the
39
+ question's position gives the answer away. Both links carry
40
+ ``target="_blank"`` and ``rel="noopener noreferrer"``, with or without the
41
+ reading history.
42
+
43
+ **Critical moments.** They come from the ``[%eval]`` comments the analysis
44
+ step writes (``pgn_postmortem.analysis``), and only from those: a game read
45
+ without the ``PostmortemAnalysis`` header has none, whatever comments its
46
+ source carried. For every move, the mover's winning chances before and after
47
+ it are the lichess win percentages (``analysis.win_percent``) of the
48
+ evaluation of the position before the move (the ``[%eval]`` of the previous
49
+ move) and after it (its own ``[%eval]``; a move that gives checkmate has none
50
+ and counts as 100 for the side that gave it). The difference is what the
51
+ move lost; it is graded with the analysis step's thresholds
52
+ (``analysis.classify``: 10/20/30 points by default, lichess's), so the grades
53
+ here are the NAGs the analysis step wrote. **A critical moment is a move that
54
+ lost at least ``thresholds.mistake`` points (20 by default), a mistake or a
55
+ blunder, or an outcome swing** (below). Other graded moves get a note but no
56
+ diagram. The first move of a game has no evaluation before it (the analysis
57
+ step does not write the start position's), so it is never graded; from the
58
+ standard start no single move loses that much.
59
+
60
+ **Outcome swings** (ROADMAP.md, F-6). Each analyzed position gets an
61
+ *expected outcome* from White's winning chances after the move: White winning
62
+ at ``outcome_bands[1]`` (60 by default) or more, Black winning at
63
+ ``outcome_bands[0]`` (40) or less, level in between. A move is an outcome
64
+ swing when all three hold:
65
+
66
+ 1. it makes the expected outcome worse for the side that played it (winning
67
+ to level, level to losing, or winning to losing);
68
+ 2. it cost that side at least ``thresholds.inaccuracy`` points (10 by
69
+ default), so every swing is a graded move;
70
+ 3. a better move exists: the engine's first choice in the position before
71
+ the move differs from the move played. The first choice is the first move
72
+ of any engine line stored at that position: the move's own better line,
73
+ or the previous move's refutation, which is the engine's line from the
74
+ same position. With no line stored there the move is not a swing either.
75
+ With the thresholds the analysis used, no line there means the engine's
76
+ first choice was the move played (the analysis stores a graded move's
77
+ better line whenever the engine's first choice differs); with a lower
78
+ inaccuracy threshold than the analysis used, a move graded between the
79
+ two thresholds may have no line of its own, and then nothing is known
80
+ about a better move. Either way there is no better move to show.
81
+
82
+ A swing is a critical moment even when it cost less than
83
+ ``thresholds.mistake``; a move that is both is one moment. The note on a
84
+ swing, in the moves and in the answer, says how the expected result changed
85
+ ("an inaccuracy that turned a level game into a losing one"). A move that
86
+ changed the band without meeting all three conditions gets no such words.
87
+
88
+ At each critical moment the article shows the position before the move, from
89
+ the side of the player to move, and asks "What would you play?"; the answer
90
+ (the engine's better move and line, then what the move played allowed) is in a
91
+ ``<details>`` element, hidden until tapped. The engine lines are the
92
+ variations the analysis step attached: the best line from the position before
93
+ the move, and the refutation after it.
94
+
95
+ **Results that were not recorded** (ROADMAP.md, F-5). For a game whose
96
+ ``Result`` is ``*`` or missing, the site shows the result ``shown_result``
97
+ gives, in this order: the board's, if the final position is checkmate (the
98
+ mating side wins), stalemate or insufficient material (a draw); else, for an
99
+ analyzed game whose final move carries an ``[%eval]``, a win for the side
100
+ with at least ``presume_threshold`` (70 by default, 55 to 95) winning chances
101
+ by ``analysis.win_percent`` (a forced mate counts as 100), and a draw
102
+ otherwise; else the site says the result was not recorded. The result is
103
+ shown like a recorded one, with no marker, everywhere outside the PGN section
104
+ (the infobox, the lead, the end of the moves, the conclusion, the index).
105
+ The game itself is untouched: its ``Result`` header, so its id and its file
106
+ name, and the article's PGN section stay as the source had them. A recorded
107
+ result is always shown as recorded.
108
+
109
+ **The quiz** (ROADMAP.md, F-9). ``build_site`` takes the player's name and
110
+ aliases (``player``, ``aliases``), matched as ``Collection.read`` matches them:
111
+ letter case and surrounding spaces are ignored. A site without a player (no
112
+ name given, or only blank ones) has no quiz page and no link to it, since no
113
+ move is the player's own; a rebuild without a player removes the
114
+ ``quiz.html`` an earlier build wrote (it carries the generator marker,
115
+ ``GENERATOR``), and never a ``quiz.html`` without it. With a player, the
116
+ index links at its top to ``quiz.html``, which lists every critical moment
117
+ (the rules above) played by the player, in every game where White or Black
118
+ is one of the player's names (both sides when both are), and none of the
119
+ opponents'. They are ordered by the winning chances the move lost (the
120
+ exact value, not the rounded one shown), the most first; ties go by the
121
+ game's position in the index, then the article's file name, then move
122
+ order. Each line shows its rank, the move played (``30... Rd2``), the points
123
+ it lost and the game (its date and the opponent), and links to the
124
+ question in its article (``games/<file>.html#moment-N``); the page shows no
125
+ diagram and no answer. A player without a critical moment of their own gets
126
+ a quiz page that says so.
127
+
128
+ **The reading history** (ROADMAP.md, F-8). Every page carries, inline and
129
+ byte for byte, the script ``pgn_postmortem/static/history.js`` (package
130
+ data). In the reader's browser only (``localStorage``, never sent anywhere)
131
+ it records a game as viewed when its article is opened, and an answer as
132
+ revealed when its ``<details class="answer">`` is opened, keyed by its move
133
+ (``31b``: Black's 31st), not by the moment's number, which changes when the
134
+ rules for critical moments do. The index shows a "Recently viewed" list (the
135
+ latest 10 of its own games, newest first), a mark on each viewed game with
136
+ how many of its current questions' answers were revealed ("2/4"), and a
137
+ "Clear history" button that removes this site's history after a
138
+ confirmation. The quiz page marks "answered" each question whose answer was
139
+ revealed, keeping the order; it has no button of its own, and the index's
140
+ clears its marks too. For it the pages carry these ``data-`` attributes and
141
+ no others: ``data-site`` on ``<html>`` (the site key), ``data-game`` on the
142
+ article (the ``PostmortemId``), ``data-move`` on each answer,
143
+ ``data-game`` and ``data-moves`` (the moves of its current questions) on
144
+ each game in the index, and ``data-game`` and ``data-move`` on each line of
145
+ the quiz. Every stored key starts with ``pgn-postmortem:`` and
146
+ the site key, which the pages carry so that sites sharing an origin (all
147
+ GitHub Pages sites of one user, or ``file://`` pages in some browsers) keep
148
+ separate histories; by default it is derived from the title
149
+ (``default_site_key``), so two sites with the same title share one. The
150
+ index's history section is in the HTML with the ``hidden`` attribute, and
151
+ only the script shows it; its styling is in the script too, added when it
152
+ shows something, so ``assets/style.css`` is the same with or without the
153
+ history. With scripts off, or with no storage, the pages read as they would
154
+ without the history. ``build_site(..., history=False)`` leaves out the
155
+ script, the section and the ``data-`` attributes.
156
+ """
157
+
158
+ from __future__ import annotations
159
+
160
+ import hashlib
161
+ import html
162
+ import re
163
+ import unicodedata
164
+ from collections.abc import Iterable
165
+ from dataclasses import dataclass, field
166
+ from importlib import resources
167
+ from pathlib import Path
168
+ from urllib.parse import quote
169
+
170
+ import chess
171
+ import chess.engine
172
+ import chess.pgn
173
+ import chess.svg
174
+
175
+ from pgn_postmortem.analysis import LICHESS_THRESHOLDS, MATE_SCORE, Thresholds, classify, win_percent
176
+ from pgn_postmortem.collection import (
177
+ ANALYSIS_HEADER,
178
+ CollectedGame,
179
+ Collection,
180
+ date_fields,
181
+ file_stem,
182
+ format_game,
183
+ is_number,
184
+ player_names,
185
+ strip_game,
186
+ )
187
+
188
+ GRADES = {
189
+ chess.pgn.NAG_DUBIOUS_MOVE: ("inaccuracy", "An inaccuracy", "?!"),
190
+ chess.pgn.NAG_MISTAKE: ("mistake", "A mistake", "?"),
191
+ chess.pgn.NAG_BLUNDER: ("blunder", "A blunder", "??"),
192
+ }
193
+ POSITION_SYMBOLS = {10: "=", 13: "∞", 14: "⩲", 15: "⩱", 16: "±", 17: "∓", 18: "+−", 19: "−+"}
194
+ RESULTS = {"1-0": "1–0", "0-1": "0–1", "1/2-1/2": "½–½"}
195
+ NOT_RECORDED = "*" # the PGN result of a game in progress or with an unknown result
196
+ PRESUME_THRESHOLD = 70.0 # the winning chances (%) that make an unrecorded result a win (owner, 2026-09-24)
197
+ PRESUME_THRESHOLD_RANGE = (55.0, 95.0)
198
+ # White's winning chances (%) at or below which Black is winning, and at or above which White is. The owner
199
+ # changed the shaping's 35/65 to 40/60 on 2026-09-25, so that the example game's 15. Nxc5 (47% -> 37%) is a question.
200
+ OUTCOME_BANDS = (40.0, 60.0)
201
+ OUTCOMES = ("losing", "level", "winning") # the expected outcome for one side, from worst to best
202
+ CHANGES = {
203
+ ("winning", "level"): "a winning game into a level one",
204
+ ("level", "losing"): "a level game into a losing one",
205
+ ("winning", "losing"): "a winning game into a losing one",
206
+ }
207
+ MONTHS = [
208
+ "January", "February", "March", "April", "May", "June",
209
+ "July", "August", "September", "October", "November", "December",
210
+ ] # fmt: skip
211
+ NUMBER_WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
212
+ NBSP = "\u00a0" # between a move number and its move, so a line never breaks between them
213
+ GENERATOR = '<meta name="generator" content="pgn-postmortem">' # in every page; marks what a rebuild may delete
214
+ QUIZ_PAGE = "quiz.html" # the quiz list of the player's own mistakes (ROADMAP.md, F-9)
215
+ SITE_KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") # the reading history's site key (ROADMAP.md, F-8)
216
+ LICHESS_ANALYSIS = "https://lichess.org/analysis/" # lichess's analysis board (ROADMAP.md, F-10)
217
+ NEW_TAB = 'target="_blank" rel="noopener noreferrer"' # the lichess links open in a new tab, so the book stays open
218
+
219
+
220
+ # --- reading the analysis ---------------------------------------------------
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class MoveReview:
225
+ """One mainline move and what the analysis says about it. Without
226
+ analysis (or for the first move) ``before``/``after`` are None and the
227
+ move is not graded."""
228
+
229
+ node: chess.pgn.ChildNode
230
+ before: float | None # the mover's winning chances (0-100) before the move
231
+ after: float | None # and after it
232
+ score_before: chess.engine.PovScore | None
233
+ score_after: chess.engine.PovScore | None
234
+ grade: int | None # NAG_DUBIOUS_MOVE, NAG_MISTAKE, NAG_BLUNDER or None
235
+ critical: bool
236
+ # the mover's expected outcome before and after the move ("losing", "level", "winning"), when analyzed
237
+ outcomes: tuple[str, str] | None = None
238
+ swing: bool = False # an outcome swing (the rule is in the module docstring)
239
+
240
+ @property
241
+ def mover(self) -> chess.Color:
242
+ return not self.node.turn()
243
+
244
+ @property
245
+ def board_before(self) -> chess.Board:
246
+ return self.node.parent.board()
247
+
248
+ @property
249
+ def symbol(self) -> str:
250
+ return GRADES[self.grade][2] if self.grade else ""
251
+
252
+ @property
253
+ def change(self) -> str:
254
+ """``turned a level game into a losing one`` for a swing, else nothing."""
255
+ return f"turned {CHANGES[self.outcomes]}" if self.swing else ""
256
+
257
+ @property
258
+ def loss(self) -> float:
259
+ """The winning chances the move cost the mover, exactly as graded
260
+ (0 when it gained, or without analysis)."""
261
+ if self.before is None or self.after is None:
262
+ return 0.0
263
+ return max(self.before - self.after, 0.0)
264
+
265
+
266
+ def is_analyzed(game: chess.pgn.Game) -> bool:
267
+ """Whether the game carries the analysis step's marker header."""
268
+ return ANALYSIS_HEADER in game.headers
269
+
270
+
271
+ def white_cp(node: chess.pgn.ChildNode) -> tuple[int | None, chess.engine.PovScore | None]:
272
+ """The White-POV evaluation after ``node`` in centipawns (a mate as
273
+ ``MATE_SCORE`` minus its length, as the analysis step counts it), and the
274
+ score it came from; a checkmate counts as a full mate for the side that
275
+ gave it."""
276
+ score = node.eval()
277
+ if score is not None:
278
+ return score.white().score(mate_score=MATE_SCORE), score
279
+ if node.board().is_checkmate():
280
+ return (MATE_SCORE if node.turn() == chess.BLACK else -MATE_SCORE), None
281
+ return None, None
282
+
283
+
284
+ def check_outcome_bands(bands: object) -> tuple[float, float]:
285
+ """``bands`` as ``(lower, upper)``; raise ``ValueError`` unless both are
286
+ finite numbers with 0 < lower < 50 < upper < 100."""
287
+ try:
288
+ lower, upper = bands
289
+ except (TypeError, ValueError):
290
+ lower = upper = None
291
+ numbers = all(isinstance(value, int | float) and not isinstance(value, bool) for value in (lower, upper))
292
+ if not (numbers and 0 < lower < 50 < upper < 100): # also rejects NaN and infinities
293
+ raise ValueError(
294
+ "outcome_bands must be two finite numbers (lower, upper), White's winning chances in percent, "
295
+ f"with 0 < lower < 50 < upper < 100; not {bands!r}"
296
+ )
297
+ return float(lower), float(upper)
298
+
299
+
300
+ def expected_outcome(white: float, bands: tuple[float, float], color: chess.Color) -> str:
301
+ """``winning``, ``level`` or ``losing`` for ``color``, from White's winning
302
+ chances ``white``: White is winning at ``bands[1]`` or more, Black at
303
+ ``bands[0]`` or less."""
304
+ lower, upper = bands
305
+ leader = chess.WHITE if white >= upper else chess.BLACK if white <= lower else None
306
+ return "level" if leader is None else "winning" if leader == color else "losing"
307
+
308
+
309
+ def has_better_move(node: chess.pgn.ChildNode) -> bool:
310
+ """Whether the engine's first choice before ``node``'s move differs from
311
+ it: some engine line is stored at the position before the move, and none
312
+ starts with the move played (the rule is in the module docstring)."""
313
+ first_choices = {line.move for line in node.parent.variations[1:]}
314
+ return bool(first_choices) and node.move not in first_choices
315
+
316
+
317
+ def review_moves(
318
+ game: chess.pgn.Game,
319
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
320
+ outcome_bands: tuple[float, float] = OUTCOME_BANDS,
321
+ ) -> list[MoveReview]:
322
+ """Every mainline move with its grade, its expected outcomes, and whether
323
+ it is an outcome swing and a critical moment (the rules are in the module
324
+ docstring). An invalid ``outcome_bands`` raises ``ValueError``."""
325
+ bands = check_outcome_bands(outcome_bands)
326
+ analyzed = is_analyzed(game)
327
+ reviews = []
328
+ cp_before: int | None = None
329
+ score_before: chess.engine.PovScore | None = None
330
+ for node in game.mainline():
331
+ cp_after, score_after = white_cp(node) if analyzed else (None, None)
332
+ before = after = outcomes = None
333
+ grade, critical, swing = None, False, False
334
+ if cp_before is not None and cp_after is not None:
335
+ mover = not node.turn()
336
+ white_before, white_after = win_percent(cp_before), win_percent(cp_after)
337
+ before, after = white_before, white_after
338
+ if mover == chess.BLACK:
339
+ before, after = 100 - before, 100 - after
340
+ loss = max(before - after, 0.0)
341
+ grade = classify(loss, thresholds)
342
+ # the bands are read from White's chances as computed, so an edge set to a position's exact value holds
343
+ outcomes = (expected_outcome(white_before, bands, mover), expected_outcome(white_after, bands, mover))
344
+ # With the clamped ``loss`` at the floor the mover's chances fell, so its band can only stay or
345
+ # worsen: the first condition overlaps the second. Both are kept, as the rule states them.
346
+ swing = (
347
+ OUTCOMES.index(outcomes[1]) < OUTCOMES.index(outcomes[0])
348
+ and loss >= thresholds.inaccuracy
349
+ and has_better_move(node)
350
+ )
351
+ critical = loss >= thresholds.mistake or swing
352
+ reviews.append(MoveReview(node, before, after, score_before, score_after, grade, critical, outcomes, swing))
353
+ cp_before, score_before = cp_after, score_after
354
+ return reviews
355
+
356
+
357
+ def critical_moments(
358
+ game: chess.pgn.Game,
359
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
360
+ outcome_bands: tuple[float, float] = OUTCOME_BANDS,
361
+ ) -> list[MoveReview]:
362
+ """The game's critical moments, in move order: the moves that lost the
363
+ mover at least ``thresholds.mistake`` points of winning chances, and the
364
+ outcome swings."""
365
+ return [review for review in review_moves(game, thresholds, outcome_bands) if review.critical]
366
+
367
+
368
+ # --- the result -----------------------------------------------------------------
369
+
370
+
371
+ def check_presume_threshold(threshold: float) -> None:
372
+ """Raise ``ValueError`` unless ``threshold`` is from 55 to 95."""
373
+ low, high = PRESUME_THRESHOLD_RANGE
374
+ if not low <= threshold <= high: # also rejects NaN
375
+ raise ValueError(
376
+ f"presume_threshold must be from {low:g} to {high:g} (a side's winning chances in percent), "
377
+ f"not {threshold!r}"
378
+ )
379
+
380
+
381
+ def final_white_chances(game: chess.pgn.Game) -> float | None:
382
+ """White's winning chances (0-100) in the final position of an analyzed
383
+ game, from the ``[%eval]`` of its last move (a forced mate counts as 100
384
+ for the side with the mate), or None: not analyzed, or no eval there."""
385
+ if not is_analyzed(game):
386
+ return None
387
+ score = game.end().eval()
388
+ if score is None:
389
+ return None
390
+ white = score.white()
391
+ if white.is_mate():
392
+ return 100.0 if white > chess.engine.Cp(0) else 0.0
393
+ return win_percent(white.score())
394
+
395
+
396
+ def shown_result(game: chess.pgn.Game, presume_threshold: float = PRESUME_THRESHOLD) -> str:
397
+ """The result the site shows for ``game``, in PGN notation: its
398
+ ``Result`` header when one is recorded; otherwise the board's result,
399
+ else the result presumed from the analysis, else ``*`` (not recorded).
400
+ The rule is in the module docstring. ``game`` is not changed."""
401
+ check_presume_threshold(presume_threshold)
402
+ recorded = (game.headers.get("Result") or NOT_RECORDED).strip()
403
+ if recorded not in ("", NOT_RECORDED):
404
+ return recorded
405
+ board = game.end().board()
406
+ if board.is_checkmate():
407
+ return "0-1" if board.turn == chess.WHITE else "1-0"
408
+ if board.is_stalemate() or board.is_insufficient_material():
409
+ return "1/2-1/2"
410
+ white = final_white_chances(game)
411
+ if white is None:
412
+ return NOT_RECORDED
413
+ if white >= presume_threshold:
414
+ return "1-0"
415
+ if 100 - white >= presume_threshold:
416
+ return "0-1"
417
+ return "1/2-1/2"
418
+
419
+
420
+ def engine_line(node: chess.pgn.GameNode) -> tuple[list[chess.Move], str] | None:
421
+ """The first engine line the analysis step attached off ``node`` (a
422
+ variation, not the mainline), and the symbol of the position it ends in."""
423
+ if len(node.variations) < 2:
424
+ return None
425
+ start = node.variations[1]
426
+ moves = [start.move, *start.mainline_moves()]
427
+ last = start.end()
428
+ symbol = next((POSITION_SYMBOLS[nag] for nag in sorted(last.nags) if nag in POSITION_SYMBOLS), "")
429
+ return moves, symbol
430
+
431
+
432
+ # --- text helpers -----------------------------------------------------------
433
+
434
+
435
+ def esc(text: object) -> str:
436
+ """Text for an element's content."""
437
+ return html.escape(str(text), quote=False)
438
+
439
+
440
+ def attr(text: object) -> str:
441
+ """Text for a quoted attribute value."""
442
+ return html.escape(str(text), quote=True)
443
+
444
+
445
+ def known(value: str | None) -> str | None:
446
+ value = (value or "").strip()
447
+ return None if value in ("", "?", "-", "??", "????.??.??") else value
448
+
449
+
450
+ def display_name(name: str | None) -> str:
451
+ """A player's name as prose wants it: PGN's ``Last, First`` becomes
452
+ ``First Last``."""
453
+ name = known(name)
454
+ if name is None:
455
+ return "Unknown"
456
+ if name.count(",") == 1:
457
+ last, first = (part.strip() for part in name.split(","))
458
+ if last and first:
459
+ return f"{first} {last}"
460
+ return name
461
+
462
+
463
+ def number_word(n: int) -> str:
464
+ return NUMBER_WORDS[n] if 0 <= n < len(NUMBER_WORDS) else str(n)
465
+
466
+
467
+ def plural(n: int, word: str, words: str | None = None) -> str:
468
+ """``one move``, ``two moves``; a consonant and ``y`` become ``ies``
469
+ (``two inaccuracies``)."""
470
+ if words is None:
471
+ words = word[:-1] + "ies" if word.endswith("y") and word[-2:-1] not in ("", *"aeiou") else word + "s"
472
+ return f"{number_word(n)} {word if n == 1 else words}"
473
+
474
+
475
+ def date_parts(date: str | None) -> tuple[int | None, int | None, int | None]:
476
+ """The year, month and day as numbers, each None unless it is ASCII digits
477
+ (as ``file_stem`` reads them) and, for the month and day, in range."""
478
+ y, m, d = date_fields(date or "")
479
+ year = int(y) if is_number(y) else None
480
+ month = int(m) if is_number(m) and 1 <= int(m) <= 12 else None
481
+ day = int(d) if is_number(d) and 1 <= int(d) <= 31 and month else None
482
+ return year, month, day
483
+
484
+
485
+ def format_date(date: str | None) -> str | None:
486
+ """``2019.03.14`` as ``14 March 2019``; a partial date as far as it goes."""
487
+ year, month, day = date_parts(date)
488
+ if year is None:
489
+ return None
490
+ if month is None:
491
+ return str(year)
492
+ return f"{day} {MONTHS[month - 1]} {year}" if day else f"{MONTHS[month - 1]} {year}"
493
+
494
+
495
+ def format_time_control(value: str | None) -> str | None:
496
+ """``600`` as ``10 min``, ``180+2`` as ``3 min + 2 s``; anything else as written."""
497
+ value = known(value)
498
+ if value is None:
499
+ return None
500
+ match = re.fullmatch(r"(\d+)(?:\+(\d+))?", value)
501
+ if not match:
502
+ return value
503
+ base, inc = int(match[1]), match[2]
504
+ text = f"{base // 60} min" if base % 60 == 0 else f"{base} s"
505
+ return f"{text} + {int(inc)} s" if inc and int(inc) else text
506
+
507
+
508
+ def move_label(board: chess.Board, move: chess.Move, symbol: str = "") -> str:
509
+ """``4. Qxf7#`` or ``3... Nf6``: the move with its number, from ``board``."""
510
+ dots = "." if board.turn == chess.WHITE else "..."
511
+ return f"{board.fullmove_number}{dots}{NBSP}{board.san(move)}{symbol}"
512
+
513
+
514
+ def line_text(board: chess.Board, moves: list[chess.Move]) -> str:
515
+ """A line of moves in SAN with move numbers, from ``board``."""
516
+ board = board.copy()
517
+ parts = []
518
+ for i, move in enumerate(moves):
519
+ if board.turn == chess.WHITE:
520
+ parts.append(f"{board.fullmove_number}.{NBSP}{board.san(move)}")
521
+ elif i == 0:
522
+ parts.append(f"{board.fullmove_number}...{NBSP}{board.san(move)}")
523
+ else:
524
+ parts.append(board.san(move))
525
+ board.push(move)
526
+ return " ".join(parts)
527
+
528
+
529
+ def format_eval(score: chess.engine.PovScore) -> str:
530
+ white = score.white()
531
+ if white.is_mate():
532
+ mate = white.mate()
533
+ return f"#{mate}" if mate > 0 else f"#−{-mate}"
534
+ return f"{white.score() / 100:+.2f}".replace("-", "−")
535
+
536
+
537
+ def symbol_html(symbol: str) -> str:
538
+ """A position symbol (``+−``) after a line, kept on the line's last line."""
539
+ return f'{NBSP}<span class="sym">{symbol}</span>' if symbol else ""
540
+
541
+
542
+ def result_text(result: str, not_recorded: str) -> str:
543
+ """``1-0`` as ``1–0``; ``*`` as the words ``not_recorded``, never a bare
544
+ ``*``; any other value as written."""
545
+ return RESULTS.get(result, not_recorded if result == NOT_RECORDED else result)
546
+
547
+
548
+ def percent(value: float) -> str:
549
+ return f"{value:.0f}%"
550
+
551
+
552
+ def side(color: chess.Color) -> str:
553
+ return "White" if color == chess.WHITE else "Black"
554
+
555
+
556
+ def moves_played(game: chess.pgn.Game) -> int:
557
+ board = game.end().board()
558
+ return board.fullmove_number - (1 if board.turn == chess.WHITE else 0)
559
+
560
+
561
+ def mate_clause(review: MoveReview) -> str:
562
+ """``it allows mate in 2``, ``it lets a mate in 3 slip``, or nothing."""
563
+ mover = review.mover
564
+ before = review.score_before.pov(mover).mate() if review.score_before else None
565
+ after = review.score_after.pov(mover).mate() if review.score_after else None
566
+ if after is not None and after < 0 and not (before is not None and before < 0):
567
+ return f"it allows mate in {-after}"
568
+ if before is not None and before > 0 and not (after is not None and after > 0):
569
+ return f"it lets a mate in {before} slip"
570
+ return ""
571
+
572
+
573
+ def grade_words(review: MoveReview) -> str:
574
+ """``A mistake``, or for an outcome swing ``A mistake that turned a level
575
+ game into a losing one``."""
576
+ _, kind, _ = GRADES[review.grade]
577
+ return f"{kind} that {review.change}" if review.swing else kind
578
+
579
+
580
+ def note_text(review: MoveReview) -> str:
581
+ """The note after a graded move in the moves section."""
582
+ kind = grade_words(review)
583
+ clause = mate_clause(review)
584
+ chances = f"{side(review.mover)}'s winning chances fall from {percent(review.before)} to {percent(review.after)}"
585
+ return f"{kind}: {clause}; {chances}." if clause else f"{kind}: {chances}."
586
+
587
+
588
+ # --- the diagrams -------------------------------------------------------------
589
+
590
+
591
+ PIECE_CLASSES = {symbol: ("w" if symbol.isupper() else "b") + symbol.upper() for symbol in chess.svg.PIECES}
592
+
593
+
594
+ def piece_css() -> str:
595
+ """One CSS rule per piece, each an SVG data URI: the one piece set every
596
+ diagram shares (Colin M.L. Burnett's, as python-chess ships it)."""
597
+ rules = []
598
+ for symbol in "KQRBNPkqrbnp":
599
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45">{chess.svg.PIECES[symbol]}</svg>'
600
+ data = svg.replace('"', "'").replace("#", "%23").replace("<", "%3C").replace(">", "%3E")
601
+ rules.append(f'.{PIECE_CLASSES[symbol]}{{background-image:url("data:image/svg+xml,{data}")}}')
602
+ return "\n".join(rules) + "\n"
603
+
604
+
605
+ def describe_position(board: chess.Board) -> str:
606
+ """The pieces in words, for screen readers: ``White: Ke1, Qh5, pawns a2, b2``."""
607
+ parts = []
608
+ for color in (chess.WHITE, chess.BLACK):
609
+ pieces = []
610
+ for piece_type in (chess.KING, chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT):
611
+ letter = chess.piece_symbol(piece_type).upper()
612
+ pieces += [letter + chess.square_name(sq) for sq in board.pieces(piece_type, color)]
613
+ pawns = [chess.square_name(sq) for sq in board.pieces(chess.PAWN, color)]
614
+ if pawns:
615
+ pieces.append(("pawn " if len(pawns) == 1 else "pawns ") + ", ".join(pawns))
616
+ parts.append(f"{side(color)}: {', '.join(pieces) or 'nothing'}")
617
+ return ". ".join(parts) + "."
618
+
619
+
620
+ def board_html(board: chess.Board, *, flipped: bool = False, highlight: Iterable[int] = (), label: str = "") -> str:
621
+ """The position as an 8x8 grid of empty elements, one line per rank. Each
622
+ cell draws its own square: its colour (``l`` or ``d``), its piece (a class
623
+ of the shared stylesheet) and the highlight (``hl``), so they share one box
624
+ at any size; the board draws no square pattern of its own, which would
625
+ round differently from the cells when a square is not a whole number of
626
+ pixels. The coordinates are ``data-`` attributes on the edge squares."""
627
+ highlight = set(highlight)
628
+ ranks = range(8) if flipped else range(7, -1, -1)
629
+ files = list(range(7, -1, -1) if flipped else range(8))
630
+ rows = []
631
+ for row, rank in enumerate(ranks):
632
+ cells = []
633
+ for col, file in enumerate(files):
634
+ square = chess.square(file, rank)
635
+ classes = ["l" if chess.BB_LIGHT_SQUARES & chess.BB_SQUARES[square] else "d"]
636
+ piece = board.piece_at(square)
637
+ if piece:
638
+ classes.append(PIECE_CLASSES[piece.symbol()])
639
+ if square in highlight:
640
+ classes.append("hl")
641
+ attrs = f' class="{" ".join(classes)}"'
642
+ if col == 0:
643
+ attrs += f' data-r="{rank + 1}"'
644
+ if row == 7:
645
+ attrs += f' data-f="{chess.FILE_NAMES[file]}"'
646
+ cells.append(f"<i{attrs}></i>")
647
+ rows.append("".join(cells))
648
+ description = f"{label} {describe_position(board)}".strip()
649
+ return f'<div class="board" role="img" aria-label="{attr(description)}">\n' + "\n".join(rows) + "\n</div>"
650
+
651
+
652
+ # --- the lichess links (ROADMAP.md, F-10) --------------------------------------
653
+
654
+
655
+ def lichess_game_url(game: chess.pgn.Game) -> str | None:
656
+ """The game on lichess's analysis board: ``LICHESS_ANALYSIS`` ``pgn/``
657
+ and the mainline moves in SAN, without move numbers and without the check
658
+ and mate signs (``+`` in a path reads as a space), separated by spaces and
659
+ URL-encoded (``e4%20e5%20Qh5``). None for a game that does not start from
660
+ the standard position, or has no moves."""
661
+ board = game.board()
662
+ if board.fen() != chess.STARTING_FEN or game.next() is None:
663
+ return None
664
+ sans = []
665
+ for move in game.mainline_moves():
666
+ sans.append(board.san(move).rstrip("+#"))
667
+ board.push(move)
668
+ return f"{LICHESS_ANALYSIS}pgn/{quote(' '.join(sans), safe='')}"
669
+
670
+
671
+ def lichess_position_url(board: chess.Board) -> str:
672
+ """The position on lichess's analysis board: ``LICHESS_ANALYSIS`` and
673
+ its FEN, with the spaces written as ``_``."""
674
+ return LICHESS_ANALYSIS + quote(board.fen().replace(" ", "_"), safe="/")
675
+
676
+
677
+ def lichess_link(url: str, text: str) -> str:
678
+ return f'<a href="{attr(url)}" {NEW_TAB}>{esc(text)}</a>'
679
+
680
+
681
+ # --- the reading history ------------------------------------------------------
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class History:
686
+ """What the pages carry for the reading history: the site key, and the
687
+ script that every page inlines (the rule is in the module docstring)."""
688
+
689
+ site_key: str
690
+ script: str
691
+
692
+
693
+ HISTORY_SECTION = (
694
+ '<section id="history" class="history" hidden>\n'
695
+ "<h2>Recently viewed</h2>\n"
696
+ '<ol id="history-recent" class="games"></ol>\n'
697
+ '<p><button type="button" id="history-clear">Clear history</button> '
698
+ '<span class="note">Kept in this browser only.</span></p>\n'
699
+ "</section>\n"
700
+ )
701
+
702
+
703
+ def history_script() -> str:
704
+ """The reading-history script, ``pgn_postmortem/static/history.js``, as
705
+ its bytes read (package data)."""
706
+ return (resources.files("pgn_postmortem") / "static" / "history.js").read_bytes().decode("utf-8")
707
+
708
+
709
+ def default_site_key(title: str) -> str:
710
+ """The site key for a site titled ``title``: its words in ASCII, lower
711
+ case, joined by ``-`` (at most 40 characters), then the first 8 hex
712
+ digits of the title's SHA-256, so that only the same title gives the same
713
+ key: ``Games of Ada Example`` is ``games-of-ada-example-`` and 8 digits."""
714
+ ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii").lower()
715
+ words = "-".join(re.findall(r"[a-z0-9]+", ascii_title))[:40].strip("-")
716
+ digest = hashlib.sha256(title.encode("utf-8")).hexdigest()[:8]
717
+ return f"{words}-{digest}" if words else digest
718
+
719
+
720
+ def check_site_key(key: object) -> str:
721
+ """``key``, or ``ValueError`` unless it is 1 to 64 ASCII letters, digits,
722
+ ``.``, ``_`` and ``-``, starting with a letter or a digit."""
723
+ if not (isinstance(key, str) and SITE_KEY.fullmatch(key)):
724
+ raise ValueError(
725
+ "site_key must be 1 to 64 ASCII letters, digits, '.', '_' or '-', starting with a letter or a digit; "
726
+ f"not {key!r}"
727
+ )
728
+ return key
729
+
730
+
731
+ def data(history: History | None, **values: str) -> str:
732
+ """`` data-game="…"`` and the like for the reading history, or nothing
733
+ when the pages are built without it."""
734
+ if history is None:
735
+ return ""
736
+ return "".join(f' data-{name}="{attr(value)}"' for name, value in values.items())
737
+
738
+
739
+ # --- the pages ----------------------------------------------------------------
740
+
741
+
742
+ @dataclass
743
+ class Article:
744
+ """One game's page, with what the index and the navigation need."""
745
+
746
+ item: CollectedGame
747
+ reviews: list[MoveReview]
748
+ analyzed: bool
749
+ result: str # the result shown (``shown_result``): recorded, from the board, presumed, or "*"
750
+
751
+ @property
752
+ def game(self) -> chess.pgn.Game:
753
+ return self.item.game
754
+
755
+ @property
756
+ def stem(self) -> str:
757
+ return file_stem(self.game, self.item.id)
758
+
759
+ @property
760
+ def filename(self) -> str:
761
+ return f"{self.stem}.html"
762
+
763
+ @property
764
+ def white(self) -> str:
765
+ return display_name(self.game.headers.get("White"))
766
+
767
+ @property
768
+ def black(self) -> str:
769
+ return display_name(self.game.headers.get("Black"))
770
+
771
+ @property
772
+ def year(self) -> int | None:
773
+ return date_parts(self.game.headers.get("Date"))[0]
774
+
775
+ @property
776
+ def title(self) -> str:
777
+ return f"{self.white} vs. {self.black}" + (f", {self.year}" if self.year else "")
778
+
779
+ @property
780
+ def presumed(self) -> bool:
781
+ """Whether the result shown was not recorded but read from the board
782
+ or presumed from the analysis."""
783
+ return self.result != self.game.headers.get("Result", NOT_RECORDED)
784
+
785
+ @property
786
+ def moments(self) -> list[MoveReview]:
787
+ return [review for review in self.reviews if review.critical]
788
+
789
+
790
+ def page(title: str, body: str, *, root: str, site_title: str, history: History | None = None,
791
+ home: bool | None = None) -> str: # fmt: skip
792
+ """A whole page. ``home`` puts the link to the index at the top: by
793
+ default on every page but the index (``root`` is empty only there and on
794
+ the quiz page, which asks for it)."""
795
+ home = root != "" if home is None else home
796
+ home = f'<header class="top"><a href="{root}index.html">{esc(site_title)}</a></header>\n' if home else ""
797
+ return (
798
+ "<!DOCTYPE html>\n"
799
+ f'<html lang="en"{data(history, site=history.site_key) if history else ""}>\n'
800
+ "<head>\n"
801
+ '<meta charset="utf-8">\n'
802
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
803
+ '<meta name="color-scheme" content="light dark">\n'
804
+ f"{GENERATOR}\n"
805
+ f"<title>{esc(title)}</title>\n"
806
+ f'<link rel="stylesheet" href="{root}assets/style.css">\n'
807
+ "</head>\n"
808
+ "<body>\n"
809
+ f"{home}"
810
+ "<main>\n"
811
+ f"{body}"
812
+ "</main>\n"
813
+ '<footer class="bottom">Made with pgn-postmortem. Analysis by Stockfish; '
814
+ "pieces by Colin M.L. Burnett.</footer>\n"
815
+ + (f"<script>{history.script}</script>\n" if history else "")
816
+ + "</body>\n"
817
+ "</html>\n"
818
+ )
819
+
820
+
821
+ def lead_html(article: Article, thresholds: Thresholds) -> str:
822
+ headers = article.game.headers
823
+ date = format_date(headers.get("Date"))
824
+ site, event, round_ = known(headers.get("Site")), known(headers.get("Event")), known(headers.get("Round"))
825
+ where = ""
826
+ if date:
827
+ where += f" on {esc(date)}"
828
+ if site:
829
+ where += f" at {esc(site)}"
830
+ first = f"<b>{esc(article.white)} vs. {esc(article.black)}</b> is a chess game"
831
+ if where:
832
+ first += f" played{where}"
833
+ if event:
834
+ first += f", in round {esc(round_)} of “{esc(event)}”" if round_ else f", part of “{esc(event)}”"
835
+ if not date:
836
+ first += "; its date is not recorded"
837
+ sentences = [first + "."]
838
+
839
+ n = moves_played(article.game)
840
+ moves = plural(n, "move")
841
+ board = article.game.end().board()
842
+ result = article.result
843
+ if result in ("1-0", "0-1"):
844
+ winner = chess.WHITE if result == "1-0" else chess.BLACK
845
+ name = article.white if winner == chess.WHITE else article.black
846
+ how = "by checkmate " if board.is_checkmate() else ""
847
+ sentences.append(f"{esc(name)}, with the {side(winner).lower()} pieces, won {how}in {moves}.")
848
+ elif result == "1/2-1/2":
849
+ how = " by stalemate" if board.is_stalemate() else ""
850
+ sentences.append(f"It was drawn{how} after {moves}.")
851
+ else:
852
+ sentences.append(f"Its result is not recorded; the score stops after {moves}.")
853
+
854
+ opening, eco = known(headers.get("Opening")), known(headers.get("ECO"))
855
+ if opening:
856
+ sentences.append(f"The opening was the {esc(opening)}" + (f" (ECO {esc(eco)})." if eco else "."))
857
+ elif eco:
858
+ sentences.append(f"The opening is classified as ECO {esc(eco)}.")
859
+
860
+ threshold = f"{thresholds.mistake:g}"
861
+ k = len(article.moments)
862
+ if not article.analyzed:
863
+ sentences.append("It has not been analyzed yet, so this article has no notes, critical moments or questions.")
864
+ elif k:
865
+ sentences.append(
866
+ f"The engine found {plural(k, 'critical moment')}, where a single move cost at least {threshold} points "
867
+ f"of winning chances or changed the expected result; {'it is' if k == 1 else 'each is'} a “what would "
868
+ "you play?” question below."
869
+ )
870
+ else:
871
+ sentences.append(
872
+ "The engine found no critical moment: no single move cost either side "
873
+ f"{threshold} points of winning chances."
874
+ )
875
+ return f'<p class="lead">{" ".join(sentences)}</p>\n'
876
+
877
+
878
+ def infobox_html(article: Article) -> str:
879
+ headers = article.game.headers
880
+ end = article.game.end()
881
+ last = f"after {move_label(end.parent.board(), end.move)}" if end.move else ""
882
+
883
+ def player(color: str) -> str:
884
+ name = esc(article.white if color == "White" else article.black)
885
+ elo = known(headers.get(f"{color}Elo"))
886
+ return f"{name} ({esc(elo)})" if elo else name
887
+
888
+ rows = [
889
+ ("White", player("White")),
890
+ ("Black", player("Black")),
891
+ ("Result", esc(result_text(article.result, "Not recorded"))),
892
+ ("Date", esc(format_date(headers.get("Date")) or "Unknown")),
893
+ ]
894
+ for label, key in (("Event", "Event"), ("Round", "Round"), ("Site", "Site")):
895
+ if value := known(headers.get(key)):
896
+ rows.append((label, esc(value)))
897
+ if value := format_time_control(headers.get("TimeControl")):
898
+ rows.append(("Time control", esc(value)))
899
+ opening, eco = known(headers.get("Opening")), known(headers.get("ECO"))
900
+ if opening or eco:
901
+ rows.append(("Opening", esc(" ".join(filter(None, [eco, opening])))))
902
+ rows.append(("Moves", str(moves_played(article.game))))
903
+ if value := known(headers.get("Termination")):
904
+ rows.append(("Termination", esc(value)))
905
+ rows.append(("Analysis", esc(headers[ANALYSIS_HEADER]) if article.analyzed else "Not analyzed yet"))
906
+ if article.analyzed:
907
+ links = ", ".join(f'<a href="#moment-{i}">{i}</a>' for i in range(1, len(article.moments) + 1))
908
+ rows.append(("Critical moments", links or "None"))
909
+ # under the final position; none for a set-up position or a game without moves
910
+ game_url = lichess_game_url(article.game)
911
+ lichess = f'\n<div class="lichess">{lichess_link(game_url, "Open this game on lichess")}</div>' if game_url else ""
912
+
913
+ highlight = (end.move.from_square, end.move.to_square) if end.move else ()
914
+ board = board_html(end.board(), highlight=highlight, label=f"The final position{' ' + last if last else ''}.")
915
+ body = "\n".join(f"<tr><th>{label}</th><td>{value}</td></tr>" for label, value in rows)
916
+ return (
917
+ '<table class="infobox">\n'
918
+ f"<caption>{esc(article.title)}</caption>\n"
919
+ f'<tr><td colspan="2" class="figure">\n{board}\n'
920
+ f'<div class="caption">The final position{" " + esc(last) if last else ""}</div>{lichess}</td></tr>\n'
921
+ f"{body}\n"
922
+ "</table>\n"
923
+ )
924
+
925
+
926
+ def move_key(board: chess.Board) -> str:
927
+ """The move about to be played on ``board`` as the reading history keys
928
+ it: ``31b`` for Black's 31st move, ``4w`` for White's 4th."""
929
+ return f"{board.fullmove_number}{'w' if board.turn == chess.WHITE else 'b'}"
930
+
931
+
932
+ def moment_html(review: MoveReview, number: int, history: History | None = None) -> str:
933
+ board = review.board_before
934
+ mover = side(review.mover)
935
+ previous = review.node.parent
936
+ if previous.move is not None:
937
+ where = f"After {move_label(previous.parent.board(), previous.move)}"
938
+ highlight = (previous.move.from_square, previous.move.to_square)
939
+ else:
940
+ where = "The starting position"
941
+ highlight = ()
942
+ caption = f"{where}, {mover} to move."
943
+ diagram = board_html(board, flipped=review.mover == chess.BLACK, highlight=highlight, label=caption)
944
+
945
+ played = move_label(board, review.node.move, review.symbol)
946
+ best = engine_line(review.node.parent)
947
+ if best and best[0][0] != review.node.move:
948
+ moves, symbol = best
949
+ answer = (
950
+ f"<p><b>Best was {esc(move_label(board, moves[0]))}</b>, the engine's line being "
951
+ f"{esc(line_text(board, moves))}{symbol_html(symbol)}. "
952
+ f"With it, {mover}'s winning chances stay at {percent(review.before)}.</p>\n"
953
+ )
954
+ else:
955
+ answer = f"<p>The analysis records no better move than {esc(played)}.</p>\n"
956
+ kind = grade_words(review)
957
+ clause = mate_clause(review)
958
+ answer += (
959
+ f"<p>In the game {mover} played <b>{esc(played)}</b>, {kind.lower()}"
960
+ + (f": {clause}. " if clause else ". ")
961
+ + f"{mover}'s winning chances fell from {percent(review.before)} to {percent(review.after)}."
962
+ )
963
+ refutation = engine_line(review.node)
964
+ if refutation:
965
+ moves, symbol = refutation
966
+ answer += f" The refutation: {esc(line_text(review.node.board(), moves))}{symbol_html(symbol)}."
967
+ answer += "</p>\n"
968
+ # inside the hidden answer, since an engine on the question's position gives the answer away
969
+ position = lichess_link(lichess_position_url(board), "Analyze this position on lichess")
970
+ answer += f'<p class="lichess">{position}</p>\n'
971
+ return (
972
+ f'<div class="moment" id="moment-{number}">\n'
973
+ "<figure>\n"
974
+ f"{diagram}\n"
975
+ f"<figcaption>Critical moment {number}. {esc(caption)} <b>What would you play?</b></figcaption>\n"
976
+ "</figure>\n"
977
+ f'<details class="answer"{data(history, move=move_key(board))}>\n'
978
+ "<summary>Show the answer</summary>\n"
979
+ f"{answer}"
980
+ "</details>\n"
981
+ "</div>\n"
982
+ )
983
+
984
+
985
+ def moves_html(article: Article, history: History | None = None) -> str:
986
+ """The moves as paragraphs, with a note after each graded move, broken
987
+ by a diagram and a question before each critical moment."""
988
+ blocks: list[str] = []
989
+ tokens: list[str] = []
990
+ need_number = True
991
+ number = 0
992
+
993
+ def flush() -> None:
994
+ if tokens:
995
+ blocks.append(f'<p class="moves">{" ".join(tokens)}</p>\n')
996
+ tokens.clear()
997
+
998
+ for review in article.reviews:
999
+ if review.critical:
1000
+ flush()
1001
+ number += 1
1002
+ blocks.append(moment_html(review, number, history))
1003
+ need_number = True
1004
+ board = review.board_before
1005
+ san = esc(board.san(review.node.move) + review.symbol)
1006
+ if board.turn == chess.WHITE:
1007
+ token = f"{board.fullmove_number}.{NBSP}{san}"
1008
+ elif need_number:
1009
+ token = f"{board.fullmove_number}...{NBSP}{san}"
1010
+ else:
1011
+ token = san
1012
+ need_number = False
1013
+ if review.grade:
1014
+ token = f"<b>{token}</b> <span class=\"note\">({esc(note_text(review))})</span>"
1015
+ need_number = True
1016
+ tokens.append(token)
1017
+ tokens.append(esc(result_text(article.result, "(result not recorded)")))
1018
+ flush()
1019
+ return "".join(blocks)
1020
+
1021
+
1022
+ def conclusion_html(article: Article) -> str:
1023
+ game = article.game
1024
+ end = game.end()
1025
+ board = end.board()
1026
+ last = esc(move_label(end.parent.board(), end.move)) if end.move else "the start"
1027
+ result = article.result
1028
+ sentences = []
1029
+ winner = {"1-0": chess.WHITE, "0-1": chess.BLACK}.get(result)
1030
+ if board.is_checkmate():
1031
+ name = article.white if not board.turn == chess.WHITE else article.black
1032
+ sentences.append(f"{esc(name)} delivered checkmate with {last}.")
1033
+ elif board.is_stalemate():
1034
+ sentences.append(f"The game ended in stalemate after {last}.")
1035
+ elif board.is_insufficient_material():
1036
+ sentences.append(f"The game ended after {last}, with too little material left on the board for a mate.")
1037
+ else:
1038
+ if result == NOT_RECORDED:
1039
+ sentences.append(f"The score stops after {last}; the result was not recorded.")
1040
+ else:
1041
+ sentences.append(f"The game ended {esc(RESULTS.get(result, result))} after {last}.")
1042
+ final = article.reviews[-1] if article.reviews else None
1043
+ cp = white_cp(final.node)[0] if (final and article.analyzed) else None
1044
+ if cp is not None:
1045
+ white_chances = win_percent(cp)
1046
+ if winner is None:
1047
+ sentences.append(
1048
+ f"The engine gives White {percent(white_chances)} winning chances in the final position."
1049
+ )
1050
+ else:
1051
+ name = esc(article.white if winner == chess.WHITE else article.black)
1052
+ chances = white_chances if winner == chess.WHITE else 100 - white_chances
1053
+ if chances >= 60:
1054
+ sentences.append(f"{name} was clearly ahead: the engine gives {percent(chances)} winning chances.")
1055
+ elif article.presumed: # a win presumed from these chances, so from the position itself
1056
+ sentences.append(
1057
+ f"{name} was ahead: the engine gives {percent(chances)} winning chances in the final position."
1058
+ )
1059
+ else:
1060
+ sentences.append(
1061
+ f"The engine gives {name} only {percent(chances)} winning chances in the final position, "
1062
+ "so the result came from outside the position on the board: a resignation, the clock or "
1063
+ "an adjudication."
1064
+ )
1065
+ if termination := known(game.headers.get("Termination")):
1066
+ sentences.append(f"The source records the termination as “{esc(termination)}”.")
1067
+ if article.analyzed:
1068
+ tallies = []
1069
+ for color in (chess.WHITE, chess.BLACK):
1070
+ counts = [
1071
+ plural(sum(1 for r in article.reviews if r.mover == color and r.grade == nag), name)
1072
+ for nag, (name, _, _) in GRADES.items()
1073
+ ]
1074
+ made = [count for count in counts if not count.startswith("no ")]
1075
+ tallies.append(f"{side(color)} made " + (" and ".join(made) if made else "none"))
1076
+ sentences.append(f"In the engine's grading, {tallies[0]}, and {tallies[1]}.")
1077
+ return f"<p>{' '.join(sentences)}</p>\n"
1078
+
1079
+
1080
+ def pgn_text(item: CollectedGame) -> str:
1081
+ """The game as plain PGN: its headers and moves, without the analysis."""
1082
+ return format_game(strip_game(item.game, item.id))
1083
+
1084
+
1085
+ def article_html(article: Article, previous: Article | None, following: Article | None, *, site_title: str,
1086
+ thresholds: Thresholds, history: History | None = None) -> str: # fmt: skip
1087
+ nav = []
1088
+ if previous:
1089
+ nav.append(f'<a rel="prev" href="{previous.filename}">← {esc(previous.title)}</a>')
1090
+ if following:
1091
+ nav.append(f'<a rel="next" href="{following.filename}">{esc(following.title)} →</a>')
1092
+ body = (
1093
+ f"<article{data(history, game=article.item.id)}>\n"
1094
+ f"<h1>{esc(article.title)}</h1>\n"
1095
+ f"{infobox_html(article)}"
1096
+ f"{lead_html(article, thresholds)}"
1097
+ '<h2 id="game">The game</h2>\n'
1098
+ f"{moves_html(article, history)}"
1099
+ '<h2 id="conclusion">Conclusion</h2>\n'
1100
+ f"{conclusion_html(article)}"
1101
+ '<h2 id="pgn">PGN</h2>\n'
1102
+ f'<pre class="pgn">{esc(pgn_text(article.item))}</pre>\n'
1103
+ "</article>\n"
1104
+ + (f'<nav class="pager">\n{chr(10).join(nav)}\n</nav>\n' if nav else "")
1105
+ )
1106
+ return page(article.title, body, root="../", site_title=site_title, history=history)
1107
+
1108
+
1109
+ def index_years(articles: list[Article]) -> list[tuple[int | None, list[Article]]]:
1110
+ """The index's sections: the years in order (as numbers), then the
1111
+ undated games; in each, the articles in the order given (by file name,
1112
+ as ``build_site`` sorts them)."""
1113
+ years: dict[int | None, list[Article]] = {}
1114
+ for article in articles:
1115
+ years.setdefault(article.year, []).append(article)
1116
+ dated = sorted(year for year in years if year is not None)
1117
+ return [(year, years[year]) for year in [*dated, *([None] if None in years else [])]]
1118
+
1119
+
1120
+ def index_order(articles: list[Article]) -> list[Article]:
1121
+ """The articles in the order the index lists them."""
1122
+ return [article for _, section in index_years(articles) for article in section]
1123
+
1124
+
1125
+ @dataclass(frozen=True)
1126
+ class Quiz:
1127
+ """What the index needs to link to the quiz page: its title and its
1128
+ number of questions."""
1129
+
1130
+ title: str
1131
+ questions: int
1132
+
1133
+
1134
+ def index_html(articles: list[Article], site_title: str, history: History | None = None,
1135
+ quiz: Quiz | None = None) -> str: # fmt: skip
1136
+ sections = index_years(articles)
1137
+ dated = [year for year, _ in sections if year is not None]
1138
+
1139
+ def anchor(year: int | None) -> str:
1140
+ return f"y{year}" if year is not None else "undated"
1141
+
1142
+ def heading(year: int | None) -> str:
1143
+ return str(year) if year is not None else "Undated"
1144
+
1145
+ years = dict(sections)
1146
+ order = [year for year, _ in sections]
1147
+ span = f"from {dated[0]} to {dated[-1]}" if len(dated) > 1 else (f"from {dated[0]}" if dated else "")
1148
+ undated = len(years.get(None, []))
1149
+ summary = f"{plural(len(articles), 'game').capitalize()}"
1150
+ if span:
1151
+ summary += f" {span}"
1152
+ if undated and dated:
1153
+ summary += f" ({number_word(undated)} undated)"
1154
+ summary += (
1155
+ ". Every game has its own article. In the analyzed ones, each critical moment is a question, "
1156
+ "“what would you play?”, with the answer hidden until you tap it."
1157
+ )
1158
+ parts = [f"<h1>{esc(site_title)}</h1>\n", f'<p class="lead">{summary}</p>\n']
1159
+ if quiz:
1160
+ count = plural(quiz.questions, "question") if quiz.questions else "no questions"
1161
+ parts.append(f'<p class="quiz-link"><a href="{QUIZ_PAGE}">{esc(quiz.title)}</a>: {count}.</p>\n')
1162
+ if history:
1163
+ parts.append(HISTORY_SECTION)
1164
+ if len(order) > 1:
1165
+ links = " ".join(f'<a href="#{anchor(year)}">{heading(year)}</a>' for year in order)
1166
+ parts.append(f'<nav class="years">{links}</nav>\n')
1167
+ for year in order:
1168
+ parts.append(f'<section id="{anchor(year)}">\n<h2>{heading(year)}</h2>\n<ol class="games">\n')
1169
+ for article in years[year]:
1170
+ headers = article.game.headers
1171
+ meta = [format_date(headers.get("Date")) or "Undated"]
1172
+ if event := known(headers.get("Event")):
1173
+ meta.append(event)
1174
+ if article.analyzed:
1175
+ k = len(article.moments)
1176
+ meta.append(f"{number_word(k)} question{'' if k == 1 else 's'}" if k else "no questions")
1177
+ else:
1178
+ meta.append("not analyzed")
1179
+ questions = " ".join(move_key(review.board_before) for review in article.moments)
1180
+ parts.append(
1181
+ f'<li{data(history, game=article.item.id, moves=questions)}><a href="games/{article.filename}">'
1182
+ f"{esc(article.white)} vs. {esc(article.black)}</a> "
1183
+ f'<span class="result">{esc(result_text(article.result, "result not recorded"))}</span>'
1184
+ f'<span class="meta">{esc(" · ".join(meta))}</span></li>\n'
1185
+ )
1186
+ parts.append("</ol>\n</section>\n")
1187
+ return page(site_title, "".join(parts), root="", site_title=site_title, history=history)
1188
+
1189
+
1190
+ # --- the quiz (ROADMAP.md, F-9) -----------------------------------------------------
1191
+
1192
+
1193
+ @dataclass(frozen=True)
1194
+ class QuizEntry:
1195
+ """One line of the quiz: a critical moment that the player played."""
1196
+
1197
+ article: Article
1198
+ review: MoveReview
1199
+ number: int # the moment's number in its article, as in its anchor ``moment-N``
1200
+ loss: float # the winning chances the move cost, exactly
1201
+ position: int # the game's position in the index, from 0
1202
+ filename: str # the article's file name
1203
+ ply: int # the move's place in its game
1204
+
1205
+ def order(self) -> tuple[float, int, str, int]:
1206
+ """The quiz's order: the most points lost first, then the index's
1207
+ order, then the file name, then move order."""
1208
+ return (-self.loss, self.position, self.filename, self.ply)
1209
+
1210
+
1211
+ def own_colors(game: chess.pgn.Game, names: set[str]) -> set[chess.Color]:
1212
+ """The sides of ``game`` whose name is one of ``names`` (as
1213
+ ``player_names`` gives them): White's, Black's, both or neither, compared
1214
+ as ``Collection.read`` compares them."""
1215
+ colors = set()
1216
+ for color, key in ((chess.WHITE, "White"), (chess.BLACK, "Black")):
1217
+ if (game.headers.get(key) or "").strip().casefold() in names:
1218
+ colors.add(color)
1219
+ return colors
1220
+
1221
+
1222
+ def quiz_order(entries: Iterable[QuizEntry]) -> list[QuizEntry]:
1223
+ return sorted(entries, key=QuizEntry.order)
1224
+
1225
+
1226
+ def quiz_entries(articles: list[Article], names: set[str]) -> list[QuizEntry]:
1227
+ """The quiz for the player called ``names``, in its order: every critical
1228
+ moment of ``articles`` (given in the index's order) played by one of the
1229
+ player's sides."""
1230
+ entries = []
1231
+ for position, article in enumerate(articles):
1232
+ own = own_colors(article.game, names)
1233
+ for number, review in enumerate(article.moments, 1):
1234
+ if review.mover in own:
1235
+ entries.append(
1236
+ QuizEntry(article, review, number, review.loss, position, article.filename, review.node.ply())
1237
+ )
1238
+ return quiz_order(entries)
1239
+
1240
+
1241
+ def player_label(player: str | None, aliases: Iterable[str]) -> str:
1242
+ """The player's name as the quiz shows it: ``player``'s, else the first
1243
+ alias's, as prose wants it (``Example, Ada`` is ``Ada Example``)."""
1244
+ name = next((name for name in [player or "", *aliases] if name.strip()), "")
1245
+ return display_name(name)
1246
+
1247
+
1248
+ def quiz_title(label: str) -> str:
1249
+ return f"Quiz: {label}'s own mistakes, worst first"
1250
+
1251
+
1252
+ def quiz_html(entries: list[QuizEntry], games: int, unanalyzed: int, label: str, site_title: str,
1253
+ thresholds: Thresholds, history: History | None = None) -> str: # fmt: skip
1254
+ """The quiz page: one line per question, linking to it. ``games`` is the
1255
+ number of the player's games, ``unanalyzed`` how many of them have no
1256
+ analysis yet; the page without questions says both. With questions, the
1257
+ lead counts the games they come from."""
1258
+ title = quiz_title(label)
1259
+ name = esc(label)
1260
+ what = (
1261
+ f"a move that cost at least {thresholds.mistake:g} points of winning chances or changed the expected result"
1262
+ )
1263
+ of_games = f"{plural(games, 'game')} of {name}'s"
1264
+ parts = [f"<h1>{esc(title)}</h1>\n"]
1265
+ if not entries:
1266
+ if games:
1267
+ lead = f"The quiz has no questions: in {of_games}, no move {name} played is a critical moment ({what})."
1268
+ else:
1269
+ lead = f"The quiz has no questions: no game here is {name}'s."
1270
+ if unanalyzed:
1271
+ lead += (
1272
+ f" {number_word(unanalyzed).capitalize()} of them {'is' if unanalyzed == 1 else 'are'} not analyzed "
1273
+ "yet, and only analyzed games have critical moments."
1274
+ )
1275
+ parts.append(f'<p class="lead">{lead}</p>\n')
1276
+ return page(title, "".join(parts), root="", site_title=site_title, history=history, home=True)
1277
+
1278
+ # the games the questions come from, not all of the player's games
1279
+ sources = len({entry.article.item.id for entry in entries})
1280
+ parts.append(
1281
+ f'<p class="lead">{plural(len(entries), "question").capitalize()} from '
1282
+ f"{plural(sources, 'game')} of {name}'s: "
1283
+ f"{'it is' if len(entries) == 1 else 'each is'} a critical "
1284
+ f"moment where {name} was the one to move, {what}. The move that cost the most comes first, with the "
1285
+ "points of winning chances each one cost. Each line leads to its “what would you play?” question, where "
1286
+ "the answer stays hidden until you tap it.</p>\n"
1287
+ )
1288
+ parts.append('<ol id="quiz" class="games quiz">\n')
1289
+ for rank, entry in enumerate(entries, 1):
1290
+ article, review = entry.article, entry.review
1291
+ board = review.board_before
1292
+ points = f"{entry.loss:.0f}"
1293
+ opponent = article.black if review.mover == chess.WHITE else article.white
1294
+ meta = f"{format_date(article.game.headers.get('Date')) or 'Undated'} · vs. {opponent}"
1295
+ parts.append(
1296
+ f"<li{data(history, game=article.item.id, move=move_key(board))}>"
1297
+ f'<span class="rank">{rank}.</span> '
1298
+ f'<a href="games/{entry.filename}#moment-{entry.number}">{esc(move_label(board, review.node.move))}</a> '
1299
+ f'<span class="lost">{points} point{"" if points == "1" else "s"}</span>'
1300
+ f'<span class="meta">{esc(meta)}</span></li>\n'
1301
+ )
1302
+ parts.append("</ol>\n")
1303
+ return page(title, "".join(parts), root="", site_title=site_title, history=history, home=True)
1304
+
1305
+
1306
+ STYLE = """\
1307
+ /* pgn-postmortem site. Mobile first; light or dark as the OS says. */
1308
+ :root {
1309
+ --bg: #ffffff; --fg: #202122; --muted: #54595d; --link: #3366cc; --rule: #a2a9b1;
1310
+ --box: #f8f9fa; --note: #54595d; --answer: #eaf3ff;
1311
+ --light: #f0d9b5; --dark: #b58863; --hl-light: #f7d764; --hl-dark: #d6ab36;
1312
+ }
1313
+ @media (prefers-color-scheme: dark) {
1314
+ :root {
1315
+ --bg: #101418; --fg: #e8eaed; --muted: #a8adb3; --link: #8ab4f8; --rule: #3c4043;
1316
+ --box: #1b2026; --note: #b0b6bd; --answer: #17263a;
1317
+ --light: #d8c3a0; --dark: #9c7453; --hl-light: #e8ca60; --hl-dark: #c49b32;
1318
+ }
1319
+ }
1320
+ * { box-sizing: border-box; }
1321
+ html { -webkit-text-size-adjust: 100%; }
1322
+ body {
1323
+ margin: 0; background: var(--bg); color: var(--fg);
1324
+ font: 1rem/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
1325
+ }
1326
+ a { color: var(--link); text-decoration: none; }
1327
+ a:hover, a:focus { text-decoration: underline; }
1328
+ main { max-width: 52rem; margin: 0 auto; padding: 0 1rem 2rem; }
1329
+ .top { border-bottom: 1px solid var(--rule); padding: .6rem 1rem; font-weight: 600; }
1330
+ .bottom { max-width: 52rem; margin: 0 auto; padding: 1rem; color: var(--muted); font-size: .85rem;
1331
+ border-top: 1px solid var(--rule); }
1332
+ h1, h2 { font-family: Georgia, "Linux Libertine", "Times New Roman", serif; font-weight: normal; line-height: 1.25; }
1333
+ h1 { font-size: 1.8rem; margin: 1rem 0 .5rem; border-bottom: 1px solid var(--rule); padding-bottom: .2rem; }
1334
+ h2 { font-size: 1.4rem; margin: 1.6rem 0 .5rem; border-bottom: 1px solid var(--rule); padding-bottom: .1rem; }
1335
+ .lead { margin-top: 0; }
1336
+ .moves { font-weight: 600; overflow-wrap: anywhere; }
1337
+ .moves b { font-weight: 700; }
1338
+ .note { font-weight: normal; font-style: italic; color: var(--note); }
1339
+ .infobox { width: 100%; margin: 0 0 1rem; border: 1px solid var(--rule); background: var(--box);
1340
+ border-collapse: collapse; font-size: .9rem; line-height: 1.4; }
1341
+ .infobox caption { font-weight: bold; font-size: 1.05rem; padding: .4rem; }
1342
+ .infobox th, .infobox td { text-align: left; vertical-align: top; padding: .25rem .5rem; }
1343
+ .infobox th { white-space: nowrap; }
1344
+ .infobox .figure { padding: .5rem; }
1345
+ .infobox .board { max-width: 100%; }
1346
+ .caption { font-size: .85rem; color: var(--muted); text-align: center; margin-top: .3rem; }
1347
+ .infobox .lichess { text-align: center; font-size: .85rem; margin-top: .3rem; }
1348
+ @media (min-width: 44rem) {
1349
+ .infobox { float: right; clear: right; width: 20rem; margin: 0 0 1rem 1.2rem; }
1350
+ }
1351
+ h2 { overflow: hidden; }
1352
+ article::after { content: ""; display: block; clear: both; }
1353
+ .sym { white-space: nowrap; }
1354
+ .board {
1355
+ display: grid; grid-template-columns: repeat(8, 1fr); aspect-ratio: 1; width: 100%; max-width: 24rem;
1356
+ border: 1px solid var(--rule); font: 600 .65rem/1 system-ui, sans-serif; margin: 0 auto;
1357
+ }
1358
+ .board i { position: relative; background-size: 100% 100%; background-repeat: no-repeat; }
1359
+ /* Each cell draws its own square's colour, under its piece: no pattern on the board, whose squares
1360
+ would round differently from the cells. The highlight is solid: rgba(255, 213, 0, .45) over the
1361
+ square's colour (.4 in dark mode), worked out, so the piece stays drawn over it. */
1362
+ .board i.l { background-color: var(--light); }
1363
+ .board i.d { background-color: var(--dark); }
1364
+ .board i.l.hl { background-color: var(--hl-light); }
1365
+ .board i.d.hl { background-color: var(--hl-dark); }
1366
+ .board i[data-r]::before, .board i[data-f]::after { position: absolute; color: #5b4632; opacity: .8; }
1367
+ .board i[data-r]::before { content: attr(data-r); top: 3%; left: 4%; }
1368
+ .board i[data-f]::after { content: attr(data-f); bottom: 3%; right: 6%; }
1369
+ figure { margin: 1rem 0 .5rem; }
1370
+ figcaption { text-align: center; font-size: .9rem; color: var(--muted); margin-top: .4rem; }
1371
+ figcaption b { color: var(--fg); }
1372
+ .moment { margin: 1.2rem 0; }
1373
+ .answer { display: flow-root; background: var(--answer); border-radius: .4rem; padding: .2rem .8rem; max-width: 36rem;
1374
+ margin: 0 auto; }
1375
+ .answer summary { cursor: pointer; font-weight: 600; padding: .5rem 0; min-height: 2.75rem; display: flex;
1376
+ align-items: center; }
1377
+ .answer summary::before { content: "▸"; margin-right: .4rem; }
1378
+ .answer[open] summary::before { content: "▾"; }
1379
+ .answer summary::-webkit-details-marker { display: none; }
1380
+ .answer summary { list-style: none; }
1381
+ .pgn { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--box); border: 1px solid var(--rule);
1382
+ padding: .6rem; font-size: .85rem; }
1383
+ .pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem 1rem;
1384
+ border-top: 1px solid var(--rule); padding-top: .8rem; margin-top: 1.5rem; }
1385
+ .years { display: flex; flex-wrap: wrap; gap: .3rem 1rem; margin: 0 0 1rem; }
1386
+ .years a { padding: .3rem 0; }
1387
+ .games { list-style: none; padding: 0; margin: 0; }
1388
+ .games li { padding: .5rem 0; border-bottom: 1px solid var(--rule); }
1389
+ .games .result { margin-left: .5rem; font-weight: 600; }
1390
+ .games .meta { display: block; color: var(--muted); font-size: .85rem; }
1391
+ .quiz .rank { display: inline-block; min-width: 2rem; color: var(--muted); }
1392
+ .quiz .lost { margin-left: .5rem; font-weight: 600; }
1393
+ /* The piece set (Colin M.L. Burnett's, as python-chess ships it), shared by every diagram. */
1394
+ """
1395
+
1396
+
1397
+ @dataclass
1398
+ class SiteReport:
1399
+ articles: list[Path] = field(default_factory=list)
1400
+ analyzed: int = 0
1401
+ critical_moments: int = 0
1402
+ removed: list[Path] = field(default_factory=list)
1403
+ quiz: Path | None = None # the quiz page, when the site has a player
1404
+ quiz_questions: int = 0
1405
+
1406
+ def summary(self, out_dir: str | Path) -> str:
1407
+ text = (
1408
+ f"Wrote {len(self.articles)} article(s) to {out_dir}: {self.analyzed} analyzed, "
1409
+ f"{len(self.articles) - self.analyzed} not analyzed yet, {self.critical_moments} critical moment(s)."
1410
+ )
1411
+ if self.quiz:
1412
+ text += f" The quiz lists {self.quiz_questions} of them, the player's own."
1413
+ return text
1414
+
1415
+
1416
+ def write_text(path: Path, text: str) -> None:
1417
+ path.parent.mkdir(parents=True, exist_ok=True)
1418
+ path.write_bytes(text.encode("utf-8")) # "\n" line ends on every OS, for byte-for-byte golden files
1419
+
1420
+
1421
+ def is_generated(path: Path) -> bool:
1422
+ """Whether ``path`` is a page this builder wrote: it carries the
1423
+ ``GENERATOR`` line in its head, where ``page`` puts it."""
1424
+ with path.open("rb") as handle:
1425
+ head = handle.read(1024)
1426
+ return f"\n{GENERATOR}\n".encode() in head
1427
+
1428
+
1429
+ def build_site(
1430
+ games: Iterable[CollectedGame],
1431
+ out_dir: str | Path,
1432
+ *,
1433
+ title: str = "Games",
1434
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
1435
+ presume_threshold: float = PRESUME_THRESHOLD,
1436
+ outcome_bands: tuple[float, float] = OUTCOME_BANDS,
1437
+ history: bool = True,
1438
+ site_key: str | None = None,
1439
+ player: str | None = None,
1440
+ aliases: Iterable[str] = (),
1441
+ ) -> SiteReport:
1442
+ """Write the site for ``games`` (a ``Collection``, or any iterable of
1443
+ ``CollectedGame``) to ``out_dir``: ``index.html``, ``assets/style.css``
1444
+ and one ``games/<date>-<id>.html`` per game, in date order.
1445
+
1446
+ Games read with ``Collection.read(..., keep_analysis=True)`` from the
1447
+ analysis step's output get notes, critical moments and questions; any
1448
+ other game gets an article without them. A page that an earlier build
1449
+ wrote in ``out_dir/games/`` for a game no longer in ``games`` is removed,
1450
+ whatever its name, so the site has one article per game; a file there
1451
+ that the builder did not write (no ``GENERATOR`` line) is never touched.
1452
+ The output depends only on the games and the options: building twice
1453
+ gives the same bytes.
1454
+
1455
+ A game whose result was not recorded is shown with the result
1456
+ ``shown_result`` gives (the rule is in the module docstring):
1457
+ ``presume_threshold`` is the winning chances, in percent, at which the
1458
+ analysis makes it a win, 70 by default. A value below 55 or above 95
1459
+ raises ``ValueError`` before anything is written. The games are not
1460
+ changed.
1461
+
1462
+ ``outcome_bands`` is the pair ``(lower, upper)`` of White's winning
1463
+ chances, in percent, that decides a position's expected outcome for the
1464
+ outcome swings (the rule is in the module docstring): Black is winning at
1465
+ ``lower`` or less, White at ``upper`` or more, 40 and 60 by default. Both
1466
+ must be finite, with 0 < lower < 50 < upper < 100; any other pair raises
1467
+ ``ValueError`` before anything is written.
1468
+
1469
+ ``history`` puts the reading history in the pages (the rule is in the
1470
+ module docstring); with ``history=False`` the pages carry no script, no
1471
+ history section and no ``data-`` attribute for it. ``site_key`` is the
1472
+ key the history is stored under, by default ``default_site_key(title)``:
1473
+ 1 to 64 ASCII letters, digits, ``.``, ``_`` and ``-``, starting with a
1474
+ letter or a digit; any other value raises ``ValueError`` before anything
1475
+ is written, with or without the history.
1476
+
1477
+ ``player`` and ``aliases`` name the player, as ``Collection.read`` takes
1478
+ them and compares them (letter case and surrounding spaces ignored), for
1479
+ the quiz page ``quiz.html`` (the rule is in the module docstring). When
1480
+ neither is given and ``games`` is a ``Collection``, the names it was read
1481
+ with are used; names given here take their place.
1482
+ """
1483
+ check_presume_threshold(presume_threshold)
1484
+ check_outcome_bands(outcome_bands)
1485
+ key = check_site_key(site_key) if site_key is not None else default_site_key(title)
1486
+ reading = History(key, history_script()) if history else None
1487
+ aliases = tuple(aliases)
1488
+ if player is None and not aliases and isinstance(games, Collection):
1489
+ player, aliases = games.player, games.aliases
1490
+ names = player_names(player, aliases)
1491
+ out_dir = Path(out_dir)
1492
+ articles = make_articles(games, thresholds, presume_threshold, outcome_bands)
1493
+
1494
+ report = SiteReport()
1495
+ write_text(out_dir / "assets" / "style.css", STYLE + piece_css())
1496
+ for i, article in enumerate(articles):
1497
+ previous = articles[i - 1] if i > 0 else None
1498
+ following = articles[i + 1] if i + 1 < len(articles) else None
1499
+ path = out_dir / "games" / article.filename
1500
+ html_text = article_html(article, previous, following, site_title=title, thresholds=thresholds, history=reading)
1501
+ write_text(path, html_text)
1502
+ report.articles.append(path)
1503
+ report.analyzed += article.analyzed
1504
+ report.critical_moments += len(article.moments)
1505
+ quiz = None
1506
+ if names:
1507
+ in_order = index_order(articles)
1508
+ entries = quiz_entries(in_order, names)
1509
+ players = [article for article in in_order if own_colors(article.game, names)]
1510
+ label = player_label(player, aliases)
1511
+ unanalyzed = sum(not article.analyzed for article in players)
1512
+ quiz_text = quiz_html(entries, len(players), unanalyzed, label, title, thresholds, reading)
1513
+ write_text(out_dir / QUIZ_PAGE, quiz_text)
1514
+ quiz = Quiz(quiz_title(label), len(entries))
1515
+ report.quiz, report.quiz_questions = out_dir / QUIZ_PAGE, len(entries)
1516
+ write_text(out_dir / "index.html", index_html(articles, title, reading, quiz))
1517
+
1518
+ written = {path.name for path in report.articles}
1519
+ for path in sorted((out_dir / "games").glob("*.html")):
1520
+ if path.name not in written and is_generated(path):
1521
+ path.unlink()
1522
+ report.removed.append(path)
1523
+ stale = out_dir / QUIZ_PAGE
1524
+ if not names and stale.is_file() and is_generated(stale): # a site without a player has no quiz page
1525
+ stale.unlink()
1526
+ report.removed.append(stale)
1527
+ return report
1528
+
1529
+
1530
+ def make_articles(
1531
+ games: Iterable[CollectedGame],
1532
+ thresholds: Thresholds = LICHESS_THRESHOLDS,
1533
+ presume_threshold: float = PRESUME_THRESHOLD,
1534
+ outcome_bands: tuple[float, float] = OUTCOME_BANDS,
1535
+ ) -> list[Article]:
1536
+ """An ``Article`` for each game, with its reviewed moves and the result
1537
+ it shows, sorted by file name."""
1538
+ articles = []
1539
+ for item in games:
1540
+ analyzed = is_analyzed(item.game)
1541
+ result = shown_result(item.game, presume_threshold)
1542
+ articles.append(Article(item, review_moves(item.game, thresholds, outcome_bands), analyzed, result))
1543
+ articles.sort(key=lambda article: article.stem)
1544
+ return articles