astro bun Deno logo
Skip to content
Go back

为Markdown文档添加 Python 在线运行功能

Edit page

我们使用Mkdocs时,写的Python代码段是不支持在线运行的。 如果能像Mdbook写Rust代码段那样能在线运行就好了。

alt text

我使用的Mkdocs的主题是mkdocs-material。 不同的主题实现的代码可能有所不同,但大体思路是一致的。 包括说,即使你所使用的技术不是Mkdocs,其思路也是通用的!

Pyodide

PyodideCPythonWebAssembly/Emscripten的移植。 Pyodide使得使用micropip在浏览器中安装和运行Python包成为可能。 Pyodide带有一个健壮的JavascriptPython外部函数接口, 因此您可以在代码中以最小的摩擦自由混合这两种语言。这包括对错误处理的完全支持(以一种语言抛出错误,在另一种语言中捕获错误),异步/等待等等。在浏览器内部使用时,Python 可以完全访问 Web API。

因此哈,我们可以使用Pyodide来试下代码段的在线运行。

创建code-runner.js

创建的自定义js/css文件,一定要放在docs文件目录下。 我这里是将code-runner.js放置在/docs/assets/js目录下。

要注意: 其获取DOM的操作是和不同的主题相关联。 我使用的主题是mkdocs-material。其代码中使用的alert$document$等,其能力是mkdocs-material所提供的。 一定要根据你的主题修改出相适配的代码。

class CodeRunner {
    static instance = null;
    
    constructor() {
        if (CodeRunner.instance) {
            return CodeRunner.instance;
        }
        
        this.pyodideInstance = null;
        this.isLoading = false;
        
        CodeRunner.instance = this;
    }
    static getInstance() {
        if (!CodeRunner.instance) {
            CodeRunner.instance = new CodeRunner();
        }
        return CodeRunner.instance;
    }
    
