# Markdown 插件

把新的语法接入增量解析、语义查询和源码呈现，而不引入 HTML 往返。

## 先区分三类插件

| 能力 | MSFE 入口 | 是否改源码 |
| --- | --- | --- |
| 新语法识别 | `addMarkdownExtensions()` 的 Lezer Markdown 扩展 | 否 |
| 已有语法的组件和视觉效果 | `addSource()`、`addNodeView()`、`addProjectionLayers()` | 渲染本身不改 |
| 输入和粘贴时替换文本 | `InputRule`、`PasteRule` | 是，参与事务和历史 |

`markdown-it` 插件接受 token 流、规则链和 HTML renderer；MSFE 使用 Lezer 的增量语法树。这两种协议不同，不能把 `md.use(plugin)` 的插件对象直接放进 `extensions`。可以在隔离的非编辑预览中使用第三方渲染库，但解析、源码范围和写回仍需自己接入。

## 一个真实的解析插件

### 可选文档属性

`Frontmatter` 只识别源码。只有显式安装 `DocumentProperties` 才会将文首 YAML 显示成属性表；
MD Editor 默认不安装它。Vue 宿主可以复用公共编辑面板：

~~~vue
<script setup lang="ts">
import { MdEditor, MdDocumentProperties } from '@quirekit/md-editor/vue'
import { DocumentProperties } from '@quirekit/msfe-markdown-extras'
import { VueComponentViewRenderer } from '@quirekit/msfe-vue'

const extensions = [DocumentProperties.configure({
  render: VueComponentViewRenderer(MdDocumentProperties), locale: 'zh-CN',
  defaultCollapsed: true,
})]
const markdown = '---\ntitle: 示例\ntags: [写作, 编辑]\n---\n\n正文'
</script>
<template><MdEditor :markdown="markdown" :extensions="extensions" /></template>
~~~

MD Editor 已包含 `Frontmatter`；直接使用 MSFE Core 时须在 `extensions` 中同时加入
`Frontmatter`。不传 `render` 时提供原生属性展示和查看源码入口。

`setDocumentPropertiesEnabled(editor, false)` 可恢复原始 Frontmatter，`true` 再次启用，
不重建编辑器、不清空撤销、不改正文和选区。用 `documentPropertiesEnabled(editor.state)`
读取实际状态。示例页的“文档属性”开关使用这个公共接口。

`defaultCollapsed: true` 让属性区初始只显示标题和数量，适合优先展示正文的产品；
省略或设为 `false` 时默认展开。此配置同时适用于 Vue 面板与默认原生渲染器。
用户手动切换后，同一文档内的编辑、模式切换、投影开关和视口重挂载都会保留选择；
`replaceDocument` 加载另一篇文档时重新使用默认值。`enabled: false` 会显示原 YAML，
与折叠属性区不同。跨会话记忆可由宿主保存，并作为下一次安装的默认值。

运行时可通过 `documentPropertiesCollapsed(editor.state)` 读取、
`setDocumentPropertiesCollapsed(editor, true)` 收起，`false` 展开；自定义渲染器使用
`view.collapsed` 与 `view.setCollapsed()`。只读模式也能折叠，不改变编辑权限。

`MdDocumentProperties` 默认预览六个字段，长文本与标签列表可单独展开。
点击字段值直接编辑，确认与取消保持同行；单行 Enter 保存，Escape 取消，多行可用
⌘/Ctrl + Enter 保存。收起面板保留未保存草稿，展开状态不会写入 Markdown。

字符串、数字、布尔和字符串列表可编辑，操作参与源码撤销。其他值展示原文；无效 YAML、
重复键、别名、锚点、显式标签、行内或缩进根映射、显式或跨行键、TOML、未闭合或超过
20,000 字符的头部回退源码。新增属性名始终编码为字符串键。
写回只替换目标范围，保留其他字段、注释和空行；文档或头部已变化时拒绝过期表单。
源码模式显示完整原文，编辑器只读时不提供修改控件。

### 自定义徽标

本例支持 `:badge[label]:`，保留可见分隔符并用主题色标记。未闭合、超长或含嵌套方括号的内容保持普通源码；代码 span 内的同样字符串不被当作徽标。

### plugins/editor.vue

