鸿蒙 PC Markdown 编辑器链接补全与离线校验:中文路径、标题锚点与授权边界
Markdown 的链接语法只有方括号和圆括号,但在桌面编辑器里把链接做可靠,远比拼出 [文字](路径) 复杂。用户希望输入几个字就找到工作区文档,希望中文目录不乱码,希望标题修改后能提前发现失效链接,还希望点击预览中的链接时直接跳到目标位置。与此同时,编辑器不能把普通 Markdown 变成私有知识库格式,不能为了补全扫描未授权目录,更不能因为一个 ../ 或符号链接越过用户选择的工作区。
本文讨论一套面向鸿蒙 PC 的离线链接工作流:ArkWeb 负责编辑上下文、键盘入口和候选交互,ArkTS 负责授权目录枚举、路径解析、目标读取、锚点生成和安全跳转。实现已经进入公开仓库 https://gitcode.com/VON-/codex_md_oh,功能提交为 113a52e。文章中的代码、测试数字和应用截图都来自这一提交,不把后续专业渲染、语义知识图谱或真机竞品数据写成现有能力。

链接工作流首先是一个 PC 编辑任务
移动端常见做法是打开表单,分别填写显示文本和 URL。PC 编辑器更适合键盘连续操作:选择一段文字,按 Ctrl+K,输入目标文件名或 #,用方向键选择,按 Enter 插入;光标已经处于现有链接目标中时,再按 Ctrl+K 只修改目标,不破坏标签。这样的路径才符合桌面写作的节奏。
链接助手还承担校验入口。Ctrl+Shift+K 校验当前文档,结果必须区分"目标文件不存在""标题不存在""路径越过工作区""需要先授权工作区"和"目标文件无法读取"。把所有错误都显示为"链接无效"会迫使用户自行猜测,也无法在大型文档中定位源链接。
因此完整任务不是"插入一个字符串",而是五段闭环:获取编辑上下文、离线发现候选、生成可迁移目标、校验并解释错误、安全导航到最新文件内容。每一段都需要在键盘、文件系统和安全边界之间保持一致。
为什么坚持标准 Markdown
很多知识库产品用双向链接、文档 ID 或私有 URI 提供更强功能,但代价是离开该产品后语义下降。OhMarkdown 当前选择标准行内链接:
markdown
[产品规划](docs/%E4%BA%A7%E5%93%81%E8%A7%84%E5%88%92.md)
[安装步骤](docs/%E4%BA%A7%E5%93%81%E8%A7%84%E5%88%92.md#%E5%AE%89%E8%A3%85%E6%AD%A5%E9%AA%A4)
[当前章节](#%E5%AE%89%E8%A3%85%E6%AD%A5%E9%AA%A4)
文件仍是唯一事实来源,不建立必须同步的链接数据库,不修改 YAML 元数据,也不写入隐藏索引。其他支持 Markdown 的工具至少可以读取标签、相对路径和锚点;即使不同渲染器对标题 ID 的细节略有差异,正文也不会被锁定在 OhMarkdown 中。
标准格式并不意味着功能只能简陋。编辑器可以在输入阶段提供候选、在保存前校验、在点击时重新解析,只要最终落盘仍是普通 Markdown。产品优势来自编辑过程的可靠性,而不是文件格式的封闭性。
ArkUI 与 ArkWeb 的责任分界
鸿蒙 PC 应用采用 ArkUI 工作台和 ArkWeb 编辑内核。CodeMirror 知道光标、选区、撤销历史和当前未保存正文,适合决定插入范围;ArkTS 持有系统授权 URI 和 CoreFileKit 能力,适合访问工作区。链接功能沿用这个边界,没有把文件系统权限下放给 Web。
Web 侧只发送受限命令:
ts
type NativeCommand = 'new' | 'open' | 'openWorkspace' | 'save' | 'saveAs' | 'autoSave' | 'find' |
'findWorkspace' | 'quickOpen' | 'linkComplete' | 'linkValidate' | 'linkCancel' | 'openLink' |
'viewSource' | 'viewSplit' | 'viewPreview' | 'exportHtml' | 'print';
补全请求包含 requestId、查询文本和必要时的当前正文;校验请求包含当前正文;打开请求包含链接文本和当前正文。Web 不传任意目标 URI,也不能要求 ArkTS 读取某个绝对路径。原生先以当前文档和授权工作区为基准重新解析,确认目标合法后才读取。
ArkTS 返回候选或诊断的 JSON,Web 只渲染文本节点,不把目标当 HTML 注入。回调脚本中的请求 ID、结果 JSON和错误信息再次经过 JSON.stringify,避免引号或换行破坏脚本边界。这是一条窄协议:它足以完成链接任务,却没有变成通用文件访问接口。
从选区和光标恢复插入上下文
按 Ctrl+K 时,编辑器必须先保存 CodeMirror 上下文,再把焦点交给查询框。若直接在弹窗打开后读取 selection,浏览器焦点变化可能使原选择折叠,最后得到空标签。实现把全局快捷键监听放在捕获阶段,并在显示助手前计算插入范围。
选择区非空时,整段选择成为标签,候选确认后替换为完整链接。光标无选择时,代码检查本行中光标是否位于 ]( 与后续 ) 之间;如果是,只记录目标范围和原标签,确认后只替换目标。图片语法  被明确排除,避免把图片资源链接误当普通导航链接。
ts
function findLinkInsertionContext(): { context: LinkInsertionContext; query: string } {
const selection = editor.state.selection.main;
if (selection.from !== selection.to) {
return {
context: {
from: selection.from,
to: selection.to,
label: editor.state.sliceDoc(selection.from, selection.to),
replaceTargetOnly: false
},
query: ''
};
}
const line = editor.state.doc.lineAt(selection.head);
const cursorInLine = selection.head - line.from;
const lineText = line.text;
const targetStartMarker = lineText.lastIndexOf('](', cursorInLine);
if (targetStartMarker >= 0) {
const targetStart = targetStartMarker + 2;
const targetEnd = lineText.indexOf(')', targetStart);
const labelStart = lineText.lastIndexOf('[', targetStartMarker);
if (targetEnd >= cursorInLine && cursorInLine >= targetStart && labelStart >= 0 &&
(labelStart === 0 || lineText[labelStart - 1] !== '!')) {
return {
context: {
from: line.from + targetStart,
to: line.from + targetEnd,
label: lineText.slice(labelStart + 1, targetStartMarker),
replaceTargetOnly: true
},
query: lineText.slice(targetStart, targetEnd)
};
}
}
return {
context: {
from: selection.head,
to: selection.head,
label: '',
replaceTargetOnly: false
},
query: ''
};
}
这段代码来自 Web 编辑器真实实现。关键是"先捕获上下文,再移动焦点"。自动化分别覆盖选择中文文本创建链接和已有目标内只替换路径,避免键盘入口在弹窗改造后退化。
相对路径必须以当前文档目录为基准
工作区根目录不是每个链接的直接基准。假设当前文档是 docs/guide/start.md,目标是 reference/api.md,正确结果是 ../../reference/api.md。算法先求当前目录和目标路径的公共前缀,再为当前目录剩余层级生成 ..,最后追加目标剩余段。
ts
export function createRelativeDocumentTarget(
currentRelativePath: string,
targetRelativePath: string
): string {
const currentDirectory = getDirectorySegments(currentRelativePath);
const targetSegments = targetRelativePath.split('/');
let commonLength = 0;
while (commonLength < currentDirectory.length && commonLength < targetSegments.length &&
currentDirectory[commonLength] === targetSegments[commonLength]) {
commonLength += 1;
}
const relativeSegments: Array<string> = [];
for (let index = commonLength; index < currentDirectory.length; index += 1) {
relativeSegments.push('..');
}
targetSegments.slice(commonLength).forEach((segment: string): void => {
relativeSegments.push(segment);
});
return relativeSegments.join('/');
}
算法只处理服务已经枚举出的工作区相对路径,因此候选不会凭空指向工作区外部。路径使用正斜线,保持 Markdown 的跨平台表达;不把鸿蒙设备上的真实沙箱路径写入正文。移动整个文件夹后,相对结构未变的链接仍然有效,这是标准相对链接比绝对 URI 更适合项目文档的原因。
中文路径应逐段编码而不是整体编码
encodeURIComponent 直接作用于整条路径会把 / 编成 %2F,破坏层级;完全不编码又会在不同解析器、预览引擎和分享链路中产生差异。实现按 / 分段编码,保留 . 和 ..,再用 / 拼回。
ts
export function encodeMarkdownLinkTarget(target: string): string {
return target.split('/').map((segment: string): string => {
if (segment === '.' || segment === '..') {
return segment;
}
return encodeURIComponent(segment);
}).join('/');
}
例如 ../参考/接口说明.md 得到 ../%E5%8F%82%E8%80%83/%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md。显示候选时仍可展示可读的 参考/接口说明.md,写入正文时使用稳定目标。编码不是为了隐藏中文,而是把文件名从显示层转换为 URI 目标层。
解析时只对路径段和锚点执行 decodeURIComponent,非法百分号编码会产生明确错误,而不是静默把损坏目标当成另一个文件。反斜线、查询参数、协议和绝对路径在解码前就被拒绝,避免平台路径语义混进 Markdown 相对路径。
反向解析必须证明没有越界
校验和点击导航都需要把 Markdown 目标还原成工作区相对路径。解析从当前文档目录段开始,普通段压栈,. 忽略,.. 弹栈;当栈已经为空仍遇到 .. 时立即拒绝。最终结果为空也拒绝,因为它没有指向文档。
ts
export function resolveRelativeDocumentPath(
currentRelativePath: string,
targetPath: string
): string {
if (targetPath.length === 0) return currentRelativePath;
if (targetPath.startsWith('/') || targetPath.includes('\\') || targetPath.includes('?') ||
isExternalTarget(targetPath)) {
throw new Error('The Markdown link target is not a safe workspace-relative path.');
}
const resolved = getDirectorySegments(currentRelativePath);
for (const segment of decodeTargetPart(targetPath).split('/')) {
if (segment.length === 0 || segment === '.') continue;
if (segment === '..') {
if (resolved.length === 0) {
throw new Error('The Markdown link target leaves the workspace.');
}
resolved.pop();
continue;
}
if (!isSafeEntryName(segment)) {
throw new Error('The Markdown link target contains an invalid path segment.');
}
resolved.push(segment);
}
if (resolved.length === 0) {
throw new Error('The Markdown link target does not identify a document.');
}
return resolved.join('/');
}
仅做字符串前缀比较并不够安全,/workspace-a 和 /workspace-attack 可能共享前缀;先规范相对段再与已枚举文档集合匹配更可靠。服务不会根据解析结果任意拼接磁盘路径,而是从授权枚举结果中查找 relativePath 完全相等的文档,并使用其 URI 读取。
工作区枚举不跟随符号链接
用户授权一个目录,不代表允许沿符号链接访问其他目录。枚举使用 lstat 检查每个条目,符号链接直接跳过;版本库元数据、依赖目录和图片资源目录也不参与 Markdown 链接候选。这样既缩小安全面,也减少大量无关文件。
服务限制每个目录最多 2000 项、总目录 2000 个、候选文档 5000 份。支持扩展名为 .md、.markdown、.mdown、.mkd 和 .txt。达到上限后返回已有候选,不让一个异常工作区无限消耗内存。候选最多显示 80 项,查询使用文件名和相对目标的完整、前缀和包含分数排序。
目录枚举每次异步让出执行权,并在目录恢复、条目循环和目标读取后检查补全代际。旧查询被新查询替代后,即使底层 listFile 已经发出,结果也不能提交到 UI。这个机制比只在界面隐藏加载动画更重要,因为旧扫描仍可能较晚完成。
当前文档标题必须使用未保存缓冲区
链接补全最容易出现的错觉是:用户刚输入一个标题,按 Ctrl+K 却找不到,保存后才出现。这说明实现错误地只读取磁盘。当前文档的标题候选必须来自 CodeMirror 当前缓冲区,因为用户的编辑意图还没有落盘。
Web 只在查询包含 # 时附带当前正文,减少普通文件名补全的 Bridge 负载。路径部分为空时,服务直接对当前内容提取标题;路径部分精确指向其他文档时,才从工作区枚举中找到目标并安全读取。查询 docs/产品规划.md#安装 因而同时包含两个阶段:先解析文档,再过滤标题。
模拟器截图展示了未打开文件夹、未保存标题时的真实结果。输入"鸿蒙PC链接测试"后按 Ctrl+K,查询 # 立即出现同名候选。这不是 Mock Bridge,也不是浏览器页面,而是最新 Debug HAP 中 ArkWeb 和 ArkTS 的实际往返。
标题锚点需要稳定处理中文与重复项
标题锚点生成器先去除首尾空白并转为小写,保留拉丁字母、数字、中日韩统一表意文字和下划线,其他字符转为待处理分隔符。连续分隔符归并为一个 -,结尾分隔符移除。中文不会被音译,也不会全部消失。
ts
export function slugifyMarkdownHeading(title: string): string {
const normalized = title.trim().toLocaleLowerCase();
let result = '';
let separatorPending = false;
for (let index = 0; index < normalized.length; index += 1) {
const character = normalized[index];
const code = normalized.charCodeAt(index);
const isLatinOrNumber = (code >= 48 && code <= 57) || (code >= 97 && code <= 122);
const isCjk = (code >= 0x3400 && code <= 0x4DBF) || (code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0xF900 && code <= 0xFAFF);
if (isLatinOrNumber || isCjk || character === '_') {
if (separatorPending && result.length > 0 && !result.endsWith('-')) result += '-';
result += character;
separatorPending = false;
} else {
separatorPending = result.length > 0;
}
}
return result.replace(/-+$/g, '');
}
同一文档重复标题不能产生相同目标。提取阶段用 Map 记录基础锚点出现次数:第一次使用 安装步骤,第二次为 安装步骤-1,第三次为 安装步骤-2。空标题回退到 section,同样参与计数。补全、校验和导航共用这一个生成器,避免三个路径各自定义规则。
需要明确的是,不同 Markdown 生态的标题 ID 规则并非完全统一。当前实现保证 OhMarkdown 内部生成、校验和跳转一致,并保留标准 #anchor 形式。G3-10 仍需扩大与常见渲染器的兼容语料,不能把内部一致性等同于所有外部工具百分之百一致。
链接解析不能把代码样例当成真实目标
技术文档经常在代码围栏和行内代码中展示 [示例](missing.md)。如果校验器把它们当真实链接,就会产生大量误报。解析器按行扫描,维护反引号代码围栏状态,围栏内不解析;行内反引号之间也跳过; 和转义的 \[ 不作为普通链接。
标准链接允许标签与圆括号之间有空格,目标可以使用尖括号包裹,也可能包含转义字符和一层或多层括号。解析器追踪括号深度,找到真实关闭括号;遇到可选标题前的空格时截取目标,并继续寻找链接末尾。每个结果记录 from 和 to,诊断点击时可以精确选中完整链接。
当前解析范围有意受限于标准行内链接,没有把引用式链接、HTML <a>、Wiki Link 或脚注解释为本地文档链接。先把可验证的核心语法做准,比用一个复杂正则声称支持全部 Markdown 更可靠。新增语法必须同时增加解析、校验、导航和错误定位用例。
离线校验的状态模型
校验摘要包含 checked、valid、invalid、skipped、truncated 和诊断数组。外部协议链接计入 checked 后标记 skipped,因为它们不属于离线本地文件校验;本地链接成功解析且目标存在时计入 valid;越界、缺失或不可读计入 invalid。
目标为空但有 #anchor 时表示当前文档。工作区未授权时,当前文档锚点仍可校验;指向其他文档的路径返回 workspace-required,而不是误报文件不存在。工作区已授权但集合中找不到目标时返回 missing-document。目标存在但标题集合中没有锚点时返回 missing-anchor。
ts
const anchorExists = extractHeadingTargets(targetContent ?? '')
.some((heading: HeadingTarget): boolean => heading.anchor === split.anchor);
if (!anchorExists) {
diagnostics.push(createDiagnostic(
link,
LinkDiagnosticCode.MISSING_ANCHOR,
`Heading anchor not found: #${split.anchor}`
));
continue;
}
valid += 1;
同一轮校验可能多次链接到同一文件,因此服务用 Map<relativePath, content> 缓存已读取正文。单文档最多校验 500 个链接,超过后 truncated=true,UI 不应把部分校验说成完整通过。目标文档最大 4 MiB,超限返回不可读取诊断,避免校验动作把大文档全部堆入内存。
读取目标文档时复用文件安全原则
链接读取不是保存,但仍需面对符号链接替换、非 UTF-8、读取中变化和超大文件。服务用 READ_ONLY | NOFOLLOW 打开文档,打开后再次 stat 大小,按 64 KiB 分块读取,并使用 fatal: true 的 UTF-8 解码器。实际读取字节数与打开后的大小不一致时,报告文件在读取期间变化。
UTF-8 BOM 在解码后去除,不让标题第一个字符带上不可见标记。严格解码意味着损坏文件不会被替换字符悄悄改变标题。校验器捕获单个目标读取错误并生成 unreadable-document,其他链接继续处理;补全精确目标标题时则把错误返回助手,因为无法提供可信候选。
链接服务没有复用通用文档会话的完整 BOM、换行元数据,因为它只读标题和校验目标,不写回文件。真正点击跨文档链接后,WorkspaceShell 仍调用正式 readUtf8Document 和 applyOpenedDocument 建立会话,继续继承安全保存、指纹、恢复和多标签逻辑。
点击预览链接时重新解析而不是信任 href
预览由 Markdown 渲染并经过净化,本地链接点击事件阻止浏览器默认导航,发送 openLink。外部链接同样保持默认禁用,不交给原生本地解析。原生拿到 href 后再次检查长度、协议和相对路径,不信任 DOM 已经安全。
ts
private async resolveAndOpenEditorLink(request: EditorLinkRequest, href: string): Promise<void> {
this.operationInProgress = true;
try {
const currentContent = request.content ?? this.documentContent;
const resolution = await this.workspaceLinkController.resolve(
this.workspaceRootUri,
this.documentUri,
this.documentName,
currentContent,
href
);
if (!resolution.currentDocument) {
const openedDocument = await readUtf8Document(resolution.uri);
await this.applyOpenedDocument(openedDocument);
}
this.viewMode = 'source';
this.setEditorMode('source');
await this.editorController.runJavaScript(
`window.OhMarkdownEditor?.jumpToOffset(${resolution.offset}) === true`
);
} finally {
this.operationInProgress = false;
}
}
当前文档使用 Web 发送的最新缓冲区解析标题,保证未保存标题可跳;跨文档先由链接服务证明 URI 属于工作区,再由正式文档服务重新读取,避免用校验缓存直接建立编辑会话。跳转强制进入源码模式,因为预览 DOM 的节点位置不等于 CodeMirror 文档偏移。
jumpToOffset 在 Web 端再次验证整数范围,创建折叠选区并滚动到标题顶部附近。Bridge 两侧都检查失败,原生只有在返回值严格为 true 时显示成功状态。这样不会出现目标没打开但状态栏说"已跳转"的假成功。
连续输入的取消和迟到结果
用户输入 d、do、doc 时可能产生三次请求。界面使用 140 ms 防抖减少无意义扫描;每次真实请求分配 link-N ID,服务启动新完成任务时增加 generation。旧任务在异步目录操作恢复后发现 generation 不匹配,抛出取消错误。
只有服务取消仍不够,因为旧 Promise 的 catch 也可能晚于新结果到达。Web 的 activeLinkCompletionRequestId 会拒绝非当前 ID;助手已经关闭时同样拒绝。关闭动作清除定时器、请求 ID、候选和诊断,并向原生发送 linkCancel。这是服务代际和界面请求 ID 的双层保护。
取消不是性能优化的装饰,它决定结果是否可信。若旧查询覆盖新查询,用户看到的候选和输入框不一致,按 Enter 会插入错误文件。链接工作流把"候选属于哪个编辑时刻"作为协议的一部分,而不是依赖执行速度碰运气。
键盘、焦点和中英文界面
链接助手使用固定高度的头部、查询框、滚动结果区、校验区和底部动作区,候选动态变化不会改变工作台几何。方向上、下键循环选择,Enter 插入当前候选,Escape 关闭并在下一帧归还 CodeMirror 焦点。查询框通过 aria-activedescendant 指向当前候选,候选使用 role=option 和 aria-selected。
标题、占位文本、空状态、校验摘要、错误类型和按钮均随运行时语言切换。语言变更只更新 DOM 文本和属性,不重建 CodeMirror,不丢失链接上下文。长目标路径单行省略,完整相对路径放在 title;窄窗口下对话框宽度受视口约束,列表保持内部滚动。
模拟器验证使用真实 Ctrl+K 键值组合打开助手,并在输入框输入 #。截图中可见中文标题候选、编码后的锚点目标、当前文档校验按钮以及 OhMarkdown 完整 PC 工作台语境。这个证据同时覆盖快捷键路由、焦点、Bridge、中文标题和响应式弹层,而不是只证明一个纯函数返回值。
自动化如何覆盖两侧契约
Web Playwright 使用受控 Bridge 返回中文文档候选和标题候选,验证四条用户路径:选择"产品说明"后插入 [产品说明](docs/%E4%BA%A7%E5%93%81.md);在 [安装](docs/产品.md) 目标内部补全后只替换目标;离线诊断点击后链接被选中且焦点回到编辑器;预览本地链接发送 openLink,外部链接不发送命令。
ArkTS 纯函数测试覆盖跨目录相对路径、逐段中文编码、解码、工作区逃逸拒绝、中文标题锚点和忽略代码/图片伪链接。最终 UnitTestBuild 确认这些断言和服务代码在 ArkTS 严格规则下可编译。
ohosTest 在模拟器应用沙箱真实创建 README.md 和 docs/产品规划.md。输入 docs/产品规划.md# 返回两个标题,第二项"安装步骤"目标为编码后的路径和锚点。随后校验三条链接,得到一条有效、一个缺失锚点和一个越界错误,再解析有效链接到目标第 3 行。最终设备汇总为 8/8 通过,新增链接用例耗时 19 ms。
Web 最终回归为 34/34,Debug HAP 和 ohosTest HAP 均构建成功。19 ms 只代表小型沙箱语料中的设备用例,不代表 1000 文件工作区性能,更不能直接与其他编辑器比较。测试数字只有注明语料和边界才有意义。
性能上限与仍需补齐的证据
当前实现优先保证边界清楚:5000 份文档、2000 个目录、单目录 2000 项、80 个候选、500 条待校验链接、4 MiB 目标文档。目录和文档读取异步进行,连续查询可取消,但普通路径补全仍需要枚举工作区,没有持久索引。对于大型仓库,重复打开助手的冷扫描成本需要在 G3-10 用统一语料测量。
如果未来引入缓存,应明确失效规则,而不是让候选长期落后于文件系统。可选方案包括短时根目录快照、文件观察器驱动失效或复用工作区搜索目录清单。每种方案都要保留授权边界、符号链接拒绝和文件变化后的权威重读。为了几个毫秒引入不可解释的陈旧链接,不符合编辑器可靠性目标。
标题兼容也需要继续扩大。当前生成器在 OhMarkdown 的补全、校验和导航内一致,中文和重复标题稳定;不同 Markdown 渲染器对标点、emoji、HTML 实体和重复后缀可能不同。后续应建立跨渲染器标题语料,记录可兼容范围,并在必要时允许用户复制原始锚点,而不是未经测量宣称全生态一致。
鸿蒙 PC 真机、Release 构建、1000 文件压力、连续取消后的 CPU/内存和竞品统一计时尚未完成。当前竞争优势记分为 3 分:实现、自动化和模拟器设备闭环已具备,但缺真机和竞品量化。这个分数边界必须与功能完成状态同时记录。
设计取舍总结
这套链接工作流的关键不在候选弹层本身,而在几项约束同时成立:文件仍是标准 Markdown;当前未保存标题可立即补全;中文路径可读地展示、稳定地编码;目标只能来自授权工作区;符号链接和 .. 不能扩大权限;校验错误能回到源码位置;点击时重新读取最新文档;连续输入不会被旧扫描覆盖。
ArkWeb 和 ArkTS 各自做擅长的部分,Bridge 保持窄命令而不是通用能力。服务中的相对路径、锚点和诊断模型可独立测试,界面中的选择区、焦点和键盘路径用 Playwright 与模拟器复验。最终产物仍是普通 [标签](路径#锚点),没有以产品便利为理由牺牲文件可迁移性。
G3-06 因而可以结束,但链接能力仍有清晰后续:跨渲染器锚点兼容、统一大工作区性能、真机输入响应和竞品同任务测量。把完成与边界一起写入技术文章,比只展示一个成功截图更接近长期可维护的产品工程。