From 3a2fab0b23196a4122bcee6d9b81d3f421f11bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Wed, 26 Apr 2023 22:48:49 +0200 Subject: [PATCH] feat: Add `pyodide` fence --- docs/insiders/changelog.md | 4 +- docs/insiders/goals.yml | 8 +- docs/usage/pyodide.md | 85 +++++++++++++++++++ mkdocs.yml | 2 + src/markdown_exec/__init__.py | 4 +- src/markdown_exec/formatters/pyodide.py | 60 ++++++++++++++ src/markdown_exec/mkdocs_plugin.py | 20 ++++- src/markdown_exec/pyodide.css | 49 +++++++++++ src/markdown_exec/pyodide.js | 105 ++++++++++++++++++++++++ 9 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 docs/usage/pyodide.md create mode 100644 src/markdown_exec/formatters/pyodide.py create mode 100644 src/markdown_exec/pyodide.css create mode 100644 src/markdown_exec/pyodide.js diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md index d359ee4..ae3267b 100644 --- a/docs/insiders/changelog.md +++ b/docs/insiders/changelog.md @@ -2,6 +2,6 @@ ## Markdown Exec Insiders -### 1.0.0 April 22, 2023 { id="1.0.0" } +### 1.0.0 April 26, 2023 { id="1.0.0" } -- Release first Insiders version +- Add a [`pyodide` fence](../usage/pyodide/) diff --git a/docs/insiders/goals.yml b/docs/insiders/goals.yml index 896b924..f6e21cd 100644 --- a/docs/insiders/goals.yml +++ b/docs/insiders/goals.yml @@ -1 +1,7 @@ -goals: {} +goals: + 500: + name: PlasmaVac User Guide + features: + - name: Pyodide fence + ref: ../usage/pyodide/ + since: 2023/04/26 diff --git a/docs/usage/pyodide.md b/docs/usage/pyodide.md new file mode 100644 index 0000000..d833ef8 --- /dev/null +++ b/docs/usage/pyodide.md @@ -0,0 +1,85 @@ +# Pyodide + +[:octicons-heart-fill-24:{ .pulse } Sponsors only](../../insiders){ .insiders } — +[: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!") +``` +```` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index e0e7761..736c7ba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 @@ -93,6 +94,7 @@ markdown_extensions: check_paths: true base_path: [docs/snippets, "."] - pymdownx.highlight +- pymdownx.keys - pymdownx.superfences: custom_fences: - name: mermaid diff --git a/src/markdown_exec/__init__.py b/src/markdown_exec/__init__.py index 2245bd0..6d09f73 100644 --- a/src/markdown_exec/__init__.py +++ b/src/markdown_exec/__init__.py @@ -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 @@ -34,6 +35,7 @@ "py": _format_python, "python": _format_python, "pycon": _format_pycon, + "pyodide": _format_pyodide, "sh": _format_sh, "tree": _format_tree, } @@ -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) diff --git a/src/markdown_exec/formatters/pyodide.py b/src/markdown_exec/formatters/pyodide.py new file mode 100644 index 0000000..408389a --- /dev/null +++ b/src/markdown_exec/formatters/pyodide.py @@ -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 = '' +clear_emoji = '' + +template = """ + + + + + + +
+
+Editor (session: %(session)s)%(play_emoji)s Run +
+
%(initial_code)s
+
+Output%(clear_emoji)s Clear +
+
+
+ + +""" + +_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 diff --git a/src/markdown_exec/mkdocs_plugin.py b/src/markdown_exec/mkdocs_plugin.py index cb57aaf..d629854 100644 --- a/src/markdown_exec/mkdocs_plugin.py +++ b/src/markdown_exec/mkdocs_plugin.py @@ -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") diff --git a/src/markdown_exec/pyodide.css b/src/markdown_exec/pyodide.css new file mode 100644 index 0000000..3f53b54 --- /dev/null +++ b/src/markdown_exec/pyodide.css @@ -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; +} \ No newline at end of file diff --git a/src/markdown_exec/pyodide.js b/src/markdown_exec/pyodide.js new file mode 100644 index 0000000..7168f18 --- /dev/null +++ b/src/markdown_exec/pyodide.js @@ -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();