跳转至

复制本页 Markdown 源:不用官方插件,自己做

每页右上角有一个复制按钮,复制的是这一页的 .md 源文件;页头有一行 <link rel="alternate" type="text/markdown"> 指向同一个文件。Zensical 0.0.67 起官方 llmstxt 插件也给这两样,本站没用,自己做了——官方给的是从 HTML 转回来的 Markdown,本站要的是源文件本身。

为什么不用官方插件

  • 源文件就是 AI 写的、给 AI 读的:不存在「AI 读不懂源文件」的问题。官方坚持转换,理由是源文件里的 snippet、指令没展开,维护者在 #8591 的原话是 "serving the raw Markdown sources is not enough"——在本站,snippet 是故意留的指针,相对链接本身就是给 AI 逐层读的链接图
  • 转换是负收益:2026-10-15 实测,图片、iframe、音视频整个丢,连说明文字都不剩;选项卡标签、代码块标题、自定义块类型、时间线分类、frontmatter 丢;snippet 一律展开,关不掉
  • 复制按钮和插件绑死:不开插件没有按钮;开了,按钮复制的就是转换版
  • 想要的只有两样:按钮和发现链接,都能自己做,下面就是做法

逐条实测和三条备选路线见Zensical 升级评估 · llmstxt;决定记在能力时间线 10-15「评估官方 llmstxt 插件与自研复制按钮」那一条。

源文件地址:借 page.edit_url

难点在于页面地址 /x/ 看不出源文件是 /x.md 还是 /x/index.md——普通文章是前者,目录首页是后者。Zensical 的模板里 page 没有 file 字段,写 {{ page.file.src_uri }} 直接报 undefined value,拿不到源文件路径。

能拿到的是 edit_url:edit_uri 设成绝对地址时,edit_url = edit_uri + 源文件路径。把 repo_url 和 edit_uri 都设成站点根:

repo_url: https://wiki.liuhetian.work/
edit_uri: https://wiki.liuhetian.work/

edit_url 就正好是源文件的线上地址:首页 /index.md、普通文章 /posts/foo.md、目录首页 /posts/sub/index.md,不在 nav 里的页也对,0.0.62 和 0.0.70 实测一样。不设 repo_url 时 edit_url 是空的,两个都得设。

  • 副作用一个:repo_url 让顶栏和侧栏抽屉多出「Go to repository」入口,来自主题的 partials/source.html,放一个空的 overrides/partials/source.html2 关掉
  • 编辑按钮不会冒出来:它要开 content.action.edit 才显示,本站不开
  • 域名是线上的:edit_url 永远指向 https://wiki.liuhetian.work/,发现链接给 AI 用,本来就该指线上

发现链接

overrides/main.html 和首页的 home.html 在页头写两行,按 llms.txt 提案的约定:

<link rel="alternate" type="text/markdown" href="{{ page.edit_url }}">
<link rel="describedby" href="{{ 'llms.txt' | url }}">

AI 落在任何一个 HTML 页,顺着 alternate 就拿到源文件,不用知道「普通页把 / 换成 .md、目录首页加 index.md」这套规则。

复制按钮:相对链接怎么变成能直接访问的地址

按钮由 vendor/md-copy.js 插进页面右上角,样式借主题的 .md-content__button;页头没有发现链接就不出按钮。点下去取源文件、改写、写进剪贴板。改写的目的是让贴进对话框的文字自成一体:读的人没有站点上下文,相对路径对它没有意义。

以源文件的线上地址为基准,用浏览器标准的 URL 解析拼出来:

const base = link.href;   // 发现链接的地址,如 https://wiki.liuhetian.work/projects/wiki/age-mosaic.md
const abs = (target, base) => (!target || KEEP.test(target) ? target : new URL(target, base).href);

new URL(相对路径, 基准) 就是浏览器解析相对链接的规则:取基准所在目录,再按 ..、./ 走。打码那篇里的真实链接:

源文件里写的 复制出去的
../ai-assistant/dev-env.md#proxy https://wiki.liuhetian.work/projects/ai-assistant/dev-env.md#proxy
cos-deploy/index.md https://wiki.liuhetian.work/projects/wiki/cos-deploy/index.md
assets/age-open.py https://wiki.liuhetian.work/projects/wiki/assets/age-open.py
/projects/wiki/assets/age-passphrase.html(iframe 的 src) https://wiki.liuhetian.work/projects/wiki/assets/age-passphrase.html