```vue
<script setup lang="ts">
import { StarterKit } from "@quirekit/msfe-starter-kit";
import EditorExample from "../shared/editor-example.vue";
import type { ExampleLocale } from "../shared/types";
import { Badge } from "./extension";
const props = withDefaults(defineProps<{ /** Host UI language. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const markdown = props.locale === "en" ? '# Parser plugin\n\nRelease :badge[reviewed]:\n\nUnclosed :badge[unfinished\n\n`:badge[plain code]:`\n' : '# 解析插件\n\n发布 :badge[已审核]:\n\n未闭合 :badge[尚未完成\n\n`:badge[代码字面值]:`\n';
</script>
<template><EditorExample :locale="locale" :markdown="markdown" :extensions="[StarterKit, Badge]" /></template>

```

### shared/editor-example.vue

```vue
<script setup lang="ts">
import { computed, provide, ref, shallowRef } from "vue";
import { useMsfeEditor, VueNodeViewHost } from "@quirekit/msfe-vue";
import type { AnyExtension, CodeLanguage } from "@quirekit/msfe-core";
import { QuireButton, QuireSegmented, QuireProseSource } from "@quirekit/ui/vue";
import { Appearance } from "./appearance";
import { exampleMessages } from "./messages";
import { exampleLocaleKey } from "./locale";
import type { ExampleLocale } from "./types";
const props = defineProps<{
  /** Initial canonical source; changing this prop does not replace an open document. */ markdown: string;
  /** Explicitly installed feature descriptors. */ extensions: readonly AnyExtension[];
  /** Language of host controls. */ locale: ExampleLocale;
  /** Optional exact lazy code-language registry. */ codeLanguages?: readonly CodeLanguage[];
  /** A product preset already supplies the public Prose presentation. */ prose?: boolean;
}>();
const text = computed(() => exampleMessages[props.locale]);
provide(exampleLocaleKey, computed(() => props.locale));
const parent = shallowRef<HTMLElement | null>(null);
const snapshot = ref<string>();
const mode = ref("live");
const items = computed(() => [{ value: "live", label: text.value.live }, { value: "source", label: text.value.source }, { value: "locked", label: text.value.locked }]);
const { editor, host } = useMsfeEditor(parent, () => ({ markdown: props.markdown, mode: "live", label: text.value.editor,
  extensions: [...props.extensions, ...(props.prose ? [] : [Appearance])], codeLanguages: props.codeLanguages }));
/** @param value - Requested display mode. */
function changeMode(value: string | null | undefined): void {
  if (value === "live" || value === "source" || value === "locked") { mode.value = value; editor.value?.setMode(value); }
}
</script>
<template>
  <div class="grid gap-4 p-qk-surface">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <QuireSegmented :model-value="mode" :items="items" :label="text.modes" size="sm" @update:model-value="changeMode" />
      <div class="flex gap-2">
        <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.undo()">{{ text.undo }}</QuireButton>
        <QuireButton size="sm" variant="outline" @click="snapshot = editor?.getMarkdown()">{{ text.readMarkdown }}</QuireButton>
      </div>
    </div>
    <slot :editor="editor" />
    <div ref="parent" class="overflow-hidden rounded-qk-control border border-qk-border-subtle" />
    <VueNodeViewHost :host="host" />
    <QuireProseSource v-if="snapshot !== undefined" :code="snapshot" language="markdown" filename="document.md" />
  </div>
</template>

```

### shared/appearance.ts

