自研组件
写作规范语法表指过来的几样。Material 原生语法 AI 都会,这里只写本站自己做的组件和踩过的坑。
mermaid 的坑
-
边标签里有圆括号就套双引号:
-->|文本|里出现(或),解析器就断在那里(控制台报got 'PS'),整张图退化成代码块原样显示。节点标签A["..."]习惯带引号,容易漏的是边标签: -
√别进标签:Maple Mono 下它的字形像积分号∫,写成文字「的平方根」 - 渲染效果由用户自己在浏览器里看,AI 不截图:上面两类问题
zensical build和 check-links 都报不出来,mermaid 渲染在 closed shadow root 里、脚本也测不到;写了 mermaid 就提醒用户看一眼
嵌入交互单页
要展示交互效果(组件、可视化、小工具),把纯客户端的自包含单页放进所属 skill 或文章的 assets/,用原生 <iframe> 同域嵌入。AI 顺同一个 URL 能读到未压缩的源码。
真身在 assets/react-spa-demo.html,React 迷你 CRUD。
- 只能纯客户端:对象存储是静态托管,带服务端的应用只能外部部署后跨域嵌
- 不走 CDN:运行时库用全站共享的
docs/vendor/,demo 里写根绝对路径/vendor/xxx.js;不放docs/assets/,那会跟主题产物的site/assets/混居 - 免构建:React 钉 18 的 UMD(19 起不发 UMD)配
htm的 tagged template,源码即真身 - 逻辑不压缩:vite 构建产物(
base: './'后整个目录丢进assets/)也能嵌,但 minified 对 AI 不可读,算二等公民 src写站点根绝对路径,且指到具体.html文件(/skills/<skill>/assets/xxx.html,vite 目录产物写到xxx-demo/index.html):目录式 URL 下相对路径容易算错层级,目录 URL 能不能解析到入口又取决于托管配置。活例:前端风格收集的 task1–task7- 样式给固定高度 +
loading="lazy"
assets/react-spa-demo.html —— 演示单页源码(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>React 单页 demo:商品管理</title>
<!--
react-spa-demo.html —— 本 wiki「嵌入交互单页」规范(skills/wiki-guide/reference/components.md「嵌入交互单页」)的演示真身。
约定的落实:
- 纯客户端:全部状态在内存里,无任何服务端依赖,静态托管(对象存储)直接可跑
- 自包含:运行时库 vendor 在全站共享的 /vendor/(仓库 docs/vendor/)、根绝对路径引用,不走 CDN
· React 18 UMD(React 19 起官方不再发 UMD 构建,免构建场景钉 18)
· htm —— 1KB 的 tagged template,替代 JSX,从而不需要 Babel/打包器
- demo 逻辑不压缩:本文件随 wiki 发布、有独立 URL,AI GET 到即可读懂全部实现
内容:一个迷你「CRUD 表格」形状(呼应 skills/dashboard 的 open-dashboard 文章)——
搜索 / 分类筛选 / 分页 / 新增 / 编辑 / 上下架 / 删除 / 统计卡片 / toast 反馈。
-->
<style>
/* 设计令牌:亮暗两套,跟随系统 prefers-color-scheme */
:root {
--bg: #f6f7f9; --card: #ffffff; --fg: #1f2328; --muted: #6a737d;
--border: #e4e7eb; --primary: #3564d7; --primary-fg: #ffffff; --danger: #c4372c;
--chip-on-bg: #e3f4e8; --chip-on-fg: #176e34;
--chip-off-bg: #f0f1f3; --chip-off-fg: #6a737d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #15181c; --card: #1e2227; --fg: #e6e9ec; --muted: #8b949e;
--border: #333a42; --primary: #5b8bf0; --danger: #e5695f;
--chip-on-bg: #12351f; --chip-on-fg: #5fca86;
--chip-off-bg: #2a2f36; --chip-off-fg: #8b949e;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.6 system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.page { max-width: 880px; margin: 0 auto; padding: 20px 16px 28px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.head h1 { margin: 0; font-size: 20px; }
.sub { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 14px 0; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
.stat { padding: 10px 14px; }
.stat-label { margin: 0; color: var(--muted); font-size: 12px; }
.stat-value { margin: 2px 0 0; font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
.toolbar { display: flex; gap: 8px; margin-bottom: 10px; }
.search { flex: 1; }
input, select {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
background: var(--card); color: var(--fg); font: inherit;
}
input:focus, select:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
table { width: 100%; border-collapse: collapse; background: var(--card);
border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
thead th { text-align: left; font-size: 12px; color: var(--muted); font-weight: 500;
padding: 9px 12px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 55%, var(--card)); }
tbody td { padding: 9px 12px; border-bottom: 1px solid var(--border); }
tbody tr:last-child td { border-bottom: 0; }
td.name { font-weight: 550; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.ops { text-align: right; white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 26px 12px; }
.warn { color: var(--danger); font-weight: 650; }
.chip { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: 12px; }
.chip-on { background: var(--chip-on-bg); color: var(--chip-on-fg); }
.chip-off { background: var(--chip-off-bg); color: var(--chip-off-fg); }
.btn { padding: 7px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--card); color: var(--fg); font: inherit; cursor: pointer; }
.btn:hover:not(:disabled) { border-color: var(--primary); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.link { border: 0; background: none; color: var(--primary); font: inherit; cursor: pointer; padding: 0 5px; }
.link.danger { color: var(--danger); }
.pager { display: flex; justify-content: space-between; align-items: center;
margin-top: 10px; color: var(--muted); font-size: 13px; }
.pager div { display: flex; gap: 8px; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.42);
display: flex; align-items: center; justify-content: center; padding: 16px; }
.modal { background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 18px 20px; width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 10px; }
.modal h2 { margin: 0 0 2px; font-size: 16px; }
.modal label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.modal input, .modal select { width: 100%; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.err { margin: 0; color: var(--danger); font-size: 12px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.toast { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
background: var(--fg); color: var(--bg); padding: 8px 16px; border-radius: 999px;
font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect } = React;
const html = htm.bind(React.createElement); // htm 替代 JSX:html`<${组件} 属性=${值} />`
const PAGE_SIZE = 5;
const CATS = ["数码", "服饰", "食品", "家居"];
const SEED = [
{ id: 1, name: "无线降噪耳机", cat: "数码", price: 899, stock: 42, on: true },
{ id: 2, name: "机械键盘 87 键", cat: "数码", price: 399, stock: 18, on: true },
{ id: 3, name: "4K 显示器 27 寸", cat: "数码", price: 1499, stock: 7, on: true },
{ id: 4, name: "纯棉基础 T 恤", cat: "服饰", price: 79, stock: 230, on: true },
{ id: 5, name: "防晒轻薄外套", cat: "服饰", price: 199, stock: 0, on: false },
{ id: 6, name: "羊毛混纺围巾", cat: "服饰", price: 129, stock: 64, on: true },
{ id: 7, name: "云南小粒咖啡豆 500g", cat: "食品", price: 68, stock: 96, on: true },
{ id: 8, name: "冻干草莓脆 100g", cat: "食品", price: 25, stock: 150, on: true },
{ id: 9, name: "山核桃仁礼盒", cat: "食品", price: 158, stock: 12, on: false },
{ id: 10, name: "香薰蜡烛(雪松)", cat: "家居", price: 89, stock: 40, on: true },
{ id: 11, name: "折叠收纳箱三件套", cat: "家居", price: 119, stock: 55, on: true },
{ id: 12, name: "陶瓷马克杯 350ml", cat: "家居", price: 39, stock: 0, on: true },
];
function StatCard({ label, value }) {
return html`
<div class="card stat">
<p class="stat-label">${label}</p>
<p class="stat-value">${value}</p>
</div>`;
}
// 新增 / 编辑共用一个弹窗:item 无 id 视为新增
function Modal({ item, cats, onSave, onClose }) {
const isNew = !item.id;
const [f, setF] = useState({
name: item.name || "", cat: item.cat || cats[0],
price: item.price ?? "", stock: item.stock ?? "",
});
const [err, setErr] = useState("");
const set = k => e => setF({ ...f, [k]: e.target.value });
const submit = e => {
e.preventDefault();
const price = Number(f.price), stock = Number(f.stock);
if (!f.name.trim()) return setErr("名称不能为空");
if (f.price === "" || !Number.isFinite(price) || price < 0) return setErr("价格要是非负数字");
if (f.stock === "" || !Number.isInteger(stock) || stock < 0) return setErr("库存要是非负整数");
onSave({ ...item, name: f.name.trim(), cat: f.cat, price, stock });
};
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onClose(); }}>
<form class="modal" onSubmit=${submit}>
<h2>${isNew ? "新增商品" : "编辑商品"}</h2>
<label>名称
<input value=${f.name} onChange=${set("name")} placeholder="商品名称" autoFocus />
</label>
<label>分类
<select value=${f.cat} onChange=${set("cat")}>
${cats.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
</select>
</label>
<div class="row2">
<label>价格(¥)
<input type="number" min="0" step="0.01" value=${f.price} onChange=${set("price")} />
</label>
<label>库存
<input type="number" min="0" step="1" value=${f.stock} onChange=${set("stock")} />
</label>
</div>
${err && html`<p class="err">${err}</p>`}
<div class="actions">
<button type="button" class="btn" onClick=${onClose}>取消</button>
<button type="submit" class="btn primary">${isNew ? "创建" : "保存"}</button>
</div>
</form>
</div>`;
}
function App() {
const [rows, setRows] = useState(SEED);
const [q, setQ] = useState("");
const [cat, setCat] = useState("全部");
const [page, setPage] = useState(1);
const [editing, setEditing] = useState(null); // null=关闭 | {}=新增 | 行对象=编辑
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const filtered = useMemo(
() => rows.filter(r =>
(cat === "全部" || r.cat === cat) &&
r.name.toLowerCase().includes(q.trim().toLowerCase())),
[rows, q, cat],
);
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const cur = Math.min(page, pages);
const view = filtered.slice((cur - 1) * PAGE_SIZE, cur * PAGE_SIZE);
const stockValue = rows.reduce((s, r) => s + r.price * r.stock, 0);
const save = item => {
if (item.id) {
setRows(rows.map(r => (r.id === item.id ? item : r)));
setToast(`已保存「${item.name}」`);
} else {
const id = Math.max(0, ...rows.map(r => r.id)) + 1;
setRows([{ ...item, id, on: true }, ...rows]);
setToast(`已创建「${item.name}」`);
}
setEditing(null);
};
const toggle = r => {
setRows(rows.map(x => (x.id === r.id ? { ...x, on: !x.on } : x)));
setToast(`「${r.name}」已${r.on ? "下架" : "上架"}`);
};
const remove = r => {
if (!window.confirm(`删除「${r.name}」?`)) return;
setRows(rows.filter(x => x.id !== r.id));
setToast(`已删除「${r.name}」`);
};
return html`
<div class="page">
<header class="head">
<div>
<h1>商品管理</h1>
<p class="sub">React 18 + htm 自包含单页 · 数据在内存里,刷新即重置</p>
</div>
<button class="btn primary" onClick=${() => setEditing({})}>+ 新增商品</button>
</header>
<div class="stats">
<${StatCard} label="商品总数" value=${rows.length} />
<${StatCard} label="在售" value=${rows.filter(r => r.on).length} />
<${StatCard} label="库存货值" value=${"¥ " + stockValue.toLocaleString("zh-CN")} />
</div>
<div class="toolbar">
<input class="search" placeholder="搜索商品名…" value=${q}
onChange=${e => { setQ(e.target.value); setPage(1); }} />
<select value=${cat} onChange=${e => { setCat(e.target.value); setPage(1); }}>
<option value="全部">全部分类</option>
${CATS.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
</select>
</div>
<table>
<thead>
<tr><th>名称</th><th>分类</th><th class="num">价格</th><th class="num">库存</th><th>状态</th><th class="ops">操作</th></tr>
</thead>
<tbody>
${view.map(r => html`
<tr key=${r.id}>
<td class="name">${r.name}</td>
<td>${r.cat}</td>
<td class="num">¥ ${r.price.toLocaleString("zh-CN")}</td>
<td class="num">${r.stock === 0 ? html`<span class="warn">0</span>` : r.stock}</td>
<td><span class=${"chip " + (r.on ? "chip-on" : "chip-off")}>${r.on ? "在售" : "下架"}</span></td>
<td class="ops">
<button class="link" onClick=${() => setEditing(r)}>编辑</button>
<button class="link" onClick=${() => toggle(r)}>${r.on ? "下架" : "上架"}</button>
<button class="link danger" onClick=${() => remove(r)}>删除</button>
</td>
</tr>`)}
${view.length === 0 && html`
<tr><td class="empty" colSpan="6">没有匹配的商品 —— 换个关键词或清空筛选试试</td></tr>`}
</tbody>
</table>
<footer class="pager">
<span>共 ${filtered.length} 件 · 第 ${cur} / ${pages} 页</span>
<div>
<button class="btn" disabled=${cur <= 1} onClick=${() => setPage(cur - 1)}>上一页</button>
<button class="btn" disabled=${cur >= pages} onClick=${() => setPage(cur + 1)}>下一页</button>
</div>
</footer>
${editing !== null && html`
<${Modal} item=${editing} cats=${CATS} onSave=${save} onClose=${() => setEditing(null)} />`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>
可运行 JS 代码块 {.js .run}
语言机制、算法、数据结构这类不依赖 DOM 的例子,fence 上加 .run,读者当场看到输出:
const xs = [3, 1, 2]
console.log(xs.sort((a, b) => a - b))
console.log({ 对象也会格式化: true, 嵌套: [1, { k: "v" }] })
vendor/js-runner.js 在代码框右下角浮一个「运行」按钮、下方接输出面板,Pygments 高亮和复制按钮原样保留;脚本没加载就退化成普通代码块。
- 在 Web Worker 里跑,没有
document/window:要摸 DOM 走嵌入交互单页。不用 iframe 是因为它和父页共用主线程,死循环会卡死整页;Worker 能被硬杀,死循环 5 秒后砍掉 - 值要
console.log出来才看得见,不是 REPL;对象、Map、Set、循环引用都有格式化 - 顶层
await直接写;主体跑完后会等排队的setTimeout执行完再收尾,不用await等住;从点「运行」起总共 5 秒,超时整个砍掉 —— 没clearInterval的setInterval一定撞上这条线 - 故意写坏是正当用法:报错带行号,坏例子和好例子一样值得放
- 只能运行,不能在页面上改(编辑态会丢掉 Pygments 高亮),所以例子要自带对照组:把「换成 X 会怎样」写成注释或第二个块
打码:age:… 小字与 ```age 块
不想公开、泄露了也不致命的小字 —— 服务器地址、域名、用户名 —— 写进源文件时就是 age 密文。HTML、.md 镜像、git、AI 拿到的全是密文;浏览器里显示成马赛克,点开输口令就地还原。
在仓库根目录生成密文,只用 wiki 公钥:
bash scripts/age-seal.sh # 输一行(不回显)→ 打印 age:…,贴进反引号里
bash scripts/age-seal.sh -a # 粘贴多行、Ctrl-D 结束 → 打印 armor,放进 age 代码块
- 按泄露后果决定放不放:口令被猜中时这些会一起漏。地址、域名、用户名放心放;API key 先在厂商控制台设好用量上限再放;能直接接管账号的凭证不放
- 只写在反引号里:行内代码或代码块都认,比如
ssh ubuntu@age:…嵌在命令中间;正文里裸写的不认 - 举例只写
age:…:脚本认age:加「age-encryption.org/v1」的 base64 前缀,前缀写全的例子会被当成密文、解不开标红 - 块解开后是素
<pre><code>,没有语法高亮,要高亮的内容别放进age块 - wiki 钥匙对专用:私钥在部署机
~/.config/age/wiki.key,公钥在scripts/age-seal.sh,口令锁住的副本在vendor/age-init.js的WIKI_KEY_AGE;换钥匙对时三处一起改,旧密文重新加密 - 命令行读
.md源时,管道接uv run scripts/age-open.py整页解开;用法和凭据查找顺序写在脚本头部 - 复制按钮跟着解锁走:没解锁复制成【已加密】,解锁过复制出明文
活例:开发环境第 0 步代码块里的服务器地址和登录密码。
时间线 <div class="zx-timeline" markdown>
一串按时间排的事件画成一条从上往下的线,每条是一个 ###:
2026-08-14 · 字体本地化
开屏的时候加载很慢……改为本地化吧
theme.font: false,字体真身放进 vendor/fonts/。条目里放代码块、表格、提示框都行,线不会断。
- div 里第一个元素必须是
###;开头的<div …>和结尾的</div>前后各空一行,里面不缩进 data-kind写分类,渲染成标题旁的角标:只有「配置」的方块空心,含「前端」或「流水线」的填荧光绿;不写就是空心、没有角标- 日期写在标题最前面,从旧到新往下排,新的追加在末尾
- 样式在
zx-theme.css第 10 节,纯 CSS
活例:wiki 介绍 · 能力时间线。
复制时跳过 <div class="zx-no-copy" markdown>
右上角的复制按钮把页面源文件复制进对话框。读者要看、粘进对话框会被 AI 误读的内容,包起来:
网页上照常显示,左边一道虚线、上方一个「不复制」小字;读 .md 源的 AI 也看得到,只有复制按钮整块跳过;块放在页面任何位置都行。写法同时间线:开头结尾前后各空一行,里面不缩进。复制按钮另外还会去掉 frontmatter 和代码以外的 HTML 注释;目录下有页面链回的入口在 frontmatter 写 copy_source: true,复制时末尾带一行源地址,该写没写、不该写写了 check-links 都会拦。样式在 zx-theme.css 第 12 节。
活例:盘问我页尾的维护记录,写法见写 skill · 页尾链到维护记录。
要按读者的选择决定哪段复制,见分支。
填空 <input>
prompt 里要读者先填的空,直接写 HTML 输入框:网页上就地填,复制按钮把填的值换进源文件再复制。
- 单行
<input>、多行<textarea>、选项<select>,写进句子里,一个标签写在一行内;<textarea>单独占一行会把段落切成两段 - 必须写
placeholder:空着复制出去是【placeholder】;<select>想留空,第一项写<option value="">提示</option> - 同一个变量用几次就写几次,
name相同:在一个框里填,别的同名框跟着变;只用一次、也没被分支用到的不写name required:没填就不让复制,浏览器气泡指过去- 不另写「占位没填先问」这类说明:输入框本身就说明了哪里要填 —— 复制出去空着的是【placeholder】,直接读
.md源的 AI 看到的是带 placeholder 的标签 - 单选、复选框不算填空;代码块里写的只是文字。样式在
zx-theme.css第 11 节
活例:人话翻译。