<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Hugo | Lin Shang</title><link>https://lin-shang.github.io/tags/hugo/</link><atom:link href="https://lin-shang.github.io/tags/hugo/index.xml" rel="self" type="application/rss+xml"/><description>Hugo</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Wed, 17 Dec 2025 00:00:00 +0800</lastBuildDate><image><url>https://lin-shang.github.io/media/icon_hu_7fea8c3aa2e7dad1.png</url><title>Hugo</title><link>https://lin-shang.github.io/tags/hugo/</link></image><item><title>如果不只是展示代码：Run Python in My Hugo blog.</title><link>https://lin-shang.github.io/blog/interactive_python_demo/</link><pubDate>Wed, 17 Dec 2025 00:00:00 +0800</pubDate><guid>https://lin-shang.github.io/blog/interactive_python_demo/</guid><description>&lt;h2 id="背景为什么我们需要活的代码"&gt;背景：为什么我们需要“活”的代码？&lt;/h2&gt;
&lt;p&gt;作为一名计算机科学/AI方向的研究者，我们在撰写技术博客时，常常需要展示算法逻辑或数据处理流程。传统的 Hugo 博客通过 Markdown 的代码块（Code Block）展示代码，虽然美观，但它是&lt;strong&gt;死&lt;/strong&gt;的。&lt;/p&gt;
&lt;p&gt;读者无法修改参数来验证想法，无法查看中间变量的状态，更无法直观地看到绘图结果。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果能在博客里直接嵌入一个类似 Jupyter Notebook 的可交互环境，岂不美哉？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;起初我认为这需要部署一个后端服务（如 JupyterHub），直到我发现了 &lt;strong&gt;PyScript&lt;/strong&gt; 和 &lt;strong&gt;WebAssembly (WASM)&lt;/strong&gt;。这意味着我们可以在&lt;strong&gt;纯静态&lt;/strong&gt;的网页（如 GitHub Pages）中，利用&lt;strong&gt;浏览器&lt;/strong&gt;的算力来运行 Python，无需任何服务器成本。&lt;/p&gt;
&lt;p&gt;本文将复盘我从原型到最终实现的完整踩坑历程，并提供一套开箱即用的解决方案。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="核心技术栈"&gt;核心技术栈&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hugo (Extended)&lt;/strong&gt;: 静态网站生成器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PyScript (Pyodide)&lt;/strong&gt;: 基于 WASM 的浏览器端 Python 运行时。&lt;/li&gt;
&lt;li&gt;CodeJar + PrismJS: 关键组件。CodeJar 处理编辑行为（如 Tab 缩进、光标管理），PrismJS 负责语法高亮。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Micropip&lt;/strong&gt;: 浏览器端的 Python 包管理器（用于安装 Numpy 等）。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="避坑指南由死到活的四个难点"&gt;避坑指南：由“死”到“活”的四个难点&lt;/h2&gt;
&lt;p&gt;在直接给出代码之前，我想先分享开发过程中遇到的几个“史诗级”大坑，这能帮你理解为什么代码要写成最终那个样子。&lt;/p&gt;
&lt;h3 id="1-浏览器的安全锁-coopcoep"&gt;1. 浏览器的“安全锁” (COOP/COEP)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：Python 环境根本无法启动，控制台报错 &lt;code&gt;SharedArrayBuffer is not defined&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：为了防止幽灵熔断（Spectre）攻击，现代浏览器默认禁用了高精度计时和共享内存，除非服务器发送特定的安全响应头。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：GitHub Pages 无法配置服务器头。我们必须使用 &lt;code&gt;coi-serviceworker.js&lt;/code&gt; 脚本，在前端通过 Service Worker 欺骗浏览器，开启隔离环境。&lt;/p&gt;
&lt;h3 id="2-html-压缩导致的缩进消失术"&gt;2. HTML 压缩导致的“缩进消失术”&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：本地运行正常，推送到 GitHub 后，Python 代码的所有缩进全没了，导致 &lt;code&gt;IndentationError&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：GitHub Actions 构建时使用了 &lt;code&gt;hugo --minify&lt;/code&gt;。HTML 压缩器认为 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; 里的空格是多余的，直接删除了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：&lt;strong&gt;Base64 传输法&lt;/strong&gt;。在 Hugo 模板层将代码转为 Base64 字符串，传给前端后再用 JS 解码。HTML 压缩器看不懂 Base64，自然不敢乱动。&lt;/p&gt;
&lt;h3 id="3-编辑体验contenteditable-vs-高亮"&gt;3. 编辑体验：contentEditable vs 高亮&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：普通的 textarea 没有高亮；使用 div contenteditable 虽然可以渲染 HTML 标签来高亮，但每次输入会导致光标乱跳。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：引入 CodeJar。这是一个仅 2KB 的微型库，它接管了 contenteditable 元素的光标和输入事件，完美配合 PrismJS 实现实时高亮。&lt;/p&gt;
&lt;h3 id="4-无法加载第三方库"&gt;4. 无法加载第三方库&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;import numpy&lt;/code&gt; 报错。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：浏览器里没有预装这些库。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：在 PyScript 启动配置中预声明 &lt;code&gt;packages&lt;/code&gt;，并使用 &lt;code&gt;micropip&lt;/code&gt; 进行动态加载。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="手把手教程复刻同款-mini-ide"&gt;手把手教程：复刻同款 Mini IDE&lt;/h2&gt;
&lt;p&gt;请按照以下目录结构创建或修改你的 Hugo 项目文件。&lt;/p&gt;
&lt;h3 id="第一步配置-service-worker-static"&gt;第一步：配置 Service Worker (static)&lt;/h3&gt;
&lt;p&gt;创建文件 &lt;code&gt;static/js/coi-serviceworker.js&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;下载地址：
&lt;em&gt;(将该文件内容完整复制进去即可)&lt;/em&gt;&lt;/p&gt;
&lt;h3 id="第二步注入依赖库-head-hooks"&gt;第二步：注入依赖库 (Head Hooks)&lt;/h3&gt;
&lt;p&gt;我们需要在页面头部引入 PyScript、PrismJS（高亮）和 Service Worker。
创建或修改 &lt;code&gt;layouts/_partials/hooks/head-end/custom_style.html&lt;/code&gt;：&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Details&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-txt" data-lang="txt"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;直接查看 https://github.com/LIN-SHANG/lin-shang.github.io/tree/main/layouts/_partials/hooks/head-end/custom_style.html
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/details&gt;
&lt;h3 id="第三步打造-shortcode-组件-核心"&gt;第三步：打造 Shortcode 组件 (核心)&lt;/h3&gt;
&lt;p&gt;这是集大成者。它实现了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Base64 传输：防止 Hugo 压缩破坏代码格式。&lt;/li&gt;
&lt;li&gt;CodeJar 集成：提供 IDE 般的编辑体验。&lt;/li&gt;
&lt;li&gt;变量监控树：右侧实时显示 Python 变量结构。&lt;/li&gt;
&lt;li&gt;Matplotlib 绘图与导出：支持画图并一键复制为 PNG。&lt;/li&gt;
&lt;li&gt;Ctrl+Enter 运行选中代码。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;创建文件 &lt;code&gt;layouts/shortcodes/py-ide.html&lt;/code&gt;
&lt;details&gt;
&lt;summary&gt;Details&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-txt" data-lang="txt"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;直接查看 https://github.com/LIN-SHANG/lin-shang.github.io/tree/main/layouts/shortcodes/py-ide.html
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/details&gt;
&lt;/p&gt;
&lt;h3 id="第四步部署配置-deployyml"&gt;第四步：部署配置 (deploy.yml)&lt;/h3&gt;
&lt;p&gt;最后，也是最容易被忽视的一步。为了让 Hugo 正确编译上述代码，GitHub Actions 必须使用 &lt;strong&gt;Extended&lt;/strong&gt; 版本。&lt;/p&gt;
&lt;p&gt;修改 &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;前面的很多代码
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Setup Hugo&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;peaceiris/actions-hugo@v3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hugo-version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;0.152.2&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 建议固定一个较新版本,不建议低于 0.148&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;extended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# &amp;lt;--- 必须开启！&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;后面的很多代码
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这是我生成的 PyTorch 执行过程演示：
&lt;div id="trace-wrapper-2" class="stanford-trace-wrapper" style="
position: relative; margin: 2rem 0; width: 100%; border-radius: 8px; overflow: hidden;
background: transparent; border: 1px solid var(--ide-border, #d0d7de);
/* 初始高度 */
min-height: 150px;
transition: border-color 0.3s;
"&gt;
&lt;div style="position: absolute; top: 9px; right: 12px; z-index: 50; display: flex; gap: 8px;"&gt;
&lt;button id="trace-fs-2" onclick="toggleFullscreen('2')" style="
background: transparent; border: 1px solid rgba(127,127,127,0.2); color: inherit;
padding: 3px 8px; border-radius: 4px; cursor: pointer;
font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1;
backdrop-filter: blur(2px); transition: all 0.2s; opacity: 0.6;
" title="Toggle Fullscreen"&gt;⛶&lt;/button&gt;
&lt;button id="trace-copy-2" onclick="copyTrace('2')" style="
background: transparent; border: 1px solid rgba(127,127,127,0.2); color: inherit;
padding: 3px 8px; border-radius: 4px; cursor: pointer;
font-family: -apple-system, sans-serif; font-size: 11px; font-weight: 600;
backdrop-filter: blur(2px); transition: all 0.2s; opacity: 0.6;
"&gt;Copy&lt;/button&gt;
&lt;/div&gt;
&lt;iframe
id="trace-iframe-2"
src='https://lin-shang.github.io/cs336-viewer/?trace=%2ftraces%2ftrace_source_code.demo.json'
width="100%" height="200px" scrolling="no" frameborder="0"
style="display: block; width: 100%; border: none;" allowfullscreen&gt;
&lt;/iframe&gt;
&lt;/div&gt;
&lt;style&gt;
.stanford-trace-wrapper:fullscreen { padding: 20px; overflow-y: auto; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; background: var(--bg-color, #fff); }
.stanford-trace-wrapper:fullscreen iframe { height: 95vh !important; width: 100% !important; max-width: 1600px; }
.stanford-trace-wrapper:-webkit-full-screen { padding: 20px; overflow-y: auto; display: flex; flex-direction: column; }
.stanford-trace-wrapper:-webkit-full-screen iframe { height: 95vh !important; width: 100% !important; }
&lt;/style&gt;
&lt;script&gt;
window.toggleFullscreen = function(id) {
const wrapper = document.getElementById('trace-wrapper-' + id);
const btn = document.getElementById('trace-fs-' + id);
if (!document.fullscreenElement) {
if(wrapper.requestFullscreen) wrapper.requestFullscreen();
else if(wrapper.webkitRequestFullscreen) wrapper.webkitRequestFullscreen();
btn.innerHTML = "✖"; btn.style.borderColor = "#2da44e"; btn.style.color = "#2da44e"; btn.style.opacity = "1";
} else {
if(document.exitFullscreen) document.exitFullscreen();
else if(document.webkitExitFullscreen) document.webkitExitFullscreen();
btn.innerHTML = "⛶"; btn.style.borderColor = "rgba(127,127,127,0.2)"; btn.style.color = "inherit"; btn.style.opacity = "0.6";
}
};
window.copyTrace = function(id) {
const iframe = document.getElementById('trace-iframe-' + id);
if(!iframe || !iframe.contentDocument) return;
const doc = iframe.contentDocument;
const lineElements = doc.querySelectorAll('.line');
if (lineElements.length &gt; 0) {
const codeLines = Array.from(lineElements).map(lineEl =&gt; {
if (lineEl.children.length &gt;= 2) return lineEl.lastElementChild.innerText;
return lineEl.innerText.replace(/^\s*\d+/, '');
});
copyToClip(codeLines.join('\n'), id);
} else {
const raw = doc.body.innerText;
copyToClip(raw, id);
}
};
function copyToClip(text, id) {
navigator.clipboard.writeText(text).then(() =&gt; {
const btn = document.getElementById('trace-copy-' + id);
const originalText = btn.innerText;
btn.innerText = "✓"; btn.style.color = "#2da44e"; btn.style.borderColor = "#2da44e"; btn.style.opacity = "1";
setTimeout(() =&gt; { btn.innerText = originalText; btn.style.color = "inherit"; btn.style.borderColor = "rgba(127,127,127,0.2)"; btn.style.opacity = "0.6"; }, 2000);
});
}
(function() {
const iframe = document.getElementById("trace-iframe-2");
const wrapper = iframe.parentElement;
const isAutoHeight = "auto" === "auto";
let resizeObserver = null;
function updateTheme() {
if (!iframe.contentDocument) return;
const doc = iframe.contentDocument;
const html = document.documentElement;
const isDark = html.classList.contains('dark') || html.getAttribute('data-theme') === 'dark';
if (isDark) doc.documentElement.classList.add('dark');
else doc.documentElement.classList.remove('dark');
const globalFont = getComputedStyle(document.documentElement).getPropertyValue('--global-code-font') || "monospace";
let st = doc.getElementById('hugo-inj-layout');
if(!st) { st = doc.createElement('style'); st.id='hugo-inj-layout'; doc.head.appendChild(st); }
st.innerHTML = `
/* 强制 html/body 只有自动高度，不许继承 iframe 高度 */
html, body {
height: auto !important;
min-height: 0 !important;
overflow: hidden !important; /* 让 iframe 外部不可滚动，完全依赖 iframe 高度 */
margin: 0 !important;
padding: 0 !important;
}
#root {
height: auto !important;
min-height: 0 !important;
display: block !important; /* 确保它是块级，撑开内容 */
}
/* 确保内容容器不被拉伸 */
.trace-viewer-container {
height: auto !important;
min-height: 0 !important;
}
/* --- 仅新增：强制所有元素使用父页面的字体 --- */
* {
font-family: ${globalFont} !important;
}
`;
}
function initAutoResize() {
if (!isAutoHeight || !iframe.contentDocument) return;
const doc = iframe.contentDocument;
const target = doc.querySelector('.trace-viewer-container') || doc.getElementById('root') || doc.body;
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = new ResizeObserver(entries =&gt; {
if (document.fullscreenElement) return;
for (let entry of entries) {
const rect = entry.target.getBoundingClientRect();
let newHeight = rect.height;
if (newHeight &lt; 50) newHeight = entry.target.scrollHeight;
newHeight += 20;
if (Math.abs(iframe.clientHeight - newHeight) &gt; 5) {
iframe.style.height = newHeight + "px";
}
}
});
resizeObserver.observe(target);
}
iframe.onload = () =&gt; {
updateTheme();
setTimeout(initAutoResize, 200);
};
new MutationObserver(updateTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
document.addEventListener('fullscreenchange', () =&gt; {
const btn = document.getElementById('trace-fs-2');
if (!document.fullscreenElement) {
btn.innerHTML = "⛶"; btn.style.borderColor = "rgba(127,127,127,0.2)"; btn.style.color = "inherit"; btn.style.opacity = "0.6";
setTimeout(initAutoResize, 200);
}
});
})();
&lt;/script&gt;&lt;/p&gt;
&lt;h2 id="效果展示与使用"&gt;效果展示与使用&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;功能亮点：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;逐行运行&lt;/strong&gt;：选中某一行，按 Ctrl + Enter，仅运行选中部分。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变量探查&lt;/strong&gt;：右侧会自动解析当前的变量，字典和列表可以折叠展开。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;绘图支持&lt;/strong&gt;：调用 show_plot() 即可显示 Matplotlib 图像。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态保持&lt;/strong&gt;：变量在多次运行之间是共享的，无需重复定义。&lt;/li&gt;
&lt;/ol&gt;
&lt;style&gt;
.py-ide-wrapper {
--ide-font: var(--global-code-font, monospace);
--ide-size: var(--global-code-size, 14.5px);
--ide-lh: var(--global-line-height, 1.6);
--ide-bg: #ffffff;
--ide-fg: #24292e;
--ide-border: #d0d7de;
--ide-header-bg: #f6f8fa;
--ide-btn-run: #2da44e;
--ide-btn-text: #ffffff;
--ide-shadow: 0 4px 12px rgba(0,0,0,0.08);
--tree-key: #005cc5;
--tree-val: #032f62;
--tree-type: #6a737d;
}
:root[data-theme="dark"] .py-ide-wrapper,
html.dark .py-ide-wrapper,
body.dark .py-ide-wrapper {
--ide-bg: #161b22;
--ide-fg: #d4d4d4;
--ide-border: #30363d;
--ide-header-bg: #0d1117;
--ide-btn-run: #238636;
--ide-btn-text: #ffffff;
--ide-shadow: 0 8px 24px rgba(0,0,0,0.5);
--tree-key: #79c0ff;
--tree-val: #a5d6ff;
--tree-type: #8b949e;
}
.py-ide-container {
background-color: var(--ide-bg);
color: var(--ide-fg);
border: 1px solid var(--ide-border);
border-radius: 8px;
margin: 2rem 0;
overflow: hidden;
display: flex; flex-wrap: wrap; width: 100%;
box-shadow: var(--ide-shadow);
transition: all 0.3s ease;
}
.py-header {
height: 40px; padding: 0 12px; display: flex; justify-content: space-between; align-items: center;
background-color: var(--ide-header-bg); border-bottom: 1px solid var(--ide-border);
font-family: -apple-system, sans-serif; font-size: 12px; font-weight: 600; color: var(--ide-fg);
}
.py-editor {
min-height: 200px; padding: 15px;
font-family: var(--ide-font) !important;
font-size: var(--ide-size) !important;
line-height: var(--ide-lh) !important;
background-color: transparent; color: inherit; outline: none; white-space: pre; overflow: auto;
}
.py-output {
padding: 10px 15px; border-top: 1px solid var(--ide-border);
background-color: rgba(127,127,127,0.05); color: var(--ide-fg);
font-family: var(--ide-font) !important;
font-size: 13px;
white-space: pre-wrap; max-height: 250px; overflow-y: auto;
}
.py-main-col { flex: 1; min-width: 300px; display: flex; flex-direction: column; border-right: 1px solid var(--ide-border); }
.py-sidebar-col { width: 260px; border-left: 1px solid var(--ide-border); display: flex; flex-direction: column; font-size: 13px; font-family: var(--ide-font); }
.py-btn-group { display: flex; gap: 8px; align-items: center; }
.py-run-btn {
background: var(--ide-btn-run) !important; color: var(--ide-btn-text) !important;
border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer;
font-weight: 600; font-size: 11px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);
transition: transform 0.1s, opacity 0.2s;
}
.py-run-btn:active { transform: translateY(1px); }
.py-icon-btn {
background: transparent; color: var(--ide-fg); border: 1px solid var(--ide-border);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 11px;
}
.py-tree-container { padding: 10px; flex: 1; overflow-y: auto; line-height: 1.5; }
.t-node { margin-left: 10px; }
.t-key { color: var(--tree-key); font-weight: bold; }
.t-val { color: var(--tree-val); }
.t-type { color: var(--tree-type); font-size: 0.85em; margin-left: 5px; font-style: italic; }
.t-arrow { display: inline-block; width: 12px; cursor: pointer; opacity: 0.7; font-size: 10px; user-select: none; }
.t-children { display: none; border-left: 1px solid var(--ide-border); margin-left: 5px; padding-left: 5px; }
.t-children.open { display: block; }
.py-plot-container { background: white; padding: 15px; text-align: center; border-top: 1px solid var(--ide-border); display: none; }
&lt;/style&gt;
&lt;div class="py-ide-wrapper"&gt;
&lt;div class="py-ide-container"&gt;
&lt;div class="py-main-col"&gt;
&lt;div class="py-header"&gt;
&lt;span&gt;TERMINAL&lt;/span&gt;
&lt;div class="py-btn-group"&gt;
&lt;button id="copy-code-3" class="py-icon-btn" onclick="copyIdeCode('3')"&gt;COPY&lt;/button&gt;
&lt;button class="py-icon-btn" py-click="reset_code_3"&gt;RESET&lt;/button&gt;
&lt;button id="btn-3" class="py-run-btn" py-click="run_code_3"&gt;▶ RUN&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div id="editor-3" class="py-editor language-python" data-code="IyDwn5KhIOWwj&amp;#43;aKgOW3p&amp;#43;&amp;#43;8muWKqOaAgeWuieijheesrOS4ieaWueW6kyDwn5iOCiMg5Y&amp;#43;q6ZyA6KaBICMgaW5zdGFsbDogcGFja2FnZV8xLCBwYWNrYWdlXzIgCiMgaW5zdGFsbDogcGFuZGFzCmltcG9ydCBudW1weSBhcyBucCAKaW1wb3J0IG1hdHBsb3RsaWIucHlwbG90IGFzIHBsdCAKCiMgMS4g5a6a5LmJ5pWw5o2uCnggPSBucC5saW5zcGFjZSgwLCAxMCwgMTAwKSAKeSA9IG5wLnNpbih4KSAKeTIgPSBucC5jb3MoeCkKCiMgMi4g57uY5Yi25Zu&amp;#43;5YOPCnBsdC5maWd1cmUoZmlnc2l6ZT0oNiwgNCkpCnBsdC5wbG90KHgsIHksIGxhYmVsPSdTaW4nLCBjb2xvcj0nIzRDQUY1MCcpCnBsdC5wbG90KHgsIHkyLCBsYWJlbD0nQ29zJywgY29sb3I9JyNGRkMxMDcnLCBsaW5lc3R5bGU9Jy0tJykKcGx0LnRpdGxlKCJJbnRlcmFjdGl2ZSBQbG90IGluIEh1Z28iKSAKcGx0LmxlZ2VuZCgpCnBsdC5ncmlkKFRydWUsIGFscGhhPTAuMykKCiMgMy4g5pi&amp;#43;56S65Zu&amp;#43;5YOPCnNob3dfcGxvdCgpIAoKIyA0LiDov5nph4znmoTlj5jph48geCDlkowgeSDkvJroh6rliqjmmL7npLrlnKjlj7Pkvqflj5jph4/moJHkuK0="&gt;&lt;/div&gt;
&lt;div class="py-output" id="output-3"&gt;&gt; Ready.&lt;/div&gt;
&lt;div id="plot-header-3" class="py-header" style="display: none; justify-content: flex-end; border-top: 1px solid var(--ide-border);"&gt;
&lt;button id="copy-png-btn-3" class="py-icon-btn"&gt;Save PNG&lt;/button&gt;
&lt;/div&gt;
&lt;div class="py-plot-container" id="plot-3"&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class="py-sidebar-col"&gt;
&lt;div class="py-header"&gt;VARIABLES&lt;/div&gt;
&lt;div id="tree-3" class="py-tree-container"&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script type="module"&gt;
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@3.7.0/codejar.min.js';
window.renderJsonTree = function(id, jsonStr) {
const container = document.getElementById(id);
if(!container) return;
let data = {};
try { data = JSON.parse(jsonStr); } catch(e) { return; }
if (Object.keys(data).length === 0) {
container.innerHTML = '&lt;div style="color:var(--tree-type); padding:5px;"&gt;(Empty)&lt;/div&gt;';
return;
}
const buildTree = (obj) =&gt; {
let html = '';
for (const [key, info] of Object.entries(obj)) {
const hasChild = info.children !== null;
const arrow = hasChild ? '▶' : ' ';
const val = info.val;
const type = info.type;
const childHtml = hasChild ? `&lt;div class="t-children"&gt;${buildTree(info.children)}&lt;/div&gt;` : '';
html += `
&lt;div class="t-node"&gt;
&lt;span class="t-arrow" onclick="toggleTree(this)"&gt;${arrow}&lt;/span&gt;
&lt;span class="t-key"&gt;${key}&lt;/span&gt;:
&lt;span class="t-val"&gt;${val}&lt;/span&gt;
&lt;span class="t-type"&gt;${type}&lt;/span&gt;
${childHtml}
&lt;/div&gt;`;
}
return html;
};
container.innerHTML = buildTree(data);
};
window.toggleTree = function(el) {
const children = el.parentElement.querySelector('.t-children');
if (children) {
const isOpen = children.classList.toggle('open');
el.innerText = isOpen ? '▼' : '▶';
}
};
window.copyIdeCode = function(ordinal) {
const el = document.getElementById(`editor-${ordinal}`);
if(el) {
navigator.clipboard.writeText(el.innerText).then(() =&gt; {
const btn = document.getElementById(`copy-code-${ordinal}`);
const old = btn.innerText; btn.innerText = "✓";
setTimeout(() =&gt; { btn.innerText = old; }, 1500);
});
}
};
document.addEventListener("DOMContentLoaded", () =&gt; {
const editorEl = document.getElementById("editor-3");
if (!editorEl) return;
const highlight = (editor) =&gt; {
if (window.Prism &amp;&amp; Prism.languages.python) {
editor.innerHTML = Prism.highlight(editor.textContent, Prism.languages.python, 'python');
}
};
const jar = CodeJar(editorEl, highlight, { tab: ' ' });
try {
const raw = editorEl.getAttribute("data-code");
const decoded = decodeURIComponent(escape(window.atob(raw)));
jar.updateCode(decoded);
} catch (e) { console.error(e); }
editorEl.addEventListener('keydown', (e) =&gt; {
if ((e.ctrlKey || e.metaKey) &amp;&amp; e.key === 'Enter') {
e.preventDefault();
document.getElementById("btn-3").click();
}
});
const copyBtn = document.getElementById("copy-png-btn-3");
if(copyBtn) {
copyBtn.onclick = () =&gt; {
const plot = document.getElementById("plot-3");
const svg = plot.querySelector('svg');
if(!svg) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const data = (new XMLSerializer()).serializeToString(svg);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(data);
img.onload = () =&gt; {
canvas.width = svg.viewBox.baseVal.width * 2;
canvas.height = svg.viewBox.baseVal.height * 2;
ctx.fillStyle = 'white'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.drawImage(img, 0,0,canvas.width,canvas.height);
canvas.toBlob(b =&gt; navigator.clipboard.write([new ClipboardItem({'image/png':b})]));
alert("Plot copied!");
};
};
}
});
&lt;/script&gt;
&lt;script type="py" config='{"packages": ["micropip", "numpy", "matplotlib"]}'&gt;
from pyscript import window, document
import sys, io, micropip, asyncio, html, json, types
if 'kernels' not in globals(): kernels = {}
kernels[3] = {}
class DOMStream:
def __init__(self, element_id): self.id = element_id
def write(self, text):
el = document.getElementById(self.id)
if el: el.innerText += text; el.scrollTop = el.scrollHeight
def flush(self): pass
# 2. 变量树生成器 (恢复 Python 逻辑)
def get_scope_tree(scope):
tree_data = {}
def safe_str(v):
try: s = str(v)
except: s = "&lt;error&gt;"
return html.escape(s[:50] + "..." if len(s) &gt; 50 else s)
for k, v in scope.items():
# 过滤系统变量
if k.startswith("_") or k in ['open', 'exit', 'quit', 'In', 'Out', 'get_ipython', 'show_plot', 'matplotlib', 'plt', 'micropip', 'fm', 'pyfetch', 'os', 'sys', 'io', 'html', 'json', 'types', 'asyncio', 'DOMStream', 'kernels', 'get_scope_tree']: continue
if isinstance(v, (types.ModuleType, types.FunctionType, type)): continue
val_type = type(v).__name__
children = None
display_val = safe_str(v)
# 处理 List/Dict 嵌套
if isinstance(v, dict):
children = {}
display_val = f"dict[{len(v)}]"
for sk, sv in list(v.items())[:20]:
children[str(sk)] = {"val": safe_str(sv), "type": type(sv).__name__, "children": None}
elif isinstance(v, (list, tuple)):
children = {}
display_val = f"{val_type}[{len(v)}]"
for i, sv in enumerate(v[:20]):
children[str(i)] = {"val": safe_str(sv), "type": type(sv).__name__, "children": None}
tree_data[k] = {"val": display_val, "type": val_type, "children": children}
return json.dumps(tree_data)
def _show_plot():
import matplotlib.pyplot as plt
div = document.getElementById("plot-3")
hdr = document.getElementById("plot-header-3")
buf = io.StringIO()
plt.savefig(buf, format='svg', bbox_inches='tight')
div.innerHTML = buf.getvalue()
div.style.display = "block"
hdr.style.display = "flex"
plt.clf()
def reset_code_3(e):
kernels[3] = {"show_plot": _show_plot}
document.getElementById("output-3").innerText = "&gt; Reset.\n"
window.renderJsonTree("tree-3", "{}")
document.getElementById("plot-3").style.display = "none"
document.getElementById("plot-header-3").style.display = "none"
async def run_code_3(e):
btn = document.getElementById("btn-3")
out = document.getElementById("output-3")
editor = document.getElementById("editor-3")
btn.disabled = True
btn.innerText = "..."
out.innerText = ""
# 获取代码
code = window.getSelection().toString()
if not code.strip(): code = editor.textContent
# --- 优化后的安装包逻辑 ---
# 扫描代码中的所有行，寻找以 # install: 开头的行
lines = code.split('\n')
for line in lines:
line = line.strip()
if line.startswith("# install:"):
pkgs = [p.strip() for p in line.replace("# install:", "").split(",") if p.strip()]
if pkgs:
out.innerText += f"&gt; 正在安装依赖: {', '.join(pkgs)}...\n"
try:
await micropip.install(pkgs)
out.innerText += "&gt; 安装成功！\n"
except Exception as err:
out.innerText += f"&gt; 安装失败: {err}\n"
# -----------------------
scope = kernels[3]
if "show_plot" not in scope: scope["show_plot"] = _show_plot
stream = DOMStream("output-3")
sys.stdout = stream
sys.stderr = stream
try:
await asyncio.sleep(0.1)
# 执行代码
exec(code, scope, scope)
except Exception as err:
print(f"Error: {err}")
finally:
sys.stdout = sys.__stdout__
# 渲染变量树
try:
tree = get_scope_tree(scope)
window.renderJsonTree("tree-3", tree)
except: pass
btn.disabled = False
btn.innerText = "▶ RUN"
&lt;/script&gt;
&lt;h2 id="结语"&gt;结语&lt;/h2&gt;
&lt;p&gt;通过这次折腾，我们将一个“只读”的 Hugo 博客变成了一个“可交互”的教学平台。虽然 PyScript 的初始化速度（约 1-2 秒）仍有待提升，但对于 Numpy、Pandas 和基础算法教学，这已经是一个堪称完美的轻量级方案。&lt;/p&gt;
&lt;p&gt;P.S. 记得给浏览器一点时间下载 Python 内核，看到 &amp;ldquo;System Ready&amp;rdquo; 后再点击运行。&lt;/p&gt;</description></item></channel></rss>