```ts
import { Extension } from "@quirekit/msfe-core";
import { EditorView } from "@quirekit/cm-view";
import { Prec } from "@quirekit/cm-state";
import { HighlightStyle, syntaxHighlighting } from "@quirekit/cm-language";
import { tags } from "@lezer/highlight";
import { themeVariableReferences as token } from "@quirekit/ui";

/** Consumer-owned theme; source semantics stay in the installed feature packages. */
export const Appearance = Extension.create({ name: "documentationAppearance",
  /** @returns Scoped typography, surfaces and syntax colors from the host design system. */
  addCodeMirrorExtensions() {
    return Prec.high([
      EditorView.theme({
        "&": { ...token.typography.body, color: token.color.text.primary, backgroundColor: token.color.surface.page, minHeight: "180px" },
        ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.8" },
        ".cm-content": { padding: token.spacing.surface, caretColor: token.color.text.primary },
        ".cm-line": { padding: "0" },
        ".cm-gutters": { color: token.color.text.tertiary, backgroundColor: token.color.surface.subtle, border: "none" },
        ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
        ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: token.color.selection.document },
        ".cm-cursor": { borderLeftColor: token.color.text.primary },
        ".msfe-h1": { ...token.typography.pageTitle },
        ".msfe-h2, .msfe-h3": { ...token.typography.sectionTitle },
        ".msfe-heading": { paddingBlock: token.spacing.inline },
        ".msfe-link": { color: token.color.text.accent },
        ".msfe-code-line, .msfe-inline-code": { ...token.typography.code },
        ".consumer-status": { verticalAlign: "middle" },
        "&.cm-focused": { outline: "none" },
      }),
      syntaxHighlighting(HighlightStyle.define([
        { tag: tags.heading, color: token.color.text.primary, fontWeight: "600" },
        { tag: tags.strong, fontWeight: "700" },
        { tag: tags.emphasis, fontStyle: "italic" },
        { tag: [tags.link, tags.url], color: token.color.text.accent },
        { tag: tags.processingInstruction, color: token.color.text.tertiary },
        { tag: tags.monospace, color: token.color.text.secondary },
      ])),
    ]);
  },
});

```

### shared/messages.ts

```ts
const chinese = {
  editor: "Markdown 示例编辑器", modes: "编辑器模式", source: "源码", live: "实时预览", locked: "只读",
  undo: "撤销", redo: "重做", reset: "重置", readMarkdown: "读取 Markdown", readingPreview: "阅读预览", insertStatus: "插入状态", done: "已完成", todo: "待处理",
  nativeEditor: "原生 DOM 示例编辑器", statusEditor: "自定义状态编辑器",
  initial: "## 把想法写下来\n\n在这里编辑 **Markdown**，也可以切换到源码。\n\n- [x] 保留 Markdown 原文\n- [ ] 试试修改这一行\n",
  nativeInitial: "## 原生 DOM 接入\n\n这个实例直接使用 `createMsfeEditor()`。\n",
  statusInitial: "## 自定义状态\n\n设计评审：[[todo]]\n\n点击状态按钮，然后读取 Markdown 或撤销。\n",
};
const english: { [Key in keyof typeof chinese]: typeof chinese[Key] } = {
  editor: "Markdown example editor", modes: "Editor mode", source: "Source", live: "Live", locked: "Locked",
  undo: "Undo", redo: "Redo", reset: "Reset", readMarkdown: "Read Markdown", readingPreview: "Reading preview", insertStatus: "Insert status", done: "Done", todo: "Pending",
  nativeEditor: "Native DOM example editor", statusEditor: "Custom status editor",
  initial: "## Write your ideas\n\nEdit **Markdown** here, or switch to source.\n\n- [x] Preserve the Markdown source\n- [ ] Try editing this line\n",
  nativeInitial: "## Native DOM integration\n\nThis instance uses `createMsfeEditor()` directly.\n",
  statusInitial: "## Custom status\n\nDesign review: [[todo]]\n\nClick the status, then read the Markdown or undo.\n",
};
export const exampleMessages = { "zh-CN": chinese, en: english };

```

### shared/locale.ts

```ts
import type { ComputedRef, InjectionKey } from "vue";
import type { ExampleLocale } from "./types";

/** Host UI language shared through the existing Vue provider tree, never stored in Markdown. */
export const exampleLocaleKey: InjectionKey<ComputedRef<ExampleLocale>> = Symbol("example-locale");

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

### plugins/extension.ts

```ts
import { Mark, defineSource } from "@quirekit/msfe-core";
import { EditorView } from "@quirekit/cm-view";
import { themeVariableReferences as token } from "@quirekit/ui";
import { badgeSyntax } from "./syntax";
/** Source-native plugin: parser recognition and visual treatment are separate hooks. */
export const Badge = Mark.create({ name: "exampleBadge",
  /** @returns Incremental parser syntax, not markdown-it renderer callbacks. */
  addMarkdownExtensions() { return [badgeSyntax]; },
  /** @returns A semantic reader; no editable view or mutation command is needed. */
  addSource() { return defineSource({ nodes: ["ExampleBadge"],
    /** @param document - Canonical text. @param node - Confirmed token. @returns The literal label. */
    read(document, node) { return { label: document.slice(node.from + 7, node.to - 2) }; },
    /** @returns No field writes are offered by this read-only visual extension. */
    patch() { return null; },
  }); },
  /** @returns Viewport-bounded mark descriptions; this plugin intentionally keeps delimiters visible. */
  addProjectionLayers() { const name = this.name; return [{ key: "exampleBadge.color", placement: "inline",
    /** @param input - Current visible source. @returns Highlight ranges for confirmed badges. */
    build(input) { return input.query(name).matches.map(match => ({ type: "mark" as const, from: match.from, to: match.to, class: "example-badge" })); },
  }]; },
  /** @returns Theme-scoped colors from the existing design system. */
  addCodeMirrorExtensions() { return EditorView.theme({ ".example-badge": { color: token.color.text.accent, backgroundColor: token.color.surface.subtle, borderRadius: token.radius.control } }); },
});

