# llm-wiki-sidebar 插件改进方案 v2:溯源/站内相对链接可点击(修订版)
> 日期:2026-09-05 | 状态:**已实施(v1.1.1 已部署;v1.1.0 首轮实测发现 CM 污染缺陷,见 §7)** | 取代:`improvement-source-link-navigation.md`(v1)的**客户端设计部分**
> 目标包:`formal-package/llm-wiki-sidebar`(package.json 1.0.0 → 1.1.0)
> v1 保留:§3.1 服务端改造、§3.2(a) CSS、§3.2(b) resolver、§3.4 版本文档、§4.1 部署、§P3/P4/P5 调查结论
> v1 废弃:§3.2(c) `decorateAnchor`(`` 点击拦截)——其前提被证伪,见 §1
> v1 新增替代:**预览 DOM 文本配对装饰**(不改源码、不依赖 `` 渲染、编辑保存零污染)
---
## 0. TL;DR
- **v1 的致命误判**:宿主共享渲染器 `MarkdownText` 对相对链接目标做白名单过滤(只放行 `http/https/mailto`),**过滤失败时连 `` 元素都不渲染**,只渲染标签纯文本,URL 从 DOM 中彻底丢失。v1 的 `decorateAnchor` 拦截方案因此是死代码。
- **为什么不能改源码绕过**:插件把改写后的 `fp.content` 传给查看器后,编辑模式的 CodeMirror 文档就是改写后的文本,`Ctrl+S` 会把改写后的 URL 写回 wiki 源文件;保存路径在 TextEditor 内部(`api.fsWrite(scope, path, view.state.doc.toString())`),插件没有任何 save 钩子。源码重写路线在插件侧不可行(上游侧可行,见 §5.8)。
- **v2 核心机制**:URL 在插件手里(`fp.content`),标签文本在 DOM 里(渲染后的纯文本节点)。按文档顺序把"源码解析出的链接序"与"预览拼接文本"配对,用与现有 `[[wikilink]]` 装饰完全相同的 text-node 替换手法把标签包成可点 span。**零源码污染、零编辑态影响、不依赖 MarkdownText 的任何渲染行为假设。**
- **改动量**:`lib/index.js` 约 +6 行(同 v1);`lib/client.js` 约 +240 行(v1 估 +90,但机制无效;v2 的增量来自配对算法本身)。
---
## 1. v1 判断的修正(为什么 v1 客户端方案不可行)
### 1.1 真正的渲染链路与证据(v1 查错了产物)
v1 检查的 `dsh-web-frontend/dist/assets/langs/markdown-*.js` / `mdx-*.js` 是 **shiki 语言高亮分包**,不是链接渲染器。真实链路:
1. better-sidebar markdown 预览复用共享组件 `MarkdownText`
(`dsh-better-sidebar/lib/client-editor.js`:`_deepseek_ai_dsh_client_ui_primitives.MarkdownText`,仅传 `text` + `labels`,**无 urlTransform**);
2. 该模块由宿主主 bundle 提供(`@deepseek-ai/dsh-web-frontend/dist/assets/index-Df-65__b.js` 的模块表
`"@deepseek-ai/dsh-client-ui-primitives": Fp`,导出 `MarkdownText: ic`);
3. 其链接渲染器带 URL 白名单:
```js
// Ku:解析不出 http/https/mailto 的目标 → 返回 ""(相对路径在此处 throw → catch → "")
function Ku(n){try{switch(new URL(n).protocol){case"http:":case"https:":case"mailto:":return n;default:return""}}catch{return""}}
// Xu:目标为空 → 渲染 Fragment(纯文本,非 );命中 →
function Xu(n,i,o){const l=Ku(n);return l===""?jsx(Fragment,{children:i}):jsx("a",{href:l,...target:"_blank"...})}
```
4. node 精确复现结果:
| 输入 dest | 渲染结果 |
|---|---|
| `raw/articles/x.md` | **Fragment 纯文本,无 ``,URL 丢失** |
| `../raw/a.md` / `concepts/myth.md` / `/wiki/raw/a.md` / `#anchor` | 同上 |
| `https://…` / `mailto:…` | ``(唯一存活场景) |
5. 旁证:better-sidebar 自己对图片就是这么绕的——`src/client/markdown-images.ts` 注释原文:本地图片路径被改写为 GUI origin 前缀的绝对 URL,"*so the shared MarkdownText http(s) allowlist accepts them*"。**图片有源码级重写,链接没有。**
### 1.2 由此 v1 §3.2(c) 是死代码
- 相对链接 → DOM 里没有 `` → walk 的 `tag === "A"` 分支永远遇不到它们;
- 唯一渲染成 `` 的是外链,而 `resolveLink` 对带 scheme 的输入返回 null(不拦截);
- 两者交集为空 → `decorateAnchor` 对所有输入 no-op,§3.3 行为表全部不会发生;
- 且 URL 已从 DOM 丢失,**纯客户端 DOM 拦截在原理上走不通**。
### 1.3 为什么"改写源码让链接活过白名单"也不可行(插件侧)
思路(对齐图片的重写做法):把 `fp.content` 中相对 dest 改写为 GUI origin 前缀绝对 URL → 渲染成真 `` → 再拦截点击。
致命伤:`save()` 直接写 CodeMirror 文档——
```ts
// dsh-better-sidebar/src/client/TextEditor.tsx:292
const save = (): void => {
...
api.fsWrite(scope, path, view.state.doc.toString()) // ← CM 文档即插件传入的(已改写)文本
```
插件传什么 `fp.content`,CM 文档就是什么;`Ctrl+S` 会把改写后的 URL 写回 wiki 源文件,**破坏纯 markdown 可移植性**(Obsidian/GitHub 里变成死链),且保存路径在 TextEditor 内部,插件无 save 钩子可拦。故插件侧唯一安全层是**渲染后的 DOM**——这正是 v2 的设计空间。
---
## 2. 新方案总览
### 2.1 设计原则
1. **不碰 `fp.content`**:装饰只发生在预览 DOM 的文本节点上(与现有 `[[wikilink]]` 装饰同层、同手法)。编辑模式、保存路径零影响。
2. **不依赖 `` 渲染**:配对基于"拼接文本 + 节点偏移表",对 React 如何切分文本节点不敏感。
3. **失败降级**:任何配对不确信的链接保持纯文本(延续 v1"未命中不劫持"哲学)。配对错位的后果上限是"点的是同页另一处同文出现"——**指向不会错**(dest 来自源码解析而非 DOM 猜测)。
4. **解析与拦截分离**:v1 的 `makeLinkResolver`(dest → 页面/raw 文件 → 绝对路径)与拦截机制无关,原样保留。
### 2.2 保留 / 废弃 / 新增对照
| v1 条目 | v2 处置 | 说明 |
|---|---|---|
| §3.1 服务端 raws+`path`、map+`raws` | ✅ 原样保留 | 无争议 |
| §3.2(a) CSS `.llmw-mdlink` | ✅ 原样保留 | — |
| §3.2(b) `collapsePath` + `makeLinkResolver` | ✅ 原样保留(全文内联) | 解析逻辑与拦截无关 |
| §3.2(c) `decorateAnchor` / walk A 分支改造 | ❌ 废弃 | §1.2 死代码;walk 的 `tag === "A" → continue` 维持原状 |
| §3.2(d) 接线 | 🔧 重写 | 传 `fp.content` 给配对装饰器 |
| §3.2(e) `_internal` 导出 | 🔧 扩充 | 增加 `collectSourceLinks`/`plainify` |
| §3.3 行为规则表 | 🔧 更新 | 见 §3.3 |
| §3.4 版本与 README | ✅ 原样保留 | 1.0.0 → 1.1.0 |
| §4.1 部署步骤 | ✅ 原样保留 | 服务端/客户端两半一起拷 |
| §4.2 生效验证 | 🔧 修正 | 搜 bundle 字符串只能证明部署,不能证明行为(v1 会假阳性) |
| §4.3 单测 | 🔧 扩充 | 交付可执行 `test-mdlink-v2.mjs`(从文档提取代码端到端验证);用例表见 §4.3 |
| §4.4 GUI 验收 | 🔧 扩充 | 新增"编辑保存后文件字节不变"等检查 |
| (无) | ➕ 新增 | `plainify` / `collectSourceLinks` / `buildTextIndex` / `unwrapMdLinks` / `decorateMarkdownLinks` |
---
## 3. 详细设计
### 3.1 服务端 `lib/index.js`(同 v1 §3.1,原文保留)
**(a) raw 条目补 `path`**(`loadWiki` 内 `raws.push`,现 255 行附近):
```js
wiki.raws.push({
slug: f.name.slice(0, -3),
rel: 'raw/' + subPrefix + f.name,
path: root + '/raw/' + subPrefix + f.name, // ← 新增
ingested: parsed.meta ? parsed.meta.ingested : undefined,
shaStored: stored,
shaActual: createHash('sha256').update(parsed.body).digest('hex'),
})
```
**(b) map 响应带上 raws**(`mapHandler` 末行,现 453 行):
```js
return {
ok: true, root: wiki.root, exists: true, pages, backlinks,
raws: wiki.raws.map((r) => ({ rel: r.rel, path: r.path, slug: r.slug })), // ← 新增
}
```
### 3.2 客户端 `lib/client.js`
#### (a) CSS(同 v1 §3.2(a))
在 `.llmw-wlink-miss`(现 80 行)之后插入:
```js
".llmw-mdlink{color:var(--dsw-alias-brand-primary,#4d6bfe);cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;}",
".llmw-mdlink:hover{background:var(--dsw-alias-bg-layer-2,#2c2f35);}",
```
#### (b) `collapsePath` + `makeLinkResolver`(同 v1 §3.2(b),全文内联以便自包含实施)
落点:`el()` 帮助函数之后(现 86 行附近),与 (c) 相邻。
```js
// Collapse '.'/'..' segments ('' leading segment = absolute marker, preserved).
// Note: a LEADING '..' (empty stack) is silently dropped — with this
// wiki's flat global-slug namespace the resolver still lands on the
// right target ('../raw/a.md' → 'raw/a.md'), so the quirk is kept.
function collapsePath(p) {
const out = [];
for (const seg of String(p).split("/")) {
if (seg === "") { if (out.length === 0) out.push(""); continue; }
if (seg === ".") continue;
if (seg === "..") { if (out.length > 0 && out[out.length - 1] !== "") out.pop(); continue; }
out.push(seg);
}
return out.join("/");
}
// Pure resolver for in-page markdown links → internal wiki targets.
// Returns { kind: "page"|"file", path, title } or null for external/anchor/unresolvable.
// opts: { wikiRoot, pageDir, pages: [{slug,path,title}], raws: [{rel,path}] }
function makeLinkResolver(opts) {
const wikiRoot = String((opts && opts.wikiRoot) || "").replace(/\/+$/, "") + "/";
const pageDir = String((opts && opts.pageDir) || "");
const pages = (opts && opts.pages) || [];
const raws = (opts && opts.raws) || [];
const pageBySlug = new Map(), pageByRel = new Map(), fileByRel = new Map();
for (const p of pages) {
const slug = String(p.slug || "").toLowerCase();
if (!slug) continue;
pageBySlug.set(slug, p);
const abs = String(p.path || "").replace(/\/+$/, "");
if (wikiRoot.length > 1 && abs.toLowerCase().indexOf(wikiRoot.toLowerCase()) === 0) {
const rel = abs.slice(wikiRoot.length).toLowerCase(); // "concepts/x.md"
pageByRel.set(rel, p);
pageByRel.set(rel.replace(/\.md$/, ""), p);
}
}
for (const r of raws) {
const rel = String(r.rel || "").toLowerCase();
if (!rel) continue;
fileByRel.set(rel, r);
if (r.path) fileByRel.set(String(r.path).toLowerCase(), r);
}
return function resolveLink(href) {
let h = String(href || "").trim();
if (!h || h.charAt(0) === "#") return null; // in-page anchor
if (/^[a-z][a-z0-9+.\-]*:/i.test(h)) return null; // any scheme: http/mailto/tel/data/blob/js
try { h = decodeURIComponent(h); } catch (e) {} // %20 / 中文文件名
h = h.split("#")[0].replace(/\\/g, "/");
if (!h) return null;
const cands = [];
const push = (p) => {
if (!p) return;
if (/^\/wiki\//i.test(p)) p = p.slice(5); // site-absolute "/wiki/..."
else if (p.charAt(0) === "/") return; // other site-absolute → not a wiki file
p = p.replace(/^\.\/+/, "").replace(/^wiki\//i, ""); // "./x" / "wiki/x" → root-relative
p = collapsePath(p);
if (p && cands.indexOf(p) === -1) cands.push(p);
};
push(h); // (1) root-relative guess
if (pageDir) { // (2) page-dir-relative guess
const abs = collapsePath(pageDir + h).toLowerCase();
if (abs.toLowerCase().indexOf(wikiRoot.toLowerCase()) === 0) push(abs.slice(wikiRoot.length));
}
for (const c of cands) {
const key = c.toLowerCase();
const page = pageByRel.get(key) || pageByRel.get(key.replace(/\.md$/, ""));
if (page) return { kind: "page", path: page.path, title: page.title || page.slug };
const file = fileByRel.get(key) || fileByRel.get((wikiRoot + c).toLowerCase());
if (file) return { kind: "file", path: file.path, title: file.rel };
const stem = key.replace(/\.md$/, "").split("/").pop(); // last-resort page-stem match
const byStem = pageBySlug.get(stem);
if (byStem) return { kind: "page", path: byStem.path, title: byStem.title || byStem.slug };
}
return null;
};
}
```
#### (c) 新增纯函数:`plainify` + `collectSourceLinks`
落点:`el()` 帮助函数之后(现 86 行附近),与 (b) 相邻。
```js
// Source text → what rendering leaves as plain text. Strips markdown
// emphasis markers and resolves [[wikilink]]/[[page|alias]] to its
// rendered label, so match-context signatures taken from the SOURCE
// line up with the RENDERED preview text.
function plainify(s) {
return String(s)
.replace(/\[\[([^\]\|]+)(?:\|([^\]]+))?\]\]/g, (m0, page, alias) => alias || page)
.replace(/[`*_~\\]/g, "")
.replace(/\u0000/g, "");
}
// Parse the ORIGINAL markdown into the document-ordered list of inline
// links whose destination is a relative .md path — the pairing input for
// preview decoration. Fenced blocks, inline code spans and the YAML
// frontmatter block are masked first; image links and labels that
// rendering would re-interpret (formatting/entities) are skipped so
// decoration only ever wraps text the preview shows verbatim.
function collectSourceLinks(text) {
const all = String(text || "").split("\n");
// Mask the frontmatter block (--- ... ---).
let start = 0;
if ((all[0] || "").trim() === "---") {
for (let i = 1; i < all.length; i++) {
if (all[i].trim() === "---") { start = i + 1; break; }
}
}
const lines = start > 0 ? all.slice(start) : all;
// Mask fenced code blocks (keep line structure).
let fence = null;
for (let i = 0; i < lines.length; i++) {
const open = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
if (fence === null) {
if (open) { fence = open[1].charAt(0); lines[i] = ""; }
} else if (open && open[1].charAt(0) === fence) {
fence = null; lines[i] = "";
} else {
lines[i] = "";
}
}
// Strip inline code spans BEFORE plainify (plainify would eat the
// backticks and unmask them), THEN plainify the whole body: wikilinks
// collapse to their rendered labels and emphasis markers vanish, so
// context windows sliced around a match line up with the RENDERED
// preview text (a window cut from raw source could start mid-
// [[wikilink]] and never match the preview's joined text).
const flat = plainify(lines.join("\n").replace(/(`+)[\s\S]*?\1/g, ""));
const out = [];
const re = /(!?)\[([^\]\n]{1,120})\]\(([^()\s]+)(?:\s+"[^"]*")?\)/g;
let m;
while ((m = re.exec(flat)) !== null) {
if (m[1] === "!") continue; // image
const label = m[2];
const dest = m[3];
if (label.trim() === "") continue;
if (/[`*_~[\]()<>|!#\\&]/.test(label)) continue; // formatting label → renders differently
if (/^[a-z][a-z0-9+.\-]*:/i.test(dest)) continue; // scheme: external/mailto → real already
if (dest.charAt(0) === "#") continue; // in-page anchor → out of scope
out.push({
label,
dest,
before: flat.slice(Math.max(0, m.index - 16), m.index),
after: flat.slice(m.index + m[0].length, m.index + m[0].length + 6),
});
if (out.length >= 600) break;
}
return out;
}
```
设计要点:
- `before`/`after` 是匹配上下文签名(16/6 字符),`plainify` 后与渲染文本对齐(含 wikilink 别名还原——本 wiki 每页都有 `[[wikilink]]`,此步显著提高签名命中率);
- label 过滤掉含格式化字符者(`[**来源**](x.md)` 不装饰,优雅降级为纯文本);
- dest 只保留**相对** `.md` 目标——外链本来就渲染成真 ``,无需管;`#锚点` 渲染同样被 MarkdownText 丢弃,但预览没有可跳转的锚点目标,明确出圈。
#### (d) 新增 DOM 层:文本索引 + 配对装饰
落点:`renderBuiltin` 之后、`decorateWikiLinks` 之前(现 121 行附近)。**`decorateWikiLinks` 本体一行不改**(v1 对 A 分支的改动废弃)。
```js
// Text containers whose contents must never be wrapped as md-links.
// (a: real anchors — wrapping inside would fight the anchor's own
// behavior; .llmw-mdlink: our own output, excluded so the rebuilt index
// stays consistent within one pass. .llmw-wlink text is deliberately
// INCLUDED — the wikilink decorator runs first, and its labels are part
// of the rendered text the source-derived context signatures match
// against, on the first run and on every observer-triggered re-run.)
const MDLINK_TAGS = { CODE: 1, PRE: 1, TEXTAREA: 1, SCRIPT: 1, STYLE: 1, A: 1 };
// Depth-first joined text of the preview's renderable text nodes, with a
// node-start offset table. Matching runs on the joined string (robust to
// how React/MarkdownText split text nodes); wrapping only needs the one
// node covering the label range.
function buildTextIndex(root) {
const pieces = [];
const nodes = [];
let total = 0;
let visited = 0;
(function walk(node) {
const children = node.childNodes;
for (let i = 0; i < children.length; i++) {
if (++visited > 6000 || total > 200000) return;
const child = children[i];
if (child.nodeType === 3) {
if (child.nodeValue) {
nodes.push({ node: child, start: total });
pieces.push(child.nodeValue);
total += child.nodeValue.length;
}
} else if (child.nodeType === 1) {
const tag = (child.tagName || "").toUpperCase();
if (MDLINK_TAGS[tag]) continue;
// The TextEditor hosts the CodeMirror SOURCE copy before the
// rendered preview — walking it binds every match to the
// hidden editor text and starves the visible preview.
if (child.classList && (child.classList.contains("cm-editor") || child.classList.contains("llmw-mdlink"))) continue;
walk(child);
}
}
})(root);
return { text: pieces.join(""), nodes };
}
function nodeAt(index, offset) {
let lo = 0, hi = index.nodes.length - 1, best = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (index.nodes[mid].start <= offset) { best = index.nodes[mid]; lo = mid + 1; }
else hi = mid - 1;
}
return best;
}
// Remove spans created by a previous decoration pass so every run
// re-binds from a pristine tree (self-healing across re-renders).
function unwrapMdLinks(root) {
if (!root || !root.querySelectorAll) return;
const spans = root.querySelectorAll("[data-llmw-mdlink]");
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.parentNode) span.parentNode.replaceChild(span.ownerDocument.createTextNode(span.textContent || ""), span);
}
}
// Source text → regex fragment safe to embed, with whitespace runs made
// fuzzy: block boundaries DROP newlines in the rendered text and soft
// breaks may turn into spaces, so the preview's joined text can hold
// less whitespace than the source — `\s*` absorbs both directions.
function fuzzyNeedle(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s*");
}
// Pair source-parsed links with their rendered label text and wrap the
// labels into clickable spans. Match order: full 16-char before-signature
// (label pinned right after the anchor) → 8-char before-tail (same
// pinning) → label-only SEARCH from the cursor (document order).
// Whitespace-fuzzy throughout. A match that cannot be wrapped safely
// (label straddling nodes, guarded container) still advances the cursor
// so later links cannot rebind it.
function decorateMarkdownLinks(root, sourceLinks, resolveLink, onOpenTarget) {
if (!root || !sourceLinks || sourceLinks.length === 0 || typeof resolveLink !== "function") return 0;
const doc = root.ownerDocument;
if (!doc) return 0;
let wrapped = 0;
let cursor = 0;
let index = buildTextIndex(root);
for (let i = 0; i < sourceLinks.length && wrapped < 600; i++) {
const link = sourceLinks[i];
const target = resolveLink(link.dest);
if (!target) continue; // unresolvable → leave plain (no hijack)
const label = link.label;
const labelNeedle = "^\\s*" + fuzzyNeedle(label);
const heads = [];
if (link.before) {
heads.push(fuzzyNeedle(link.before)); // full 16-char signature
heads.push(fuzzyNeedle(link.before.slice(-8))); // 8-char tail
}
heads.push(null); // label-only fallback
let hit = -1;
for (const head of heads) {
if (head !== null) {
const re = new RegExp(head, "g");
re.lastIndex = cursor;
const m = re.exec(index.text);
if (!m) continue;
const anchor = m.index + m[0].length;
// Context head found → the label must start right at the
// anchor (leading whitespace tolerated): pin it instead of
// searching on, or prose containing the same word would
// steal the match.
const m2 = new RegExp(labelNeedle).exec(index.text.slice(anchor, anchor + label.length + 8));
if (!m2 || m2[0].length < label.length) continue;
hit = anchor + m2[0].length - label.length;
} else {
// Label-only fallback: SEARCH the first occurrence at/after
// the cursor (document order) — the accepted heuristic when
// no context signature is available. Already-wrapped labels
// are excluded from the join, so the search self-corrects
// past previous bindings.
const re = new RegExp(fuzzyNeedle(label), "g");
re.lastIndex = cursor;
const m = re.exec(index.text);
if (!m) continue;
hit = m.index;
}
break;
}
if (hit < 0) continue;
const entry = nodeAt(index, hit);
let ok = false;
if (entry) {
const inOff = hit - entry.start;
const value = entry.node.nodeValue || "";
if (value.slice(inOff, inOff + label.length) === label) { // label must sit in ONE text node
const parent = entry.node.parentNode;
if (parent) {
const beforeText = value.slice(0, inOff);
const afterText = value.slice(inOff + label.length);
const span = doc.createElement("span");
span.setAttribute("data-llmw-mdlink", "1");
span.className = "llmw-mdlink";
span.title = "→ " + (target.title || target.path);
span.textContent = label;
span.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
try { onOpenTarget(target); } catch (err) {}
});
const frag = doc.createDocumentFragment();
if (beforeText !== "") frag.appendChild(doc.createTextNode(beforeText));
frag.appendChild(span);
if (afterText !== "") frag.appendChild(doc.createTextNode(afterText));
parent.replaceChild(frag, entry.node);
wrapped++;
cursor = hit + label.length;
index = buildTextIndex(root); // node boundaries changed; joined text unchanged
ok = true;
}
}
}
if (!ok) cursor = hit + label.length;
}
return wrapped;
}
```
机制说明(为什么这样配对是稳的):
- **拼接文本对节点切分不敏感**:`^([来源](raw/x.md))` 渲染为文本节点序列 `"^("`、`"来源"`、`")"`,拼接后 `"^(来源)"` 与源码 plainify 形态一致——React/MarkdownText 怎么切节点都无所谓;
- **上下文签名消歧**:正文散文里恰好出现"来源"二字的误配,被"前 16 字符签名 + 锚点后紧跟标签"挡住(签名含 `^(` 时基本唯一命中);签名匹配是空白模糊的(`\s*`),段落边界丢 `\n`、软换行变空格都不影响命中;
- **上下文从 plainify 后全文切取**:避免窗口切进 `[[wikilink]]` 中间导致签名永远失配(plainify 先把 wikilink 折叠为渲染标签,再切窗口);
- **包装保持文本不变**:`before + label + after` 三段 replaceChild 后整树 `textContent` 不变,故 `cursor`(拼接文本偏移)在重建索引后依然有效;
- **失败也推进 cursor**:找到了文本但不可安全包装(跨节点/受控容器)时,跳过该位置,防止后续链接回绑同一处;
- **与 wikilink 装饰的顺序**:wikilink **先**跑、mdlink **后**跑——wlink 标签文本要留在拼接文本里,源码签(含 wikilink 别名还原)才能在首轮与 observer 重跑轮次都稳定命中;`buildTextIndex` 只排除 `.llmw-mdlink`(自身产物),纳入 wlink 文本。mdlink label 过滤掉了 `[[`,不会与 wlink span 交叠。
#### (e) `run()` 接线(改现 248-252 行)
```js
const run = () => {
if (disposed || !container) return;
mutating = true;
try {
unwrapMdLinks(container);
decorateWikiLinks(container, map ? bySlug : null, openBySlug);
if (map) decorateMarkdownLinks(container, sourceLinks, resolveLink, onOpenTarget);
} catch (e) {} finally { mutating = false; }
};
```
现有的 late timers(400/1500ms)、MutationObserver、`mutating` 防回环、`[isWiki, mode, fp.path, fp.content, map]` 依赖数组**全部沿用不动**;shiki/Mermaid 异步重建后 observer 触发 run() → unwrap 恢复原始文本 → 重建索引 → 重新配对,行为自愈。
#### (f) `WikiPageViewer` 接线(改现 232-240 行之后)
```js
const bySlug = {};
if (map && map.pages) {
for (const p of map.pages) bySlug[p.slug.toLowerCase()] = p;
}
const slug = String(fp.path || "").split("/").pop().replace(/\.md$/i, "").toLowerCase();
const openBySlug = (target) => {
const hit = bySlug[String(target).split("#")[0]];
if (hit) sidebar.openFile(scope, hit.path, hit.title);
};
// --- v2 新增 ---
const sourceLinks = collectSourceLinks(typeof fp.content === "string" ? fp.content : "");
const pageDir = (() => {
const p = String(fp.path || "");
const i = p.lastIndexOf("/");
return i > 0 ? p.slice(0, i + 1) : "";
})();
const resolveLink = makeLinkResolver({
wikiRoot: wikiRoot || "",
pageDir,
pages: map && Array.isArray(map.pages) ? map.pages : [],
raws: map && Array.isArray(map.raws) ? map.raws : [],
});
const onOpenTarget = (t) => { try { sidebar.openFile(scope, t.path, t.title); } catch (e) {} };
```
说明:`wikiRoot`(201 行)、`scope`(199 行)已存在;`map.raws` 由 §3.1 提供,`Array.isArray` 守卫使新旧服务端版本兼容(旧版下 raw 链接不装饰,行为同"未命中不劫持")。
#### (g) 可测性导出(`exports.apply` 之前)
```js
exports._internal = { makeLinkResolver, collapsePath, plainify, collectSourceLinks, decorateMarkdownLinks, unwrapMdLinks };
```
### 3.3 解析与拦截规则汇总(v2 版)
| 源文本 | 预览渲染形态 | v2 行为 |
|---|---|---|
| `^([来源](raw/articles/x.md))` | 纯文本 `^(来源)`(无 ``) | 配对成功 → 可点 span → `openFile(/raw/articles/x.md)` |
| `[页面](concepts/myth.md)` | 纯文本 | 同上 → `openFile` 打开该页 |
| `../raw/a.md`(自 `concepts/` 页发出) | 纯文本 | resolver 候选(2) 命中 → 打开 |
| `concepts/myth.md#简介` | 纯文本 | dest 剥 `#` 后命中页面 → 打开页面 |
| `[[wikilink]]` | 文本节点 | 现状不变(`decorateWikiLinks` 原样) |
| `https://…` / `mailto:…` | 真 `` | 不拦截(浏览器新标签,现状即正确) |
| `raw/not-in-map.md` / 深层 `raw/a/b/c.md` | 纯文本 | resolver 未命中 → **不包装**(不劫持,同 v1 哲学) |
| 围栏/行内码里的 `[a](b.md)` | 字面文本 | 不包装(collectSourceLinks 已 mask) |
| `` | `
`(better-sidebar 已重写可显示) | 不涉及 |
| `[**来源**](x.md)`(格式化 label) | 渲染为粗体文本 | 不包装(label 过滤,优雅降级) |
| `#anchor` / `/abs/path` | 纯文本 | 不处理——预览无锚点目标,明确出圈(见 §5.6) |
### 3.4 版本与包文档(同 v1 §3.4)
- `package.json`:`"version": "1.0.0" → "1.1.0"`。
- `README.md` Features 加一条:**Clickable provenance & relative `.md` links** — 溯源标记(`^([来源](raw/...))`)与任何站内相对 markdown 链接在预览中可点击,经与目录页相同的 `openFile` 通路打开;不修改源文件、编辑模式零影响。
---
## 4. 部署与验证
### 4.1 部署步骤(同 v1 §4.1)
```bash
S=/home/roy/dsh_work/llm-wiki-migration/formal-package/llm-wiki-sidebar
I=/home/roy/.dsh/profiles/web/llm-wiki-sidebar
node --check $S/lib/client.js && node --check $S/lib/index.js # 先过语法
cp $S/lib/client.js $S/lib/index.js $I/lib/ # 安装副本(node_modules 是它的 symlink)
# 服务端与客户端两半一起部署:map.raws 缺失时客户端优雅降级(raw 链接不装饰)
```
生效机制沿用 v1 §P5 结论:bundle 缓存到重启,改完需重启 DSH 服务。
### 4.2 生效验证(修正 v1 §4.2 的假阳性)
1. DevTools → Sources 搜 `data-llmw-mdlink`:**只能证明新 bundle 已被服务**,不能证明行为生效;
2. **必须做 §4.3 GUI 实测**才算验证通过。
### 4.3 单元测试
**已随本方案交付两套可执行测试(与本文件同目录)**:
- `test-mdlink-impl.mjs` —— **实现级(权威)**:以 ModuleLoader stub 直接加载实施后的 `lib/client.js`,对 `exports._internal` 跑同样的断言,另含真实 wiki 页(`concepts/mythopoeia-foundation.md`,5 处溯源链接)冒烟。实施后回归跑这个。
- `test-mdlink-v2.mjs` —— 规格级:从本文档 js 代码块逐函数提取(含 `MDLINK_TAGS` 常量)在迷你 DOM stub 上运行,保证文档与实现不漂移。
- A. `collectSourceLinks`:5 条链接收集、上下文对齐(wikilink 折叠 / 连续标记 / 段落边界 `\n` 保留)
- B. `makeLinkResolver`:页面/raw 解析、外链与未命中返回 null
- C. stub DOM 端到端装饰:5 个 span、标题按文档序、围栏内容不触碰
- D. click → `openFile` 目标正确
- E. 重跑自愈:unwrap → 重包装,无重复嵌套
- F. 散文同名词不劫持(上下文签名胜出,span 落在真实标记段落)
实施后回归:`node test-mdlink-impl.mjs && node test-mdlink-v2.mjs`。新增用例时参照下表:
**A. `collectSourceLinks` 用例表**:
| # | 输入(片段) | 期望 |
|---|---|---|
| 1 | `强化^([来源](raw/articles/a.md))。` | 1 条:`label="来源"`,`dest="raw/articles/a.md"`,`before` 以 `^(` 结尾,`after` 以 `。` 开头 |
| 2 | `[外](https://x.com)` | 0 条(带 scheme) |
| 3 | 围栏代码块内的 `[a](b.md)` | 0 条 |
| 4 | 行内码 `` `[a](b.md)` `` | 0 条 |
| 5 | frontmatter 内 `sources: [raw/a.md]` | 0 条 |
| 6 | `` | 0 条(图片) |
| 7 | `[**来源**](b.md)` | 0 条(格式化 label) |
| 8 | `[上页](concepts/myth.md#简介)` | 1 条,dest 含 `#简介` |
| 9 | `…^([来源](a.md))^([来源](b.md))…` 连续两标记 | 2 条且顺序保持;第 2 条 `before` 以 `))^(` 结尾 |
| 10 | 正文含 `见[[worldbuilding]]一章` 后随溯源标记 | `before` 中 wikilink 已折叠为标签文本(先 plainify 后切窗,签名与渲染文本对齐) |
| 11 | `[[]](b.md)` / `[]()` | 0 条(空 label) |
| 12 | 段首标记(`before` 以 `\n` 结尾) | 1 条,`before` 保留 `\n`(空白模糊在配对阶段处理,收集阶段不做归一化) |
**B. `makeLinkResolver` 用例表**:沿用 v1 §4.3 的 8 条(wikiRoot=`/w/wiki/`,pageDir=`/w/wiki/concepts/`),期望不变。
### 4.4 GUI 手动验收清单(在 v1 §4.4 基础上扩充)
- [ ] 打开 `concepts/mythopoeia-foundation.md` → 预览 → 段末「来源」为点状下划线可点样式,悬停提示 `→ raw/articles/…`;
- [ ] 点击后内置查看器打开对应 raw 文件,↩ 可返回原页;
- [ ] `concepts/worldbuilding.md`(4 处)与 `comparisons/process-first-…`(3 处)同样可点;
- [ ] **wikilink(如 `[[worldbuilding]]`)与背链行为不变**(回归项);
- [ ] 文内外链(如有 https 链接)仍为新标签打开,行为不变;
- [ ] 切编辑模式:**源码仍是原始的 `^([来源](raw/…))` 文本**;Ctrl/Cmd+S 保存后 `sha256sum` 与打开前一致(**零源码污染**——本方案与"源码重写"路线的分界线);
- [ ] 预览 ↔ 编辑往返多次后,DevTools 检查无嵌套的 `[data-llmw-mdlink]`(重包装防护生效);
- [ ] CLI `wiki_lint.py` 结果与改动前一致(0 错误)。
---
## 5. 风险与边界
1. **配对是启发式,但有界**:三级降级(完整签名 → 前 8 字符 + 标签 → 标签顺序)。最坏情形是"同页另一处同文出现"被包装成可点——**dest 来自源码解析,指向永远正确**,错的只是可点位置。上下文签名 + 本 wiki `^(` 惯例 + wikilink 别名还原(plainify)把该风险压到极低。
2. **draft 窗口**:TextEditor 的预览渲染"draft 优先于已存内容"(`previewMdText = markdownPreviewSource(mdText)`,draft 胜出)。用户在编辑态打字产生 draft 后切回预览,预览显示 draft 文本而配对用的 `fp.content` 仍是已存内容 → 可能漏配/错位。保存后 `fp.content` 刷新即自愈。影响限于"编辑未保存就切预览看链接"的场景,可接受;后续可观测上游是否暴露 draft 读取口。
3. **上游渲染行为变化**:本方案依赖"相对链接渲染为纯文本标签"这一现状。若未来上游把相对 dest 渲染为真 ``(例如 better-sidebar 在 preview 层补了链接重写),标签文本会进入 A 容器——`buildTextIndex` 跳过 A,装饰自动静默退化为不包装;届时 v1 式的 `` 拦截反而成立,可二选一。两个机制不会打架。
4. **shiki/Mermaid 异步重建**:现有 late timers + MutationObserver + 每轮 unwrap→重建→重包装,行为自愈(沿用现有机制)。
5. **性能**:每次成功包装后重建一次文本索引(页级文本 ≤200K 字符、节点 ≤6000 上限内,O(链接×节点) 最坏 3.6M 步量级,实测页远小于此)。`replaced > 600` / `visited > 6000` 上限沿用现有量级。
6. **明确出圈**:`#锚点` 跳转(预览无锚点目标,需先做 heading id 方案)、绝对站内路径以外资源的预览(如相对 pdf),本期不做。
7. **raw 深层目录盲区**:同 v1(`raw/a/b/c.md` 不进 map → 不拦截),后续可把服务端扫描改递归。
8. **上游化路径(更新)**:better-sidebar 侧的最终修法是在 `markdownPreviewSource` 同层做 **preview-only 的链接重写**(与 frontmatter 隐藏同层、与图片重写同思路,编辑态零污染)。本插件方案可独立生存,亦可作为上游落地前的过渡;上游落地后本装饰器自动退役(见风险 3)。
---
## 6. 与 v1 的关键差异一句话版
> v1 想在 DOM 里拦 ``,但相对链接根本没被渲染成 ``,URL 也没进 DOM——拦截无对象。
> v2 改为:URL 一直在插件手里(`fp.content`),标签文本一直在 DOM 里(渲染后的纯文本节点),
> 按文档顺序配对后用与 `[[wikilink]]` 完全相同的 text-node 包装手法补上可点性。
> 不改源码 → 编辑保存零污染;不看 `` 脸色 → 不依赖上游渲染行为。
## 附录:本次调查涉及的关键位置(2026-09-05 版本)
| 位置 | 内容 |
|---|---|
| `@deepseek-ai/dsh-web-frontend/dist/assets/index-Df-65__b.js` | 模块表 `"@deepseek-ai/dsh-client-ui-primitives": Fp`;导出 `MarkdownText: ic`;`Ku`(URL 白名单)/`Xu`(""→Fragment)——§1.1 证据 |
| `dsh-better-sidebar/lib/client-editor.js`(约 2080298 偏移) | 预览经 `_deepseek_ai_dsh_client_ui_primitives.MarkdownText` 渲染,仅传 text+labels |
| `dsh-better-sidebar/src/client/markdown-images.ts` | 图片源码级重写注释——"so the shared MarkdownText http(s) allowlist accepts them" |
| `dsh-better-sidebar/src/client/markdown-html.ts` | HTML 路径 postProcessSanitized 强制 `target="_blank"`(与 markdown 链接无关) |
| `dsh-better-sidebar/src/client/TextEditor.tsx` 292 / 314 行 | `save()` 写 CM 文档(§1.3 证据);`previewMdText = markdownPreviewSource(mdText)`(draft 优先、preview-only 变换层) |
| `lib/client.js` 189 行 | `tag === "A" → continue` —— v2 保持原状(v1 的改动废弃) |
| `lib/index.js` 255-261 / 453 | §3.1 两处落点(同 v1) |
| `~/.dsh/profiles/web/` | 安装布局同 v1 §P5(已复核 `diff -r` 一致) |
---
## 7. 实施后修正(v1.1.1,2026-09-05 首轮实测发现)
### 7.1 缺陷:CodeMirror 源码副本污染配对(用户实测 5 处溯源链接仅 3 处可点)
**现象**:`mythopoeia-foundation.md` 预览中前 2 处溯源链接缺失、后 3 处可点。
**根因**(headless Chrome 驱动真实 GUI + 生产 MarkdownText 实测确认):
1. better-sidebar 的 TextEditor 在 `.llmw-viewerhost` 内**同时**挂载两份文本:隐藏的 CodeMirror
源码编辑器(`.cm-editor`,含 frontmatter、行号、原始 markdown 全语法、URL)**在前**,渲染后的
预览(URL 被 MarkdownText 白名单丢弃,标签为纯文本)**在后**。
2. `buildTextIndex` 的拼接文本因此 = **CM 源码副本 + 预览副本**,CM 副本排在前面。配对从 cursor=0
开始,签名/label-only 全部先命中 CM 副本,把**隐藏编辑器里的"来源"**包掉并消耗 cursor——预览里
用户可见的标签反而得不到包装。
3. CM 有视口虚拟化:用户窄侧栏只渲染文件前 ~30 行 → #0/#1(L17/L19)的 CM 副本存在(被隐形消费)、
#2-4(L31-37)的 CM 副本被虚拟化 → 预览副本被包装 → **恰好"2 缺失、3 可点"**。探针高窗口里 CM
渲染整个文件 → 节点数打爆 `visited > 6000` 上限,walk 死在 CM 内部,join 只剩源码(joinLen≈源文件
大小)→ 全部不装饰。两个方向的现象由同一根因解释。
4. 附带发现:v1 的 `decorateWikiLinks` 同样行走 CM 副本(隐性缺陷),本次一并修复。
**修复**:`buildTextIndex` 与 `decorateWikiLinks` 的 walk 均跳过 `.cm-editor` 子树(CodeMirror 自身
稳定类名,非 CSS-module 哈希)。装饰只作用于渲染后的预览。
**加固**:`WikiPageViewer` 的 map 拉取从一次性改为 [0, 800, 2400]ms 三次退避重试(失败时
`getMap` 会清掉缓存条目,重试即重新拉取)——消除"首次打开 map 瞬时失败 → 整页无装饰直到重开"的
用户实测现象。
**验证**:实现级测试新增 H 组回归(CM 副本在前 + 预览在后的 DOM 结构,断言 5/5 包装且无一落在
CM 内);headless GUI 探针复核真实页面 5/5 可见包装。