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

我使用的Mkdocs的主题是mkdocs-material。 不同的主题实现的代码可能有所不同,但大体思路是一致的。 包括说,即使你所使用的技术不是Mkdocs,其思路也是通用的!
Pyodide
Pyodide是CPython到WebAssembly/Emscripten的移植。Pyodide使得使用micropip在浏览器中安装和运行Python包成为可能。Pyodide带有一个健壮的Javascript与Python外部函数接口, 因此您可以在代码中以最小的摩擦自由混合这两种语言。这包括对错误处理的完全支持(以一种语言抛出错误,在另一种语言中捕获错误),异步/等待等等。在浏览器内部使用时,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,
用于引入自定义js和css
!注意路径问题。
内容如下:
{% 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