simplex-web 0.2.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.
Files changed (91) hide show
  1. simplex/README.md +32 -0
  2. simplex/cli/README.md +13 -0
  3. simplex/cli/__init__.py +5 -0
  4. simplex/cli/commands.py +384 -0
  5. simplex/deck/README.md +19 -0
  6. simplex/deck/__init__.py +7 -0
  7. simplex/deck/_template/assets/.gitkeep +0 -0
  8. simplex/deck/_template/assets/code/.gitkeep +0 -0
  9. simplex/deck/_template/assets/figures/.gitkeep +0 -0
  10. simplex/deck/_template/deck.toml +11 -0
  11. simplex/deck/_template/manim.cfg +3 -0
  12. simplex/deck/_template/notes.md +27 -0
  13. simplex/deck/_template/refs.bib +12 -0
  14. simplex/deck/_template/slides/__init__.py +7 -0
  15. simplex/deck/_template/slides/intro.py +21 -0
  16. simplex/deck/config.py +207 -0
  17. simplex/deck/registry.py +110 -0
  18. simplex/deck/scaffold.py +86 -0
  19. simplex/deck/section.py +40 -0
  20. simplex/engine/README.md +9 -0
  21. simplex/render/README.md +46 -0
  22. simplex/render/__init__.py +1 -0
  23. simplex/render/html.py +132 -0
  24. simplex/render/pdf.py +32 -0
  25. simplex/render/pptx.py +32 -0
  26. simplex/render/reconcile.py +350 -0
  27. simplex/render/runner.py +116 -0
  28. simplex/render/thumbnail.py +374 -0
  29. simplex/slides/README.md +9 -0
  30. simplex/slides/components/README.md +9 -0
  31. simplex/theme/README.md +9 -0
  32. simplex/web/README.md +33 -0
  33. simplex/web/__init__.py +1 -0
  34. simplex/web/bibliography.py +248 -0
  35. simplex/web/bibtex.py +129 -0
  36. simplex/web/builder.py +321 -0
  37. simplex/web/callouts.py +134 -0
  38. simplex/web/citations.py +118 -0
  39. simplex/web/equations.py +79 -0
  40. simplex/web/notes.py +135 -0
  41. simplex/web/refs.py +60 -0
  42. simplex/web/sidenotes.py +76 -0
  43. simplex/web/site_config.py +71 -0
  44. simplex/web/slide_ref.py +54 -0
  45. simplex/web/static/.gitkeep +0 -0
  46. simplex/web/static/README.md +23 -0
  47. simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
  48. simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
  49. simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
  50. simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
  51. simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
  52. simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
  53. simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
  54. simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
  55. simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
  56. simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
  57. simplex/web/static/htmx.min.js +1 -0
  58. simplex/web/static/katex/auto-render.min.js +1 -0
  59. simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  60. simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  61. simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  62. simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  63. simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  64. simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  65. simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  66. simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  67. simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. simplex/web/static/katex/katex.min.css +1 -0
  69. simplex/web/static/katex/katex.min.js +1 -0
  70. simplex/web/static/lucide/README.md +7 -0
  71. simplex/web/static/lucide/lucide.min.js +12 -0
  72. simplex/web/static/notes.js +68 -0
  73. simplex/web/static/reveal.js/reset.css +30 -0
  74. simplex/web/static/reveal.js/reveal.css +8 -0
  75. simplex/web/static/reveal.js/reveal.js +9 -0
  76. simplex/web/static/simplex.css +1870 -0
  77. simplex/web/static/tailwind.js +64 -0
  78. simplex/web/static/viewer.js +428 -0
  79. simplex/web/templates/README.md +19 -0
  80. simplex/web/templates/_carousel.html +117 -0
  81. simplex/web/templates/base.html +110 -0
  82. simplex/web/templates/deck.html +149 -0
  83. simplex/web/templates/index.html +20 -0
  84. simplex/web/templates/revealjs.html.j2 +374 -0
  85. simplex/web/templates/section.html +74 -0
  86. simplex/web/vendor.py +148 -0
  87. simplex_web-0.2.0.dist-info/METADATA +166 -0
  88. simplex_web-0.2.0.dist-info/RECORD +91 -0
  89. simplex_web-0.2.0.dist-info/WHEEL +4 -0
  90. simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
  91. simplex_web-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,374 @@
