keyframes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keyframes/__init__.py +1 -0
- keyframes/build.py +102 -0
- keyframes/check.py +107 -0
- keyframes/cli.py +149 -0
- keyframes/deck.py +67 -0
- keyframes/export.py +106 -0
- keyframes/guide/__init__.py +25 -0
- keyframes/guide/components/keyframes-code.md +60 -0
- keyframes/guide/components/keyframes-excalidraw.md +148 -0
- keyframes/guide/components/keyframes-mujoco.md +76 -0
- keyframes/guide/components/keyframes-slider.md +65 -0
- keyframes/guide/components/keyframes-three.md +81 -0
- keyframes/guide/core.md +113 -0
- keyframes/patch.py +77 -0
- keyframes/render.py +32 -0
- keyframes/scaffold/.gitignore +3 -0
- keyframes/scaffold/CLAUDE.md +1 -0
- keyframes/scaffold/assets/deck.css +1 -0
- keyframes/scaffold/assets/figure.svg +1 -0
- keyframes/scaffold/deck.toml +10 -0
- keyframes/scaffold/references/.gitkeep +0 -0
- keyframes/scaffold/slides/01-title.html +4 -0
- keyframes/scaffold/slides/02-intro.html +17 -0
- keyframes/server.py +204 -0
- keyframes/static/components/keyframes-code.js +423 -0
- keyframes/static/components/keyframes-excalidraw.js +189 -0
- keyframes/static/components/keyframes-mujoco.js +515 -0
- keyframes/static/components/keyframes-slider.js +255 -0
- keyframes/static/components/keyframes-three.js +224 -0
- keyframes/static/editor/editor.css +228 -0
- keyframes/static/editor/editor.js +594 -0
- keyframes/static/editor/panel.css +127 -0
- keyframes/static/editor/panel.js +357 -0
- keyframes/static/index.html +23 -0
- keyframes/static/runtime/component.js +126 -0
- keyframes/static/runtime/live.js +31 -0
- keyframes/static/runtime/math.js +33 -0
- keyframes/static/runtime/presenter.js +91 -0
- keyframes/static/runtime/runtime.css +91 -0
- keyframes/static/runtime/runtime.js +215 -0
- keyframes/static/runtime/steps.js +15 -0
- keyframes/terminal.py +144 -0
- keyframes/themes/default/STYLE_GUIDE.md +73 -0
- keyframes/themes/default/assets/arc-top.webp +0 -0
- keyframes/themes/default/assets/orbit-ring.webp +0 -0
- keyframes/themes/default/assets/streaks-top.webp +0 -0
- keyframes/themes/default/assets/streaks-wide.webp +0 -0
- keyframes/themes/default/assets/sun-top.webp +0 -0
- keyframes/themes/default/assets/title-orbit.webp +0 -0
- keyframes/themes/default/layouts/content.html +16 -0
- keyframes/themes/default/layouts/figure.html +7 -0
- keyframes/themes/default/layouts/grid-2.html +19 -0
- keyframes/themes/default/layouts/grid-3x2.html +28 -0
- keyframes/themes/default/layouts/grid-4.html +44 -0
- keyframes/themes/default/layouts/media-side.html +16 -0
- keyframes/themes/default/layouts/section-arc.html +4 -0
- keyframes/themes/default/layouts/section-streaks.html +4 -0
- keyframes/themes/default/layouts/section.html +4 -0
- keyframes/themes/default/layouts/split-wide.html +7 -0
- keyframes/themes/default/layouts/split.html +13 -0
- keyframes/themes/default/layouts/statement.html +4 -0
- keyframes/themes/default/layouts/title.html +5 -0
- keyframes/themes/default/layouts/two-column.html +5 -0
- keyframes/themes/default/theme.css +330 -0
- keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
- keyframes/themes/minimal/layouts/figure.html +7 -0
- keyframes/themes/minimal/layouts/section.html +4 -0
- keyframes/themes/minimal/layouts/statement.html +3 -0
- keyframes/themes/minimal/layouts/title-body.html +10 -0
- keyframes/themes/minimal/layouts/title.html +5 -0
- keyframes/themes/minimal/layouts/two-column.html +11 -0
- keyframes/themes/minimal/theme.css +196 -0
- keyframes/vendor.py +181 -0
- keyframes-0.1.0.dist-info/METADATA +119 -0
- keyframes-0.1.0.dist-info/RECORD +77 -0
- keyframes-0.1.0.dist-info/WHEEL +4 -0
- keyframes-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<section data-layout="split">
|
|
2
|
+
<h2 class="title" data-block="title" data-x="88" data-y="72" data-w="800" data-h="170">The claim this slide makes, as one sentence</h2>
|
|
3
|
+
<div class="body" data-block="points" data-x="88" data-y="270" data-w="800" data-h="560">
|
|
4
|
+
Lead-in that sets up the figure:
|
|
5
|
+
<ul class="small">
|
|
6
|
+
<li>Supporting point with a number: 87% vs 41%</li>
|
|
7
|
+
<li>Second supporting point</li>
|
|
8
|
+
</ul>
|
|
9
|
+
<p class="lead" data-step="1">Takeaway, revealed last.</p>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="media figure" data-block="figure" data-x="1010" data-y="180" data-w="860" data-h="600">
|
|
12
|
+
<img class="contain" src="assets/figure.svg" alt="">
|
|
13
|
+
</div>
|
|
14
|
+
<p class="caption" data-block="caption" data-x="1010" data-y="788" data-w="860" data-h="90">What the figure shows · simulation, 100 trials</p>
|
|
15
|
+
<p class="cite" data-block="cite" data-x="88" data-y="975" data-w="800" data-h="60">Author et al. Venue Year</p>
|
|
16
|
+
<aside class="notes">Point at the figure first. Mention the main caveat. End on why it matters.</aside>
|
|
17
|
+
</section>
|
keyframes/server.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from starlette.applications import Starlette
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
13
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
14
|
+
from starlette.staticfiles import StaticFiles
|
|
15
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
16
|
+
from watchfiles import Change, awatch
|
|
17
|
+
|
|
18
|
+
from .deck import STATIC_DIR, Deck, DeckError
|
|
19
|
+
from .patch import geometry_attrs, patch_blocks
|
|
20
|
+
from .render import render_index, render_slide
|
|
21
|
+
from .terminal import TerminalManager
|
|
22
|
+
from .vendor import VendorCache
|
|
23
|
+
|
|
24
|
+
IGNORED_DIRS = {".keyframes", "dist", ".git", ".venv", "node_modules", "__pycache__"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoCacheStatic(StaticFiles):
|
|
28
|
+
def file_response(self, *args, **kwargs):
|
|
29
|
+
response = super().file_response(*args, **kwargs)
|
|
30
|
+
response.headers["Cache-Control"] = "no-store"
|
|
31
|
+
return response
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LiveReload:
|
|
35
|
+
"""Watches the deck and pushes changes to connected browsers, ignoring our own writes."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, deck: Deck):
|
|
38
|
+
self.deck = deck
|
|
39
|
+
self.clients: set[WebSocket] = set()
|
|
40
|
+
self.own_writes: dict[Path, str] = {}
|
|
41
|
+
|
|
42
|
+
def write(self, path: Path, content: str):
|
|
43
|
+
self.own_writes[path] = content
|
|
44
|
+
path.write_text(content)
|
|
45
|
+
|
|
46
|
+
async def broadcast(self, message: dict):
|
|
47
|
+
for ws in list(self.clients):
|
|
48
|
+
try:
|
|
49
|
+
await ws.send_text(json.dumps(message))
|
|
50
|
+
except Exception:
|
|
51
|
+
self.clients.discard(ws)
|
|
52
|
+
|
|
53
|
+
def _message_for(self, change: Change, path: Path) -> dict | None:
|
|
54
|
+
if path.parent == self.deck.slides_dir and path.suffix == ".html":
|
|
55
|
+
if change != Change.modified:
|
|
56
|
+
return {"type": "reload"}
|
|
57
|
+
content = path.read_text()
|
|
58
|
+
if self.own_writes.pop(path, None) == content:
|
|
59
|
+
return None
|
|
60
|
+
return {"type": "slide", "name": path.stem, "html": render_slide(path.stem, content)}
|
|
61
|
+
if path.suffix == ".css":
|
|
62
|
+
return {"type": "css"}
|
|
63
|
+
return {"type": "reload"}
|
|
64
|
+
|
|
65
|
+
async def watch(self):
|
|
66
|
+
watched = [self.deck.root, self.deck.theme_dir]
|
|
67
|
+
async for changes in awatch(*watched, watch_filter=self._watch_filter):
|
|
68
|
+
messages = []
|
|
69
|
+
for change, raw in changes:
|
|
70
|
+
message = self._message_for(change, Path(raw))
|
|
71
|
+
if message and message not in messages:
|
|
72
|
+
messages.append(message)
|
|
73
|
+
if any(m["type"] == "reload" for m in messages):
|
|
74
|
+
messages = [{"type": "reload"}]
|
|
75
|
+
for message in messages:
|
|
76
|
+
await self.broadcast(message)
|
|
77
|
+
|
|
78
|
+
def _watch_filter(self, change: Change, raw: str) -> bool:
|
|
79
|
+
return not (IGNORED_DIRS & set(Path(raw).parts)) and not raw.endswith("~")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def same_origin(headers) -> bool:
|
|
83
|
+
"""Blocks other websites from driving the local editor APIs (and its terminals)."""
|
|
84
|
+
origin = headers.get("origin")
|
|
85
|
+
return origin is None or origin.split("://", 1)[-1] == headers.get("host")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_app(deck: Deck, mode: str = "dev") -> Starlette:
|
|
89
|
+
vendor = VendorCache(deck.cache_dir)
|
|
90
|
+
live = LiveReload(deck)
|
|
91
|
+
terminals = TerminalManager(str(deck.root))
|
|
92
|
+
|
|
93
|
+
async def index(request: Request):
|
|
94
|
+
return HTMLResponse(render_index(deck, mode), headers={"Cache-Control": "no-store"})
|
|
95
|
+
|
|
96
|
+
async def vendor_file(request: Request):
|
|
97
|
+
host = request.path_params["host"]
|
|
98
|
+
path = "/" + request.path_params["path"]
|
|
99
|
+
try:
|
|
100
|
+
entry = await vendor.get(host, path, request.url.query)
|
|
101
|
+
except PermissionError as e:
|
|
102
|
+
return Response(str(e), status_code=403)
|
|
103
|
+
except httpx.HTTPError as e:
|
|
104
|
+
return Response(f"Vendor fetch failed: {e}", status_code=502)
|
|
105
|
+
return Response(
|
|
106
|
+
entry.body,
|
|
107
|
+
media_type=entry.content_type,
|
|
108
|
+
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def save_geometry(request: Request):
|
|
112
|
+
if not same_origin(request.headers):
|
|
113
|
+
return JSONResponse({"error": "cross-origin request"}, status_code=403)
|
|
114
|
+
payload = await request.json()
|
|
115
|
+
try:
|
|
116
|
+
path = deck.slide_path(payload["slide"])
|
|
117
|
+
updates = {bid: geometry_attrs(g) for bid, g in payload["blocks"].items()}
|
|
118
|
+
live.write(path, patch_blocks(path.read_text(), updates))
|
|
119
|
+
except (DeckError, KeyError, ValueError) as e:
|
|
120
|
+
return JSONResponse({"error": str(e)}, status_code=400)
|
|
121
|
+
return JSONResponse({"ok": True})
|
|
122
|
+
|
|
123
|
+
async def source(request: Request):
|
|
124
|
+
if not same_origin(request.headers):
|
|
125
|
+
return JSONResponse({"error": "cross-origin request"}, status_code=403)
|
|
126
|
+
try:
|
|
127
|
+
path = deck.slide_path(request.path_params["slide"])
|
|
128
|
+
except DeckError as e:
|
|
129
|
+
return JSONResponse({"error": str(e)}, status_code=404)
|
|
130
|
+
if request.method == "PUT":
|
|
131
|
+
path.write_text((await request.body()).decode())
|
|
132
|
+
return JSONResponse({"ok": True})
|
|
133
|
+
return Response(path.read_text(), media_type="text/plain", headers={"Cache-Control": "no-store"})
|
|
134
|
+
|
|
135
|
+
async def info(request: Request):
|
|
136
|
+
return JSONResponse({"terminals": list(terminals.commands)})
|
|
137
|
+
|
|
138
|
+
async def terminal(ws: WebSocket):
|
|
139
|
+
if not same_origin(ws.headers):
|
|
140
|
+
await ws.close(code=4403)
|
|
141
|
+
return
|
|
142
|
+
await terminals.serve(ws, ws.path_params["name"])
|
|
143
|
+
|
|
144
|
+
async def websocket(ws: WebSocket):
|
|
145
|
+
await ws.accept()
|
|
146
|
+
live.clients.add(ws)
|
|
147
|
+
try:
|
|
148
|
+
while True:
|
|
149
|
+
await ws.receive_text()
|
|
150
|
+
except WebSocketDisconnect:
|
|
151
|
+
live.clients.discard(ws)
|
|
152
|
+
|
|
153
|
+
@contextlib.asynccontextmanager
|
|
154
|
+
async def lifespan(app):
|
|
155
|
+
task = asyncio.create_task(live.watch()) if mode == "dev" else None
|
|
156
|
+
yield
|
|
157
|
+
if task:
|
|
158
|
+
task.cancel()
|
|
159
|
+
terminals.stop_all()
|
|
160
|
+
await vendor.close()
|
|
161
|
+
|
|
162
|
+
dev_routes = [
|
|
163
|
+
Route("/_api/geometry", save_geometry, methods=["POST"]),
|
|
164
|
+
Route("/_api/source/{slide}", source, methods=["GET", "PUT"]),
|
|
165
|
+
Route("/_api/info", info),
|
|
166
|
+
WebSocketRoute("/_api/live", websocket),
|
|
167
|
+
WebSocketRoute("/_api/term/{name}", terminal),
|
|
168
|
+
]
|
|
169
|
+
routes = [
|
|
170
|
+
Route("/", index),
|
|
171
|
+
*(dev_routes if mode == "dev" else []),
|
|
172
|
+
Route("/_vendor/{host}/{path:path}", vendor_file),
|
|
173
|
+
Mount("/_keyframes", NoCacheStatic(directory=STATIC_DIR)),
|
|
174
|
+
Mount("/_theme", NoCacheStatic(directory=deck.theme_dir)),
|
|
175
|
+
Mount("/", NoCacheStatic(directory=deck.root)),
|
|
176
|
+
]
|
|
177
|
+
return Starlette(routes=routes, lifespan=lifespan)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def free_port(start: int) -> int:
|
|
181
|
+
for port in range(start, start + 50):
|
|
182
|
+
with socket.socket() as s:
|
|
183
|
+
if s.connect_ex(("127.0.0.1", port)) != 0:
|
|
184
|
+
return port
|
|
185
|
+
raise RuntimeError("No free port found")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@contextlib.contextmanager
|
|
189
|
+
def background_server(deck: Deck, mode: str = "present"):
|
|
190
|
+
import uvicorn
|
|
191
|
+
|
|
192
|
+
port = free_port(8900)
|
|
193
|
+
server = uvicorn.Server(
|
|
194
|
+
uvicorn.Config(create_app(deck, mode), host="127.0.0.1", port=port, log_level="warning")
|
|
195
|
+
)
|
|
196
|
+
thread = threading.Thread(target=server.run, daemon=True)
|
|
197
|
+
thread.start()
|
|
198
|
+
while not server.started:
|
|
199
|
+
time.sleep(0.05)
|
|
200
|
+
try:
|
|
201
|
+
yield f"http://127.0.0.1:{port}/"
|
|
202
|
+
finally:
|
|
203
|
+
server.should_exit = True
|
|
204
|
+
thread.join()
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { SwComponent } from "../runtime/component.js";
|
|
2
|
+
|
|
3
|
+
const LANGUAGES = {
|
|
4
|
+
python: () => import("@codemirror/lang-python").then((m) => m.python()),
|
|
5
|
+
javascript: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
|
|
6
|
+
js: () => LANGUAGES.javascript(),
|
|
7
|
+
typescript: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
|
|
8
|
+
ts: () => LANGUAGES.typescript(),
|
|
9
|
+
jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
|
|
10
|
+
tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
|
|
11
|
+
cpp: () => import("@codemirror/lang-cpp").then((m) => m.cpp()),
|
|
12
|
+
c: () => LANGUAGES.cpp(),
|
|
13
|
+
rust: () => import("@codemirror/lang-rust").then((m) => m.rust()),
|
|
14
|
+
json: () => import("@codemirror/lang-json").then((m) => m.json()),
|
|
15
|
+
css: () => import("@codemirror/lang-css").then((m) => m.css()),
|
|
16
|
+
html: () => import("@codemirror/lang-html").then((m) => m.html()),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const RUNNERS = { python: runPython, javascript: runJavaScript, js: runJavaScript };
|
|
20
|
+
|
|
21
|
+
const STYLE = `
|
|
22
|
+
keyframes-code {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
width: 100%;
|
|
26
|
+
height: 100%;
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
text-align: left;
|
|
30
|
+
color: var(--fg);
|
|
31
|
+
background: var(--surface);
|
|
32
|
+
border: 1px solid var(--border);
|
|
33
|
+
border-radius: 16px;
|
|
34
|
+
font-family: var(--font-mono);
|
|
35
|
+
font-size: var(--keyframes-code-size, 26px);
|
|
36
|
+
line-height: 1.55;
|
|
37
|
+
font-variant-ligatures: none;
|
|
38
|
+
}
|
|
39
|
+
keyframes-code > .keyframes-code-editor { flex: 1 1 auto; min-height: 0; }
|
|
40
|
+
keyframes-code > .keyframes-code-editor > pre { margin: 0; padding: 0.8em 1.1em; font: inherit; white-space: pre; overflow: hidden; }
|
|
41
|
+
keyframes-code .cm-editor { height: 100%; }
|
|
42
|
+
keyframes-code .keyframes-dim { opacity: 0.3; transition: opacity 0.3s ease; }
|
|
43
|
+
keyframes-code .keyframes-hl { background: color-mix(in srgb, var(--accent) 9%, transparent); transition: opacity 0.3s ease; }
|
|
44
|
+
keyframes-code > .keyframes-code-bar {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 0.8em;
|
|
48
|
+
padding: 0.4em 0.5em 0.4em 1.1em;
|
|
49
|
+
border-top: 1px solid var(--border);
|
|
50
|
+
font: 600 0.62em var(--font-sans);
|
|
51
|
+
color: var(--muted);
|
|
52
|
+
}
|
|
53
|
+
keyframes-code .keyframes-code-lang { text-transform: uppercase; letter-spacing: 0.1em; }
|
|
54
|
+
keyframes-code .keyframes-code-hint { margin-left: auto; font-weight: 400; }
|
|
55
|
+
keyframes-code .keyframes-code-run {
|
|
56
|
+
font: inherit;
|
|
57
|
+
color: #fff;
|
|
58
|
+
background: var(--accent);
|
|
59
|
+
border: 0;
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
padding: 0.35em 1.1em;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
keyframes-code .keyframes-code-run:disabled { opacity: 0.55; cursor: progress; }
|
|
65
|
+
keyframes-code > .keyframes-code-output {
|
|
66
|
+
flex: 0 0 var(--keyframes-code-output, 30%);
|
|
67
|
+
margin: 0;
|
|
68
|
+
padding: 0.6em 1.1em;
|
|
69
|
+
overflow: auto;
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
background: var(--bg);
|
|
72
|
+
border-top: 1px solid var(--border);
|
|
73
|
+
font: inherit;
|
|
74
|
+
font-size: 0.8em;
|
|
75
|
+
white-space: pre-wrap;
|
|
76
|
+
overflow-wrap: anywhere;
|
|
77
|
+
}
|
|
78
|
+
keyframes-code .keyframes-code-output .err { color: #c62828; }
|
|
79
|
+
keyframes-code .keyframes-code-output .status { color: var(--muted); font-style: italic; }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
let codeMirror = null;
|
|
83
|
+
let pyodide = null;
|
|
84
|
+
let python = null;
|
|
85
|
+
let pythonQueue = Promise.resolve();
|
|
86
|
+
|
|
87
|
+
function loadCodeMirror() {
|
|
88
|
+
codeMirror ??= Promise.all([
|
|
89
|
+
import("@codemirror/state"),
|
|
90
|
+
import("@codemirror/view"),
|
|
91
|
+
import("@codemirror/commands"),
|
|
92
|
+
import("@codemirror/language"),
|
|
93
|
+
import("@lezer/highlight"),
|
|
94
|
+
]).then(([state, view, commands, language, highlight]) => ({ state, view, commands, language, highlight }));
|
|
95
|
+
return codeMirror;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadPython() {
|
|
99
|
+
pyodide ??= import("pyodide")
|
|
100
|
+
.then((m) => m.loadPyodide())
|
|
101
|
+
.then((py) => (python = py))
|
|
102
|
+
.catch((e) => {
|
|
103
|
+
pyodide = null;
|
|
104
|
+
throw e;
|
|
105
|
+
});
|
|
106
|
+
return pyodide;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function injectStyle() {
|
|
110
|
+
if (document.getElementById("keyframes-code-style")) return;
|
|
111
|
+
const style = document.createElement("style");
|
|
112
|
+
style.id = "keyframes-code-style";
|
|
113
|
+
style.textContent = STYLE;
|
|
114
|
+
document.head.append(style);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readSource(el) {
|
|
118
|
+
const script = el.querySelector(':scope > script[type="text/plain"]');
|
|
119
|
+
const text = script ? script.textContent.replaceAll("<\\/script>", "</script>") : el.textContent;
|
|
120
|
+
return dedent(text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function dedent(text) {
|
|
124
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
125
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
126
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
127
|
+
const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
|
|
128
|
+
const indent = indents.length ? Math.min(...indents) : 0;
|
|
129
|
+
return lines.map((l) => l.slice(indent).trimEnd()).join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseHighlight(spec) {
|
|
133
|
+
if (!spec) return [];
|
|
134
|
+
return spec.split("|").map((segment) => {
|
|
135
|
+
if (!segment.trim()) return null;
|
|
136
|
+
return segment.split(",").map((part) => {
|
|
137
|
+
const [a, b = a] = part.split("-").map(Number);
|
|
138
|
+
return [a, b];
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function element(tag, className, text = "") {
|
|
144
|
+
const el = document.createElement(tag);
|
|
145
|
+
el.className = className;
|
|
146
|
+
el.textContent = text;
|
|
147
|
+
return el;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function format(value) {
|
|
151
|
+
if (typeof value === "string") return value;
|
|
152
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
153
|
+
try {
|
|
154
|
+
return JSON.stringify(value) ?? String(value);
|
|
155
|
+
} catch {
|
|
156
|
+
return String(value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const SANDBOX = `<script>
|
|
161
|
+
const fmt = ${format.toString()};
|
|
162
|
+
const send = (kind, args) => parent.postMessage({ kind, text: args.map(fmt).join(" ") }, "*");
|
|
163
|
+
for (const [name, kind] of [["log", "out"], ["info", "out"], ["debug", "out"], ["warn", "err"], ["error", "err"]]) {
|
|
164
|
+
console[name] = (...args) => send(kind, args);
|
|
165
|
+
}
|
|
166
|
+
addEventListener("error", (e) => send("err", [e.error ?? e.message]));
|
|
167
|
+
addEventListener("unhandledrejection", (e) => send("err", [e.reason]));
|
|
168
|
+
addEventListener("message", async ({ data }) => {
|
|
169
|
+
try {
|
|
170
|
+
await new (async () => {}).constructor(data)();
|
|
171
|
+
} catch (e) {
|
|
172
|
+
send("err", [e]);
|
|
173
|
+
}
|
|
174
|
+
parent.postMessage({ kind: "done" }, "*");
|
|
175
|
+
});
|
|
176
|
+
<\/script>`;
|
|
177
|
+
|
|
178
|
+
async function runJavaScript(code, { print, host }) {
|
|
179
|
+
const frame = document.createElement("iframe");
|
|
180
|
+
frame.sandbox = "allow-scripts";
|
|
181
|
+
frame.hidden = true;
|
|
182
|
+
frame.srcdoc = SANDBOX;
|
|
183
|
+
const previous = host.querySelector(":scope > iframe");
|
|
184
|
+
previous?.stop.abort();
|
|
185
|
+
previous?.remove();
|
|
186
|
+
frame.stop = new AbortController();
|
|
187
|
+
host.append(frame);
|
|
188
|
+
await new Promise((resolve) => frame.addEventListener("load", resolve, { once: true }));
|
|
189
|
+
const done = new Promise((resolve) => {
|
|
190
|
+
const listen = ({ source, data }) => {
|
|
191
|
+
if (source !== frame.contentWindow) return;
|
|
192
|
+
if (data.kind === "done") resolve();
|
|
193
|
+
else print(data.text, data.kind);
|
|
194
|
+
};
|
|
195
|
+
addEventListener("message", listen, { signal: frame.stop.signal });
|
|
196
|
+
});
|
|
197
|
+
frame.contentWindow.postMessage(code, "*");
|
|
198
|
+
await done;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cleanTraceback(message) {
|
|
202
|
+
const lines = message.trimEnd().split("\n");
|
|
203
|
+
const start = lines.findIndex((l) => l.includes('File "<exec>"'));
|
|
204
|
+
return start < 0 ? message : [lines[0], ...lines.slice(start)].join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runPython(code, { print, status, packages }) {
|
|
208
|
+
if (!python) status("Loading Python…");
|
|
209
|
+
const py = await loadPython();
|
|
210
|
+
const messageCallback = (msg) => status(msg);
|
|
211
|
+
if (packages.length) await py.loadPackage(packages, { messageCallback });
|
|
212
|
+
await py.loadPackagesFromImports(code, { messageCallback });
|
|
213
|
+
status(null);
|
|
214
|
+
py.setStdout({ batched: (s) => print(s, "out") });
|
|
215
|
+
py.setStderr({ batched: (s) => print(s, "err") });
|
|
216
|
+
const globals = py.toPy({});
|
|
217
|
+
try {
|
|
218
|
+
const result = await py.runPythonAsync(code, { globals });
|
|
219
|
+
if (result !== undefined) print(String(result), "out");
|
|
220
|
+
result?.destroy?.();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
print(cleanTraceback(String(e.message ?? e)), "err");
|
|
223
|
+
} finally {
|
|
224
|
+
globals.destroy();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
class SwCode extends SwComponent {
|
|
229
|
+
#code = null;
|
|
230
|
+
#view = null;
|
|
231
|
+
#highlight = [];
|
|
232
|
+
#step = 0;
|
|
233
|
+
#decorations = null;
|
|
234
|
+
#shell = null;
|
|
235
|
+
#running = false;
|
|
236
|
+
|
|
237
|
+
get lang() {
|
|
238
|
+
return (this.getAttribute("lang") || "text").toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get code() {
|
|
242
|
+
return this.#view ? this.#view.state.doc.toString() : this.#code;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
connectedCallback() {
|
|
246
|
+
if (this.#shell) return;
|
|
247
|
+
injectStyle();
|
|
248
|
+
this.#code = readSource(this);
|
|
249
|
+
this.#highlight = parseHighlight(this.getAttribute("highlight"));
|
|
250
|
+
const size = this.getAttribute("font-size");
|
|
251
|
+
if (size) this.style.setProperty("--keyframes-code-size", /^\d+(\.\d+)?$/.test(size) ? `${size}px` : size);
|
|
252
|
+
this.#shell = { editor: element("div", "keyframes-code-editor") };
|
|
253
|
+
this.#shell.editor.append(element("pre", "", this.#code));
|
|
254
|
+
const parts = [this.#shell.editor];
|
|
255
|
+
if (this.hasAttribute("run") && RUNNERS[this.lang]) parts.push(...this.#buildRunner());
|
|
256
|
+
const markers = this.#highlight.slice(1).map((_, i) => {
|
|
257
|
+
const marker = element("span", "keyframes-code-step");
|
|
258
|
+
marker.hidden = true;
|
|
259
|
+
marker.dataset.step = String(i + 1);
|
|
260
|
+
return marker;
|
|
261
|
+
});
|
|
262
|
+
this.replaceChildren(...parts, ...markers);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#buildRunner() {
|
|
266
|
+
const bar = element("div", "keyframes-code-bar");
|
|
267
|
+
const button = element("button", "keyframes-code-run", "â–¶ Run");
|
|
268
|
+
button.addEventListener("mousedown", (e) => e.preventDefault());
|
|
269
|
+
button.addEventListener("click", () => this.run());
|
|
270
|
+
bar.append(element("span", "keyframes-code-lang", this.lang), element("span", "keyframes-code-hint", "⇧↵ to run"), button);
|
|
271
|
+
const output = element("pre", "keyframes-code-output");
|
|
272
|
+
output.append(element("span", "status", "Output appears here"));
|
|
273
|
+
Object.assign(this.#shell, { button, output });
|
|
274
|
+
return [bar, output];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async init() {
|
|
278
|
+
if (this.hasAttribute("run") && !RUNNERS[this.lang]) {
|
|
279
|
+
throw new Error(`run supports lang="python" or lang="javascript", not "${this.lang}"`);
|
|
280
|
+
}
|
|
281
|
+
const [cm, language] = await Promise.all([loadCodeMirror(), LANGUAGES[this.lang]?.()]);
|
|
282
|
+
const { EditorState, Compartment, Prec } = cm.state;
|
|
283
|
+
const { EditorView, keymap, lineNumbers, drawSelection } = cm.view;
|
|
284
|
+
const { defaultKeymap, history, historyKeymap, indentWithTab } = cm.commands;
|
|
285
|
+
const { syntaxHighlighting, bracketMatching, indentOnInput } = cm.language;
|
|
286
|
+
const readonly = this.hasAttribute("readonly");
|
|
287
|
+
this.#decorations = new Compartment();
|
|
288
|
+
const runKeys = [
|
|
289
|
+
{ key: "Mod-Enter", run: () => (this.run(), true) },
|
|
290
|
+
{ key: "Shift-Enter", run: () => (this.run(), true) },
|
|
291
|
+
];
|
|
292
|
+
const extensions = [
|
|
293
|
+
theme(EditorView),
|
|
294
|
+
syntaxHighlighting(highlightStyle(cm)),
|
|
295
|
+
drawSelection(),
|
|
296
|
+
bracketMatching(),
|
|
297
|
+
indentOnInput(),
|
|
298
|
+
history(),
|
|
299
|
+
EditorState.tabSize.of(4),
|
|
300
|
+
Prec.highest(keymap.of([...(this.#shell.button ? runKeys : []), { key: "Escape", run: blur }])),
|
|
301
|
+
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
|
302
|
+
this.#decorations.of(this.#lineDecorations(cm)),
|
|
303
|
+
language ?? [],
|
|
304
|
+
this.hasAttribute("line-numbers") ? lineNumbers() : [],
|
|
305
|
+
readonly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : [],
|
|
306
|
+
];
|
|
307
|
+
this.#view = new EditorView({ doc: this.#code, extensions });
|
|
308
|
+
this.#shell.editor.replaceChildren(this.#view.dom);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#lineDecorations(cm, segment = this.#highlight[Math.min(this.#step, this.#highlight.length - 1)]) {
|
|
312
|
+
if (!segment) return [];
|
|
313
|
+
const { Decoration, EditorView } = cm.view;
|
|
314
|
+
const hl = Decoration.line({ class: "keyframes-hl" });
|
|
315
|
+
const dim = Decoration.line({ class: "keyframes-dim" });
|
|
316
|
+
return EditorView.decorations.of((view) => {
|
|
317
|
+
const doc = view.state.doc;
|
|
318
|
+
const marks = [];
|
|
319
|
+
for (let n = 1; n <= doc.lines; n++) {
|
|
320
|
+
const on = segment.some(([a, b]) => n >= a && n <= b);
|
|
321
|
+
marks.push((on ? hl : dim).range(doc.line(n).from));
|
|
322
|
+
}
|
|
323
|
+
return Decoration.set(marks);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async #setDecorations(segment) {
|
|
328
|
+
const cm = await loadCodeMirror();
|
|
329
|
+
this.#view?.dispatch({ effects: this.#decorations.reconfigure(this.#lineDecorations(cm, segment)) });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
step(n) {
|
|
333
|
+
this.#step = n;
|
|
334
|
+
if (this.#view && this.#highlight.length) this.#setDecorations();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async snapshot() {
|
|
338
|
+
const { output } = this.#shell;
|
|
339
|
+
if (output?.querySelector(":scope > .status:only-child")) output.hidden = true;
|
|
340
|
+
if (this.#view && this.#highlight.length) await this.#setDecorations(null);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
destroy() {
|
|
344
|
+
this.#code = this.#view.state.doc.toString();
|
|
345
|
+
this.#view.destroy();
|
|
346
|
+
this.#view = null;
|
|
347
|
+
this.#shell.editor.replaceChildren(element("pre", "", this.#code));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async run() {
|
|
351
|
+
const { button, output } = this.#shell;
|
|
352
|
+
if (this.#running || !button) return;
|
|
353
|
+
this.#running = true;
|
|
354
|
+
button.disabled = true;
|
|
355
|
+
output.replaceChildren();
|
|
356
|
+
let statusLine = null;
|
|
357
|
+
const status = (text) => {
|
|
358
|
+
statusLine?.remove();
|
|
359
|
+
statusLine = text ? output.appendChild(element("span", "status", `${text}\n`)) : null;
|
|
360
|
+
};
|
|
361
|
+
const print = (text, kind) => {
|
|
362
|
+
output.append(element("span", kind, `${text}\n`));
|
|
363
|
+
output.scrollTop = output.scrollHeight;
|
|
364
|
+
};
|
|
365
|
+
const packages = (this.getAttribute("packages") || "").split(/[\s,]+/).filter(Boolean);
|
|
366
|
+
const runner = RUNNERS[this.lang];
|
|
367
|
+
const job = () => runner(this.code, { print, status, packages, host: this });
|
|
368
|
+
try {
|
|
369
|
+
status("Running…");
|
|
370
|
+
if (runner === runPython) await (pythonQueue = pythonQueue.then(job, job));
|
|
371
|
+
else await job();
|
|
372
|
+
status(null);
|
|
373
|
+
if (!output.childNodes.length) output.append(element("span", "status", "(no output)"));
|
|
374
|
+
} catch (e) {
|
|
375
|
+
status(null);
|
|
376
|
+
print(String(e?.message ?? e), "err");
|
|
377
|
+
} finally {
|
|
378
|
+
this.#running = false;
|
|
379
|
+
button.disabled = false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function blur(view) {
|
|
385
|
+
view.contentDOM.blur();
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function theme(EditorView) {
|
|
390
|
+
return EditorView.theme({
|
|
391
|
+
"&": { height: "100%", backgroundColor: "transparent", color: "var(--fg)", fontSize: "inherit" },
|
|
392
|
+
"&.cm-focused": { outline: "none" },
|
|
393
|
+
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "1.55" },
|
|
394
|
+
".cm-content": { padding: "0.8em 0", caretColor: "var(--accent)" },
|
|
395
|
+
".cm-line": { padding: "0 1.1em" },
|
|
396
|
+
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--accent)", borderLeftWidth: "2px" },
|
|
397
|
+
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection":
|
|
398
|
+
{ backgroundColor: "color-mix(in srgb, var(--accent) 20%, transparent)" },
|
|
399
|
+
".cm-gutters": { backgroundColor: "transparent", color: "var(--muted)", border: "none", opacity: "0.6" },
|
|
400
|
+
".cm-lineNumbers .cm-gutterElement": { padding: "0 0.2em 0 0.9em", minWidth: "1.6em" },
|
|
401
|
+
"&.cm-focused .cm-matchingBracket": { backgroundColor: "color-mix(in srgb, var(--accent-2) 25%, transparent)" },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function highlightStyle(cm) {
|
|
406
|
+
const { tags: t } = cm.highlight;
|
|
407
|
+
return cm.language.HighlightStyle.define([
|
|
408
|
+
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#7c3aed", fontWeight: "600" },
|
|
409
|
+
{ tag: [t.string, t.special(t.string), t.regexp], color: "#0f7b4a" },
|
|
410
|
+
{ tag: [t.number, t.bool, t.null, t.atom], color: "#c2570c" },
|
|
411
|
+
{ tag: [t.comment, t.lineComment, t.blockComment], color: "var(--muted)", fontStyle: "italic" },
|
|
412
|
+
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--accent)" },
|
|
413
|
+
{ tag: [t.definition(t.variableName), t.definition(t.function(t.variableName))], color: "var(--accent)", fontWeight: "600" },
|
|
414
|
+
{ tag: [t.className, t.typeName, t.namespace], color: "#b45309" },
|
|
415
|
+
{ tag: [t.self, t.special(t.variableName)], color: "#be185d" },
|
|
416
|
+
{ tag: [t.propertyName, t.attributeName], color: "#0e7490" },
|
|
417
|
+
{ tag: [t.tagName], color: "#7c3aed" },
|
|
418
|
+
{ tag: [t.operator, t.punctuation, t.bracket], color: "#5d6470" },
|
|
419
|
+
{ tag: t.invalid, color: "#c62828" },
|
|
420
|
+
]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
customElements.define("keyframes-code", SwCode);
|