Skip to content

Commit

Permalink
feat: Add pyodide fence
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Apr 26, 2023
1 parent 2822fb9 commit 3a2fab0
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 8 deletions.
4 changes: 2 additions & 2 deletions docs/insiders/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

## Markdown Exec Insiders

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

- Release first Insiders version
- Add a [`pyodide` fence](../usage/pyodide/)
8 changes: 7 additions & 1 deletion docs/insiders/goals.yml
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
goals: {}
goals:
500:
name: PlasmaVac User Guide
features:
- name: Pyodide fence
ref: ../usage/pyodide/
since: 2023/04/26
85 changes: 85 additions & 0 deletions docs/usage/pyodide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Pyodide

[:octicons-heart-fill-24:{ .pulse } Sponsors only](../../insiders){ .insiders } &mdash;
[:octicons-tag-24: Insiders 1.0.0](../../insiders/changelog#1.0.0)

This special `pyodide` fence uses [Pyodide](https://pyodide.org), [Ace](https://ace.c9.io/)
and [Highlight.js](https://highlightjs.org/) to render an interactive Python editor.
Everything runs on the client side. The first time Pyodide is loaded by the browser
can be a bit long, but then it will be cached and the next time you load the page
it will be much faster.

Click the "Run" button in the top-right corner, or hit ++ctrl+enter++ to run the code.
You can install packages with Micropip:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide
import micropip

print("Installing cowsay...")
await micropip.install("cowsay")
print("done!")
```
````

Then you can import and use the packages you installed:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide
import cowsay
cowsay.cow("Hello World")
```
````

Packages installed with Micropip are cached by the browser as well,
making future installations much faster.

## Sessions

Editors with the same session share the same `globals()` dictionary,
so you can reuse variables, classes, imports, etc., from another editor
within the same session. This is why you can import `cowsay` in this editor,
given you actually installed it in the first. Sessions are ephemeral:
everything is reset when reloading the page. This means you cannot persist
sessions across multiple pages. Try refreshing your page
and running the code of the second editor: you should get a ModuleNotFoundError.

To use other sessions, simply pass the `session="name"` option to the code block:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide session="something"
something = "hello"
```
````

Now lets print it in another editor with the same session:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide session="something"
print(something)
```
````

And in another editor with the default session:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide
print(something)
```
````

## Pre-installing packages

In your own documentation pages, you might not want to add
`import micropip; await micropip.install("your-package")`
to every editor to show how to use your package. In this case,
you can use the `install` option to pre-install packages.
The option takes a list of comma-separated package distribution names:

````md exec="1" source="tabbed-right" tabs="Markdown|Rendered"
```pyodide install="griffe,dependenpy"
import griffe
import dependenpy
print("OK!")
```
````
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ nav:
- Usage:
- usage/index.md
- Python: usage/python.md
- Pyodide: usage/pyodide.md
- Shell: usage/shell.md
- Tree: usage/tree.md
- Gallery: gallery.md
Expand Down Expand Up @@ -93,6 +94,7 @@ markdown_extensions:
check_paths: true
base_path: [docs/snippets, "."]
- pymdownx.highlight
- pymdownx.keys
- pymdownx.superfences:
custom_fences:
- name: mermaid
Expand Down
4 changes: 3 additions & 1 deletion src/markdown_exec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from markdown_exec.formatters.console import _format_console
from markdown_exec.formatters.markdown import _format_markdown
from markdown_exec.formatters.pycon import _format_pycon
from markdown_exec.formatters.pyodide import _format_pyodide
from markdown_exec.formatters.python import _format_python
from markdown_exec.formatters.sh import _format_sh
from markdown_exec.formatters.tree import _format_tree
Expand All @@ -34,6 +35,7 @@
"py": _format_python,
"python": _format_python,
"pycon": _format_pycon,
"pyodide": _format_pyodide,
"sh": _format_sh,
"tree": _format_tree,
}
Expand Down Expand Up @@ -62,7 +64,7 @@ def validator(
Success or not.
"""
exec_value = _to_bool(inputs.pop("exec", "no"))
if language != "tree" and not exec_value:
if language not in {"tree", "pyodide"} and not exec_value:
return False
id_value = inputs.pop("id", "")
id_prefix_value = inputs.pop("idprefix", None)
Expand Down
60 changes: 60 additions & 0 deletions src/markdown_exec/formatters/pyodide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Formatter for creating a Pyodide interactive editor."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from markdown import Markdown

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>'
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>'

template = """
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.16.0/ace.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/pyodide/v0.23.0/full/pyodide.js"></script>
<link title="light" rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.min.css" disabled="disabled">
<link title="dark" rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css" disabled="disabled">
<div class="pyodide">
<div class="pyodide-editor-bar">
<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>
</div>
<div id="%(id_prefix)seditor" class="pyodide-editor">%(initial_code)s</div>
<div class="pyodide-editor-bar">
<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>
</div>
<pre><code id="%(id_prefix)soutput" class="pyodide-output"></code></pre>
</div>
<script>
document.addEventListener('DOMContentLoaded', (event) => {
setupPyodide('%(id_prefix)s', install=%(install)s, themeLight='%(theme_light)s', themeDark='%(theme_dark)s', session='%(session)s');
});
</script>
"""

_counter = 0


def _format_pyodide(code: str, md: Markdown, session: str, extra: dict, **options: Any) -> str: # noqa: ARG001
global _counter # noqa: PLW0603
_counter += 1
install = extra.pop("install", "")
install = install.split(",") if install else []
theme = extra.pop("theme", "tomorrow,tomorrow_night")
if "," not in theme:
theme = f"{theme},{theme}"
theme_light, theme_dark = theme.split(",")
data = {
"id_prefix": f"exec-{_counter}--",
"initial_code": code,
"install": install,
"theme_light": theme_light.strip(),
"theme_dark": theme_dark.strip(),
"session": session or "default",
"play_emoji": play_emoji,
"clear_emoji": clear_emoji,
}
return template % data
20 changes: 16 additions & 4 deletions src/markdown_exec/mkdocs_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,24 @@ def on_config(self, config: Config, **kwargs: Any) -> Config: # noqa: ARG002
return config

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

def on_post_build(self, *, config: Config) -> None: # noqa: ARG002,D102
MarkdownConverter.counter = 0
markdown_config.reset()

def _add_asset(self, config: Config, asset_file: str, asset_type: str) -> None:
asset_filename = f"assets/_markdown_exec_{asset_file}"
asset_content = Path(__file__).parent.joinpath(asset_file).read_text()
write_file(asset_content.encode("utf-8"), os.path.join(config["site_dir"], asset_filename))
config[f"extra_{asset_type}"].insert(0, asset_filename)

def _add_css(self, config: Config, css_file: str) -> None:
self._add_asset(config, css_file, "css")

def _add_js(self, config: Config, js_file: str) -> None:
self._add_asset(config, js_file, "javascript")
49 changes: 49 additions & 0 deletions src/markdown_exec/pyodide.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
html[data-theme="light"] {
@import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.css"
}

html[data-theme="dark"] {
@import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css"
}


.ace_gutter {
z-index: 1;
}

.pyodide-editor {
width: 100%;
min-height: 200px;
max-height: 400px;
font-size: .85em;
}

.pyodide-editor-bar {
color: var(--md-primary-bg-color);
background-color: var(--md-primary-fg-color);
width: 100%;
font: monospace;
font-size: 0.75em;
padding: 2px 0 2px;
}

.pyodide-bar-item {
padding: 0 18px 0;
display: inline-block;
width: 50%;
}

.pyodide pre {
margin: 0;
}

.pyodide-output {
width: 100%;
margin-bottom: -15px;
max-height: 400px
}

.pyodide-clickable {
cursor: pointer;
text-align: right;
}
105 changes: 105 additions & 0 deletions src/markdown_exec/pyodide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
var _sessions = {};

function getSession(name, pyodide) {
if (!(name in _sessions)) {
_sessions[name] = pyodide.globals.get("dict")();
}
return _sessions[name];
}

function writeOutput(element, string) {
element.innerHTML += string + '\n';
}

function clearOutput(element) {
element.innerHTML = '';
}

async function evaluatePython(pyodide, editor, output, session) {
pyodide.setStdout({ batched: (string) => { writeOutput(output, string); } });
let result, code = editor.getValue();
clearOutput(output);
try {
result = await pyodide.runPythonAsync(code, { globals: getSession(session, pyodide) });
} catch (error) {
writeOutput(output, error);
}
if (result) writeOutput(output, result);
hljs.highlightElement(output);
}

async function initPyodide() {
let pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
return pyodide;
}

function getTheme() {
return document.body.getAttribute('data-md-color-scheme');
}

function setTheme(editor, currentTheme, light, dark) {
// https://gist.github.com/RyanNutt/cb8d60997d97905f0b2aea6c3b5c8ee0
if (currentTheme === "default") {
editor.setTheme("ace/theme/" + light);
document.querySelector(`link[title="light"]`).removeAttribute("disabled");
document.querySelector(`link[title="dark"]`).setAttribute("disabled", "disabled");
} else if (currentTheme === "slate") {
editor.setTheme("ace/theme/" + dark);
document.querySelector(`link[title="dark"]`).removeAttribute("disabled");
document.querySelector(`link[title="light"]`).setAttribute("disabled", "disabled");
}
}

function updateTheme(editor, light, dark) {
// Create a new MutationObserver instance
const observer = new MutationObserver((mutations) => {
// Loop through the mutations that occurred
mutations.forEach((mutation) => {
// Check if the mutation was a change to the data-md-color-scheme attribute
if (mutation.attributeName === 'data-md-color-scheme') {
// Get the new value of the attribute
const newColorScheme = mutation.target.getAttribute('data-md-color-scheme');
// Update the editor theme
setTheme(editor, newColorScheme, light, dark);
}
});
});

// Configure the observer to watch for changes to the data-md-color-scheme attribute
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-md-color-scheme'],
});
}

async function setupPyodide(idPrefix, install = null, themeLight = 'tomorrow', themeDark = 'tomorrow_night', session = null) {
const editor = ace.edit(idPrefix + "editor");
const run = document.getElementById(idPrefix + "run");
const clear = document.getElementById(idPrefix + "clear");
const output = document.getElementById(idPrefix + "output");

updateTheme(editor, themeLight, themeDark);

editor.session.setMode("ace/mode/python");
setTheme(editor, getTheme(), themeLight, themeDark);

writeOutput(output, "Initializing...");
let pyodide = await pyodidePromise;
if (install && install.length) {
micropip = pyodide.pyimport("micropip");
for (const package of install)
await micropip.install(package);
}
clearOutput(output);
run.onclick = () => evaluatePython(pyodide, editor, output, session);
clear.onclick = () => clearOutput(output);
output.parentElement.parentElement.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'enter') {
event.preventDefault();
run.click();
}
});
}

var pyodidePromise = initPyodide();

0 comments on commit 3a2fab0

Please sign in to comment.