Skip to content

Commit 3a2fab0

Browse files
committed
feat: Add pyodide fence
1 parent 2822fb9 commit 3a2fab0

File tree

9 files changed

+329
-8
lines changed

9 files changed

+329
-8
lines changed

docs/insiders/changelog.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
## Markdown Exec Insiders
44

5-
### 1.0.0 <small>April 22, 2023</small> { id="1.0.0" }
5+
### 1.0.0 <small>April 26, 2023</small> { id="1.0.0" }
66

7-
- Release first Insiders version
7+
- Add a [`pyodide` fence](../usage/pyodide/)

docs/insiders/goals.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
goals: {}
1+
goals:
2+
500:
3+
name: PlasmaVac User Guide
4+
features:
5+
- name: Pyodide fence
6+
ref: ../usage/pyodide/
7+
since: 2023/04/26

docs/usage/pyodide.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Pyodide
2+
3+
[:octicons-heart-fill-24:{ .pulse } Sponsors only](../../insiders){ .insiders } &mdash;
4+
[:octicons-tag-24: Insiders 1.0.0](../../insiders/changelog#1.0.0)
5+
6+
This special `pyodide` fence uses [Pyodide](https://pyodide.org), [Ace](https://ace.c9.io/)
7+
and [Highlight.js](https://highlightjs.org/) to render an interactive Python editor.
8+
Everything runs on the client side. The first time Pyodide is loaded by the browser
9+
can be a bit long, but then it will be cached and the next time you load the page
10+
it will be much faster.
11+
12+
Click the "Run" button in the top-right corner, or hit ++ctrl+enter++ to run the code.
13+
You can install packages with Micropip:
14+
15+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
16+
```pyodide
17+
import micropip
18+
19+
print("Installing cowsay...")
20+
await micropip.install("cowsay")
21+
print("done!")
22+
```
23+
````
24+
25+
Then you can import and use the packages you installed:
26+
27+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
28+
```pyodide
29+
import cowsay
30+
cowsay.cow("Hello World")
31+
```
32+
````
33+
34+
Packages installed with Micropip are cached by the browser as well,
35+
making future installations much faster.
36+
37+
## Sessions
38+
39+
Editors with the same session share the same `globals()` dictionary,
40+
so you can reuse variables, classes, imports, etc., from another editor
41+
within the same session. This is why you can import `cowsay` in this editor,
42+
given you actually installed it in the first. Sessions are ephemeral:
43+
everything is reset when reloading the page. This means you cannot persist
44+
sessions across multiple pages. Try refreshing your page
45+
and running the code of the second editor: you should get a ModuleNotFoundError.
46+
47+
To use other sessions, simply pass the `session="name"` option to the code block:
48+
49+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
50+
```pyodide session="something"
51+
something = "hello"
52+
```
53+
````
54+
55+
Now lets print it in another editor with the same session:
56+
57+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
58+
```pyodide session="something"
59+
print(something)
60+
```
61+
````
62+
63+
And in another editor with the default session:
64+
65+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
66+
```pyodide
67+
print(something)
68+
```
69+
````
70+
71+
## Pre-installing packages
72+
73+
In your own documentation pages, you might not want to add
74+
`import micropip; await micropip.install("your-package")`
75+
to every editor to show how to use your package. In this case,
76+
you can use the `install` option to pre-install packages.
77+
The option takes a list of comma-separated package distribution names:
78+
79+
````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
80+
```pyodide install="griffe,dependenpy"
81+
import griffe
82+
import dependenpy
83+
print("OK!")
84+
```
85+
````

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ nav:
1616
- Usage:
1717
- usage/index.md
1818
- Python: usage/python.md
19+
- Pyodide: usage/pyodide.md
1920
- Shell: usage/shell.md
2021
- Tree: usage/tree.md
2122
- Gallery: gallery.md
@@ -93,6 +94,7 @@ markdown_extensions:
9394
check_paths: true
9495
base_path: [docs/snippets, "."]
9596
- pymdownx.highlight
97+
- pymdownx.keys
9698
- pymdownx.superfences:
9799
custom_fences:
98100
- name: mermaid

src/markdown_exec/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from markdown_exec.formatters.console import _format_console
2020
from markdown_exec.formatters.markdown import _format_markdown
2121
from markdown_exec.formatters.pycon import _format_pycon
22+
from markdown_exec.formatters.pyodide import _format_pyodide
2223
from markdown_exec.formatters.python import _format_python
2324
from markdown_exec.formatters.sh import _format_sh
2425
from markdown_exec.formatters.tree import _format_tree
@@ -34,6 +35,7 @@
3435
"py": _format_python,
3536
"python": _format_python,
3637
"pycon": _format_pycon,
38+
"pyodide": _format_pyodide,
3739
"sh": _format_sh,
3840
"tree": _format_tree,
3941
}
@@ -62,7 +64,7 @@ def validator(
6264
Success or not.
6365
"""
6466
exec_value = _to_bool(inputs.pop("exec", "no"))
65-
if language != "tree" and not exec_value:
67+
if language not in {"tree", "pyodide"} and not exec_value:
6668
return False
6769
id_value = inputs.pop("id", "")
6870
id_prefix_value = inputs.pop("idprefix", None)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Formatter for creating a Pyodide interactive editor."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
if TYPE_CHECKING:
8+
from markdown import Markdown
9+
10+
play_emoji = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7Z"></path></svg>'
11+
clear_emoji = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.14 3c-.51 0-1.02.2-1.41.59L2.59 14.73c-.78.77-.78 2.04 0 2.83L5.03 20h7.66l8.72-8.73c.79-.77.79-2.04 0-2.83l-4.85-4.85c-.39-.39-.91-.59-1.42-.59M17 18l-2 2h7v-2"></path></svg>'
12+
13+
template = """
14+
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.16.0/ace.js"></script>
15+
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
16+
<script type="text/javascript" src="https://cdn.jsdelivr.net/pyodide/v0.23.0/full/pyodide.js"></script>
17+
<link title="light" rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.min.css" disabled="disabled">
18+
<link title="dark" rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css" disabled="disabled">
19+
20+
<div class="pyodide">
21+
<div class="pyodide-editor-bar">
22+
<span class="pyodide-bar-item">Editor (session: %(session)s)</span><span id="%(id_prefix)srun" title="Run: press Ctrl-Enter" class="pyodide-bar-item pyodide-clickable"><span class="twemoji">%(play_emoji)s</span> Run</span>
23+
</div>
24+
<div id="%(id_prefix)seditor" class="pyodide-editor">%(initial_code)s</div>
25+
<div class="pyodide-editor-bar">
26+
<span class="pyodide-bar-item">Output</span><span id="%(id_prefix)sclear" class="pyodide-bar-item pyodide-clickable"><span class="twemoji">%(clear_emoji)s</span> Clear</span>
27+
</div>
28+
<pre><code id="%(id_prefix)soutput" class="pyodide-output"></code></pre>
29+
</div>
30+
31+
<script>
32+
document.addEventListener('DOMContentLoaded', (event) => {
33+
setupPyodide('%(id_prefix)s', install=%(install)s, themeLight='%(theme_light)s', themeDark='%(theme_dark)s', session='%(session)s');
34+
});
35+
</script>
36+
"""
37+
38+
_counter = 0
39+
40+
41+
def _format_pyodide(code: str, md: Markdown, session: str, extra: dict, **options: Any) -> str: # noqa: ARG001
42+
global _counter # noqa: PLW0603
43+
_counter += 1
44+
install = extra.pop("install", "")
45+
install = install.split(",") if install else []
46+
theme = extra.pop("theme", "tomorrow,tomorrow_night")
47+
if "," not in theme:
48+
theme = f"{theme},{theme}"
49+
theme_light, theme_dark = theme.split(",")
50+
data = {
51+
"id_prefix": f"exec-{_counter}--",
52+
"initial_code": code,
53+
"install": install,
54+
"theme_light": theme_light.strip(),
55+
"theme_dark": theme_dark.strip(),
56+
"session": session or "default",
57+
"play_emoji": play_emoji,
58+
"clear_emoji": clear_emoji,
59+
}
60+
return template % data

src/markdown_exec/mkdocs_plugin.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,24 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: ARG002
8282
return config
8383

8484
def on_env(self, env: Environment, *, config: Config, files: Files) -> Environment | None: # noqa: ARG002,D102
85-
css_filename = "assets/_markdown_exec_ansi.css"
86-
css_content = Path(__file__).parent.joinpath("ansi.css").read_text()
87-
write_file(css_content.encode("utf-8"), os.path.join(config["site_dir"], css_filename))
88-
config["extra_css"].insert(0, css_filename)
85+
self._add_css(config, "ansi.css")
86+
if "pyodide" in self.languages:
87+
self._add_css(config, "pyodide.css")
88+
self._add_js(config, "pyodide.js")
8989
return env
9090

9191
def on_post_build(self, *, config: Config) -> None: # noqa: ARG002,D102
9292
MarkdownConverter.counter = 0
9393
markdown_config.reset()
94+
95+
def _add_asset(self, config: Config, asset_file: str, asset_type: str) -> None:
96+
asset_filename = f"assets/_markdown_exec_{asset_file}"
97+
asset_content = Path(__file__).parent.joinpath(asset_file).read_text()
98+
write_file(asset_content.encode("utf-8"), os.path.join(config["site_dir"], asset_filename))
99+
config[f"extra_{asset_type}"].insert(0, asset_filename)
100+
101+
def _add_css(self, config: Config, css_file: str) -> None:
102+
self._add_asset(config, css_file, "css")
103+
104+
def _add_js(self, config: Config, js_file: str) -> None:
105+
self._add_asset(config, js_file, "javascript")

src/markdown_exec/pyodide.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
html[data-theme="light"] {
2+
@import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.css"
3+
}
4+
5+
html[data-theme="dark"] {
6+
@import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css"
7+
}
8+
9+
10+
.ace_gutter {
11+
z-index: 1;
12+
}
13+
14+
.pyodide-editor {
15+
width: 100%;
16+
min-height: 200px;
17+
max-height: 400px;
18+
font-size: .85em;
19+
}
20+
21+
.pyodide-editor-bar {
22+
color: var(--md-primary-bg-color);
23+
background-color: var(--md-primary-fg-color);
24+
width: 100%;
25+
font: monospace;
26+
font-size: 0.75em;
27+
padding: 2px 0 2px;
28+
}
29+
30+
.pyodide-bar-item {
31+
padding: 0 18px 0;
32+
display: inline-block;
33+
width: 50%;
34+
}
35+
36+
.pyodide pre {
37+
margin: 0;
38+
}
39+
40+
.pyodide-output {
41+
width: 100%;
42+
margin-bottom: -15px;
43+
max-height: 400px
44+
}
45+
46+
.pyodide-clickable {
47+
cursor: pointer;
48+
text-align: right;
49+
}

src/markdown_exec/pyodide.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
var _sessions = {};
2+
3+
function getSession(name, pyodide) {
4+
if (!(name in _sessions)) {
5+
_sessions[name] = pyodide.globals.get("dict")();
6+
}
7+
return _sessions[name];
8+
}
9+
10+
function writeOutput(element, string) {
11+
element.innerHTML += string + '\n';
12+
}
13+
14+
function clearOutput(element) {
15+
element.innerHTML = '';
16+
}
17+
18+
async function evaluatePython(pyodide, editor, output, session) {
19+
pyodide.setStdout({ batched: (string) => { writeOutput(output, string); } });
20+
let result, code = editor.getValue();
21+
clearOutput(output);
22+
try {
23+
result = await pyodide.runPythonAsync(code, { globals: getSession(session, pyodide) });
24+
} catch (error) {
25+
writeOutput(output, error);
26+
}
27+
if (result) writeOutput(output, result);
28+
hljs.highlightElement(output);
29+
}
30+
31+
async function initPyodide() {
32+
let pyodide = await loadPyodide();
33+
await pyodide.loadPackage("micropip");
34+
return pyodide;
35+
}
36+
37+
function getTheme() {
38+
return document.body.getAttribute('data-md-color-scheme');
39+
}
40+
41+
function setTheme(editor, currentTheme, light, dark) {
42+
// https://gist.github.com/RyanNutt/cb8d60997d97905f0b2aea6c3b5c8ee0
43+
if (currentTheme === "default") {
44+
editor.setTheme("ace/theme/" + light);
45+
document.querySelector(`link[title="light"]`).removeAttribute("disabled");
46+
document.querySelector(`link[title="dark"]`).setAttribute("disabled", "disabled");
47+
} else if (currentTheme === "slate") {
48+
editor.setTheme("ace/theme/" + dark);
49+
document.querySelector(`link[title="dark"]`).removeAttribute("disabled");
50+
document.querySelector(`link[title="light"]`).setAttribute("disabled", "disabled");
51+
}
52+
}
53+
54+
function updateTheme(editor, light, dark) {
55+
// Create a new MutationObserver instance
56+
const observer = new MutationObserver((mutations) => {
57+
// Loop through the mutations that occurred
58+
mutations.forEach((mutation) => {
59+
// Check if the mutation was a change to the data-md-color-scheme attribute
60+
if (mutation.attributeName === 'data-md-color-scheme') {
61+
// Get the new value of the attribute
62+
const newColorScheme = mutation.target.getAttribute('data-md-color-scheme');
63+
// Update the editor theme
64+
setTheme(editor, newColorScheme, light, dark);
65+
}
66+
});
67+
});
68+
69+
// Configure the observer to watch for changes to the data-md-color-scheme attribute
70+
observer.observe(document.body, {
71+
attributes: true,
72+
attributeFilter: ['data-md-color-scheme'],
73+
});
74+
}
75+
76+
async function setupPyodide(idPrefix, install = null, themeLight = 'tomorrow', themeDark = 'tomorrow_night', session = null) {
77+
const editor = ace.edit(idPrefix + "editor");
78+
const run = document.getElementById(idPrefix + "run");
79+
const clear = document.getElementById(idPrefix + "clear");
80+
const output = document.getElementById(idPrefix + "output");
81+
82+
updateTheme(editor, themeLight, themeDark);
83+
84+
editor.session.setMode("ace/mode/python");
85+
setTheme(editor, getTheme(), themeLight, themeDark);
86+
87+
writeOutput(output, "Initializing...");
88+
let pyodide = await pyodidePromise;
89+
if (install && install.length) {
90+
micropip = pyodide.pyimport("micropip");
91+
for (const package of install)
92+
await micropip.install(package);
93+
}
94+
clearOutput(output);
95+
run.onclick = () => evaluatePython(pyodide, editor, output, session);
96+
clear.onclick = () => clearOutput(output);
97+
output.parentElement.parentElement.addEventListener("keydown", (event) => {
98+
if (event.ctrlKey && event.key.toLowerCase() === 'enter') {
99+
event.preventDefault();
100+
run.click();
101+
}
102+
});
103+
}
104+
105+
var pyodidePromise = initPyodide();

0 commit comments

Comments
 (0)