转出来的是 .md 源和真身文件的地址,不是 HTML 页面——源文件里的链接本来就指向 .md,AI 拿到直接读源。

  • 拼出来的地址一定存在:deploy.sh 用 rsync 把 docs/ 原样镜像上桶,每个源文件在线上的相对位置和在 docs/ 里一样,在 docs/ 里成立的相对路径,以线上地址为基准解析也成立。这也是镜像必须放 /x.md、不能像官方那样放 /x/index.md 的原因——基准深一层,相对链接全错
  • 基准用线上,取文件用当前站点:基准取发现链接的完整地址,复制出去的链接都指向线上;取源文件只用它的路径部分、按当前站点去拿,本地预览上也能点(要先镜像 .md,见下一条)
  • 本地预览要先镜像 .md:zensical serve 不出源文件,请求 /x.md 被路由到网页 /x/,状态码照样是 200。在仓库根跑一次 deploy.sh 里那行 rsync,把 .md 镜像进 site/,serve 才原样给出源文件;按钮见到 text/html 就报「取到的是网页不是源文件」,不把整页 HTML 当源文件复制。serve 有时漏拷刚改过的 vendor/ 脚本,改了脚本效果没变,先比一下 docs/vendor/ 和 site/vendor/ 里的是否一致
  • 改写哪些:逐行匹配 Markdown 链接和图片的 ](目标)、链接定义 [名字]: 目标(脚注 [^1]: 除外)、HTML 的 src="../…" 和 href="../…"
  • 不动哪些:已带协议的(https:、mailto:)、页内锚点 #cli、// 开头、<…> 包着的;代码块里(按 ``` / ~~~ 围栏判断进出)和行内代码里的——写作规范里举例写的链接不能被改
  • snippet 另算:--8<-- "路径" 的路径不相对当前页,相对 snippet 的 base_path(先 docs/,再仓库根)。先拼「站点根 + 路径」,发一个 HEAD 请求:站上有就用站上的地址(如 vendor/age-core.js);没有说明它只在仓库根(如 scripts/age-open.py),指向 GitHub 仓库。只引几行的 "x.py:31:36" 先剥掉范围再探,地址只留文件,范围写成「(第 31–36 行)」跟在后面 —— 整串拿去探必然不在站上,会落到 GitHub 一个不存在的地址
  • 去掉 frontmatter:description 这类元数据是给站点用的,粘进对话框是噪音,连同后面的空行一起去掉;贴出去的只有正文(2026-10-15 起。此前 frontmatter 原样保留,后面插一行 <!-- 来源:… --> 注释,粘贴后也是噪音,一并去掉)
  • 子页会链回的入口,末尾带源地址:frontmatter 写了 copy_source: true 的,复制出的正文末尾加一行「本文源地址:」加本页 .md 的线上地址,下面再跟一句「上面就是这个地址的全文。之后读到的页面再链到这个地址(包括带 # 的小节链接),直接看上文对应的地方,不要重复打开。」—— 光给地址,AI 不一定想到要对号入座。读者把入口贴给 AI,AI 顺链接读到子页,子页又写着「见入口 · 用法」这类 —— 贴进来的文字没有网址,AI 认不出那就是开头那篇,会再抓一遍;有了这行就认得出。其他页面不加:只想复制一段文字贴给不联网的 AI 时,这行是噪音。只管向下:子页链上级是去读共守的规矩,上级链回它只是菜单里的一行。哪些入口该写由 check-links.py 算,该写没写、不该写写了都拦部署(2026-10-20 起,全站 17 个入口)
  • zx-no-copy 块整块不复制:用 <div class="zx-no-copy" markdown> 和 </div> 包起来的内容(写法同时间线:开头结尾前后各空一行,里面不缩进),网页上照常显示、读 .md 源的 AI 也看得到,只是不进对话框。给维护记录这类「读者要看得到、但粘进对话框会被当成在说正在讨论的事」的内容用;块可以放在页面任何位置,前后的正文、脚注照常复制。块里嵌套的 div 按层数配对,代码块里写的不算。网页上左边一道虚线、上方一个「不复制」小字。活例:盘问我页尾的维护记录
  • 分支按选择去留:<div data-when="名=值" markdown> 包起来的内容,名为「名」的控件选了「值」才复制,只写「名」就是那个框填了才复制。网页上条件不成立的块由脚本挂上 zx-no-copy,和写死的那种一个样子;复制时拿源文件里写的条件和同一批控件值再算一遍,用的是同一个函数,结果和网页上一致,不成立的整块去掉,成立的只去掉外壳 div。不复制的块里的 required 不拦。写法见分支
  • 去掉 HTML 注释:网页上本来不显示,对话框里也不要,整行、行尾、跨行的都去;代码块和行内代码里举例写的注释不动。删完留下的连续空行并成一行
  • 填空换成填的值:正文里的 <input>、<textarea>、<select> 是给读者填的空,复制时标签换成填的值,空着的写成【placeholder】;值在链接改写之后才嵌进去,填的内容不会被当成链接改写。写法见自研组件 · 填空
  • 打码跟着解锁走:这个标签页解锁过,`age:…` 换成明文、```age 块换成普通代码块;没解锁写成【已加密】,解锁了还解不开写成【解不开】。几百字的密文贴给 AI 只占上下文,它手里没钥匙也解不开;解锁过的,网页上看到的就是明文,复制出来跟看到的一致。解密借 age-init.js 的 WikiAge.open,用的是解锁时记下的私钥,见打码
  • 写剪贴板:https 下用 navigator.clipboard;本地 http 预览没有这个接口,退回隐藏 textarea 加 execCommand("copy")

实现

文件 做什么
mkdocs.yml repo_url、edit_uri 设成站点根;extra_javascript 挂 vendor/md-copy.js
overrides/main.html、overrides/home.html 页头写 alternate 和 describedby 两行
overrides/partials/source.html 空文件,关掉 repo_url 带出来的仓库入口
vendor/md-copy.js 复制按钮、改写、填空、分支和打码,约 300 行,不压缩
scripts/check-links.py 检查 6 算哪些入口有子页链回,核对 frontmatter 的 copy_source: true
stylesheets/zx-theme.css 第 11、12 节 填空控件的样式;不复制的块的样式

2026-10-15 测过(当时还没改成去掉 frontmatter):正式站构建并照 deploy.sh 镜像 .md 后起本地预览,在打码那篇把剪贴板接口换成记录、自动点按钮——frontmatter 保留、来源注释在、三种 snippet 各指对地方、正文相对链接和 iframe 的 src 改成线上地址、#cli 锚点和代码块里的命令不动。

2026-10-26 在 node 里测过打码:未解锁时收藏夹·网站、开发环境第 0 步的 6 处密文都换成【已加密】;放一把临时生成的测试私钥,用它加密的行内小字、代码块里的小字、缩进在提示框里的 age 块都换成明文,wiki 钥匙加密的那 6 处换成【解不开】;写作规范里举例的 `age:…` 和嵌在示例里的 age 块不动,没有密文的页面复制结果和改之前逐字节一致。

vendor/md-copy.js —— 复制按钮源码(未压缩)
// 「复制本页 Markdown」按钮:复制的是这一页的 .md 源文件本身,不是从 HTML 转回来的版本——
// 源文件由 AI 写、给 AI 读,原样最好(为什么不用官方 llmstxt 插件、原理和测试,见 projects/wiki/md-copy.md)。
// 源文件地址由 overrides/main.html 写进页头 <link rel="alternate" type="text/markdown">,取自 page.edit_url
// (mkdocs.yml 的 edit_uri 指向站点根,edit_url 就等于源文件地址)。取文件时只用它的路径、按当前站点去拿,
// 本地预览也能用(zensical serve 不出源文件,要先把 .md 镜像进 site/);改写链接时用它的原始地址,复制出去的链接都指向线上。
// 复制前做这几件事,让贴进对话框的文字自成一体、只剩正文:
//   - 代码块以外的相对链接、图片、HTML 的 src/href 改成绝对地址;代码块和行内代码里举例写的链接不动
//   - --8<-- "路径" 改成真身的绝对地址:先按站点根找(snippet 的 base_path 先 docs),找不到就指向 GitHub 仓库;
//     只引几行("x.py:31:36")或一段("x.py:name")的,地址只留文件,范围写在后面
//   - 去掉 frontmatter:description 之类是给站点用的,粘进对话框是噪音
//   - frontmatter 写了 copy_source: true 的,末尾加「本文源地址:…」和一句「链回这里就看上文,不要重复打开」:
//     目录下有子页链回这个入口,AI 顺链接读到子页、再看到链回来的地址,认得出就是贴进来的这篇,不用再抓一遍;
//     光给地址它未必想到要对号入座,所以把话说明白。该不该写由 check-links.py 算
//   - 去掉 <div class="zx-no-copy" markdown> … </div> 整块:维护记录之类,网页上照常显示、读 .md 源的 AI
//     也看得到,只是不进对话框;块里嵌套的 div 按层数配对,代码块里写的不算
//   - <div data-when="名=值" markdown> … </div> 是分支:名为「名」的控件选了「值」才复制;只写「名」就是那个框
//     填了才复制。网页上条件不成立的挂上 zx-no-copy,和写死的那种一个样子;复制时拿源文件里的条件和同一批控件值
//     再算一遍(同一个 holds,结果和网页上一致),不成立的整块去掉,成立的只去掉外壳 div,里面照常复制
//   - 去掉代码以外的 HTML 注释:网页上本来就不显示,对话框里也不要;去掉后留下的连续空行并成一行
//   - 正文里手写的 <input>、<textarea>、<select> 是填空:读者在网页上就地填,复制时标签换成填的值(下拉框取
//     选中项的文字),原样嵌入、不参与链接改写;空着的写成【placeholder】;写了 required 的没填就不复制。
//     网页上第 n 个控件对应源文件代码以外第 n 个标签,数量对不上就报错不复制;标签要写在一行里,单选、复选不算。
//     name 相同的是同一个变量:在一个框里填,别的同名框跟着变。不复制的块里的 required 不拦
//   - 打码跟着页面走:这个标签页解锁过,`age:…` 和 ```age 块换成明文(块换成普通代码块);没解锁写成【已加密】,
//     解锁了还解不开写成【解不开】。几百字的密文贴给 AI 只占上下文、它也解不开。解密借 vendor/age-init.js 的 WikiAge.open
// 页头没有那行 link 就不出按钮。
(function () {
  const REPO = "https://github.com/liuhetian/wiki/blob/main/";
  const SVG = d => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${d}"/></svg>`;
  const ICON_COPY = SVG("M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1Z");
  const ICON_DONE = SVG("M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59 21 7Z");
  const KEEP = /^(?:[a-z][a-z0-9+.-]*:|#|\/\/|<)/i;        // 已带协议、页内锚点、协议相对、<…> 包着的:不动
  const SNIPPET = /^(\s*)--8<--\s+(["'])(.+?)\2\s*$/;
  const NO_COPY = /^\s*<div\b[^>]*\bclass="[^"]*\bzx-no-copy\b/;
  const WHEN = /^\s*<div\b[^>]*\bdata-when="([^"]*)"/;
  const divDepth = line => {                                // 行内代码里写的 div 不算
    const s = line.replace(/`[^`]*`/g, "");
    return (s.match(/<div\b/gi) || []).length - (s.match(/<\/div>/gi) || []).length;
  };
  const FIELD = /<textarea\b[^>]*>.*?<\/textarea>|<select\b[^>]*>.*?<\/select>|<input\b[^>]*>/gi;
  const NOT_FIELD = /^<input\b[^>]*\btype=["']?(?:radio|checkbox|hidden)\b/i;
  const FIELD_SEL = "input:not([type=radio]):not([type=checkbox]):not([type=hidden]), textarea, select"; // 选项卡底下是隐藏的 radio
  const MARK = "\u0000";
  const AGE_TOKEN = /age:YWdlLWVuY3J5cHRpb24ub3JnL3Yx[A-Za-z0-9+/]*={0,2}/g;  // 和 age-init.js 的 TOKEN 一致
  const AGE_FENCE = /^(\s*)(`{3,}|~{3,})\s*age\s*$/;

  const abs =(target, base) => (!target || KEEP.test(target) ? target : new URL(target, base).href);

  // 代码块以外的一行:跳过行内代码,改写 [x](y)、![x](y)、[x]: y 和 HTML 的 src/href
  function rewriteLine(line, base) {
    if (/^\s*\[(?!\^)[^\]]+\]:\s*\S/.test(line))            // 链接定义;[^1]: 是脚注,不算
      return line.replace(/^(\s*\[[^\]]+\]:\s*)(\S+)/, (_, head, t) => head + abs(t, base));
    return line.split(/(`[^`]*`)/).map((part, i) => i % 2 ? part : part
      .replace(/(\]\()([^)\s]+)/g, (_, head, t) => head + abs(t, base))
      .replace(/\b(src|href)="([^"]+)"/g, (_, attr, t) => `${attr}="${abs(t, base)}"`)
    ).join("");
  }

  // 代码块以外的一行:填空控件的标签换成记号 \0序号\0,序号就是它在网页上是第几个控件;行内代码里写的不算
  function markFields(line, seen) {
    return line.split(/(`[^`]*`)/).map((part, i) => i % 2 ? part : part.replace(FIELD, tag =>
      NOT_FIELD.test(tag) ? tag : MARK + seen.n++ + MARK)).join("");
  }

  // 控件里填的值;空着就写成【提示】,粘进对话框一看就知道哪里还空着
  function fieldValue(el) {
    const opt = el.tagName === "SELECT" ? el.selectedOptions[0] : null;
    const value = opt ? opt.value && opt.text : el.value;
    return value && value.trim() ? value : `【${(opt ? opt.text : el.placeholder) || ""}】`;
  }

  // 分支的条件:「名=值」是名为「名」的控件当前值等于「值」,只写「名」是那个框填了东西;找不到这个名字就不成立
  function holds(cond, fields) {
    const eq = cond.indexOf("=");
    const name = eq < 0 ? cond : cond.slice(0, eq);
    const el = fields.find(f => f.name === name);
    if (!el) return false;
    return eq < 0 ? !!el.value.trim() : el.value === cond.slice(eq + 1);
  }

  const RANGE = /^(.+?)(?::(\d*)(?::(\d*))?|:([A-Za-z_][\w-]*))$/;
  function splitRange(path) {
    const m = !path.includes("://") && path.match(RANGE);
    if (!m || (m[2] === undefined && !m[4]) || (!m[2] && !m[3] && !m[4])) return [path, ""];
    if (m[4]) return [m[1], `(片段 ${m[4]})`];
    return [m[1], `(第 ${m[2] || 1}${m[3] ? `–${m[3]}` : " 行起"}${m[3] ? " 行" : ""})`];
  }

  async function snippetUrl(raw, base) {
    const [path, range] = splitRange(raw);
    const site = new URL(path, new URL("/", base)).href;
    try {
      const r = await fetch(new URL(new URL(site).pathname, location.origin), { method: "HEAD" });
      if (r.ok) return [site, range];
    } catch (e) { /* 取不到就当不在站上 */ }
    return [REPO + path, range];                            // 站上没有的是仓库根的文件(mkdocs.yml、deploy.sh)
  }

  // 打码:解锁过换成明文,没解锁、解不开换成记号
  async function openAge(cipher, block) {
    try {
      const text = globalThis.WikiAge ? await WikiAge.open(cipher, block) : null;
      return text === null ? { mark: "【已加密】" } : { text };
    } catch (e) {
      return { mark: "【解不开】" };
    }
  }

  // ```age 块:明文放进普通代码块,围栏比明文里最长的一串反引号多一个;记号单独一行
  async function openAgeBlock({ indent, armor }) {
    const r = await openAge(armor.map(l => l.trim()).join("\n"), true);
    if (r.mark) return indent + r.mark;
    const f = "`".repeat(Math.max(3, ...(r.text.match(/`+/g) || []).map(s => s.length + 1)));
    return [f, ...r.text.split("\n"), f].map(l => l && indent + l).join("\n");
  }

  // `age:…` 小字:同一段密文只解一次
  async function openAgeTokens(text) {
    const found = [...new Set(text.match(AGE_TOKEN) || [])];
    const opened = new Map(await Promise.all(found.map(async t => {
      const r = await openAge(t, false);
      return [t, r.mark || r.text];
    })));
    return text.replace(AGE_TOKEN, t => opened.get(t));
  }

  // 去掉一行里代码以外的 HTML 注释,行内代码原样跳过;注释跨行时 state.open 记着还没收尾
  function stripComments(line, state) {
    let res = "", i = 0;
    while (i < line.length) {
      if (state.open) {
        const e = line.indexOf("-->", i);
        if (e < 0) return res;
        state.open = false;
        i = e + 3;
        continue;
      }
      const code = line[i] === "`" && line.slice(i).match(/^(`+).*?[^`]\1(?!`)/);
      if (code) { res += code[0]; i += code[0].length; continue; }
      if (line.startsWith("<!--", i)) { state.open = true; i += 4; continue; }
      res += line[i++];
    }
    return res;
  }

  async function build(source, base, fields) {
    let lines = source.split("\n");
    const fm = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
    const withSource = fm > 0 && lines.slice(1, fm).some(l => /^copy_source:\s*true\s*$/.test(l));
    if (fm > 0) lines = lines.slice(fm + 1);                // 去掉 frontmatter
    let fence = null;                                       // { ch, len }:当前所在代码块的围栏
    const comment = { open: false };
    let skip = 0;                                           // 在 zx-no-copy 块里时:还没配上 </div> 的层数
    let depth = 0;                                          // 照常复制的部分:代码以外的 div 层数
    const unwrap = [];                                      // 条件成立的分支:进去之前的层数,配上它的 </div> 那行不要
    const seen = { n: 0 };                                  // 数到第几个填空控件
    let age = null;                                         // 在 ```age 块里:{ i: 开头那行, indent, armor: 密文各行 }
    const jobs = [];
    const out = lines.map((line, i) => {
      if (!fence && (comment.open || line.includes("<!--"))) {
        const rest = stripComments(line, comment);
        if (!rest.trim()) return null;                      // 整行都是注释:删掉这行
        line = rest;
      }
      const m = line.match(/^\s*(`{3,}|~{3,})/);
      if (m) {
        if (!fence) {
          fence = { ch: m[1][0], len: m[1].length };
          const a = !skip && line.match(AGE_FENCE);
          if (a) { age = { i, indent: a[1], armor: [] }; return ""; }  // 先占位,解完换成整块
        } else if (m[1][0] === fence.ch && m[1].length >= fence.len && /^\s*[`~]+\s*$/.test(line)) {
          fence = null;
          if (age) {
            const a = age;
            age = null;
            jobs.push(openAgeBlock(a).then(block => { out[a.i] = block; }));
            return null;
          }
        }
        return skip ? null : line;
      }
      if (age) { age.armor.push(line); return null; }
      if (skip) {
        if (!fence) {
          skip += divDepth(line);
          markFields(line, seen);                           // 块里的控件网页上也有,照样占序号
        }
        return null;
      }
      const when = !fence && line.match(WHEN);
      const on = when && holds(when[1], fields);
      if (on && divDepth(line) > 0) {                       // 条件成立的分支:外壳不要,里面照常复制
        unwrap.push(depth);
        depth += divDepth(line);
        return null;
      }
      if ((when && !on) || (!fence && NO_COPY.test(line))) {  // 写死的 zx-no-copy、条件不成立的分支:整块去掉
        skip = Math.max(divDepth(line), 0);
        return null;
      }
      if (!fence) {
        depth += divDepth(line);
        if (unwrap.length && depth === unwrap[unwrap.length - 1] && /^\s*<\/div>\s*$/.test(line)) {
          unwrap.pop();
          return null;
        }
      }
      const s = line.match(SNIPPET);
      if (s) {
        jobs.push(snippetUrl(s[3], base).then(([u, range]) => { out[i] = `${s[1]}--8<-- "${u}"${range}`; }));
        return line;
      }
      return fence ? line : rewriteLine(markFields(line, seen), base);
    });
    if (seen.n !== fields.length)
      throw new Error(`填空对不上:源文件 ${seen.n} 个,网页上 ${fields.length} 个`);
    await Promise.all(jobs);
    // 删掉的 frontmatter、注释、zx-no-copy 块会留下空行:开头的空行不要,删过东西的地方连着的空行只留一行
    const kept = [];
    let gap = true;
    for (const line of out) {
      if (line === null) { gap = true; continue; }
      if (!line.trim() && gap && !(kept.length && kept[kept.length - 1].trim())) continue;
      if (line.trim()) gap = false;
      kept.push(line);
    }
    const text = (await openAgeTokens(kept.join("\n")))   // 先解密文再嵌填的值,读者填的字不会被当成密文
      .replace(new RegExp(`${MARK}(\\d+)${MARK}`, "g"), (_, i) => fieldValue(fields[i]));
    return withSource
      ? `${text.trimEnd()}\n\n本文源地址:${base}\n上面就是这个地址的全文。之后读到的页面再链到这个地址(包括带 # 的小节链接),直接看上文对应的地方,不要重复打开。\n`
      : text;
  }

  async function writeClipboard(text) {
    if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
    const ta = document.createElement("textarea");           // 非 https 的预览站没有 clipboard API
    ta.value = text;
    ta.style.cssText = "position:fixed;opacity:0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    if (!ok) throw new Error("浏览器不让写剪贴板");
  }

  function init() {
    const link = document.querySelector('link[rel="alternate"][type="text/markdown"]');
    const inner = document.querySelector(".md-content__inner");
    if (!link || !inner) return;
    const base = link.href;                                 // 线上地址:改写链接用
    const whens = [...inner.querySelectorAll("div[data-when]")];
    const fetchUrl = new URL(new URL(base).pathname, location.origin); // 当前站点:取文件用

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "md-content__button md-icon zx-md-copy";
    btn.title = "复制本页 Markdown 源";
    btn.setAttribute("aria-label", btn.title);
    btn.innerHTML = ICON_COPY;
    btn.addEventListener("click", async () => {
      const fields = [...inner.querySelectorAll(FIELD_SEL)];
      const missing = fields.find(el => !el.closest(".zx-no-copy") && !el.checkValidity());
      if (missing) {                                        // required 没填:不复制,展开折叠块、用浏览器的气泡指过去
        for (let d = missing.closest("details"); d; d = d.parentElement.closest("details")) d.open = true;
        missing.reportValidity();
        return;
      }
      try {
        const r = await fetch(fetchUrl);
        if (!r.ok) throw new Error(`取源文件失败:${r.status}`);
        if ((r.headers.get("content-type") || "").includes("text/html"))  // zensical serve 把 /x.md 路由到网页 /x/
          throw new Error("取到的是网页不是源文件:本地预览要先把 .md 镜像进 site/");
        await writeClipboard(await build(await r.text(), base, fields));
        btn.innerHTML = ICON_DONE;
        btn.title = "已复制";
      } catch (e) {
        btn.title = "复制失败:" + e.message;
      }
      setTimeout(() => { btn.innerHTML = ICON_COPY; btn.title = "复制本页 Markdown 源"; }, 1500);
    });
    inner.prepend(btn);

    // 分支:条件不成立的挂上 zx-no-copy,网页上就是「不复制」的样子;打开页面算一次,之后每次填写再算
    const applyWhen = () => {
      const fields = [...inner.querySelectorAll(FIELD_SEL)];
      for (const w of whens) w.classList.toggle("zx-no-copy", !holds(w.dataset.when, fields));
    };
    if (whens.length) applyWhen();

    // 同名的填空是同一个变量:在一个框里填,别的同名框同步;只挂一个监听,不扫页面
    inner.addEventListener("input", e => {
      const el = e.target;
      if (!el.matches(FIELD_SEL)) return;
      if (el.name)
        for (const other of inner.querySelectorAll(FIELD_SEL))
          if (other !== el && other.name === el.name) other.value = el.value;
      if (whens.length) applyWhen();
    });
  }

  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
  else init();
})();

没做的

  • 自动测试替换了剪贴板接口;真实写进剪贴板、按钮在页面上的样子,由人在浏览器里点过确认
  • 改写是逐行正则,不是完整的 Markdown 解析:网址里带括号的链接、跨行的链接、双反引号包的行内代码、单引号写的 HTML 属性可能处理不对,本站现有写法里没碰到

  1. #859,2026-10-15 读。 ↩

  2. overrides/partials/source.html,2026-10-15 读。 ↩