MarkText 中 @muyajs/core 包全景解析:TS 版 muya 引擎的架构、命令与合规基线
本文以 packages/muya/CLAUDE.md 为骨架,系统介绍 MarkText monorepo 中 TypeScript 版 muya 编辑器引擎(@muyajs/core)的目录布局、完整命令清单、分层架构(插件系统、Editor 调度、块树、状态往返、内联渲染、UI 层)、工具链强制的代码约定以及构建管线细节,帮助读者掌握该引擎的接入方式与底层实现原理。
包定位:从 JS 到 TypeScript 的迁移背景
packages/muya 是 muya 编辑器引擎的 TypeScript 重写版本,已迁入 MarkText monorepo,发布名为 @muyajs/core。从 package.json 可以确认:包名 @muyajs/core,当前版本 0.2.0,依赖中包含 ot-json1、ot-text-unicode、marked、snabbdom、katex、mermaid、vega 等核心库,engines 要求 Node ≥ 20.19。
关键的架构事实是:桌面端渲染进程现在消费 @muyajs/core 作为编辑器引擎;遗留的 JS 引擎 packages/muyajs(@marktext/muyajs,muya/ 别名)正在退役,仓库中只剩极少数调用点仍引用它。
另一个重要边界:MarkText 根目录的 ESLint 配置显式忽略了 packages/muya/**,因此该包拥有完全独立的工具链(ESLint/antfu 风格、stylelint、madge、vitest),应被视为一个自包含的包,遵循自己的规范而非根目录规范。上游 muya monorepo 中的桩包(packages/facade、packages/findReplace)因没有源码而未迁移。
目录布局
packages/muya 内部布局如下(源自 CLAUDE.md,已逐一在仓库中核对):
src/—@muyajs/core的 TypeScript 源码,公开 API 入口为 src/index.ts;test/spec/— CommonMark / GFM 规范合规测试套件,通过test:spec命令运行,使用独立的 vitest 配置 vitest.spec.config.ts;examples/—muya-examples,一个基于 Vite 的原生 TS 演示工程,通过workspace:*消费@muyajs/core,在仓库根 pnpm-workspace.yaml 中列为独立 workspace;e2e/—muya-e2e,Playwright 真浏览器 E2E 套件,自带e2e/host/宿主页面,配套 e2e/README.md 与 e2e/BACKLOG.md;- eslint.config.mjs、
.stylelintrc、.madgerc— 包级工具链配置。
从 src/ 的实际目录结构看,源码组织与文档描述完全一致:block/(base、commonMark、gfm、extra、content、mixins、scrollPage)、state/、inlineRenderer/、ui/、selection/、history/、clipboard/、event/、i18n/、locales/ 等模块齐备。
命令清单(在 MarkText 仓库根执行)
以下命令均须从 MarkText 仓库根目录运行(对应 package.json 的 scripts 字段):
# 启动 examples 的 Vite dev server(上游的 pnpm dev / Turbo dev:demo 未在此接线,直接跑 vite)
pnpm -C packages/muya/examples dev:demo
# 构建:tsc && vite build,产出 lib/{es,umd,cjs} 与 lib/types
pnpm -C packages/muya build
# Vitest 单元测试(测试与源码同置于 src/**/__tests__/ 下)
pnpm -C packages/muya test
pnpm -C packages/muya coverage
# 单测单文件
pnpm -C packages/muya exec vitest run path/to/file.test.ts
# CommonMark 0.31 + GFM 0.29-gfm 规范合规套件
pnpm -C packages/muya test:spec
pnpm -C packages/muya test:spec:commonmark
pnpm -C packages/muya test:spec:gfm
# Lint 系列
pnpm -C packages/muya lint # ESLint,覆盖 src test(antfu 配置)
pnpm -C packages/muya lint:fix
pnpm -C packages/muya lint:types # tsc --noEmit
pnpm -C packages/muya lint:css # Stylelint,覆盖 src/**/*.css
# 循环依赖检查(CI 强制执行)
pnpm -C packages/muya check-circular # madge --circular src/index.ts
# Playwright E2E(chromium/firefox/webkit)
pnpm -C packages/muya/e2e e2e # e2e:install 为一次性浏览器安装
环境与构建目标约束:Node ≥ 20.19(与 MarkText 根一致),构建目标为 chrome70——这一点在 vite.config.ts 的 build.target 中可以直接核实。
合规套件的"只升不降"机制
test:spec 是理解这个包质量保障哲学的关键。合规测试针对 renderToStaticHTML(..., { sanitize: false }) 运行——注意它测量的是解析器的规范符合度,而不是 DOMPurify 净化器(后者正确地非常激进,会剥掉 raw-HTML 放行类用例)。
expected-failures.json 锁定了当前失败用例的编号:任何已列入名单的用例一旦开始通过,套件就会失败(你必须把它从名单中移除);任何未列入名单的用例一旦开始失败,套件同样失败。净效果是:合规度只能升,不能降。
基线数据记录在 test/spec/conformance.md(PR-6a 时点):
| 套件 | 通过 | 总数 | 通过率 |
|---|---|---|---|
| CommonMark 0.31 | 572 | 652 | 87.7% |
| GFM 0.29-gfm | 580 | 672 | 86.3% |
从分章节数据看,CommonMark 套件中"强调与加粗"(Emphasis and strong emphasis)达到 100%(132/132)、代码片段(Code spans)100%,而"制表符"(Tabs)仅 9.1%(1/11)、"实体与数字字符引用"29.4%,是主要失分点。
对比判定的实现细节值得一读:test/spec/runner.ts 手工实现了一个 normalizeHtml 归一化器——统一自闭合标签写法(<br> → <br />)、按字母序重排属性、折叠相邻标签之间的空白——目的是消除 cmark 与 marked 之间那些语义等价但书写不同的 HTML 差异,同时刻意保留标签内部的文本内容(<pre><code> 中的换行是围栏代码块用例依赖的)。这个"能大声喊话的距离"(within shouting distance of the spec)定位,是该套件自述的设计标准。
架构解析
入口与插件系统
src/muya.ts 导出 Muya 类。UI 插件通过静态方法 Muya.use(Plugin, options) 全局注册,在 muya.init() 中实例化;插件以 Plugin.pluginName 为键,存储在实例的 _uiPlugins 上。源码注释明确了插件契约:
export interface IMuyaPluginConstructor {
pluginName: string;
new(muya: Muya, options: Record<string, unknown>): unknown;
}
examples/src/main.ts 中的插件集合是接线工具栏、选择器、菜单的标准参考。实际示例展示了完整的接线方式:Muya.use(InlineFormatToolbar)、Muya.use(ImageEditTool, { imagePathPicker, imageAction })、Muya.use(LinkTools, { jumpClick }) 等十余个 UI 插件,随后 new Muya(container, { markdown, ...options }) → m.locale(...) → m.init() 完成生命周期(见 examples/src/main.ts)。
构造函数的行为(src/muya.ts 第 149–158 行):new Muya(element, options) 先用 getContainer 把传入的 DOM 元素替换为一个 contenteditable 新 div,然后依次构造 EventCenter、Editor、Ui、I18n 四个核心组件。在 muya.init() 执行 Editor.init() 之前什么都不渲染——Editor.init() 调用 registerBlocks() 并创建根 ScrollPage。
Editor:运行时调度中枢
src/editor/index.ts 中的 Editor 持有全部运行时模块:JSONState、InlineRenderer、Selection、Search、Clipboard、History 以及根 ScrollPage。它负责维护 activeContentBlock(当前聚焦的叶子块),并将 DOM 事件(click、input、keydown、keyup、compositionstart/end)通过 RxJS merge 合并后路由到当前块的对应处理器(clickHandler、inputHandler 等)。任何监听块上用户输入的代码最终都经过这条分发链。
OT 应用机制是这里的精髓:Editor.updateContents(operations, selection, source) 把 ot-json1 操作应用到活的块树上。源码中的 pick/drop 游标(第 109–124 行起的 descend/restore/pick/drop 系列函数)是手工改写自 ot-json1.apply 的版本,目的是在遍历操作路径时能实际调用块树实例方法:block.replaceWith、container.insertBefore、ScrollPage.loadBlock(name).create(...)、以及对匹配子文档调用 otText.type.apply——由此保证块树与 JSON 状态始终同步(in lockstep)。
块树:注册制 + 类继承
所有块继承 TreeNode → Parent → (Content | Format)(位于 src/block/base/)。Parent 拥有一个 children 的 LinkedList 和一个 attachments 列表(存放不参与状态的节点,如图标、复选框);Content 是持有实际文本的叶子;Format 在 Content 基础上增加内联格式处理。
具体块类分布在 src/block/{commonMark,gfm,extra,content} 下,必须在 registerBlocks() 中注册。从 src/block/index.ts 可以看到完整的注册清单:ScrollPage 先注册自己,然后依次注册 Paragraph、AtxHeading、SetextHeading、BlockQuote、ThematicBreak、CodeBlock、OrderList、BulletList、TaskList(含 TaskListItem/TaskListCheckbox)、Table(Table/Inner/Row/Cell/CellContent)、HTML(Block/Preview/Container)、Math(Block/Preview/Container)、Frontmatter、Diagram(Block/Container/Preview)、Footnote 等。
查找链路是:ScrollPage(src/block/scrollPage/index.ts)维护一个静态 registeredBlocks 映射,所有加载走 ScrollPage.loadBlock(blockName).create(muya, state)。新增块类型的铁律:不在此注册,loadBlock 只会告警并返回 undefined。
另外,block/mixins/{containerQueryBlock,leafQueryBlock}.ts 是应用于块类的构造器 mixin,提供 queryBlock/路径解析能力(ROADMAP 注明这是从属性 mixin 有意切换而来的)。
状态层与 Markdown 往返
src/state/ 目录是文档模型的所在地:
JSONState(state/index.ts)是文档的唯一事实来源(source of truth),暴露ot-json1的invert/compose/transform静态方法——从源码结构看,整套架构已为 OT 协同编辑做好准备,只是尚未接入任何传输层;markdownToState.ts用marked把 Markdown 解析为状态树;stateToMarkdown.ts反向序列化;markdownToHtml.ts与htmlToMarkdown.ts(使用turndown+joplin-turndown-plugin-gfm)负责 HTML 桥接。MarkdownToHtml从公开 API 再导出(在 src/index.ts 中可见);- 行内文本编辑被编码为嵌套在 json1 操作之内的
ot-text-unicode操作(见Editor.updateContents中d.es分支)。
两条容易被忽略的实现细节(来自 CLAUDE.md,且与源码一致):
- 引用式链接/图片定义(
[ref]: url "title")不是状态中的一等块类型。markdownToState的case 'def'会把原始定义行重新放进一个paragraph状态节点,使其能通过 Markdown 序列化无损往返。而InlineRenderer.collectReferenceDefinitions()在每次渲染遍历时遍历活块树,填充一个 labels Map,供 lexer 展开[text][ref]与![alt][ref]时查询。ILinkReferenceDefinitionState只是兼容用的废弃桩——不要编写产生它的新代码路径。 - TOC 按需派生:
getTOC(muya)(state/getTOC.ts),公开方法为muya.getTOC(),slug 生成沿用自 commit9cb2cbe8的 marktext 兼容正则(src/utils/slug.ts 导出generateGithubSlug)。
内联渲染与 DOM
src/inlineRenderer/ 用自研的 lexer/rules 管线对行内内容分词,通过 snabbdom 渲染到虚拟 DOM(序列化则用 snabbdom-to-html)。公式、代码高亮与图表分别由 KaTeX、Prism、Mermaid、Vega/Vega-Lite、PlantUML 承担——这些依赖在 package.json 的 dependencies 中可以逐一核对。
UI 层
src/ui/ 下每个子目录都是一个浮动工具/菜单(行内格式工具栏、图片工具、段落前置按钮、表格工具、表情选择器等),继承自 baseFloat 或 baseScrollFloat,使用 @floating-ui/dom 定位。它们从 src/index.ts 导入并再导出,由宿主通过 Muya.use(...) 注册。Ui(src/ui/ui.ts)是编辑器与之对话的注册表。
公开 API 面
src/index.ts 是唯一的发布入口:package.json 的 exports 映射在开发期把 . 指向 ./src/index.ts,发布后指向 ./lib/es/index.js(见 package.json 的 publishConfig.exports)。这个文件应保持为唯一的导出中枢。当前公开导出包括:Muya 类、MarkdownToHtml、renderToStaticHTML、TState/IMuyaOptions 类型、全部 10 个 locale(en、zhCN、zhTW、ja 等)、20 个 UI 插件类,以及 escapeHTML/sanitize/unescapeHTML/wordCount/generateGithubSlug/getImageInfo 等工具函数。
外观契约(排版选项与 CSS 自定义属性)
muya 支持两种等价的方式设定自身内容的排版:传入 options,或直接覆盖 CSS 自定义属性(纯 CSS 主题化)。变量设置在编辑器根节点(.mu-editor)上,由捆绑样式表消费;每个变量都有 CSS 内置默认值,因此什么都不传就渲染独立默认样式。
选项(IMuyaOptions) |
CSS 变量 | 默认值 | 作用范围 |
|---|---|---|---|
fontSize(number, px) |
--mu-font-size |
16px |
.mu-editor 基础文本 |
lineHeight(number) |
--mu-line-height |
1.6 |
.mu-editor 基础文本 |
editorFontFamily(string) |
--mu-font-family |
Open Sans 字体栈 | .mu-editor 基础文本 |
codeFontSize(number, px) |
--mu-code-font-size |
90% |
仅 .mu-code-block |
codeFontFamily(string) |
--mu-code-font-family |
DejaVu Sans Mono 字体栈 | 仅 .mu-code-block |
wrapCodeBlocks(boolean) |
—(.mu-code-wrap 根类) |
关闭(pre) |
代码块换行 |
这些默认值可直接在 blockSyntax.css 中核实:font-size: var(--mu-font-size, 16px)、font-family: var(--mu-font-family, 'Open Sans', ...)、line-height: var(--mu-line-height, 1.6),代码块处为 var(--mu-code-font-size, 90%)。
三个边界约束需要注意:
- 行内代码(
code.mu-inline-rule)刻意不受上述变量驱动——它保持相对尺寸0.8em/ 等宽字体; - 编辑区宽度(
--editor-area-width)与配色盘(--editor-color-*)是宿主负责的另一套既有契约; - 所有运行时变更统一走
muya.setOptions({...})。
此外从 muya.ts 源码可以看到一个与选项相关的实用机制:PARSE_AFFECTING_OPTIONS(isGitlabCompatibilityEnabled、math、footnote、frontMatter、trimUnnecessaryCodeBlockEmptyLines)这一组选项会改变块结构的分类(例如 GitLab 兼容模式下 ```math 与代码块的互换),而仅基于已解析状态的渲染重建无法反映这种变化——因此触及这些选项时必须从 Markdown 重新解析整个文档。
工具链强制的代码约定
- ESLint(eslint.config.mjs,antfu 基础)叠加:
complexity≤ 20、max-lines-per-function≤ 200(对非测试 TS 为警告级);- 接口名必须以
I[A-Z0-9]开头(如IMuyaOptions、IPlugin),命名约定规则会拦截不合规的接口——这解释了源码中大量I前缀命名的由来; - 私有类成员必须以下划线开头(如
_uiPlugins、_activeContentBlock); - 风格:4 空格缩进、强制分号、禁用 React 规则、禁用 Markdown linting;
- 禁止在审计过的边界辅助函数之外使用
value as unknown as X双重断言——应使用类型守卫或命名辅助函数(examples/src/main.ts 中对Intl的 polyfill 处理就是一个被显式豁免的边界案例)。
- Madge 循环依赖检查(
pnpm -C packages/muya check-circular)在 CI 中运行——新增循环导入会直接让构建失败。 - 测试文件(
*.test.ts、*.spec.ts)与vite.config.ts从上述严格 TS lint 规则中豁免。
上游 muya 曾接线 Conventional Commits + husky + lint-staged + release-it,但这些没有迁入 MarkText——MarkText 不用 husky/commitlint,且 @muyajs/core 不从此仓库发布。提交风格遵循 MarkText 根目录的贡献指南。
构建管线要点
vite.config.ts 揭示了两个容易踩坑的细节:
vite-plugin-dts@5(经unplugin-dts间接依赖)使用outDirs(复数)而非outDir。配置中dts({ entryRoot: 'src', outDirs: 'lib/types' })依赖这一点把类型声明产出到lib/types/——这正是package.json的publishConfig.exports[*].types指向的路径。如果哪天看到lib/index.d.ts直接出现在lib/下,说明这个选项名回归了。@laynezh/vite-plugin-lib-assets负责静态资源路由:*.png进lib/assets/icons/,字体文件进lib/assets/fonts/(outputPath回调中按扩展名分流)。
库模式构建(build.lib)以 src/index.ts 为唯一入口,产出 es/umd/cjs 三种格式、文件名统一为 ${format}/index.js,与 main/module/publishConfig 的声明完全对应。
小结
packages/muya/CLAUDE.md 实际上是一份高密度引擎维护手册,其核心信息可归纳为四条主线:注册制块树(新增块必须走 registerBlocks())、OT 驱动的树状态同步(ot-json1 + 手工 pick/drop)、只升不降的规范合规基线(expected-failures.json + 87.7%/86.3% 基线)、以及自包含的独立工具链(antfu ESLint + madge + 独立 vitest 配置)。对希望向 MarkText 贡献编辑器能力、或想把 @muyajs/core 作为独立引擎嵌入自己产品的开发者,上述架构入口(Muya.use 插件注册、muya.init() 生命周期、setOptions 运行时契约)与命令清单构成了可直接落地的操作地图。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00