鸿蒙 PC Markdown 编辑器链接补全与离线校验:中文路径、标题锚点与授权边界 鸿蒙 PC Markdown 编辑器链接补全与离线校验中文路径、标题锚点与授权边界Markdown 的链接语法只有方括号和圆括号但在桌面编辑器里把链接做可靠远比拼出[文字](路径)复杂。用户希望输入几个字就找到工作区文档希望中文目录不乱码希望标题修改后能提前发现失效链接还希望点击预览中的链接时直接跳到目标位置。与此同时编辑器不能把普通 Markdown 变成私有知识库格式不能为了补全扫描未授权目录更不能因为一个../或符号链接越过用户选择的工作区。本文讨论一套面向鸿蒙 PC 的离线链接工作流ArkWeb 负责编辑上下文、键盘入口和候选交互ArkTS 负责授权目录枚举、路径解析、目标读取、锚点生成和安全跳转。实现已经进入公开仓库 https://gitcode.com/VON-/codex_md_oh功能提交为113a52e。文章中的代码、测试数字和应用截图都来自这一提交不把后续专业渲染、语义知识图谱或真机竞品数据写成现有能力。链接工作流首先是一个 PC 编辑任务移动端常见做法是打开表单分别填写显示文本和 URL。PC 编辑器更适合键盘连续操作选择一段文字按CtrlK输入目标文件名或#用方向键选择按 Enter 插入光标已经处于现有链接目标中时再按CtrlK只修改目标不破坏标签。这样的路径才符合桌面写作的节奏。链接助手还承担校验入口。CtrlShiftK校验当前文档结果必须区分“目标文件不存在”“标题不存在”“路径越过工作区”“需要先授权工作区”和“目标文件无法读取”。把所有错误都显示为“链接无效”会迫使用户自行猜测也无法在大型文档中定位源链接。因此完整任务不是“插入一个字符串”而是五段闭环获取编辑上下文、离线发现候选、生成可迁移目标、校验并解释错误、安全导航到最新文件内容。每一段都需要在键盘、文件系统和安全边界之间保持一致。为什么坚持标准 Markdown很多知识库产品用双向链接、文档 ID 或私有 URI 提供更强功能但代价是离开该产品后语义下降。OhMarkdown 当前选择标准行内链接[产品规划](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 侧只发送受限命令typeNativeCommandnew|open|openWorkspace|save|saveAs|autoSave|find|findWorkspace|quickOpen|linkComplete|linkValidate|linkCancel|openLink|viewSource|viewSplit|viewPreview|exportHtml|print;补全请求包含requestId、查询文本和必要时的当前正文校验请求包含当前正文打开请求包含链接文本和当前正文。Web 不传任意目标 URI也不能要求 ArkTS 读取某个绝对路径。原生先以当前文档和授权工作区为基准重新解析确认目标合法后才读取。ArkTS 返回候选或诊断的 JSONWeb 只渲染文本节点不把目标当 HTML 注入。回调脚本中的请求 ID、结果 JSON和错误信息再次经过JSON.stringify避免引号或换行破坏脚本边界。这是一条窄协议它足以完成链接任务却没有变成通用文件访问接口。从选区和光标恢复插入上下文按CtrlK时编辑器必须先保存 CodeMirror 上下文再把焦点交给查询框。若直接在弹窗打开后读取 selection浏览器焦点变化可能使原选择折叠最后得到空标签。实现把全局快捷键监听放在捕获阶段并在显示助手前计算插入范围。选择区非空时整段选择成为标签候选确认后替换为完整链接。光标无选择时代码检查本行中光标是否位于](与后续)之间如果是只记录目标范围和原标签确认后只替换目标。图片语法![...](...)被明确排除避免把图片资源链接误当普通导航链接。functionfindLinkInsertionContext():{context:LinkInsertionContext;query:string}{constselectioneditor.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:};}constlineeditor.state.doc.lineAt(selection.head);constcursorInLineselection.head-line.from;constlineTextline.text;consttargetStartMarkerlineText.lastIndexOf(](,cursorInLine);if(targetStartMarker0){consttargetStarttargetStartMarker2;consttargetEndlineText.indexOf(),targetStart);constlabelStartlineText.lastIndexOf([,targetStartMarker);if(targetEndcursorInLinecursorInLinetargetStartlabelStart0(labelStart0||lineText[labelStart-1]!!)){return{context:{from:line.fromtargetStart,to:line.fromtargetEnd,label:lineText.slice(labelStart1,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。算法先求当前目录和目标路径的公共前缀再为当前目录剩余层级生成..最后追加目标剩余段。exportfunctioncreateRelativeDocumentTarget(currentRelativePath:string,targetRelativePath:string):string{constcurrentDirectorygetDirectorySegments(currentRelativePath);consttargetSegmentstargetRelativePath.split(/);letcommonLength0;while(commonLengthcurrentDirectory.lengthcommonLengthtargetSegments.lengthcurrentDirectory[commonLength]targetSegments[commonLength]){commonLength1;}constrelativeSegments:Arraystring[];for(letindexcommonLength;indexcurrentDirectory.length;index1){relativeSegments.push(..);}targetSegments.slice(commonLength).forEach((segment:string):void{relativeSegments.push(segment);});returnrelativeSegments.join(/);}算法只处理服务已经枚举出的工作区相对路径因此候选不会凭空指向工作区外部。路径使用正斜线保持 Markdown 的跨平台表达不把鸿蒙设备上的真实沙箱路径写入正文。移动整个文件夹后相对结构未变的链接仍然有效这是标准相对链接比绝对 URI 更适合项目文档的原因。中文路径应逐段编码而不是整体编码encodeURIComponent直接作用于整条路径会把/编成%2F破坏层级完全不编码又会在不同解析器、预览引擎和分享链路中产生差异。实现按/分段编码保留.和..再用/拼回。exportfunctionencodeMarkdownLinkTarget(target:string):string{returntarget.split(/).map((segment:string):string{if(segment.||segment..){returnsegment;}returnencodeURIComponent(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 目标还原成工作区相对路径。解析从当前文档目录段开始普通段压栈.忽略..弹栈当栈已经为空仍遇到..时立即拒绝。最终结果为空也拒绝因为它没有指向文档。exportfunctionresolveRelativeDocumentPath(currentRelativePath:string,targetPath:string):string{if(targetPath.length0)returncurrentRelativePath;if(targetPath.startsWith(/)||targetPath.includes(\\)||targetPath.includes(?)||isExternalTarget(targetPath)){thrownewError(The Markdown link target is not a safe workspace-relative path.);}constresolvedgetDirectorySegments(currentRelativePath);for(constsegmentofdecodeTargetPart(targetPath).split(/)){if(segment.length0||segment.)continue;if(segment..){if(resolved.length0){thrownewError(The Markdown link target leaves the workspace.);}resolved.pop();continue;}if(!isSafeEntryName(segment)){thrownewError(The Markdown link target contains an invalid path segment.);}resolved.push(segment);}if(resolved.length0){thrownewError(The Markdown link target does not identify a document.);}returnresolved.join(/);}仅做字符串前缀比较并不够安全/workspace-a和/workspace-attack可能共享前缀先规范相对段再与已枚举文档集合匹配更可靠。服务不会根据解析结果任意拼接磁盘路径而是从授权枚举结果中查找relativePath完全相等的文档并使用其 URI 读取。工作区枚举不跟随符号链接用户授权一个目录不代表允许沿符号链接访问其他目录。枚举使用lstat检查每个条目符号链接直接跳过版本库元数据、依赖目录和图片资源目录也不参与 Markdown 链接候选。这样既缩小安全面也减少大量无关文件。服务限制每个目录最多 2000 项、总目录 2000 个、候选文档 5000 份。支持扩展名为.md、.markdown、.mdown、.mkd和.txt。达到上限后返回已有候选不让一个异常工作区无限消耗内存。候选最多显示 80 项查询使用文件名和相对目标的完整、前缀和包含分数排序。目录枚举每次异步让出执行权并在目录恢复、条目循环和目标读取后检查补全代际。旧查询被新查询替代后即使底层listFile已经发出结果也不能提交到 UI。这个机制比只在界面隐藏加载动画更重要因为旧扫描仍可能较晚完成。当前文档标题必须使用未保存缓冲区链接补全最容易出现的错觉是用户刚输入一个标题按CtrlK却找不到保存后才出现。这说明实现错误地只读取磁盘。当前文档的标题候选必须来自 CodeMirror 当前缓冲区因为用户的编辑意图还没有落盘。Web 只在查询包含#时附带当前正文减少普通文件名补全的 Bridge 负载。路径部分为空时服务直接对当前内容提取标题路径部分精确指向其他文档时才从工作区枚举中找到目标并安全读取。查询docs/产品规划.md#安装因而同时包含两个阶段先解析文档再过滤标题。模拟器截图展示了未打开文件夹、未保存标题时的真实结果。输入“鸿蒙PC链接测试”后按CtrlK查询#立即出现同名候选。这不是 Mock Bridge也不是浏览器页面而是最新 Debug HAP 中 ArkWeb 和 ArkTS 的实际往返。标题锚点需要稳定处理中文与重复项标题锚点生成器先去除首尾空白并转为小写保留拉丁字母、数字、中日韩统一表意文字和下划线其他字符转为待处理分隔符。连续分隔符归并为一个-结尾分隔符移除。中文不会被音译也不会全部消失。exportfunctionslugifyMarkdownHeading(title:string):string{constnormalizedtitle.trim().toLocaleLowerCase();letresult;letseparatorPendingfalse;for(letindex0;indexnormalized.length;index1){constcharacternormalized[index];constcodenormalized.charCodeAt(index);constisLatinOrNumber(code48code57)||(code97code122);constisCjk(code0x3400code0x4DBF)||(code0x4E00code0x9FFF)||(code0xF900code0xFAFF);if(isLatinOrNumber||isCjk||character_){if(separatorPendingresult.length0!result.endsWith(-))result-;resultcharacter;separatorPendingfalse;}else{separatorPendingresult.length0;}}returnresult.replace(/-$/g,);}同一文档重复标题不能产生相同目标。提取阶段用 Map 记录基础锚点出现次数第一次使用安装步骤第二次为安装步骤-1第三次为安装步骤-2。空标题回退到section同样参与计数。补全、校验和导航共用这一个生成器避免三个路径各自定义规则。需要明确的是不同 Markdown 生态的标题 ID 规则并非完全统一。当前实现保证 OhMarkdown 内部生成、校验和跳转一致并保留标准#anchor形式。G3-10 仍需扩大与常见渲染器的兼容语料不能把内部一致性等同于所有外部工具百分之百一致。链接解析不能把代码样例当成真实目标技术文档经常在代码围栏和行内代码中展示[示例](missing.md)。如果校验器把它们当真实链接就会产生大量误报。解析器按行扫描维护反引号代码围栏状态围栏内不解析行内反引号之间也跳过![图片](...)和转义的\[不作为普通链接。标准链接允许标签与圆括号之间有空格目标可以使用尖括号包裹也可能包含转义字符和一层或多层括号。解析器追踪括号深度找到真实关闭括号遇到可选标题前的空格时截取目标并继续寻找链接末尾。每个结果记录from和to诊断点击时可以精确选中完整链接。当前解析范围有意受限于标准行内链接没有把引用式链接、HTMLa、Wiki Link 或脚注解释为本地文档链接。先把可验证的核心语法做准比用一个复杂正则声称支持全部 Markdown 更可靠。新增语法必须同时增加解析、校验、导航和错误定位用例。离线校验的状态模型校验摘要包含checked、valid、invalid、skipped、truncated和诊断数组。外部协议链接计入 checked 后标记 skipped因为它们不属于离线本地文件校验本地链接成功解析且目标存在时计入 valid越界、缺失或不可读计入 invalid。目标为空但有#anchor时表示当前文档。工作区未授权时当前文档锚点仍可校验指向其他文档的路径返回workspace-required而不是误报文件不存在。工作区已授权但集合中找不到目标时返回missing-document。目标存在但标题集合中没有锚点时返回missing-anchor。constanchorExistsextractHeadingTargets(targetContent??).some((heading:HeadingTarget):booleanheading.anchorsplit.anchor);if(!anchorExists){diagnostics.push(createDiagnostic(link,LinkDiagnosticCode.MISSING_ANCHOR,Heading anchor not found: #${split.anchor}));continue;}valid1;同一轮校验可能多次链接到同一文件因此服务用MaprelativePath, content缓存已读取正文。单文档最多校验 500 个链接超过后truncatedtrueUI 不应把部分校验说成完整通过。目标文档最大 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 已经安全。privateasyncresolveAndOpenEditorLink(request:EditorLinkRequest,href:string):Promisevoid{this.operationInProgresstrue;try{constcurrentContentrequest.content??this.documentContent;constresolutionawaitthis.workspaceLinkController.resolve(this.workspaceRootUri,this.documentUri,this.documentName,currentContent,href);if(!resolution.currentDocument){constopenedDocumentawaitreadUtf8Document(resolution.uri);awaitthis.applyOpenedDocument(openedDocument);}this.viewModesource;this.setEditorMode(source);awaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.jumpToOffset(${resolution.offset}) true);}finally{this.operationInProgressfalse;}}当前文档使用 Web 发送的最新缓冲区解析标题保证未保存标题可跳跨文档先由链接服务证明 URI 属于工作区再由正式文档服务重新读取避免用校验缓存直接建立编辑会话。跳转强制进入源码模式因为预览 DOM 的节点位置不等于 CodeMirror 文档偏移。jumpToOffset在 Web 端再次验证整数范围创建折叠选区并滚动到标题顶部附近。Bridge 两侧都检查失败原生只有在返回值严格为 true 时显示成功状态。这样不会出现目标没打开但状态栏说“已跳转”的假成功。连续输入的取消和迟到结果用户输入d、do、doc时可能产生三次请求。界面使用 140 ms 防抖减少无意义扫描每次真实请求分配link-NID服务启动新完成任务时增加 generation。旧任务在异步目录操作恢复后发现 generation 不匹配抛出取消错误。只有服务取消仍不够因为旧 Promise 的 catch 也可能晚于新结果到达。Web 的activeLinkCompletionRequestId会拒绝非当前 ID助手已经关闭时同样拒绝。关闭动作清除定时器、请求 ID、候选和诊断并向原生发送linkCancel。这是服务代际和界面请求 ID 的双层保护。取消不是性能优化的装饰它决定结果是否可信。若旧查询覆盖新查询用户看到的候选和输入框不一致按 Enter 会插入错误文件。链接工作流把“候选属于哪个编辑时刻”作为协议的一部分而不是依赖执行速度碰运气。键盘、焦点和中英文界面链接助手使用固定高度的头部、查询框、滚动结果区、校验区和底部动作区候选动态变化不会改变工作台几何。方向上、下键循环选择Enter 插入当前候选Escape 关闭并在下一帧归还 CodeMirror 焦点。查询框通过aria-activedescendant指向当前候选候选使用roleoption和aria-selected。标题、占位文本、空状态、校验摘要、错误类型和按钮均随运行时语言切换。语言变更只更新 DOM 文本和属性不重建 CodeMirror不丢失链接上下文。长目标路径单行省略完整相对路径放在 title窄窗口下对话框宽度受视口约束列表保持内部滚动。模拟器验证使用真实CtrlK键值组合打开助手并在输入框输入#。截图中可见中文标题候选、编码后的锚点目标、当前文档校验按钮以及 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/34Debug 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 因而可以结束但链接能力仍有清晰后续跨渲染器锚点兼容、统一大工作区性能、真机输入响应和竞品同任务测量。把完成与边界一起写入技术文章比只展示一个成功截图更接近长期可维护的产品工程。

本月热点