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
simplex/web/bibtex.py ADDED
@@ -0,0 +1,129 @@
1
+ """Minimal pure-Python BibTeX parser.
2
+
3
+ A `.bib` file is a sequence of `@type{key, field = value, ...}` entries.
4
+ This module turns one such file into a sequence of
5
+ ``(key, entry_type, fields)`` triples; `bibliography.py` consumes those
6
+ and assembles `BibEntry` / `Bibliography` models on top.
7
+
8
+ Why pure-Python (not `bibtexparser`):
9
+
10
+ - We need a tiny, predictable subset: brace- or quote-delimited field values,
11
+ comma-separated fields, the `@string` / `@preamble` / `@comment` skip set.
12
+ - Hand-rolling that takes <120 LOC; we don't pull in another dep for it.
13
+
14
+ Open issues we deliberately don't handle (and don't need for deck notes):
15
+
16
+ - `@string{x = "y"}` substitution -- treats string aliases as opaque tokens.
17
+ - Concatenation with `#` -- left as-is; doesn't appear in our refs.bib files.
18
+ """
19
+
20
+ import re
21
+ from collections.abc import Iterator
22
+
23
+ _ENTRY_HEAD = re.compile(r"@(?P<type>\w+)\s*\{\s*(?P<key>[^,\s]+)\s*,", re.IGNORECASE)
24
+ _SKIP_TYPES = {"string", "preamble", "comment"}
25
+
26
+
27
+ def parse(text: str) -> Iterator[tuple[str, str, dict[str, str]]]:
28
+ """Yield `(key, entry_type, fields)` triples from a `.bib` document."""
29
+ cleaned = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("%"))
30
+ pos = 0
31
+ while pos < len(cleaned):
32
+ match = _ENTRY_HEAD.search(cleaned, pos)
33
+ if not match:
34
+ return
35
+ entry_type = match.group("type")
36
+ if entry_type.lower() in _SKIP_TYPES:
37
+ pos = _skip_balanced(cleaned, match.end() - 1)
38
+ continue
39
+ body_end = _find_close_brace(cleaned, match.end())
40
+ if body_end < 0:
41
+ return
42
+ yield match.group("key"), entry_type, _parse_fields(cleaned[match.end() : body_end])
43
+ pos = body_end + 1
44
+
45
+
46
+ def _find_close_brace(text: str, start: int) -> int:
47
+ """Return the index of the matching `}` starting from `start` (depth 1),
48
+ or -1 if unterminated. Skips over `"..."` quoted spans (BibTeX has no
49
+ backslash escapes inside quotes)."""
50
+ depth = 1
51
+ i = start
52
+ while i < len(text):
53
+ ch = text[i]
54
+ if ch == "{":
55
+ depth += 1
56
+ elif ch == "}":
57
+ depth -= 1
58
+ if depth == 0:
59
+ return i
60
+ elif ch == '"':
61
+ i = text.find('"', i + 1)
62
+ if i < 0:
63
+ return -1
64
+ i += 1
65
+ return -1
66
+
67
+
68
+ def _skip_balanced(text: str, brace_pos: int) -> int:
69
+ """Return the index *after* a `{...}` group that starts at `brace_pos`."""
70
+ end = _find_close_brace(text, brace_pos + 1)
71
+ return end + 1 if end >= 0 else len(text)
72
+
73
+
74
+ def _parse_fields(body: str) -> dict[str, str]:
75
+ fields: dict[str, str] = {}
76
+ i = 0
77
+ while i < len(body):
78
+ while i < len(body) and body[i] in " \t\r\n,":
79
+ i += 1
80
+ if i >= len(body):
81
+ break
82
+ name_start = i
83
+ while i < len(body) and body[i] not in "= \t\r\n":
84
+ i += 1
85
+ name = body[name_start:i].lower()
86
+ if not name:
87
+ break
88
+ while i < len(body) and body[i] != "=":
89
+ i += 1
90
+ i += 1
91
+ while i < len(body) and body[i] in " \t\r\n":
92
+ i += 1
93
+ if i >= len(body):
94
+ break
95
+ value, i = _read_value(body, i)
96
+ fields[name] = value
97
+ return fields
98
+
99
+
100
+ def _read_value(text: str, start: int) -> tuple[str, int]:
101
+ """Read one field value (braced, quoted, or bare token). Returns the
102
+ value plus the index of the next unread character."""
103
+ if text[start] == "{":
104
+ end = _find_close_brace(text, start + 1)
105
+ if end < 0:
106
+ return text[start + 1 :].strip(), len(text)
107
+ return _normalise_ws(text[start + 1 : end]), end + 1
108
+ if text[start] == '"':
109
+ end = text.find('"', start + 1)
110
+ if end < 0:
111
+ return text[start + 1 :].strip(), len(text)
112
+ return _normalise_ws(text[start + 1 : end]), end + 1
113
+ j = start
114
+ while j < len(text) and text[j] not in ",\n}":
115
+ j += 1
116
+ return text[start:j].strip(), j
117
+
118
+
119
+ def _normalise_ws(s: str) -> str:
120
+ return re.sub(r"\s+", " ", s).strip()
121
+
122
+
123
+ def unbrace(s: str) -> str:
124
+ """Drop the outer `{...}` braces BibTeX uses to protect title casing.
125
+
126
+ Public because `bibliography.py` calls it when rendering field values
127
+ (titles, journals) without dragging in regex internals.
128
+ """
129
+ return re.sub(r"[{}]", "", s)
simplex/web/builder.py ADDED
@@ -0,0 +1,321 @@
1
+ """Build the full static portal under ``site/``.
2
+
3
+ Pipeline (per deck):
4
+
5
+ 1. ``render.runner.render`` (subprocess, skipped when ``render=False``).
6
+ 2. ``render.pdf.export`` (best-effort).
7
+ 3. ``render.reconcile.build_manifest`` reads native section JSON +
8
+ manim-slides PresentationConfig -> main/sub tree.
9
+ 4. ``render.thumbnail.generate`` per main slide (default rule: second-to-last
10
+ subsection's last frame).
11
+ 5. ``render.html.render_html`` renders our custom RevealJS template with
12
+ the reconciled tree + palette CSS.
13
+ 6. ``web.notes.render`` runs ``notes.md`` through markdown-it.
14
+ 7. Write ``index.html`` for the deck page.
15
+
16
+ No render cache. Manim's per-animation cache + ``save_sections=True``
17
+ (applied by the plugin) gives slide-level incrementality for free.
18
+ """
19
+
20
+ import contextlib
21
+ import shutil
22
+ import subprocess
23
+ from datetime import UTC, datetime, time
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ from jinja2 import Environment, PackageLoader, select_autoescape
28
+
29
+ from simplex.deck.config import DeckConfig
30
+ from simplex.deck.registry import Section, SectionedRegistry, discover
31
+ from simplex.deck.section import SectionConfig
32
+ from simplex.render import html, pdf, reconcile, runner, thumbnail
33
+ from simplex.theme.web_css import render_web_css
34
+ from simplex.web import notes, vendor
35
+ from simplex.web.bibliography import Bibliography
36
+ from simplex.web.site_config import SiteConfig
37
+
38
+
39
+ def _jinja(site_cfg: SiteConfig) -> Environment:
40
+ env = Environment(
41
+ loader=PackageLoader("simplex.web", "templates"),
42
+ autoescape=select_autoescape(["html", "j2"]),
43
+ trim_blocks=True,
44
+ lstrip_blocks=True,
45
+ )
46
+
47
+ def static(path: str) -> str:
48
+ return site_cfg.url("static/" + path.lstrip("/"))
49
+
50
+ globals_: dict[str, Any] = cast(dict[str, Any], env.globals)
51
+ globals_["static"] = static
52
+ globals_["site"] = site_cfg
53
+ return env
54
+
55
+
56
+ def _copy_static(site_dir: Path) -> None:
57
+ """Copy bundled static assets into ``site/static/``."""
58
+ src = Path(__file__).parent / "static"
59
+ src.mkdir(parents=True, exist_ok=True)
60
+ vendor.ensure(src)
61
+ dst = site_dir / "static"
62
+ dst.mkdir(parents=True, exist_ok=True)
63
+ for entry in src.iterdir():
64
+ if entry.name in {"README.md", ".gitkeep"}:
65
+ continue
66
+ target = dst / entry.name
67
+ if entry.is_dir():
68
+ shutil.copytree(entry, target, dirs_exist_ok=True)
69
+ else:
70
+ shutil.copy2(entry, target)
71
+
72
+
73
+ def _maybe_render(
74
+ deck: DeckConfig,
75
+ media_dir: Path,
76
+ *,
77
+ render: bool,
78
+ scenes: tuple[str, ...] = (),
79
+ write_last_frame: bool = False,
80
+ ) -> None:
81
+ if not render:
82
+ return
83
+ deck_scenes = tuple(s for s in scenes if s in deck.scene_class_names)
84
+ if scenes and not deck_scenes:
85
+ return
86
+ runner.render(deck, output_dir=media_dir, scenes=deck_scenes, write_last_frame=write_last_frame)
87
+ with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError, ImportError):
88
+ pdf.export(deck, output_dir=media_dir)
89
+
90
+
91
+ def _has_pdf(deck: DeckConfig, deck_dir: Path) -> bool:
92
+ return (deck_dir / f"{deck.slug}.pdf").exists()
93
+
94
+
95
+ def _has_notes_pdf(deck_dir: Path) -> bool:
96
+ return (deck_dir / "notes.pdf").exists()
97
+
98
+
99
+ def _load_bibliography(deck_path: Path) -> Bibliography | None:
100
+ refs = deck_path / "refs.bib"
101
+ return Bibliography.load(refs) if refs.exists() else None
102
+
103
+
104
+ def _site_thumb(deck_out: Path, thumbs: dict[int, Path]) -> str | None:
105
+ """Return the cover thumbnail (main slide #1) for a deck card."""
106
+ first = thumbs.get(1)
107
+ if first is None:
108
+ return None
109
+ if first.is_absolute():
110
+ try:
111
+ first = first.relative_to(deck_out)
112
+ except ValueError:
113
+ return None
114
+ return first.as_posix()
115
+
116
+
117
+ def _deck_created_timestamp(deck: DeckConfig) -> float:
118
+ """Sort key for "latest" decks: explicit creation date, then file mtime."""
119
+ if deck.created_at is not None:
120
+ return datetime.combine(deck.created_at, time.min, tzinfo=UTC).timestamp()
121
+ toml = deck.path / "deck.toml"
122
+ with contextlib.suppress(OSError):
123
+ return toml.stat().st_mtime
124
+ return 0.0
125
+
126
+
127
+ def _deck_created_label(deck: DeckConfig) -> str:
128
+ if deck.created_at is not None:
129
+ return deck.created_at.strftime("%b %d, %Y")
130
+ toml = deck.path / "deck.toml"
131
+ with contextlib.suppress(OSError):
132
+ return datetime.fromtimestamp(toml.stat().st_mtime, UTC).strftime("%b %d, %Y")
133
+ return ""
134
+
135
+
136
+ def _latest_section(registry: SectionedRegistry, *, limit: int = 12) -> Section | None:
137
+ decks = sorted(registry.all_decks, key=_deck_created_timestamp, reverse=True)
138
+ if not decks:
139
+ return None
140
+ return Section(
141
+ config=SectionConfig(
142
+ slug="latest",
143
+ title="Latest decks",
144
+ blurb="Newest work first.",
145
+ order=-1,
146
+ ),
147
+ decks=tuple(decks[:limit]),
148
+ )
149
+
150
+
151
+ def _build_deck(
152
+ deck: DeckConfig,
153
+ *,
154
+ site_dir: Path,
155
+ site_cfg: SiteConfig,
156
+ env: Environment,
157
+ render: bool,
158
+ scenes: tuple[str, ...] = (),
159
+ watch: bool = False,
160
+ ) -> tuple[str | None, str | None]:
161
+ """Render one deck. Returns ``(cover thumbnail, carousel gif)`` hrefs."""
162
+ deck_out = site_dir / "decks" / deck.slug
163
+ deck_out.mkdir(parents=True, exist_ok=True)
164
+
165
+ _maybe_render(deck, deck_out, render=render, scenes=scenes)
166
+
167
+ manifest = reconcile.build_manifest(deck, media_dir=deck_out)
168
+ thumbs = thumbnail.generate(deck, manifest, site_deck_dir=deck_out, cache_dir=deck_out)
169
+ preview_gif = thumbnail.generate_carousel_gif(
170
+ deck,
171
+ manifest,
172
+ site_deck_dir=deck_out,
173
+ cache_dir=deck_out,
174
+ )
175
+
176
+ enriched = tuple(
177
+ main.model_copy(update={"thumbnail": thumbs.get(main.index)})
178
+ for main in manifest.main_slides
179
+ )
180
+
181
+ html.render_html(
182
+ deck,
183
+ manifest.model_copy(update={"main_slides": enriched}),
184
+ output_dir=deck_out,
185
+ static_prefix=site_cfg.url("static"),
186
+ watch=watch,
187
+ )
188
+
189
+ notes_md = deck.path / "notes.md"
190
+ notes_html = ""
191
+ if notes_md.exists():
192
+ bib = _load_bibliography(deck.path)
193
+ notes_html = notes.render(notes_md, slide_count=len(enriched), bibliography=bib)
194
+
195
+ total_seconds = sum(m.duration_s for m in enriched)
196
+ total_minutes: int | None = int(total_seconds // 60) if total_seconds > 0 else None
197
+ if deck.duration_minutes is not None:
198
+ total_minutes = deck.duration_minutes
199
+
200
+ page = env.get_template("deck.html").render(
201
+ deck=deck,
202
+ slides=enriched,
203
+ slide_count=len(enriched),
204
+ total_duration_min=total_minutes,
205
+ has_pdf=_has_pdf(deck, deck_out),
206
+ has_notes_pdf=_has_notes_pdf(deck_out),
207
+ notes_html=notes_html,
208
+ palette_css=render_web_css(deck.resolved_web_palette()),
209
+ )
210
+ (deck_out / "index.html").write_text(page, encoding="utf-8")
211
+ return (
212
+ _site_thumb(deck_out, thumbs),
213
+ preview_gif.as_posix() if preview_gif is not None else None,
214
+ )
215
+
216
+
217
+ def _build_section_page(
218
+ section: Section,
219
+ site_dir: Path,
220
+ env: Environment,
221
+ thumbs: dict[str, str | None],
222
+ preview_gifs: dict[str, str | None],
223
+ deck_dates: dict[str, str],
224
+ palette_css: str,
225
+ ) -> None:
226
+ out = site_dir / "sections" / section.config.slug
227
+ out.mkdir(parents=True, exist_ok=True)
228
+ page = env.get_template("section.html").render(
229
+ section=section,
230
+ thumbs=thumbs,
231
+ preview_gifs=preview_gifs,
232
+ deck_dates=deck_dates,
233
+ palette_css=palette_css,
234
+ )
235
+ (out / "index.html").write_text(page, encoding="utf-8")
236
+
237
+
238
+ def _build_index(
239
+ registry: SectionedRegistry,
240
+ site_dir: Path,
241
+ env: Environment,
242
+ thumbs: dict[str, str | None],
243
+ preview_gifs: dict[str, str | None],
244
+ deck_dates: dict[str, str],
245
+ palette_css: str,
246
+ ) -> None:
247
+ page = env.get_template("index.html").render(
248
+ registry=registry,
249
+ latest_section=_latest_section(registry),
250
+ thumbs=thumbs,
251
+ preview_gifs=preview_gifs,
252
+ deck_dates=deck_dates,
253
+ palette_css=palette_css,
254
+ )
255
+ (site_dir / "index.html").write_text(page, encoding="utf-8")
256
+
257
+
258
+ def _site_palette_css(site_cfg: SiteConfig) -> str:
259
+ """Return CSS for the site-wide palette (uses site_cfg.theme if set, else default)."""
260
+ from simplex.theme.presets import get as get_theme
261
+
262
+ theme_name = getattr(site_cfg, "theme", None) or "dastimator_dark"
263
+ return render_web_css(get_theme(theme_name).web_palette)
264
+
265
+
266
+ def build(
267
+ decks_dir: Path,
268
+ site_dir: Path,
269
+ *,
270
+ render: bool = True,
271
+ site_cfg: SiteConfig | None = None,
272
+ only: tuple[str, ...] = (),
273
+ scenes: tuple[str, ...] = (),
274
+ watch: bool = False,
275
+ ) -> None:
276
+ """Discover decks, render them, write the static site."""
277
+ site_cfg = site_cfg or SiteConfig.load(repo_root=decks_dir.parent)
278
+ registry = discover(decks_dir, default_section_order=site_cfg.default_section_order)
279
+ site_dir.mkdir(parents=True, exist_ok=True)
280
+ env = _jinja(site_cfg)
281
+ _copy_static(site_dir)
282
+
283
+ only_set = set(only)
284
+ deck_thumbs: dict[str, str | None] = {}
285
+ deck_preview_gifs: dict[str, str | None] = {}
286
+ deck_dates = {deck.slug: _deck_created_label(deck) for deck in registry.all_decks}
287
+ for section in registry.sections:
288
+ for deck in section.decks:
289
+ if only_set and deck.slug not in only_set:
290
+ continue
291
+ deck_thumbs[deck.slug], deck_preview_gifs[deck.slug] = _build_deck(
292
+ deck,
293
+ site_dir=site_dir,
294
+ site_cfg=site_cfg,
295
+ env=env,
296
+ render=render,
297
+ scenes=scenes,
298
+ watch=watch,
299
+ )
300
+
301
+ site_palette_css = _site_palette_css(site_cfg)
302
+ for section in registry.sections:
303
+ _build_section_page(
304
+ section,
305
+ site_dir,
306
+ env,
307
+ deck_thumbs,
308
+ deck_preview_gifs,
309
+ deck_dates,
310
+ site_palette_css,
311
+ )
312
+
313
+ _build_index(
314
+ registry,
315
+ site_dir,
316
+ env,
317
+ deck_thumbs,
318
+ deck_preview_gifs,
319
+ deck_dates,
320
+ site_palette_css,
321
+ )
@@ -0,0 +1,134 @@
1
+ r"""Theorem-environment callouts + ``\ref{}`` resolution.
2
+
3
+ Rewrites ``<blockquote><p><strong>Theorem 3.1.</strong>...</p></blockquote>``
4
+ shapes into colour-coded, anchorable ``<aside>`` blocks:
5
+
6
+ <aside class="callout callout-theorem" id="theorem-3-1">
7
+ <span class="callout-tag">Theorem 3.1.</span> Let f...
8
+ </aside>
9
+
10
+ Recognised types (case-insensitive): **theorem, lemma, proposition,
11
+ corollary, claim, fact, definition, example, remark, proof, observation,
12
+ note**. Anything else stays a normal blockquote.
13
+
14
+ Also resolves the placeholders emitted by `web/refs.py`:
15
+
16
+ <a class="ref" data-simplex-ref="theorem-3-1" href="#theorem-3-1">
17
+ theorem-3-1
18
+ </a>
19
+ |
20
+ v
21
+ <a class="ref" href="#theorem-3-1">Theorem 3.1</a>
22
+
23
+ Unknown ref ids get the ``ref-stale`` class (same convention as
24
+ ``cite-stale`` / ``slide-ref-stale``).
25
+
26
+ Pure HTML transformation -- the markdown-it pipeline stays untouched.
27
+ """
28
+
29
+ import re
30
+ from collections import defaultdict
31
+
32
+ # Types we colour-code. Order matters only for matching priority -- longer
33
+ # names first so "Proposition" beats a hypothetical "Prop" prefix.
34
+ _TYPES: tuple[str, ...] = (
35
+ "Proposition",
36
+ "Definition",
37
+ "Observation",
38
+ "Corollary",
39
+ "Theorem",
40
+ "Example",
41
+ "Lemma",
42
+ "Remark",
43
+ "Proof",
44
+ "Claim",
45
+ "Fact",
46
+ "Note",
47
+ )
48
+ _TYPES_LC: frozenset[str] = frozenset(t.lower() for t in _TYPES)
49
+ _TYPE_ALT = "|".join(_TYPES)
50
+
51
+ # Match a leading `<p><strong>Theorem 3.1.</strong>` inside a blockquote.
52
+ # The number is optional (``Proof.`` / ``Remark.`` may stand alone). We
53
+ # capture the trailing period(s) too so the tag prints faithfully.
54
+ _TAG_RE = re.compile(
55
+ rf"<p>\s*<strong>\s*(?P<type>{_TYPE_ALT})"
56
+ r"(?:\s+(?P<num>\d+(?:\.\d+)*))?\s*\.\s*</strong>\s*",
57
+ re.IGNORECASE,
58
+ )
59
+ _BLOCKQUOTE_RE = re.compile(r"<blockquote>(?P<body>.*?)</blockquote>", re.DOTALL)
60
+ _REF_RE = re.compile(
61
+ r'<a class="ref" data-simplex-ref="(?P<id>[^"]+)" '
62
+ r'href="#(?P=id)">(?P<fallback>[^<]+)</a>'
63
+ )
64
+
65
+
66
+ def transform(html: str, *, extra_labels: dict[str, str] | None = None) -> str:
67
+ """Rewrite theorem-style blockquotes and resolve `\\ref{}` placeholders.
68
+
69
+ `extra_labels` lets other passes (e.g. `equations.transform`) seed the
70
+ label map: any id present there is reachable via `\\ref{id}` and
71
+ rendered with the supplied display string. Callout ids overwrite
72
+ extra labels of the same name (rare, but the callout is the more
73
+ specific definition).
74
+
75
+ Two-pass:
76
+ 1. Walk every blockquote, detect callout type, rewrite to `<aside>`,
77
+ and record the label map (`"theorem-3-1" -> "Theorem 3.1"`).
78
+ 2. Walk every `<a class="ref">` placeholder and substitute the
79
+ display label, combining `extra_labels` with the callout map.
80
+ """
81
+ labels: dict[str, str] = dict(extra_labels or {})
82
+ # Auto-numbering for unnumbered callouts of the same type (e.g. multiple
83
+ # standalone "Proof." blocks). Keyed by lowercase type.
84
+ counters: dict[str, int] = defaultdict(int)
85
+
86
+ def rewrite_blockquote(match: re.Match[str]) -> str:
87
+ body = match.group("body")
88
+ tag_match = _TAG_RE.search(body)
89
+ if not tag_match:
90
+ return match.group(0)
91
+ # Bail out if the tag isn't actually at the top of the blockquote --
92
+ # we don't want to rewrite a quoted theorem reference appearing
93
+ # mid-paragraph as a callout.
94
+ prefix = body[: tag_match.start()].strip()
95
+ if prefix:
96
+ return match.group(0)
97
+
98
+ kind = tag_match.group("type").lower()
99
+ if kind not in _TYPES_LC:
100
+ return match.group(0)
101
+
102
+ num = tag_match.group("num")
103
+ if num:
104
+ slug_num = num.replace(".", "-")
105
+ display = f"{tag_match.group('type').title()} {num}"
106
+ else:
107
+ counters[kind] += 1
108
+ slug_num = str(counters[kind])
109
+ display = tag_match.group("type").title()
110
+
111
+ block_id = f"{kind}-{slug_num}"
112
+ # Record both the bare id and the numbered display so refs work.
113
+ labels[block_id] = display
114
+
115
+ tag_html = f'<span class="callout-tag">{display}.</span> '
116
+ new_body = body[: tag_match.start()] + "<p>" + tag_html + body[tag_match.end() :]
117
+ return (
118
+ f'<aside class="callout callout-{kind}" id="{block_id}" role="note">{new_body}</aside>'
119
+ )
120
+
121
+ out = _BLOCKQUOTE_RE.sub(rewrite_blockquote, html)
122
+
123
+ def resolve_ref(match: re.Match[str]) -> str:
124
+ ref_id = match.group("id")
125
+ if ref_id in labels:
126
+ return f'<a class="ref" href="#{ref_id}">{labels[ref_id]}</a>'
127
+ # Slide refs / external IDs may legitimately not be in the label
128
+ # map; mark unresolved ones as stale so the build flags them.
129
+ return (
130
+ f'<a class="ref ref-stale" href="#{ref_id}" '
131
+ f'title="Unresolved reference: {ref_id}">{match.group("fallback")}?</a>'
132
+ )
133
+
134
+ return _REF_RE.sub(resolve_ref, out)
@@ -0,0 +1,118 @@
1
+ r"""markdown-it-py plugin -- `\cite{key1, key2}` -> linked alpha tag.
2
+
3
+ Renders as `<a class="cite" href="#bib-{key}">[Auth23]</a>`. Multiple keys
4
+ inside one `\cite{...}` produce a single bracket with separators:
5
+ `[Auth23, Smit24]`.
6
+
7
+ The plugin stashes the cited keys onto ``state.env["citations"]`` so the
8
+ notes renderer can emit a per-deck bibliography in citation order.
9
+
10
+ Unknown keys render with the extra class ``cite-stale`` (mirrors
11
+ `slide_ref`) -- the build still produces a usable page that visibly
12
+ flags the issue.
13
+ """
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from markdown_it import MarkdownIt
19
+ from markdown_it.rules_inline import StateInline
20
+
21
+ from simplex.web.bibliography import Bibliography
22
+
23
+ NAME = "cite"
24
+ ENV_KEY = "citations"
25
+
26
+ # `\cite{key1, key2}` -- whitespace permitted inside the braces.
27
+ _PATTERN = re.compile(r"\\cite\{([^}]+)\}")
28
+ # BibTeX keys are alphanumerics plus a small punctuation set.
29
+ _KEY_RE = re.compile(r"^[A-Za-z0-9_:\-./+]+$")
30
+
31
+
32
+ def make_plugin(bibliography: Bibliography | None) -> Any:
33
+ """Return a markdown-it plugin bound to the deck's bibliography."""
34
+
35
+ bib = bibliography or Bibliography.empty()
36
+
37
+ def plugin(md: MarkdownIt) -> None:
38
+ def rule(state: StateInline, silent: bool) -> bool:
39
+ if state.src[state.pos] != "\\":
40
+ return False
41
+ match = _PATTERN.match(state.src, state.pos)
42
+ if not match:
43
+ return False
44
+ keys = tuple(_clean_keys(match.group(1)))
45
+ if not keys:
46
+ return False
47
+ # Silent / validation mode: callers like `parseLinkLabel` need
48
+ # `state.pos` advanced past the match, otherwise they loop.
49
+ if silent:
50
+ state.pos = match.end()
51
+ return True
52
+ _record_keys(state, keys)
53
+ token = state.push("html_inline", "", 0)
54
+ token.content = _render(keys, bib)
55
+ state.pos = match.end()
56
+ return True
57
+
58
+ # Must run before `escape`: markdown-it's escape rule consumes the
59
+ # leading backslash unconditionally, so `\cite{...}` never reaches
60
+ # `link`-level rules. Putting `cite` ahead of `escape` keeps the
61
+ # backslash available to match the LaTeX-style pattern.
62
+ md.inline.ruler.before("escape", NAME, rule)
63
+
64
+ return plugin
65
+
66
+
67
+ def _clean_keys(raw: str) -> list[str]:
68
+ out: list[str] = []
69
+ for piece in raw.split(","):
70
+ key = piece.strip()
71
+ if key and _KEY_RE.match(key):
72
+ out.append(key)
73
+ return out
74
+
75
+
76
+ def _record_keys(state: StateInline, keys: tuple[str, ...]) -> None:
77
+ """Append to `state.env[ENV_KEY]` so the renderer can build a bib list."""
78
+ used: list[str] = state.env.setdefault(ENV_KEY, [])
79
+ for key in keys:
80
+ if key not in used:
81
+ used.append(key)
82
+
83
+
84
+ def _render(keys: tuple[str, ...], bib: Bibliography) -> str:
85
+ parts: list[str] = []
86
+ for key in keys:
87
+ if bib.has(key):
88
+ entry = bib.get(key)
89
+ parts.append(
90
+ f'<a class="cite" href="#bib-{_attr(key)}" '
91
+ f'title="{_attr(_title(entry))}">{_text(entry.alpha_key)}</a>'
92
+ )
93
+ else:
94
+ parts.append(
95
+ f'<a class="cite cite-stale" href="#" '
96
+ f'title="Unknown citation key: {_attr(key)}">{_text(key)}?</a>'
97
+ )
98
+ inner = ", ".join(parts)
99
+ return f'<span class="cite-group">[{inner}]</span>'
100
+
101
+
102
+ def _title(entry: Any) -> str:
103
+ title = entry.fields.get("title", entry.key)
104
+ if entry.year is not None:
105
+ return f"{title} ({entry.year})"
106
+ return title
107
+
108
+
109
+ _ATTR = {"&": "&amp;", "<": "&lt;", '"': "&quot;"}
110
+ _TEXT = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
111
+
112
+
113
+ def _attr(s: str) -> str:
114
+ return "".join(_ATTR.get(c, c) for c in s)
115
+
116
+
117
+ def _text(s: str) -> str:
118
+ return "".join(_TEXT.get(c, c) for c in s)