跳转至

自研组件

写作规范语法表指过来的几样。Material 原生语法 AI 都会,这里只写本站自己做的组件和踩过的坑。

mermaid 的坑

  • 边标签里有圆括号就套双引号:-->|文本| 里出现 ( 或 ),解析器就断在那里(控制台报 got 'PS'),整张图退化成代码块原样显示。节点标签 A["..."] 习惯带引号,容易漏的是边标签:

    B -->|一个 Z 除以 √(χ²/n)| C      ← 整图不渲染
    B -->|"一个 Z 除以 √(χ²/n)"| C    ← 正确
    
  • √ 别进标签: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 代码块
```age
-----BEGIN AGE ENCRYPTED FILE-----
…
-----END AGE ENCRYPTED FILE-----
```
  • 按泄露后果决定放不放:口令被猜中时这些会一起漏。地址、域名、用户名放心放;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 误读的内容,包起来:

<div class="zx-no-copy" markdown>

读者要看、复制时不要的内容

</div>

网页上照常显示,左边一道虚线、上方一个「不复制」小字;读 .md 源的 AI 也看得到,只有复制按钮整块跳过;块放在页面任何位置都行。写法同时间线:开头结尾前后各空一行,里面不缩进。复制按钮另外还会去掉 frontmatter 和代码以外的 HTML 注释;目录下有页面链回的入口在 frontmatter 写 copy_source: true,复制时末尾带一行源地址,该写没写、不该写写了 check-links 都会拦。样式在 zx-theme.css 第 12 节。

活例:盘问我页尾的维护记录,写法见写 skill · 页尾链到维护记录。

要按读者的选择决定哪段复制,见分支。

填空 <input>

prompt 里要读者先填的空,直接写 HTML 输入框:网页上就地填,复制按钮把填的值换进源文件再复制。

用中学生能听懂的话解释<input name="概念" placeholder="填写概念">。规则:
  • 单行 <input>、多行 <textarea>、选项 <select>,写进句子里,一个标签写在一行内;<textarea> 单独占一行会把段落切成两段
  • 必须写 placeholder:空着复制出去是【placeholder】;<select> 想留空,第一项写 <option value="">提示</option>
  • 同一个变量用几次就写几次,name 相同:在一个框里填,别的同名框跟着变;只用一次、也没被分支用到的不写 name
  • required:没填就不让复制,浏览器气泡指过去
  • 不另写「占位没填先问」这类说明:输入框本身就说明了哪里要填 —— 复制出去空着的是【placeholder】,直接读 .md 源的 AI 看到的是带 placeholder 的标签
  • 单选、复选框不算填空;代码块里写的只是文字。样式在 zx-theme.css 第 11 节

活例:人话翻译。