    /**
     * 动态加载 Pyodide 脚本
     * @returns {Promise}
     */
    async loadPyodideScript() {
        return new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = 'https://cdn.jsdelivr.net/npm/pyodide/pyodide.js';
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script);
        });
    }

    /**
     * 更新输出区域内容
     * @param {HTMLElement} element - 输出元素
     * @param {string} type - 类型 (loading, success, error)
     * @param {string} message - 消息内容 (可以是 HTML)
     */
    writeOutput(element, type, message) {
        requestIdleCallback(() => {
            element.style.display = 'block';
            element.innerHTML = `<div class="output-${type}">${message}</div>`;
        })
    }
    
    /**
     * 初始化 Pyodide
     * @returns {Promise}
     */
    async initPyodide(outputElement) {
        if (this.pyodideInstance) return this.pyodideInstance;
        if (this.isLoading) {
            // 等待加载完成
            while (this.isLoading) {
                await new Promise(resolve => setTimeout(resolve, 100));
            }
            return this.pyodideInstance;
        }
        
        this.isLoading = true;
        try {
            // 动态加载 Pyodide 脚本(如果尚未加载)
            if (typeof loadPyodide === 'undefined') {
                await this.loadPyodideScript();
            }
            const loadText = '正在加载 Python 运行环境...'
            
            // 使用 alert$ Subject 发送加载状态
            if (window.alert$) {
                window.alert$.next(loadText);
            }
            this.writeOutput(outputElement, 'loading', loadText);
            
            this.pyodideInstance = await loadPyodide({
                indexURL: "https://cdn.jsdelivr.net/npm/pyodide/"
            });


            const okText = 'Python 环境加载完成!'
            // 发送加载成功消息
            if (window.alert$) {
                window.alert$.next(okText);
            }
            this.writeOutput(outputElement, 'loading', okText);
            
            return this.pyodideInstance;
        } catch (error) {
            const errorText = 'Python 环境加载失败: ' + error.message;
            
            console.error('Pyodide 加载失败:', error);
            
            // 发送错误消息
            if (window.alert$) {
                window.alert$.next(errorText);
            }
            this.writeOutput(outputElement, 'error', errorText);
            
            throw error;
        } finally {
            this.isLoading = false;
        }
    }
    
    /**
     * 运行 Python 代码
     * @param {string} code - Python 代码
     * @param {HTMLElement} outputElement - 输出元素
     * @param {HTMLButtonElement} button - 运行按钮
     */
    async runPythonCode(code, outputElement, button) {
        // 添加运行状态
        button.classList.add('running');
        button.disabled = true;
        button.title = '运行中...';
        
        try {
            const pyodide = await this.initPyodide(outputElement);
            
            // 捕获输出
            let output = '';
            pyodide.setStdout({
                batched: (text) => { output += text + '\n'; }
            });
            pyodide.setStderr({
                batched: (text) => { output += 'Error: ' + text + '\n'; }
            });
        
            this.writeOutput(outputElement, 'loading', '正在执行代码...');

            // 运行代码
            try {
                const result = await pyodide.runPythonAsync(code);
                
                // 如果代码有返回值且不是 None,也显示出来
                if (result !== undefined && result !== null && String(result) !== 'None') {
                    output += '\n返回值: ' + String(result);
                }
                
                if (output.trim()) {
                    this.writeOutput(outputElement, 'success', `<pre>${this.escapeHtml(output)}</pre>`);
                } else {
                    this.writeOutput(outputElement, 'success', '代码执行成功(无输出)');
                }
            } catch (err) {
                this.writeOutput(outputElement, 'error', `<strong>执行错误:</strong>\n<pre>${this.escapeHtml(err.message)}</pre>`);
            }
        } catch (err) {
            this.writeOutput(outputElement, 'error', `<strong>初始化错误:</strong>\n<pre>${this.escapeHtml(err.message)}</pre>`);
        } finally {
            // 恢复按钮状态
            button.classList.remove('running');
            button.disabled = false;
            button.title = '在浏览器中运行此 Python 代码';
        }
    }
    
    /**
     * HTML 转义
     * @param {string} text - 需要转义的文本
     * @returns {string}
     */
    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
    
    /**
     * 为代码块添加运行按钮
     */
    addRunButtons() {
        // Material for MkDocs 使用 div.language-python 包裹代码块
        const codeContainers = document.querySelectorAll('div.language-python, div.highlight-python, .highlight.language-python');
        
        codeContainers.forEach((container, index) => {
            // 检查是否已经添加过按钮
            if (container.querySelector('.md-code__run')) {
                return;
            }
            
            // 查找 pre 和 code 元素
            const preElement = container.querySelector('pre');
            const codeElement = container.querySelector('code');
            
            if (!preElement || !codeElement) {
                return;
            }
            
            // 查找或创建导航容器
            let navElement = container.querySelector('nav.md-code__nav');
            if (!navElement) {
                navElement = document.createElement('nav');
                navElement.className = 'md-code__nav';
                container.insertBefore(navElement, preElement);
            }
            
            // 创建运行按钮
            const runButton = document.createElement('button');
            runButton.className = 'md-code__run';
            runButton.title = '在浏览器中运行此 Python 代码';
            
        
            
            // 创建输出区域(放在代码块容器下方)
            const outputDiv = document.createElement('div');
            outputDiv.className = 'code-output';
            outputDiv.style.display = 'none';
            
            // 添加按钮点击事件
            runButton.addEventListener('click', async (e) => {
                e.preventDefault();
                e.stopPropagation();
                // 提取纯文本代码(去除 HTML 标签和行号)
                const code = codeElement.textContent || codeElement.innerText;
                await this.runPythonCode(code, outputDiv, runButton);
            });
            
            // 将运行按钮插入到导航栏(复制按钮前面)
            navElement.insertBefore(runButton, navElement.firstChild);
            
            // 将输出区域插入到代码块容器后面
            container.parentNode.insertBefore(outputDiv, container.nextSibling);
        });
    }
    
    /**
     * 初始化代码运行器
     */
    init() {
        // 页面加载完成后初始化
        document.addEventListener('DOMContentLoaded', () => {
            // 添加运行按钮
            this.addRunButtons();
            
            // 监听页面内容变化(适配 Material 主题的即时加载)
            if (typeof document$ !== 'undefined') {
                document$.subscribe(() => {
                    this.addRunButtons();
                });
            }
        });
    }
}