```

### plugins/syntax.ts

```ts
import type { MarkdownExtension } from "@quirekit/lezer-markdown";
/** A bounded single-line :badge[label]: grammar; it deliberately does not allow nested brackets. */
export const badgeSyntax: MarkdownExtension = {
  defineNodes: ["ExampleBadge"],
  parseInline: [{ name: "ExampleBadge", before: "Emphasis",
    /** @param context - Lezer's current inline context. @param next - Current character code. @param position - Absolute UTF-16 offset. @returns Consumed end or -1 to preserve ordinary Markdown. */
    parse(context, next, position) {
      if (next !== 58 || context.slice(position, position + 7) !== ":badge[") return -1;
      const tail = context.slice(position + 7, Math.min(context.end, position + 89));
      const match = /^([^\[\]\n]{1,80})\]:/.exec(tail);
      return match ? context.addElement(context.elt("ExampleBadge", position, position + 7 + match[0].length)) : -1;
    },
  }],
};

```

## 1. 贡献语法

依赖 `@quirekit/lezer-markdown`，使用 `MarkdownExtension`。`plugins/syntax.ts` 的 `defineNodes` 注册 `ExampleBadge`，`parseInline` 在看到 `:` 时先检查精确前缀，再在最多 82 个后续单元内寻找合法结束。

`context.elt()` 创建与规范源码同坐标的语法元素，`addElement()` 返回消费到的位置；不匹配返回 `-1`。`before` / `after` 是解析规则顺序，不是视觉优先级。这个例子有意不支持嵌套；更复杂语法必须定义明确的块/行内规则和回退。

简单的、不嵌套的单行字面分隔符可以直接使用 `defineSource({ syntax })`，参考 [状态节点](/extensions/custom-node)。不要为了简单 token 额外维护一个全文件解析器。

## 2. 接入语义查询

`addSource()` 将已确认的 `ExampleBadge` 解释为 `{ label }`。`read()` 只读匹配范围；需要可编辑字段时再增加 `addAttributes()` 的验证与 `patch()`。语义层不需要渲染成 HTML。

~~~ts
const query = editor.querySource('exampleBadge')
if (query.status === 'ready') {
  for (const match of query.matches) console.log(match.from, match.to, match.attrs)
}
~~~

查询默认针对当前选区，可传显式有界范围。`pending` 表示相关解析还未确认，不能误判为文档里不存在语法；`unsupported` 表示该语义入口不可用。需要解析推进时使用 `requestMarkdownParse(editor.state)`，再通过接受状态的通知重试，不在输入路径同步解析全文。

## 3. 选择视觉与写回

示例 inline 层只返回 `mark`，并用 `EditorView.theme()` 设置颜色，不隐藏分隔符。需要点击控件时使用 NodeView；需要跨行结构替换时使用 block 层。只做显示的插件不要附带输入转换，避免加载插件时悄悄改文档。

## 迁移一个 markdown-it 插件

1. 列出它的语法、输入输出、是否有块嵌套、转义和引用规则。
2. 用 Lezer 语法保证不完整输入、代码区域和嵌套上下文的行为。
3. 用源码 codec 描述字段；没有可逆编码的字段先保持只读。
4. 通过投影或组件提供视觉结果；HTML 输出另由导出管线实现。
5. 为每项能力建立源码 fixture：原样读取、局部写回、撤销、加载/错误、只读及解析未完成。

很多常用能力已经有内置扩展，不需要移植同名插件。先查 [功能与安装](/features/catalog)，再看 [输入规则](/extensions/input-rules) 和 [投影层](/extensions/projections)。