1
+ """Per-main-slide JPG thumbnails from the reconciled subsection MP4s.
2
+
3
+ Default rule: extract the **last frame** of the **second-to-last subsection**
4
+ (``subsections[-2]``). Configurable per main via
5
+ ``deck.slides["Main Name"].thumbnail_section_index``. A literal path
6
+ override at ``deck.slides["Main Name"].thumbnail`` (relative to the deck
7
+ directory) wins over the extraction rule.
8
+
9
+ Content-addressable cache: file name = sha256 of source path + mtime + deck
10
+ slug, so re-rendering a subsection naturally invalidates its thumbnail.
11
+
12
+ Extraction tries the system ``ffmpeg`` CLI first (fastest), then falls back
13
+ to PyAV -- manim already depends on PyAV for its own concatenation pipeline,
14
+ so this fallback works without any extra system binaries (the typical reason
15
+ real previews showed "no preview yet" on Windows: manim runs via PyAV but
16
+ ffmpeg.exe is not on PATH, so the old code went straight to the placeholder).
17
+ The "no video" fallback ships an inline SVG so the placeholder is always a
18
+ valid image even when neither extractor can run.
19
+ """
20
+
21
+ import contextlib
22
+ import hashlib
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import av
28
+ import av.error
29
+ from PIL import Image
30
+ from PIL.Image import Resampling
31
+
32
+ from simplex.deck.config import DeckConfig
33
+ from simplex.manifest import DeckManifest, MainSlide, Subsection
34
+
35
+ DEFAULT_WIDTH = 480
36
+ DEFAULT_SECONDARY_WIDTH = 960
37
+ DEFAULT_GIF_WIDTH = 320
38
+ DEFAULT_GIF_FPS = 6
39
+ DEFAULT_GIF_MAX_FRAMES = 24
40
+ DEFAULT_GIF_COLORS = 64
41
+ PLACEHOLDER_NAME = "_placeholder.svg"
42
+
43
+
44
+ def _key(video: Path, slug: str) -> str:
45
+ stat = video.stat()
46
+ raw = f"{slug}:{video.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}"
47
+ return hashlib.sha256(raw.encode()).hexdigest()[:24]
48
+
49
+
50
+ def _multi_video_key(videos: tuple[Path, ...], slug: str, suffix: str) -> str:
51
+ parts = [slug, suffix]
52
+ for video in videos:
53
+ stat = video.stat()
54
+ parts.append(f"{video.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}")
55
+ return hashlib.sha256("|".join(parts).encode()).hexdigest()[:24]
56
+
57
+
58
+ def _extract_frame(
59
+ video: Path,
60
+ dest: Path,
61
+ *,
62
+ width: int,
63
+ seek_from_end: bool = True,
64
+ ) -> bool:
65
+ """Extract one frame near the end (or start) of ``video`` to ``dest``.
66
+
67
+ Tries ``ffmpeg`` first; falls back to PyAV (bundled with manim) when the
68
+ CLI is missing. Returns ``True`` on success.
69
+ """
70
+ dest.parent.mkdir(parents=True, exist_ok=True)
71
+ if _try_ffmpeg(video, dest, width=width, seek_from_end=seek_from_end):
72
+ return True
73
+ return _try_pyav(video, dest, width=width, seek_from_end=seek_from_end)
74
+
75
+
76
+ def _try_ffmpeg(
77
+ video: Path,
78
+ dest: Path,
79
+ *,
80
+ width: int,
81
+ seek_from_end: bool,
82
+ ) -> bool:
83
+ """Extract a frame via the ``ffmpeg`` CLI if it's on PATH."""
84
+ if shutil.which("ffmpeg") is None:
85
+ return False
86
+ seek = ["-sseof", "-0.1"] if seek_from_end else ["-ss", "0.1"]
87
+ args = [
88
+ "ffmpeg",
89
+ "-y",
90
+ "-loglevel",
91
+ "error",
92
+ *seek,
93
+ "-i",
94
+ str(video),
95
+ "-vframes",
96
+ "1",
97
+ "-vf",
98
+ f"scale={width}:-2",
99
+ "-q:v",
100
+ "2",
101
+ str(dest),
102
+ ]
103
+ with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError):
104
+ subprocess.run(args, check=True, timeout=30)
105
+ return dest.exists()
106
+ return False
107
+
108
+
109
+ def _try_pyav(
110
+ video: Path,
111
+ dest: Path,
112
+ *,
113
+ width: int,
114
+ seek_from_end: bool,
115
+ ) -> bool:
116
+ """Decode a representative frame with PyAV and save it as JPEG.
117
+
118
+ Walks every frame and keeps the last when ``seek_from_end`` is true; for
119
+ typical slide-length clips (<10 s) that's still fast and avoids the
120
+ container-specific seek edge cases (no-keyframe, B-frame ordering, etc.)
121
+ that bite when you try to seek directly to the tail.
122
+ """
123
+ try:
124
+ with av.open(str(video)) as container:
125
+ stream = container.streams.video[0]
126
+ stream.thread_type = "AUTO"
127
+ chosen = None
128
+ for frame in container.decode(stream):
129
+ chosen = frame
130
+ if not seek_from_end:
131
+ break
132
+ if chosen is None:
133
+ return False
134
+ image = chosen.to_image()
135
+ except (av.error.FFmpegError, IndexError, OSError):
136
+ return False
137
+ src_w, src_h = image.size
138
+ if src_w <= 0 or src_h <= 0:
139
+ return False
140
+ target_h = max(1, round(src_h * width / src_w))
141
+ if (src_w, src_h) != (width, target_h):
142
+ image = image.resize((width, target_h), Resampling.LANCZOS)
143
+ image.save(str(dest), "JPEG", quality=85, optimize=True)
144
+ return dest.exists()
145
+
146
+
147
+ _PLACEHOLDER_SVG = (
148
+ '<?xml version="1.0" encoding="UTF-8"?>'
149
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" '
150
+ 'preserveAspectRatio="xMidYMid slice" role="img" '
151
+ 'aria-label="No preview available">'
152
+ '<rect width="480" height="270" fill="#242424"/>'
153
+ '<text x="240" y="142" text-anchor="middle" fill="#8a8a8a" '
154
+ 'font-family="system-ui, sans-serif" font-size="18">no preview yet</text>'
155
+ "</svg>"
156
+ )
157
+
158
+
159
+ def _placeholder(dest_dir: Path) -> Path:
160
+ """Write a 16:9 SVG placeholder once per deck and return its path.
161
+
162
+ SVG works without ffmpeg and is a single inline string, so the file is
163
+ always a valid image regardless of the host's video-tool availability.
164
+ """
165
+ placeholder = dest_dir / PLACEHOLDER_NAME
166
+ if placeholder.exists():
167
+ return placeholder
168
+ placeholder.parent.mkdir(parents=True, exist_ok=True)
169
+ placeholder.write_text(_PLACEHOLDER_SVG, encoding="utf-8")
170
+ return placeholder
171
+
172
+
173
+ def _pick_source(main: MainSlide, override_idx: int) -> Subsection | None:
174
+ """Pick which subsection's frame to use for `main`'s thumbnail.
175
+
176
+ Returns the subsection at ``override_idx`` if it has a usable video,
177
+ otherwise walks the remaining subsections (last-to-first) so a missing
178
+ video at ``-1`` (e.g. the trailing skip-animations section Manim emits
179
+ when ``construct()`` ends without a final ``play()``) doesn't degrade
180
+ into the placeholder. Returns ``None`` only when the main has no
181
+ playable video at any index.
182
+ """
183
+ subs = main.subsections
184
+ if not subs:
185
+ return None
186
+ try:
187
+ primary = subs[override_idx]
188
+ except IndexError:
189
+ primary = subs[-1]
190
+ if _has_video(primary):
191
+ return primary
192
+ # Fallback: scan from last to first, skipping the primary we already
193
+ # tried. The last sub usually carries the slide's final visual state
194
+ # (the most informative thumbnail), so we prefer it over earlier ones.
195
+ for sub in reversed(subs):
196
+ if sub is primary:
197
+ continue
198
+ if _has_video(sub):
199
+ return sub
200
+ return primary
201
+
202
+
203
+ def _has_video(sub: Subsection) -> bool:
204
+ return sub.video is not None and sub.video.exists()
205
+
206
+
207
+ def generate(
208
+ deck: DeckConfig,
209
+ manifest: DeckManifest,
210
+ *,
211
+ site_deck_dir: Path,
212
+ cache_dir: Path,
213
+ ) -> dict[int, Path]:
214
+ """Generate one JPG per main slide. Returns ``{main.index: rel_path}``."""
215
+ thumbs_dir = site_deck_dir / "thumbs"
216
+ cache_root = cache_dir / "thumbnails" / deck.slug
217
+ thumbs_dir.mkdir(parents=True, exist_ok=True)
218
+ cache_root.mkdir(parents=True, exist_ok=True)
219
+ out: dict[int, Path] = {}
220
+ for main in manifest.main_slides:
221
+ out[main.index] = _one(main, deck, thumbs_dir, cache_root)
222
+ return out
223
+
224
+
225
+ def generate_carousel_gif(
226
+ deck: DeckConfig,
227
+ manifest: DeckManifest,
228
+ *,
229
+ site_deck_dir: Path,
230
+ cache_dir: Path,
231
+ ) -> Path | None:
232
+ """Return a low-quality GIF preview for deck cards, if configured.
233
+
234
+ Resolution order:
235
+ 1. ``[web] carousel_gif = "path/to/preview.gif"`` copies the user asset.
236
+ 2. ``[web] carousel_gif_slides = [1, 3]`` samples rendered videos from
237
+ those 1-based main-slide indexes and writes a small cached GIF.
238
+ 3. No configured preview returns ``None`` so the portal keeps the static
239
+ thumbnail only.
240
+ """
241
+ previews_dir = site_deck_dir / "previews"
242
+ cache_root = cache_dir / "carousel-gifs" / deck.slug
243
+ if deck.web.carousel_gif is not None:
244
+ return _copy_gif_override(deck, previews_dir)
245
+
246
+ selected = set(deck.web.carousel_gif_slides)
247
+ if not selected:
248
+ return None
249
+ videos = tuple(
250
+ sub.video
251
+ for main in manifest.main_slides
252
+ if main.index in selected
253
+ for sub in [_pick_source(main, -1)]
254
+ if sub is not None and sub.video is not None and sub.video.exists()
255
+ )
256
+ if not videos:
257
+ return None
258
+
259
+ previews_dir.mkdir(parents=True, exist_ok=True)
260
+ cache_root.mkdir(parents=True, exist_ok=True)
261
+ key = _multi_video_key(videos, deck.slug, ",".join(str(i) for i in sorted(selected)))
262
+ cached = cache_root / f"{key}.gif"
263
+ dest = previews_dir / f"{key}.gif"
264
+ if cached.exists():
265
+ if not dest.exists():
266
+ shutil.copy2(cached, dest)
267
+ return dest.relative_to(site_deck_dir)
268
+ if _write_preview_gif(videos, dest):
269
+ shutil.copy2(dest, cached)
270
+ return dest.relative_to(site_deck_dir)
271
+ return None
272
+
273
+
274
+ def _copy_gif_override(deck: DeckConfig, previews_dir: Path) -> Path | None:
275
+ rel = deck.web.carousel_gif
276
+ if rel is None:
277
+ return None
278
+ src = deck.path / rel
279
+ if not src.exists() or src.suffix.lower() != ".gif":
280
+ return None
281
+ previews_dir.mkdir(parents=True, exist_ok=True)
282
+ key = _multi_video_key((src,), deck.slug, "override")
283
+ target = previews_dir / f"override_{key}.gif"
284
+ if not target.exists() or target.stat().st_mtime < src.stat().st_mtime:
285
+ shutil.copy2(src, target)
286
+ return target.relative_to(previews_dir.parent)
287
+
288
+
289
+ def _write_preview_gif(videos: tuple[Path, ...], dest: Path) -> bool:
290
+ frames: list[Image.Image] = []
291
+ per_video = max(1, DEFAULT_GIF_MAX_FRAMES // max(1, len(videos)))
292
+ for video in videos:
293
+ frames.extend(_sample_gif_frames(video, max_frames=per_video, width=DEFAULT_GIF_WIDTH))
294
+ if len(frames) >= DEFAULT_GIF_MAX_FRAMES:
295
+ frames = frames[:DEFAULT_GIF_MAX_FRAMES]
296
+ break
297
+ if not frames:
298
+ return False
299
+ dest.parent.mkdir(parents=True, exist_ok=True)
300
+ try:
301
+ frames[0].save(
302
+ str(dest),
303
+ save_all=True,
304
+ append_images=frames[1:],
305
+ duration=round(1000 / DEFAULT_GIF_FPS),
306
+ loop=0,
307
+ optimize=True,
308
+ )
309
+ except OSError:
310
+ return False
311
+ return dest.exists()
312
+
313
+
314
+ def _sample_gif_frames(video: Path, *, max_frames: int, width: int) -> list[Image.Image]:
315
+ out: list[Image.Image] = []
316
+ try:
317
+ with av.open(str(video)) as container:
318
+ stream = container.streams.video[0]
319
+ stream.thread_type = "AUTO"
320
+ rate = float(stream.average_rate or DEFAULT_GIF_FPS)
321
+ stride = max(1, round(rate / DEFAULT_GIF_FPS))
322
+ for i, frame in enumerate(container.decode(stream)):
323
+ if i % stride != 0:
324
+ continue
325
+ out.append(_gif_frame(frame.to_image(), width=width))
326
+ if len(out) >= max_frames:
327
+ break
328
+ except (av.error.FFmpegError, IndexError, OSError):
329
+ return []
330
+ return out
331
+
332
+
333
+ def _gif_frame(image: Image.Image, *, width: int) -> Image.Image:
334
+ src_w, src_h = image.size
335
+ if src_w > 0 and src_h > 0 and src_w != width:
336
+ target_h = max(1, round(src_h * width / src_w))
337
+ image = image.resize((width, target_h), Resampling.LANCZOS)
338
+ return image.convert("P", palette=Image.Palette.ADAPTIVE, colors=DEFAULT_GIF_COLORS)
339
+
340
+
341
+ def _one(
342
+ main: MainSlide,
343
+ deck: DeckConfig,
344
+ thumbs_dir: Path,
345
+ cache_root: Path,
346
+ ) -> Path:
347
+ # Literal path override?
348
+ override = deck.slides.get(main.name)
349
+ if override is not None and override.thumbnail is not None:
350
+ src = deck.path / override.thumbnail
351
+ if src.exists():
352
+ target = thumbs_dir / f"override_{main.index:04d}{src.suffix}"
353
+ shutil.copy2(src, target)
354
+ return target.relative_to(thumbs_dir.parent)
355
+
356
+ section_index = override.thumbnail_section_index if override is not None else -2
357
+ sub = _pick_source(main, section_index)
358
+ if sub is None or sub.video is None or not sub.video.exists():
359
+ return _placeholder(thumbs_dir).relative_to(thumbs_dir.parent)
360
+
361
+ key = _key(sub.video, deck.slug)
362
+ cached = cache_root / f"{key}.jpg"
363
+ dest = thumbs_dir / f"{key}.jpg"
364
+ if cached.exists():
365
+ if not dest.exists():
366
+ shutil.copy2(cached, dest)
367
+ return dest.relative_to(thumbs_dir.parent)
368
+ if _extract_frame(sub.video, dest, width=DEFAULT_WIDTH, seek_from_end=True):
369
+ shutil.copy2(dest, cached)
370
+ dest_2x = thumbs_dir / f"{key}@2x.jpg"
371
+ if _extract_frame(sub.video, dest_2x, width=DEFAULT_SECONDARY_WIDTH, seek_from_end=True):
372
+ shutil.copy2(dest_2x, cache_root / f"{key}@2x.jpg")
373
+ return dest.relative_to(thumbs_dir.parent)
374
+ return _placeholder(thumbs_dir).relative_to(thumbs_dir.parent)
@@ -0,0 +1,9 @@
1
+ # slides/
2
+
3
+ Namespace placeholder for `simplex.slides` in this repo.
4
+
5
+ The slide bases are shipped by `manim-simplex` and merged via PEP 420.
6
+
7
+ ## Don't
8
+
9
+ - Don't add slide classes here; keep them in manim-simplex.
@@ -0,0 +1,9 @@
1
+ # slides/components/
2
+
3
+ Placeholder for slide component helpers.
4
+
5
+ Any shared components should live in `manim-simplex` alongside the slide bases.
6
+
7
+ ## Don't
8
+
9
+ - Don't add new component code here.
@@ -0,0 +1,9 @@
1
+ # theme/
2
+
3
+ Namespace placeholder for `simplex.theme` in this repo.
4
+
5
+ Theme tokens and presets live in `manim-simplex` and are merged via PEP 420.
6
+
7
+ ## Don't
8
+
9
+ - Don't edit or add theme code here.
simplex/web/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # web/
2
+
3
+ Static-site generator: home carousels, deck pages, viewer bridge, academic
4
+ notes pipeline (markdown + math + citations + Tufte sidenotes).
5
+
6
+ ## Public surface
7
+
8
+ - `SiteConfig.load()` -- merge committed `site.toml` with env overrides
9
+ (`SIMPLEX_GA_TAG`, `SIMPLEX_BASE_URL`, `SIMPLEX_BRAND`, `SIMPLEX_PREVIEW`).
10
+ - `notes.render(notes_md_path, slide_count=..., bibliography=...)` --
11
+ markdown-it + dollarmath + footnotes + `[slide:N]` + `\cite{}` +
12
+ `\ref{}` -> Tufte-style academic HTML (serif body, Lato headings,
13
+ right-margin sidenotes, colour-coded theorem callouts, references
14
+ appendix, auto-fitted display math).
15
+ - `bibliography.Bibliography.load(refs_bib)` -- parse a `.bib`, assign
16
+ biblatex `alpha` labels (`[DHS11]`-style), render the cited subset.
17
+ - `callouts.transform(html)` -- rewrite `> **Theorem 3.1.** ...`
18
+ blockquotes as anchored `<aside class="callout callout-theorem"
19
+ id="theorem-3-1">` blocks; resolve `\ref{}` placeholders to the
20
+ display label.
21
+ - `builder.build(decks_dir, site_dir, *, render=True, site_cfg=None, only=(), scenes=(), watch=False)`
22
+ -- discover -> render -> pdf -> reconcile -> thumbnail -> notes ->
23
+ emit per-deck `slides.html` + page + per-section pages + home.
24
+
25
+ ## Don't
26
+
27
+ - Don't bundle JS or load CDNs. Tailwind / KaTeX / RevealJS / Lato /
28
+ Merriweather are vendored under `static/` (see `static/README.md`).
29
+ - Don't hand-edit anything under `site/`. Edit templates / CSS instead.
30
+ - Don't import Jinja templates as Python modules. Loaded via
31
+ `PackageLoader("simplex.web", "templates")`.
32
+ - Don't write a manual "References" section in `notes.md`. Use
33
+ `\cite{key}` and ship a `refs.bib`; the renderer appends the list.
@@ -0,0 +1 @@
1
+ """Static-site generator: notes + builder + templates."""
@@ -0,0 +1,248 @@
1
+ """BibTeX -> biblatex-alpha citation keys + HTML bibliography.
2
+
3
+ `bibtex.py` does the parsing; this module owns the data model and the
4
+ rendered output.
5
+
6
+ Public surface (see `simplex/web/README.md`):
7
+
8
+ - `Bibliography.parse(text)` / `.load(path)` -- read a `.bib` file.
9
+ - `Bibliography.get(key)` -- retrieve an entry, raises if missing.
10
+ - `Bibliography.has(key)` -- existence check (used by the citations plugin).
11
+ - `Bibliography.to_html(used)` -- ordered <ol> of the cited entries, in the
12
+ order they were first referenced. Pass an empty tuple to suppress.
13
+
14
+ Alpha labels follow `biblatex`'s `alpha` style:
15
+
16
+ 1 author -> 3 letters of last name ("Hou00")
17
+ 2-3 -> 1 letter of each last name ("KB15", "DHS11")
18
+ 4+ -> 3 letters of first three + "+" ("ABG+23")
19
+
20
+ followed by the last two digits of the year (or `??` if unknown).
21
+ """
22
+
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Self
26
+
27
+ from pydantic import BaseModel, ConfigDict
28
+
29
+ from simplex.web import bibtex
30
+
31
+
32
+ class Author(BaseModel):
33
+ """A single author. `last` is what appears in alpha labels."""
34
+
35
+ model_config = ConfigDict(frozen=True)
36
+
37
+ last: str
38
+ first: str = ""
39
+
40
+ @classmethod
41
+ def parse(cls, raw: str) -> Self:
42
+ """Parse a BibTeX author token (`Last, First` or `First Last`)."""
43
+ raw = bibtex.unbrace(raw.strip())
44
+ if "," in raw:
45
+ last, first = raw.split(",", 1)
46
+ return cls(last=last.strip(), first=first.strip())
47
+ parts = raw.split()
48
+ if len(parts) == 1:
49
+ return cls(last=parts[0])
50
+ return cls(last=parts[-1], first=" ".join(parts[:-1]))
51
+
52
+ @property
53
+ def display(self) -> str:
54
+ """`F. Last`-style initials for the bibliography list."""
55
+ if not self.first:
56
+ return self.last
57
+ initials = " ".join(f"{p[0]}." for p in self.first.split() if p)
58
+ return f"{initials} {self.last}".strip()
59
+
60
+
61
+ class BibEntry(BaseModel):
62
+ """A single bibliography entry. The field bag stays a plain dict because
63
+ BibTeX's field set is open."""
64
+
65
+ model_config = ConfigDict(frozen=True)
66
+
67
+ key: str
68
+ entry_type: str
69
+ fields: dict[str, str]
70
+ authors: tuple[Author, ...]
71
+ year: int | None
72
+ alpha_key: str
73
+
74
+ @classmethod
75
+ def make(cls, key: str, entry_type: str, fields: dict[str, str]) -> Self:
76
+ authors = _parse_authors(fields.get("author") or fields.get("editor") or "")
77
+ year = _parse_year(fields.get("year", ""))
78
+ return cls(
79
+ key=key,
80
+ entry_type=entry_type.lower(),
81
+ fields=fields,
82
+ authors=authors,
83
+ year=year,
84
+ alpha_key=_alpha_key(authors, year),
85
+ )
86
+
87
+ def render_html(self) -> str:
88
+ """Emit one <li> for the bibliography list.
89
+
90
+ The alpha-key marker (e.g. `[KB15]`) is rendered as an inline
91
+ `<span class="bib-marker">`, not a CSS counter, so the printed
92
+ bullet matches the inline `[KB15]` citation chip.
93
+ """
94
+ parts: list[str] = []
95
+ if self.authors:
96
+ parts.append(_join_authors(self.authors))
97
+ if title := self.fields.get("title"):
98
+ # Wrap titles in quotes (academic convention). Joining with ", "
99
+ # yields the canonical `Authors, "Title," Venue, Year.` shape.
100
+ parts.append(f"&ldquo;{_escape_html(bibtex.unbrace(title))}&rdquo;")
101
+ if venue := self._venue_html():
102
+ parts.append(venue)
103
+ if self.year is not None:
104
+ parts.append(str(self.year))
105
+ body = ", ".join(parts)
106
+ if link := self._link_html():
107
+ body = f"{body} {link}"
108
+ marker = f'<span class="bib-marker">[{_escape_html(self.alpha_key)}]</span>'
109
+ return f'<li id="bib-{_escape_html(self.key)}" class="bib-entry">{marker} {body}.</li>'
110
+
111
+ def _venue_html(self) -> str:
112
+ for field in ("journal", "booktitle", "publisher", "school", "institution"):
113
+ if value := self.fields.get(field):
114
+ return f"<em>{_escape_html(bibtex.unbrace(value))}</em>"
115
+ return ""
116
+
117
+ def _link_html(self) -> str:
118
+ for field in ("doi", "url"):
119
+ value = self.fields.get(field)
120
+ if not value:
121
+ continue
122
+ value = bibtex.unbrace(value)
123
+ href = value if field == "url" else f"https://doi.org/{value}"
124
+ return (
125
+ f'<a class="bib-link" href="{_escape_html(href)}" '
126
+ f'rel="noopener" target="_blank">[link]</a>'
127
+ )
128
+ return ""
129
+
130
+
131
+ class Bibliography(BaseModel):
132
+ """Parsed .bib file. `entries` is keyed by the BibTeX cite key."""
133
+
134
+ entries: dict[str, BibEntry]
135
+
136
+ @classmethod
137
+ def parse(cls, text: str) -> Self:
138
+ """Parse a BibTeX document. Duplicate keys keep the first definition;
139
+ the citations plugin marks unknown keys as stale."""
140
+ parsed: dict[str, BibEntry] = {}
141
+ for key, entry_type, fields in bibtex.parse(text):
142
+ if key in parsed:
143
+ continue
144
+ parsed[key] = BibEntry.make(key, entry_type, fields)
145
+ return cls(entries=parsed)
146
+
147
+ @classmethod
148
+ def load(cls, path: Path) -> Self:
149
+ return cls.parse(path.read_text(encoding="utf-8"))
150
+
151
+ @classmethod
152
+ def empty(cls) -> Self:
153
+ return cls(entries={})
154
+
155
+ def has(self, key: str) -> bool:
156
+ return key in self.entries
157
+
158
+ def get(self, key: str) -> BibEntry:
159
+ return self.entries[key]
160
+
161
+ def to_html(self, used: tuple[str, ...]) -> str:
162
+ """Render an ordered list of the cited entries, in citation order."""
163
+ seen: set[str] = set()
164
+ ordered: list[BibEntry] = []
165
+ for key in used:
166
+ if key in seen or key not in self.entries:
167
+ continue
168
+ seen.add(key)
169
+ ordered.append(self.entries[key])
170
+ if not ordered:
171
+ return ""
172
+ items = "\n".join(e.render_html() for e in ordered)
173
+ return (
174
+ '<section class="bibliography" aria-labelledby="bib-heading">'
175
+ '<h2 id="bib-heading">References</h2>'
176
+ f'<ol class="bib-list">\n{items}\n</ol>'
177
+ "</section>"
178
+ )
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Author / alpha-key helpers
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _parse_authors(raw: str) -> tuple[Author, ...]:
187
+ if not raw:
188
+ return ()
189
+ parts = re.split(r"\s+and\s+", raw, flags=re.IGNORECASE)
190
+ return tuple(Author.parse(p) for p in parts if p.strip())
191
+
192
+
193
+ def _parse_year(raw: str) -> int | None:
194
+ if not raw:
195
+ return None
196
+ match = re.search(r"\d{4}", raw)
197
+ return int(match.group(0)) if match else None
198
+
199
+
200
+ def _alpha_key(authors: tuple[Author, ...], year: int | None) -> str:
201
+ """biblatex-alpha label. Empty author list -> `Anon` prefix."""
202
+ suffix = f"{year % 100:02d}" if year is not None else "??"
203
+ if not authors:
204
+ return f"Anon{suffix}"
205
+ initials = [_first_letter(a.last) for a in authors]
206
+ match len(initials):
207
+ case 1:
208
+ prefix = (initials[0] + _initial_pad(authors[0].last))[:3]
209
+ case 2 | 3:
210
+ prefix = "".join(initials)
211
+ case _:
212
+ prefix = "".join(initials[:3]) + "+"
213
+ return f"{prefix}{suffix}"
214
+
215
+
216
+ def _first_letter(s: str) -> str:
217
+ for ch in s:
218
+ if ch.isalpha():
219
+ return ch.upper()
220
+ return "?"
221
+
222
+
223
+ def _initial_pad(last: str) -> str:
224
+ """For single-author keys, take chars 2-3 of the last name (lowercased)."""
225
+ letters = [c for c in last if c.isalpha()]
226
+ return "".join(letters[1:3]).lower()
227
+
228
+
229
+ def _join_authors(authors: tuple[Author, ...]) -> str:
230
+ """`A. Smith, B. Jones, and C. Lee`."""
231
+ names = [_escape_html(a.display) for a in authors]
232
+ match len(names):
233
+ case 0:
234
+ return ""
235
+ case 1:
236
+ return names[0]
237
+ case 2:
238
+ return f"{names[0]} and {names[1]}"
239
+ case _:
240
+ head = ", ".join(names[:-1])
241
+ return f"{head}, and {names[-1]}"
242
+
243
+
244
+ _ESCAPE = {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}
245
+
246
+
247
+ def _escape_html(s: str) -> str:
248
+ return "".join(_ESCAPE.get(c, c) for c in s)