// 初始化单例实例
CodeRunner.getInstance().init();code-runner.js

code-runner.css

:root {
    --md-code--run: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
}

.md-typeset pre {
    margin-bottom: 0;
}

/* 运行按钮 - 与复制按钮风格一致 */

.md-code__run {
    color: var(--md-default-fg-color--lightest);
    cursor: pointer;
    display: block;
    height: 1.5em;
    outline-color: var(--md-accent-fg-color);
    outline-offset: .1rem;
    transition: color .25s;
    width: 1.5em;
}

:hover>*>.md-code__run {
    color: var(--md-default-fg-color--light);
}

.md-code__run:hover {
    color: var(--md-accent-fg-color);
}

.md-code__run::after {
    mask-image: var(--md-code--run);
    background-color: currentcolor;
    content: "";
    display: block;
    height: 1.5em;
    margin: 0 auto;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: contain;
    mask-size: contain;
    width: 1.5em;
}

.md-code__run:focus {
    color: var(--md-accent-fg-color);
}

/* 输出区域 */
.code-output {
    border-radius: 0 0 0.5rem 0.5rem;
    border-top: 1px solid var(--md-accent-fg-color);
    background: var(--md-code-bg-color);
    color: var(--md-default-fg-color, #333);
    animation: slideDown 0.3s ease-out;
    overflow: hidden;
}

[data-md-color-scheme="slate"] .code-output {
    border-color: rgba(255, 255, 255, 0.1);
    border-top-color: var(--md-accent-fg-color);
}

@keyframes slideDown {
    from {
        max-height: 0;
        opacity: 0;
        transform: translateY(-10px);
    }
    to {
        max-height: 1000px;
        opacity: 1;
        transform: translateY(0);
    }
}

.code-output pre {
    margin: 0 !important;
    padding: 1em !important;
    background: transparent !important;
    border-radius: 0 !important;
    white-space: pre-wrap;
    word-wrap: break-word;
    font-size: 0.85em;
    line-height: 1.6;
    color: inherit;
}

.output-loading {
    padding: 1em 1.2em;
    color: var(--md-accent-fg-color);
    font-style: italic;
    font-size: 0.9em;
}

.output-success {
    padding: 0;
    color: var(--md-default-fg-color);
}

.output-success pre {
    color: inherit;
}

.output-error {
    padding: 1em 1.2em;
    background: rgba(255, 82, 82, 0.1) !important;
    color: #f44336;
}

[data-md-color-scheme="slate"] .output-error {
    background: rgba(255, 82, 82, 0.15) !important;
    color: #ef5350;
}

.output-error strong {
    display: block;
    margin-bottom: 0.5em;
    font-weight: 600;
}

.output-error pre {
    margin-top: 0.5em !important;
    padding: 0.5em !important;
    background: rgba(0, 0, 0, 0.1) !important;
    border-radius: 0.25rem !important;
}

@media screen and (max-width: 44.984375em) {
    .md-content__inner>.highlight {
        margin: 0 -.8rem;
    }
    .md-content__inner>.code-output {
        margin: 0 -.8rem;
    }
}code-runner.css

创建/overrides/main.html

我们在根目录下创建/overrides/main.html, 用于引入自定义jscss !注意路径问题。 内容如下:

{% extends "base.html" %}

{% block scripts %}
  {{ super() }}
  <script src="{{ 'assets/js/code-runner.js' | url }}"></script>
{% endblock %}

{% block styles %}
  {{ super() }}
  <link rel="stylesheet" href="{{ 'assets/css/code-runner.css' | url }}">
{% endblock %}main.html

修改mkdocs.yml

# 主题配置(不变)
theme:
  name: material
# ...
  custom_dir: overrides # 使用自定义html结构mkdocs.yml

附:目录结构

│  mkdocs.yml
├─docs
│  └─assets
│    ├─css
│    │      code-runner.css
│    └─js
│           code-runner.js
└─overrides
        main.html

end


Edit page
Share this post on:

Next Post
使用bun构建一个简易cli工具