复制本页 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 都设成站点根:
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 和 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 属性可能处理不对,本站现有写法里没碰到
-
overrides/partials/source.html,2026-10-15 读。 ↩