# Markdown plugins

Connect new syntax to incremental parsing, semantic queries and source presentation.

## Distinguish three plugin responsibilities

| Capability | MSFE entry | Mutates source? |
| --- | --- | --- |
| Recognize syntax | Lezer extensions through `addMarkdownExtensions()` | No |
| Render recognized syntax | `addSource()`, `addNodeView()`, `addProjectionLayers()` | Rendering itself does not |
| Normalize typed or pasted input | `InputRule`, `PasteRule` | Yes, through transactions and history |

markdown-it plugins consume a token stream, rule chain and HTML renderer. MSFE uses Lezer's incremental tree. A plugin intended for `md.use(plugin)` cannot be installed directly as an MSFE extension. A third-party renderer may run inside an isolated non-editable preview, but source ranges, parsing and writes still need integration.

## A working parser plugin

### Optional document properties

`Frontmatter` recognizes source only. Explicitly install `DocumentProperties` to display a leading YAML
header as a property table. MD Editor does not install it by default. Vue consumers can reuse the public form:

~~~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: 'en',
  defaultCollapsed: true,
})]
const markdown = '---\ntitle: Example\ntags: [writing, editing]\n---\n\nBody'
</script>
<template><MdEditor :markdown="markdown" :extensions="extensions" /></template>
~~~

MD Editor includes `Frontmatter`; direct MSFE Core consumers must install it alongside `DocumentProperties`.
Omitting `render` uses a native property display and a source entry point.

`setDocumentPropertiesEnabled(editor, false)` restores the literal Frontmatter; `true` enables the table again.
It preserves the instance, undo history, body and selection. Read the applied preference with
`documentPropertiesEnabled(editor.state)`. The example page uses this public API for its Document properties switch.

`defaultCollapsed: true` initially shows only the heading and count, keeping the document body prominent.
Omit it or use `false` to start expanded. Both the Vue panel and native renderer honor this setting.
Manual choices survive edits, mode changes, projection toggles and viewport remounts within the same document.
`replaceDocument` reapplies the default for the next document. `enabled: false` shows literal YAML instead
of a collapsed property panel. Hosts can persist a preference across sessions and pass it as the next default.

Read the current state with `documentPropertiesCollapsed(editor.state)`. Call
`setDocumentPropertiesCollapsed(editor, true)` to collapse or pass `false` to expand. Custom renderers use
`view.collapsed` and `view.setCollapsed()`. Readonly mode still allows folding without changing edit permissions.

`MdDocumentProperties` initially previews six fields; long text and tag lists
expand individually. Click a value to edit it with confirmation and cancellation on the same row. Enter saves
a single-line field, Escape cancels, and ⌘/Ctrl + Enter saves multiline fields. Collapsing retains unsaved drafts;
expansion state is never written to Markdown.

Strings, numbers, booleans and string lists are editable through source transactions and undo. Other values
remain literal. Invalid YAML, duplicate keys, aliases, anchors, explicit tags, flow or indented root maps,
explicit or multiline keys, TOML, unclosed headers and headers over 20,000 UTF-16 units fall back to source.
New property names are always encoded as string keys. Edits replace only the target range, retaining other fields,
comments and blank lines. Obsolete forms are rejected after a document or header change. Source mode always
shows the original header; readonly editors expose no modification controls.

### A custom badge

The example recognizes `:badge[label]:`, retains delimiters and adds theme color. Unclosed, oversized or nested-bracket forms remain ordinary source. The same spelling inside a code span is not a badge.

### 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. Contribute syntax

Depend on `@quirekit/lezer-markdown` and return a `MarkdownExtension`. `plugins/syntax.ts` registers `ExampleBadge` through `defineNodes`. Its inline parser checks the exact prefix at a colon and examines at most 82 following units for the closing form.

`context.elt()` uses canonical source coordinates, and `addElement()` returns the consumed endpoint. Return `-1` when not applicable. `before` / `after` controls parser ordering, not visual priority. Nesting is intentionally unsupported here. More complex syntax needs explicit block/inline rules and fallback behavior.

For simple, non-nested, single-line literal delimiters, use `defineSource({ syntax })` as in the [status node](/en/extensions/custom-node). Avoid maintaining a second whole-document parser for a simple token.

## 2. Add semantic queries

`addSource()` reads confirmed `ExampleBadge` nodes as `{ label }`. Read only the matched range. Add attribute validation and a precise patch if fields become editable. The semantic layer does not need HTML rendering.

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

Queries default to the current selections or accept an explicit bounded range. `pending` means parsing is unconfirmed, not that syntax is absent; `unsupported` means the semantic entry is unavailable. Use `requestMarkdownParse(editor.state)` to register demand, then retry on accepted-state notification. Do not synchronously parse the whole document during input.

## 3. Choose presentation and editing

The example's inline layer returns marks and uses a theme. It does not hide delimiters. Use a NodeView for interactive controls or a block layer for multiline replacements. A display plugin should not silently normalize source when loaded.

## Porting a markdown-it plugin

1. Describe syntax, input/output, nesting, escapes and references.
2. Implement Lezer handling for incomplete input, code contexts and nesting.
3. Describe fields in a source codec; keep fields readonly until exact encoding exists.
4. Render through projections or components; implement HTML export independently.
5. Create source fixtures for unchanged reads, precise writes, undo, failures, readonly and incomplete parsing.

Many common plugins already have built-in equivalents. Check [Features and installation](/en/features/catalog), then [Input rules](/en/extensions/input-rules) and [Projection layers](/en/extensions/projections).
