keyframes 0.1.0__tar.gz

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 (77) hide show
  1. keyframes-0.1.0/PKG-INFO +119 -0
  2. keyframes-0.1.0/README.md +98 -0
  3. keyframes-0.1.0/pyproject.toml +62 -0
  4. keyframes-0.1.0/pyproject.toml.orig +47 -0
  5. keyframes-0.1.0/src/keyframes/__init__.py +1 -0
  6. keyframes-0.1.0/src/keyframes/build.py +102 -0
  7. keyframes-0.1.0/src/keyframes/check.py +107 -0
  8. keyframes-0.1.0/src/keyframes/cli.py +149 -0
  9. keyframes-0.1.0/src/keyframes/deck.py +67 -0
  10. keyframes-0.1.0/src/keyframes/export.py +106 -0
  11. keyframes-0.1.0/src/keyframes/guide/__init__.py +25 -0
  12. keyframes-0.1.0/src/keyframes/guide/components/keyframes-code.md +60 -0
  13. keyframes-0.1.0/src/keyframes/guide/components/keyframes-excalidraw.md +148 -0
  14. keyframes-0.1.0/src/keyframes/guide/components/keyframes-mujoco.md +76 -0
  15. keyframes-0.1.0/src/keyframes/guide/components/keyframes-slider.md +65 -0
  16. keyframes-0.1.0/src/keyframes/guide/components/keyframes-three.md +81 -0
  17. keyframes-0.1.0/src/keyframes/guide/core.md +113 -0
  18. keyframes-0.1.0/src/keyframes/patch.py +77 -0
  19. keyframes-0.1.0/src/keyframes/render.py +32 -0
  20. keyframes-0.1.0/src/keyframes/scaffold/.gitignore +3 -0
  21. keyframes-0.1.0/src/keyframes/scaffold/CLAUDE.md +1 -0
  22. keyframes-0.1.0/src/keyframes/scaffold/assets/deck.css +1 -0
  23. keyframes-0.1.0/src/keyframes/scaffold/assets/figure.svg +1 -0
  24. keyframes-0.1.0/src/keyframes/scaffold/deck.toml +10 -0
  25. keyframes-0.1.0/src/keyframes/scaffold/references/.gitkeep +0 -0
  26. keyframes-0.1.0/src/keyframes/scaffold/slides/01-title.html +4 -0
  27. keyframes-0.1.0/src/keyframes/scaffold/slides/02-intro.html +17 -0
  28. keyframes-0.1.0/src/keyframes/server.py +204 -0
  29. keyframes-0.1.0/src/keyframes/static/components/keyframes-code.js +423 -0
  30. keyframes-0.1.0/src/keyframes/static/components/keyframes-excalidraw.js +189 -0
  31. keyframes-0.1.0/src/keyframes/static/components/keyframes-mujoco.js +515 -0
  32. keyframes-0.1.0/src/keyframes/static/components/keyframes-slider.js +255 -0
  33. keyframes-0.1.0/src/keyframes/static/components/keyframes-three.js +224 -0
  34. keyframes-0.1.0/src/keyframes/static/editor/editor.css +228 -0
  35. keyframes-0.1.0/src/keyframes/static/editor/editor.js +594 -0
  36. keyframes-0.1.0/src/keyframes/static/editor/panel.css +127 -0
  37. keyframes-0.1.0/src/keyframes/static/editor/panel.js +357 -0
  38. keyframes-0.1.0/src/keyframes/static/index.html +23 -0
  39. keyframes-0.1.0/src/keyframes/static/runtime/component.js +126 -0
  40. keyframes-0.1.0/src/keyframes/static/runtime/live.js +31 -0
  41. keyframes-0.1.0/src/keyframes/static/runtime/math.js +33 -0
  42. keyframes-0.1.0/src/keyframes/static/runtime/presenter.js +91 -0
  43. keyframes-0.1.0/src/keyframes/static/runtime/runtime.css +91 -0
  44. keyframes-0.1.0/src/keyframes/static/runtime/runtime.js +215 -0
  45. keyframes-0.1.0/src/keyframes/static/runtime/steps.js +15 -0
  46. keyframes-0.1.0/src/keyframes/terminal.py +144 -0
  47. keyframes-0.1.0/src/keyframes/themes/default/STYLE_GUIDE.md +73 -0
  48. keyframes-0.1.0/src/keyframes/themes/default/assets/arc-top.webp +0 -0
  49. keyframes-0.1.0/src/keyframes/themes/default/assets/orbit-ring.webp +0 -0
  50. keyframes-0.1.0/src/keyframes/themes/default/assets/streaks-top.webp +0 -0
  51. keyframes-0.1.0/src/keyframes/themes/default/assets/streaks-wide.webp +0 -0
  52. keyframes-0.1.0/src/keyframes/themes/default/assets/sun-top.webp +0 -0
  53. keyframes-0.1.0/src/keyframes/themes/default/assets/title-orbit.webp +0 -0
  54. keyframes-0.1.0/src/keyframes/themes/default/layouts/content.html +16 -0
  55. keyframes-0.1.0/src/keyframes/themes/default/layouts/figure.html +7 -0
  56. keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-2.html +19 -0
  57. keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-3x2.html +28 -0
  58. keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-4.html +44 -0
  59. keyframes-0.1.0/src/keyframes/themes/default/layouts/media-side.html +16 -0
  60. keyframes-0.1.0/src/keyframes/themes/default/layouts/section-arc.html +4 -0
  61. keyframes-0.1.0/src/keyframes/themes/default/layouts/section-streaks.html +4 -0
  62. keyframes-0.1.0/src/keyframes/themes/default/layouts/section.html +4 -0
  63. keyframes-0.1.0/src/keyframes/themes/default/layouts/split-wide.html +7 -0
  64. keyframes-0.1.0/src/keyframes/themes/default/layouts/split.html +13 -0
  65. keyframes-0.1.0/src/keyframes/themes/default/layouts/statement.html +4 -0
  66. keyframes-0.1.0/src/keyframes/themes/default/layouts/title.html +5 -0
  67. keyframes-0.1.0/src/keyframes/themes/default/layouts/two-column.html +5 -0
  68. keyframes-0.1.0/src/keyframes/themes/default/theme.css +330 -0
  69. keyframes-0.1.0/src/keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
  70. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/figure.html +7 -0
  71. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/section.html +4 -0
  72. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/statement.html +3 -0
  73. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/title-body.html +10 -0
  74. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/title.html +5 -0
  75. keyframes-0.1.0/src/keyframes/themes/minimal/layouts/two-column.html +11 -0
  76. keyframes-0.1.0/src/keyframes/themes/minimal/theme.css +196 -0
  77. keyframes-0.1.0/src/keyframes/vendor.py +181 -0
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.3
2
+ Name: keyframes
3
+ Version: 0.1.0
4
+ Summary: Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams
5
+ Keywords: slides,presentation,llm,agents,three.js,mujoco,katex
6
+ Author: Pranay Thangeda
7
+ Author-email: Pranay Thangeda <contact@prny.me>
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Multimedia :: Graphics :: Presentation
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: starlette>=1.7.0
13
+ Requires-Dist: uvicorn>=0.54.0
14
+ Requires-Dist: watchfiles>=1.3.0
15
+ Requires-Dist: websockets>=17.2
16
+ Requires-Dist: playwright>=1.63.0 ; extra == 'export'
17
+ Requires-Dist: pypdf>=6.20.1 ; extra == 'export'
18
+ Requires-Python: >=3.12
19
+ Provides-Extra: export
20
+ Description-Content-Type: text/markdown
21
+
22
+ # keyframes
23
+
24
+ HTML slide decks that LLMs write and you arrange by hand.
25
+
26
+ - **Slides are plain HTML files** on a fixed 1920×1080 canvas. Claude Code, Codex & co.
27
+ write them from your references; a generated `AGENTS.md` teaches them the format.
28
+ - **Live editor in Chrome**: press `E` to drag, resize, snap and nudge blocks. Changes are
29
+ written straight back into the slide source (only the touched attributes change), so
30
+ your layout and the LLM's edits never fight.
31
+ - **Rich embeds** as web components: three.js scenes, MuJoCo WASM physics, editable/runnable
32
+ code (JS + Python via Pyodide), Excalidraw figures, KaTeX math, video.
33
+ - **Present anywhere**: presenter view with notes and timer, offline-safe dependency cache,
34
+ PDF export, static site export.
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ uv init my-talk && cd my-talk
40
+ uv add keyframes # or: uv add 'keyframes[export]' for pdf/shot/check --render
41
+ uv run keyframes init .
42
+ uv run keyframes dev # opens Chrome; edit slides/*.html or ask your LLM to
43
+ ```
44
+
45
+ Then ask your agent something like *"Using the papers in references/, draft a 12-slide talk
46
+ on X. Put the MuJoCo demo from assets/ on slide 5."* and rearrange the result with `E`.
47
+
48
+ For exports install the browser once: `uv run playwright install chromium` (or have Chrome).
49
+
50
+ ## In the browser
51
+
52
+ | key | action |
53
+ | --- | --- |
54
+ | → / Space, ← | next / previous (build steps included) |
55
+ | E | toggle the layout editor |
56
+ | S | presenter view (notes, next slide, timer) |
57
+ | F | fullscreen · B blackout |
58
+
59
+ Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
60
+ aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
61
+ embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
62
+ ⌘Z / ⌘⇧Z undo/redo, `?` for help.
63
+
64
+ Edit mode also opens a resizable side panel (toggle with `` ` ``):
65
+
66
+ - **Source**: the current slide's HTML. Selecting a block jumps to its tag; edits save and
67
+ hot-reload as you type; drags and LLM edits flow back in.
68
+ - **Claude / Codex / Shell**: real terminals running in the deck folder (whichever CLIs are
69
+ installed). Sessions survive page reloads. `+ ref` (or `R`) types the selected blocks'
70
+ reference into the prompt, e.g. `@slides/04-problem.html (block "figure")`.
71
+
72
+ The dev server only accepts editor/terminal requests from its own page, but it does give that
73
+ page a shell: keep it bound to 127.0.0.1 (the default).
74
+
75
+ ## Commands
76
+
77
+ ```
78
+ keyframes init [path] new deck from the scaffold
79
+ keyframes dev live server + editor
80
+ keyframes present serve without the editor
81
+ keyframes check [--render] lint; --render adds browser errors and text overflow
82
+ keyframes shot [N ...] PNG screenshots → .keyframes/shots/
83
+ keyframes pdf PDF export (one page per slide, all builds shown)
84
+ keyframes build static site → dist/
85
+ keyframes guide full authoring reference (for LLMs)
86
+ keyframes themes list built-in themes
87
+ keyframes eject-theme copy the theme into the deck to customize it
88
+ ```
89
+
90
+ ## Deck layout
91
+
92
+ ```
93
+ my-talk/
94
+ deck.toml title, theme, canvas size, extra <head> tags, extra ES module imports
95
+ AGENTS.md authoring rules for LLMs (CLAUDE.md imports it)
96
+ slides/ 01-title.html, 02-motivation.html, ... (ordered by filename)
97
+ assets/ images, video, MJCF models, three.js scene modules, .excalidraw files
98
+ references/ papers/notes for the LLM to draw from (never exported)
99
+ .keyframes/ dependency cache + screenshots (gitignored)
100
+ ```
101
+
102
+ ## Themes
103
+
104
+ - `default`: a clone of Google Slides' "simple-light" template (Google Sans + Roboto Light,
105
+ orbit artwork on title/section slides, split layouts with a grey media panel).
106
+ - `minimal`: plain Inter/JetBrains Mono theme.
107
+
108
+ A theme is a folder with `theme.css` (CSS variables + text/container classes),
109
+ `STYLE_GUIDE.md` (rules the LLM follows) and `layouts/*.html` (starting-point slides).
110
+ Use `theme = "./theme"` in `deck.toml` for a deck-local theme.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ uv sync --all-extras
116
+ uv run pytest
117
+ uv run ruff check src tests && uv run ruff format src tests
118
+ cd examples/showcase && uv run keyframes dev # every component in one deck
119
+ ```
@@ -0,0 +1,98 @@
1
+ # keyframes
2
+
3
+ HTML slide decks that LLMs write and you arrange by hand.
4
+
5
+ - **Slides are plain HTML files** on a fixed 1920×1080 canvas. Claude Code, Codex & co.
6
+ write them from your references; a generated `AGENTS.md` teaches them the format.
7
+ - **Live editor in Chrome**: press `E` to drag, resize, snap and nudge blocks. Changes are
8
+ written straight back into the slide source (only the touched attributes change), so
9
+ your layout and the LLM's edits never fight.
10
+ - **Rich embeds** as web components: three.js scenes, MuJoCo WASM physics, editable/runnable
11
+ code (JS + Python via Pyodide), Excalidraw figures, KaTeX math, video.
12
+ - **Present anywhere**: presenter view with notes and timer, offline-safe dependency cache,
13
+ PDF export, static site export.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ uv init my-talk && cd my-talk
19
+ uv add keyframes # or: uv add 'keyframes[export]' for pdf/shot/check --render
20
+ uv run keyframes init .
21
+ uv run keyframes dev # opens Chrome; edit slides/*.html or ask your LLM to
22
+ ```
23
+
24
+ Then ask your agent something like *"Using the papers in references/, draft a 12-slide talk
25
+ on X. Put the MuJoCo demo from assets/ on slide 5."* and rearrange the result with `E`.
26
+
27
+ For exports install the browser once: `uv run playwright install chromium` (or have Chrome).
28
+
29
+ ## In the browser
30
+
31
+ | key | action |
32
+ | --- | --- |
33
+ | → / Space, ← | next / previous (build steps included) |
34
+ | E | toggle the layout editor |
35
+ | S | presenter view (notes, next slide, timer) |
36
+ | F | fullscreen · B blackout |
37
+
38
+ Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
39
+ aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
40
+ embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
41
+ ⌘Z / ⌘⇧Z undo/redo, `?` for help.
42
+
43
+ Edit mode also opens a resizable side panel (toggle with `` ` ``):
44
+
45
+ - **Source**: the current slide's HTML. Selecting a block jumps to its tag; edits save and
46
+ hot-reload as you type; drags and LLM edits flow back in.
47
+ - **Claude / Codex / Shell**: real terminals running in the deck folder (whichever CLIs are
48
+ installed). Sessions survive page reloads. `+ ref` (or `R`) types the selected blocks'
49
+ reference into the prompt, e.g. `@slides/04-problem.html (block "figure")`.
50
+
51
+ The dev server only accepts editor/terminal requests from its own page, but it does give that
52
+ page a shell: keep it bound to 127.0.0.1 (the default).
53
+
54
+ ## Commands
55
+
56
+ ```
57
+ keyframes init [path] new deck from the scaffold
58
+ keyframes dev live server + editor
59
+ keyframes present serve without the editor
60
+ keyframes check [--render] lint; --render adds browser errors and text overflow
61
+ keyframes shot [N ...] PNG screenshots → .keyframes/shots/
62
+ keyframes pdf PDF export (one page per slide, all builds shown)
63
+ keyframes build static site → dist/
64
+ keyframes guide full authoring reference (for LLMs)
65
+ keyframes themes list built-in themes
66
+ keyframes eject-theme copy the theme into the deck to customize it
67
+ ```
68
+
69
+ ## Deck layout
70
+
71
+ ```
72
+ my-talk/
73
+ deck.toml title, theme, canvas size, extra <head> tags, extra ES module imports
74
+ AGENTS.md authoring rules for LLMs (CLAUDE.md imports it)
75
+ slides/ 01-title.html, 02-motivation.html, ... (ordered by filename)
76
+ assets/ images, video, MJCF models, three.js scene modules, .excalidraw files
77
+ references/ papers/notes for the LLM to draw from (never exported)
78
+ .keyframes/ dependency cache + screenshots (gitignored)
79
+ ```
80
+
81
+ ## Themes
82
+
83
+ - `default`: a clone of Google Slides' "simple-light" template (Google Sans + Roboto Light,
84
+ orbit artwork on title/section slides, split layouts with a grey media panel).
85
+ - `minimal`: plain Inter/JetBrains Mono theme.
86
+
87
+ A theme is a folder with `theme.css` (CSS variables + text/container classes),
88
+ `STYLE_GUIDE.md` (rules the LLM follows) and `layouts/*.html` (starting-point slides).
89
+ Use `theme = "./theme"` in `deck.toml` for a deck-local theme.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ uv sync --all-extras
95
+ uv run pytest
96
+ uv run ruff check src tests && uv run ruff format src tests
97
+ cd examples/showcase && uv run keyframes dev # every component in one deck
98
+ ```
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "keyframes"
3
+ version = "0.1.0"
4
+ description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ keywords = [
8
+ "slides",
9
+ "presentation",
10
+ "llm",
11
+ "agents",
12
+ "three.js",
13
+ "mujoco",
14
+ "katex",
15
+ ]
16
+ classifiers = [
17
+ "License :: Other/Proprietary License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Graphics :: Presentation",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.28.1",
23
+ "starlette>=1.7.0",
24
+ "uvicorn>=0.54.0",
25
+ "watchfiles>=1.3.0",
26
+ "websockets>=17.2",
27
+ ]
28
+
29
+ [[project.authors]]
30
+ name = "Pranay Thangeda"
31
+ email = "contact@prny.me"
32
+
33
+ [project.optional-dependencies]
34
+ export = [
35
+ "playwright>=1.63.0",
36
+ "pypdf>=6.20.1",
37
+ ]
38
+
39
+ [project.scripts]
40
+ keyframes = "keyframes.cli:main"
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.11.32,<0.12.0"]
44
+ build-backend = "uv_build"
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ "pytest>=9.1.1",
49
+ "ruff>=0.17.0",
50
+ ]
51
+
52
+ [tool.ruff]
53
+ line-length = 110
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "E",
58
+ "F",
59
+ "I",
60
+ "UP",
61
+ "B",
62
+ ]
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "keyframes"
3
+ version = "0.1.0"
4
+ description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Pranay Thangeda", email = "contact@prny.me" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ keywords = ["slides", "presentation", "llm", "agents", "three.js", "mujoco", "katex"]
11
+ classifiers = [
12
+ "License :: Other/Proprietary License",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Multimedia :: Graphics :: Presentation",
15
+ ]
16
+ dependencies = [
17
+ "httpx>=0.28.1",
18
+ "starlette>=1.7.0",
19
+ "uvicorn>=0.54.0",
20
+ "watchfiles>=1.3.0",
21
+ "websockets>=17.2",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ export = [
26
+ "playwright>=1.63.0",
27
+ "pypdf>=6.20.1",
28
+ ]
29
+
30
+ [project.scripts]
31
+ keyframes = "keyframes.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.11.32,<0.12.0"]
35
+ build-backend = "uv_build"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.1.1",
40
+ "ruff>=0.17.0",
41
+ ]
42
+
43
+ [tool.ruff]
44
+ line-length = 110
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,102 @@
1
+ """Static export. Vendor files are renamed so they work on any static host (no query strings,
2
+ JS/CSS always have an extension) and every reference to a renamed file is rewritten."""
3
+
4
+ import json
5
+ import posixpath
6
+ import re
7
+ import shutil
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from .deck import STATIC_DIR, Deck, DeckError
12
+ from .render import render_index
13
+ from .vendor import VENDOR_PREFIX, _kind, vendor_import_map
14
+
15
+ _TOKEN = re.compile(r"""(?<=["'(])[^"'()\s]+(?=["')])""")
16
+ TEXT_SUFFIXES = (".js", ".mjs", ".css", ".html", ".json")
17
+
18
+
19
+ def _vendor_entries(cache: Path):
20
+ for meta_path in cache.rglob("*.meta.json"):
21
+ meta = json.loads(meta_path.read_text())
22
+ file = meta_path.with_name(meta_path.name.removesuffix(".meta.json"))
23
+ parts = urlsplit(meta["url"])
24
+ key = f"{VENDOR_PREFIX}/{parts.netloc}{parts.path}" + (f"?{parts.query}" if parts.query else "")
25
+ name = file.relative_to(cache).as_posix()
26
+ if _kind(meta["type"]) == "js" and not name.endswith((".js", ".mjs")):
27
+ name += ".js"
28
+ if _kind(meta["type"]) == "css" and not name.endswith(".css"):
29
+ name += ".css"
30
+ yield key, f"{VENDOR_PREFIX}/{name}", file, meta["type"]
31
+
32
+
33
+ def rewrite_refs(text: str, self_path: str, renames: dict[str, str]) -> str:
34
+ base = posixpath.dirname(self_path)
35
+
36
+ def sub(m: re.Match) -> str:
37
+ token = m.group(0)
38
+ path, sep, query = token.partition("?")
39
+ if not (path.startswith((".", VENDOR_PREFIX))):
40
+ return token
41
+ resolved = posixpath.normpath(posixpath.join(base, path)) + sep + query
42
+ target = renames.get(resolved)
43
+ if target is None:
44
+ return token
45
+ rel = posixpath.relpath(target, base or ".")
46
+ return rel if rel.startswith(".") else f"./{rel}"
47
+
48
+ return _TOKEN.sub(sub, text)
49
+
50
+
51
+ def _copy_tree(src: Path, dst: Path, dst_prefix: str, renames: dict[str, str]):
52
+ for file in src.rglob("*"):
53
+ if not file.is_file() or file.name == "index.html" and src == STATIC_DIR:
54
+ continue
55
+ rel = file.relative_to(src).as_posix()
56
+ out = dst / rel
57
+ out.parent.mkdir(parents=True, exist_ok=True)
58
+ if file.suffix in TEXT_SUFFIXES:
59
+ out.write_text(rewrite_refs(file.read_text(), f"{dst_prefix}/{rel}", renames))
60
+ else:
61
+ shutil.copy2(file, out)
62
+
63
+
64
+ def warm_cache(deck: Deck) -> bool:
65
+ """Load every slide once in a headless browser so all vendor files get cached."""
66
+ from .export import render_report
67
+
68
+ try:
69
+ render_report(deck)
70
+ except DeckError:
71
+ return False
72
+ return True
73
+
74
+
75
+ def build(deck: Deck, out: Path | None = None) -> Path:
76
+ out = (out or deck.root / "dist").resolve()
77
+ if out.exists():
78
+ shutil.rmtree(out)
79
+ out.mkdir(parents=True)
80
+
81
+ entries = list(_vendor_entries(deck.cache_dir / "vendor"))
82
+ renames = {key: name for key, name, _, _ in entries if key != name}
83
+
84
+ for _, name, file, content_type in entries:
85
+ target = out / name
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ if _kind(content_type):
88
+ target.write_text(rewrite_refs(file.read_text(), name, renames))
89
+ else:
90
+ shutil.copy2(file, target)
91
+
92
+ _copy_tree(STATIC_DIR, out / "_keyframes", "_keyframes", renames)
93
+ _copy_tree(deck.theme_dir, out / "_theme", "_theme", renames)
94
+ if (deck.root / "assets").is_dir():
95
+ _copy_tree(deck.root / "assets", out / "assets", "assets", renames)
96
+
97
+ import_map = {
98
+ name: rewrite_refs(f'"{path}"', "index.html", renames).strip('"')
99
+ for name, path in vendor_import_map(deck.imports).items()
100
+ }
101
+ (out / "index.html").write_text(render_index(deck, "present", import_map))
102
+ return out
@@ -0,0 +1,107 @@
1
+ import re
2
+ from collections import Counter
3
+ from html.parser import HTMLParser
4
+ from pathlib import Path
5
+
6
+ from .deck import STATIC_DIR, Deck
7
+ from .patch import find_block_tags
8
+
9
+ LOCAL_REF_ATTRS = {"src", "href", "model", "controller", "poster"}
10
+ COMPONENTS = {p.stem for p in (STATIC_DIR / "components").glob("keyframes-*.js")}
11
+
12
+
13
+ class _Collector(HTMLParser):
14
+ def __init__(self):
15
+ super().__init__()
16
+ self.tags: list[tuple[str, dict]] = []
17
+ self.depth = 0
18
+ self.roots: list[str] = []
19
+
20
+ def handle_starttag(self, tag, attrs):
21
+ if self.depth == 0:
22
+ self.roots.append(tag)
23
+ self.tags.append((tag, dict(attrs)))
24
+ if tag not in {"img", "br", "hr", "input", "meta", "link", "source", "wbr"}:
25
+ self.depth += 1
26
+
27
+ def handle_startendtag(self, tag, attrs):
28
+ if self.depth == 0:
29
+ self.roots.append(tag)
30
+ self.tags.append((tag, dict(attrs)))
31
+
32
+ def handle_endtag(self, tag):
33
+ self.depth = max(0, self.depth - 1)
34
+
35
+
36
+ def _is_local(ref: str) -> bool:
37
+ return bool(ref) and not re.match(r"^([a-z]+:|//|#|data:|_vendor/|\$)", ref)
38
+
39
+
40
+ def _check_slider(where: str, attrs: dict, ids: set[str]) -> list[str]:
41
+ target = attrs.get("for")
42
+ if not target:
43
+ return [f"{where}: <keyframes-slider> needs a for attribute"]
44
+ problems = []
45
+ if target not in ids:
46
+ problems.append(
47
+ f'{where}: <keyframes-slider for="{target}"> matches no data-block or id on this slide'
48
+ )
49
+ if "timeline" not in attrs and not attrs.get("param"):
50
+ problems.append(f'{where}: <keyframes-slider for="{target}"> needs param="..." or timeline')
51
+ return problems
52
+
53
+
54
+ def check_slide(deck: Deck, path: Path) -> list[str]:
55
+ source = path.read_text()
56
+ problems = []
57
+ where = f"slides/{path.name}"
58
+
59
+ collector = _Collector()
60
+ collector.feed(source)
61
+ if collector.roots != ["section"]:
62
+ problems.append(f"{where}: must contain exactly one root <section>, found {collector.roots}")
63
+
64
+ blocks = find_block_tags(source)
65
+ for block_id, count in Counter(b.id for b in blocks).items():
66
+ if count > 1:
67
+ problems.append(f"{where}: duplicate data-block id {block_id!r}")
68
+ for block in blocks:
69
+ geometry = {k: block.attrs.get(f"data-{k}") for k in "xywh"}
70
+ missing = [k for k, v in geometry.items() if v is None]
71
+ if missing:
72
+ problems.append(f"{where}#{block.id}: missing data-{', data-'.join(missing)}")
73
+ continue
74
+ try:
75
+ x, y, w, h = (float(geometry[k]) for k in "xywh")
76
+ except ValueError:
77
+ problems.append(f"{where}#{block.id}: non-numeric geometry {geometry}")
78
+ continue
79
+ if x < 0 or y < 0 or x + w > deck.width or y + h > deck.height:
80
+ problems.append(f"{where}#{block.id}: extends outside the {deck.width}x{deck.height} canvas")
81
+
82
+ ids = {v for _, attrs in collector.tags for k, v in attrs.items() if k in ("id", "data-block") and v}
83
+ for tag, attrs in collector.tags:
84
+ if tag == "keyframes-slider":
85
+ problems += _check_slider(where, attrs, ids)
86
+ if tag.startswith("keyframes-") and tag not in COMPONENTS:
87
+ problems.append(f"{where}: unknown component <{tag}> (available: {sorted(COMPONENTS)})")
88
+ for attr in LOCAL_REF_ATTRS & attrs.keys():
89
+ ref = (attrs[attr] or "").split("?")[0].split("#")[0]
90
+ if _is_local(ref) and not (deck.root / ref).exists():
91
+ problems.append(f'{where}: <{tag} {attr}="{ref}"> file not found')
92
+ return problems
93
+
94
+
95
+ def check_deck(deck: Deck, render: bool = False) -> list[str]:
96
+ paths = deck.slide_paths()
97
+ if not paths:
98
+ return [f"No slides found in {deck.slides_dir}"]
99
+ problems = [p for path in paths for p in check_slide(deck, path)]
100
+ if render:
101
+ from .export import render_report
102
+
103
+ for result in render_report(deck):
104
+ where = f"slides/{result.name}.html"
105
+ problems += [f"{where}: browser error: {e}" for e in result.errors]
106
+ problems += [f"{where}#{b}: content overflows its box" for b in result.overflowing]
107
+ return problems
@@ -0,0 +1,149 @@
1
+ import argparse
2
+ import shutil
3
+ import sys
4
+ import threading
5
+ import webbrowser
6
+ from pathlib import Path
7
+
8
+ from .deck import PACKAGE_DIR, THEMES_DIR, Deck, DeckError
9
+ from .guide import GUIDE_DIR
10
+
11
+ SCAFFOLD_DIR = PACKAGE_DIR / "scaffold"
12
+
13
+
14
+ def cmd_init(args):
15
+ target = Path(args.path).resolve()
16
+ if (target / "deck.toml").exists():
17
+ sys.exit(f"{target} already has a deck.toml")
18
+ shutil.copytree(SCAFFOLD_DIR, target, dirs_exist_ok=True)
19
+ shutil.copy(GUIDE_DIR / "core.md", target / "AGENTS.md")
20
+ deck_toml = target / "deck.toml"
21
+ deck_toml.write_text(
22
+ deck_toml.read_text().replace("{{title}}", args.title or target.name).replace("{{theme}}", args.theme)
23
+ )
24
+ print(f"Created deck in {target}\nNext: cd {target} && keyframes dev")
25
+
26
+
27
+ def serve(args, mode: str):
28
+ import uvicorn
29
+
30
+ from .server import create_app, free_port
31
+
32
+ deck = Deck.load(Path(args.path))
33
+ port = free_port(args.port)
34
+ url = f"http://127.0.0.1:{port}/"
35
+ print(f"keyframes {mode} → {url}")
36
+ if mode == "dev":
37
+ print("Press E in the browser to toggle the layout editor, S for presenter view.")
38
+ if not args.no_open:
39
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
40
+ uvicorn.run(create_app(deck, mode), host="127.0.0.1", port=port, log_level="warning")
41
+
42
+
43
+ def cmd_build(args):
44
+ from .build import build, warm_cache
45
+
46
+ deck = Deck.load(Path(args.path))
47
+ if not warm_cache(deck):
48
+ print("Warning: install keyframes[export] so build can preload every slide's dependencies.")
49
+ out = build(deck, Path(args.out) if args.out else None)
50
+ print(f"Built static deck → {out}")
51
+ print(f"Serve it with any static server, e.g. `python -m http.server -d {out}`")
52
+
53
+
54
+ def cmd_pdf(args):
55
+ from .export import export_pdf
56
+
57
+ deck = Deck.load(Path(args.path))
58
+ out = export_pdf(deck, Path(args.out) if args.out else deck.root / f"{deck.root.name}.pdf")
59
+ print(f"Wrote {out}")
60
+
61
+
62
+ def cmd_shot(args):
63
+ from .export import screenshot_slides
64
+
65
+ deck = Deck.load(Path(args.path))
66
+ out_dir = Path(args.out) if args.out else deck.cache_dir / "shots"
67
+ for path in screenshot_slides(deck, args.slides, out_dir, scale=args.scale):
68
+ print(path)
69
+
70
+
71
+ def cmd_check(args):
72
+ from .check import check_deck
73
+
74
+ problems = check_deck(Deck.load(Path(args.path)), render=args.render)
75
+ for problem in problems:
76
+ print(problem)
77
+ if problems:
78
+ sys.exit(1)
79
+ print("No problems found.")
80
+
81
+
82
+ def cmd_guide(args):
83
+ from .guide import full_guide
84
+
85
+ print(full_guide(Path(args.path)))
86
+
87
+
88
+ def cmd_themes(args):
89
+ for theme in sorted(p.name for p in THEMES_DIR.iterdir() if p.is_dir()):
90
+ print(theme)
91
+
92
+
93
+ def cmd_eject_theme(args):
94
+ deck = Deck.load(Path(args.path))
95
+ target = deck.root / "theme"
96
+ if target.exists():
97
+ sys.exit(f"{target} already exists")
98
+ shutil.copytree(deck.theme_dir, target)
99
+ config = deck.root / "deck.toml"
100
+ config.write_text(config.read_text().replace(f'theme = "{deck.theme}"', 'theme = "./theme"'))
101
+ print(f"Copied theme to {target} and pointed deck.toml at it.")
102
+
103
+
104
+ def build_parser() -> argparse.ArgumentParser:
105
+ parser = argparse.ArgumentParser(prog="keyframes", description=__doc__)
106
+ sub = parser.add_subparsers(required=True)
107
+
108
+ def command(name, fn, help, path=True):
109
+ p = sub.add_parser(name, help=help)
110
+ if path:
111
+ p.add_argument("path", nargs="?", default=".", help="deck folder (default: .)")
112
+ p.set_defaults(fn=fn)
113
+ return p
114
+
115
+ p = command("init", cmd_init, "create a new deck")
116
+ p.add_argument("--title")
117
+ p.add_argument("--theme", default="default")
118
+
119
+ for name, mode, help in [
120
+ ("dev", "dev", "live server with layout editor"),
121
+ ("present", "present", "serve without editor"),
122
+ ]:
123
+ p = command(name, lambda a, m=mode: serve(a, m), help)
124
+ p.add_argument("--port", type=int, default=8765)
125
+ p.add_argument("--no-open", action="store_true")
126
+
127
+ p = command("build", cmd_build, "export a static site")
128
+ p.add_argument("-o", "--out")
129
+ p = command("pdf", cmd_pdf, "export a PDF (requires keyframes[export])")
130
+ p.add_argument("-o", "--out")
131
+ p = command("shot", cmd_shot, "screenshot slides to PNG (requires keyframes[export])", path=False)
132
+ p.add_argument("-d", "--deck", dest="path", default=".", help="deck folder (default: .)")
133
+ p.add_argument("slides", nargs="*", help="slide numbers or names (default: all)")
134
+ p.add_argument("-o", "--out")
135
+ p.add_argument("--scale", type=float, default=0.5)
136
+ p = command("check", cmd_check, "lint slides; --render also loads them in a browser")
137
+ p.add_argument("--render", action="store_true")
138
+ command("guide", cmd_guide, "print the full authoring reference for LLMs")
139
+ command("themes", cmd_themes, "list built-in themes", path=False)
140
+ command("eject-theme", cmd_eject_theme, "copy the current theme into the deck to customize")
141
+ return parser
142
+
143
+
144
+ def main():
145
+ args = build_parser().parse_args()
146
+ try:
147
+ args.fn(args)
148
+ except DeckError as e:
149
+ sys.exit(str(e))