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.
- simplex/README.md +32 -0
- simplex/cli/README.md +13 -0
- simplex/cli/__init__.py +5 -0
- simplex/cli/commands.py +384 -0
- simplex/deck/README.md +19 -0
- simplex/deck/__init__.py +7 -0
- simplex/deck/_template/assets/.gitkeep +0 -0
- simplex/deck/_template/assets/code/.gitkeep +0 -0
- simplex/deck/_template/assets/figures/.gitkeep +0 -0
- simplex/deck/_template/deck.toml +11 -0
- simplex/deck/_template/manim.cfg +3 -0
- simplex/deck/_template/notes.md +27 -0
- simplex/deck/_template/refs.bib +12 -0
- simplex/deck/_template/slides/__init__.py +7 -0
- simplex/deck/_template/slides/intro.py +21 -0
- simplex/deck/config.py +207 -0
- simplex/deck/registry.py +110 -0
- simplex/deck/scaffold.py +86 -0
- simplex/deck/section.py +40 -0
- simplex/engine/README.md +9 -0
- simplex/render/README.md +46 -0
- simplex/render/__init__.py +1 -0
- simplex/render/html.py +132 -0
- simplex/render/pdf.py +32 -0
- simplex/render/pptx.py +32 -0
- simplex/render/reconcile.py +350 -0
- simplex/render/runner.py +116 -0
- simplex/render/thumbnail.py +374 -0
- simplex/slides/README.md +9 -0
- simplex/slides/components/README.md +9 -0
- simplex/theme/README.md +9 -0
- simplex/web/README.md +33 -0
- simplex/web/__init__.py +1 -0
- simplex/web/bibliography.py +248 -0
- simplex/web/bibtex.py +129 -0
- simplex/web/builder.py +321 -0
- simplex/web/callouts.py +134 -0
- simplex/web/citations.py +118 -0
- simplex/web/equations.py +79 -0
- simplex/web/notes.py +135 -0
- simplex/web/refs.py +60 -0
- simplex/web/sidenotes.py +76 -0
- simplex/web/site_config.py +71 -0
- simplex/web/slide_ref.py +54 -0
- simplex/web/static/.gitkeep +0 -0
- simplex/web/static/README.md +23 -0
- simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
- simplex/web/static/htmx.min.js +1 -0
- simplex/web/static/katex/auto-render.min.js +1 -0
- simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- simplex/web/static/katex/katex.min.css +1 -0
- simplex/web/static/katex/katex.min.js +1 -0
- simplex/web/static/lucide/README.md +7 -0
- simplex/web/static/lucide/lucide.min.js +12 -0
- simplex/web/static/notes.js +68 -0
- simplex/web/static/reveal.js/reset.css +30 -0
- simplex/web/static/reveal.js/reveal.css +8 -0
- simplex/web/static/reveal.js/reveal.js +9 -0
- simplex/web/static/simplex.css +1870 -0
- simplex/web/static/tailwind.js +64 -0
- simplex/web/static/viewer.js +428 -0
- simplex/web/templates/README.md +19 -0
- simplex/web/templates/_carousel.html +117 -0
- simplex/web/templates/base.html +110 -0
- simplex/web/templates/deck.html +149 -0
- simplex/web/templates/index.html +20 -0
- simplex/web/templates/revealjs.html.j2 +374 -0
- simplex/web/templates/section.html +74 -0
- simplex/web/vendor.py +148 -0
- simplex_web-0.2.0.dist-info/METADATA +166 -0
- simplex_web-0.2.0.dist-info/RECORD +91 -0
- simplex_web-0.2.0.dist-info/WHEEL +4 -0
- simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
simplex/web/callouts.py
ADDED
|
@@ -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)
|
simplex/web/citations.py
ADDED
|
@@ -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 = {"&": "&", "<": "<", '"': """}
|
|
110
|
+
_TEXT = {"&": "&", "<": "<", ">": ">"}
|
|
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)
|