# Custom nodes

Follow a clickable status token through syntax, fields, commands, and component integration.

## Try a field update

Click Pending to change it to Done; the reading preview updates below. Read the Markdown to confirm that only the value inside `[[todo]]` changed. Undo and redo update both views. Locked mode prevents field changes.

### status/editor.vue

```vue
<script setup lang="ts">
import { computed, h, provide, ref, shallowRef } from "vue";
import { useMsfeEditor, useMsfeState, VueNodeViewHost, VueNodeViewRenderer } from "@quirekit/msfe-vue";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { MdProse, withMdReading } from "@quirekit/md-editor/vue";
import { QuireButton, QuireSegmented, QuireProseSource } from "@quirekit/ui/vue";
import { Appearance } from "../shared/appearance";
import { Status } from "./status";
import StatusView from "./status-view.vue";
import type { StatusAttributes } from "./types";
import { exampleMessages } from "../shared/messages";
import { exampleLocaleKey } from "../shared/locale";
import type { ExampleLocale } from "../shared/types";

const props = withDefaults(defineProps<{ /** UI language supplied by the application. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const text = computed(() => exampleMessages[props.locale]);
// Node views retain this provider without changing the persisted status values.
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 }]);
// The application replaces presentation while inheriting grammar, validation and commands.
const StatusWithUI = withMdReading<StatusAttributes>(Status.extend({
  /** @returns A Vue renderer under the application's existing provider tree. */
  addNodeView() { return VueNodeViewRenderer(StatusView); },
}), {
  /** @param context - The same validated fields, with reading locale and no editing actions. @returns Semantic readonly content. */
  render(context) { return h("strong", exampleMessages[context.locale][context.attrs.value]); },
});
const { editor, host } = useMsfeEditor(parent, () => ({
  markdown: text.value.statusInitial,
  mode: "live", label: text.value.statusEditor, extensions: [StarterKit, StatusWithUI, Appearance],
}));
// Share the accepted snapshot; the Vue adapter owns subscription cleanup.
const state = useMsfeState(editor);
/** @param value - Requested mode; all modes retain the same source and history. */
function changeMode(value: string | null | undefined): void {
  if (value === "live" || value === "source" || value === "locked") { mode.value = value; editor.value?.setMode(value); }
}
/** Inserts through the same public command available to keyboard and menu consumers. */
function insert(): void { if (editor.value?.commands.insertStatus("todo")) editor.value.focus(); }
</script>
<template>
  <div class="grid gap-4 p-qk-surface">
    <div class="flex flex-wrap gap-2">
      <QuireSegmented :model-value="mode" :items="items" :label="text.modes" size="sm" @update:model-value="changeMode" />
      <QuireButton size="sm" variant="outline" :disabled="mode === 'locked'" @click="insert">{{ text.insertStatus }}</QuireButton>
      <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.undo()">{{ text.undo }}</QuireButton>
      <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.redo()">{{ text.redo }}</QuireButton>
      <QuireButton size="sm" variant="ghost" @click="snapshot = editor?.getMarkdown()">{{ text.readMarkdown }}</QuireButton>
    </div>
    <div ref="parent" class="overflow-hidden rounded-qk-control border border-qk-border-subtle" />
    <VueNodeViewHost :host="host" />
    <section v-if="state" :aria-label="text.readingPreview" class="rounded-qk-control border border-qk-border-subtle p-qk-surface">
      <p class="mb-3 text-sm text-qk-text-muted">{{ text.readingPreview }}</p>
      <MdProse :state="state" :locale="locale" />
    </section>
    <QuireProseSource v-if="snapshot !== undefined" :code="snapshot" language="markdown" filename="status.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 },
      ])),
    ]);
  },
});

```

### status/status.ts

```ts
import { Node, defineSource } from "@quirekit/msfe-core";
import type { StatusAttributes, StatusValue } from "./types";

/** @param value - Source or requested attribute. @returns Whether it has an exact source spelling. */
function validStatus(value: unknown): value is StatusValue { return value === "todo" || value === "done"; }

/** Grammar, fields and commands are independent of the consumer's component framework. */
export const Status = Node.create({ name: "status",
  /** @returns The one admitted, persistable field. */
  addAttributes() { return { value: { validate: validStatus } }; },
  /** @returns Bounded parsing and exact field updates, without a second document. */
  addSource() {
    return defineSource<StatusAttributes>({ nodes: ["StatusToken"], syntax: { name: "StatusToken", open: "[[", close: "]]" }, placement: "replace",
      /** @param document - Canonical Markdown. @param node - Confirmed token. @returns Attributes or a source fallback. */
      read(document, node) {
        const value = document.slice(node.from + 2, node.to - 2);
        return validStatus(value) ? { value } : null;
      },
      /** @param _document - Canonical source. @param node - Current valid range. @param patch - Requested fields. @returns A precise patch or refusal. */
      patch(_document, node, patch) {
        return validStatus(patch.value) ? { from: node.from + 2, to: node.to - 2, insert: patch.value } : null;
      },
    });
  },
  /** @returns Typed commands sharing the editor's transaction and history. */
  addCommands() { return { insertStatus: (value: StatusValue) => ({ tr }) => validStatus(value) && tr.insertText(`[[${value}]]`) }; },
});

```

### status/types.ts

```ts
/** Source-persisted value for the custom status syntax. */
export type StatusValue = "todo" | "done";

/** Attributes decoded from the canonical Markdown token. */
export interface StatusAttributes {
  /** Current task status. */
  readonly value: StatusValue;
}

declare module "@quirekit/msfe-core" {
  interface Commands<ReturnType> {
    /** Commands installed by this consumer extension. */
    status: {
      /** @param value - Persisted status. @returns Command outcome. */
      insertStatus: (value: StatusValue) => ReturnType;
    };
  }
}

```

### status/status-view.vue

```vue
<script setup lang="ts">
import { computed, inject } from "vue";
import type { MsfeNodeViewProps } from "@quirekit/msfe-core";
import { QuireButton } from "@quirekit/ui/vue";
import type { StatusAttributes } from "./types";
import { exampleMessages } from "../shared/messages";
import { exampleLocaleKey } from "../shared/locale";
const locale = inject(exampleLocaleKey, computed(() => "zh-CN"));
const text = computed(() => exampleMessages[locale.value]);
const props = defineProps<{ /** Derived fields and guarded source actions. */ nodeView: MsfeNodeViewProps<StatusAttributes> }>();
/** Changes one field; the editor checks the live range, permission and history. */
function toggle(): void { props.nodeView.updateAttributes({ value: props.nodeView.attrs.value === "todo" ? "done" : "todo" }); }
</script>
<template><QuireButton class="consumer-status" size="sm" variant="outline" :disabled="nodeView.readOnly" @click="toggle">{{ nodeView.attrs.value === 'done' ? text.done : text.todo }}</QuireButton></template>

```

### 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";

```

## Define syntax and fields

`status.ts` uses `Node.create()` to declare semantics. `addAttributes()` admits the `value` field and validates its values. The literal-delimiter syntax in `defineSource()` is suitable for single-line, non-nested tokens.

`read(document, node)` reads only the confirmed range. Invalid values return `null` and keep the source visible. For complex or nested syntax, supply parsing rules through `addMarkdownExtensions()`.

## Write precise changes

`patch(document, node, patch)` returns source edits. This example preserves the surrounding brackets and replaces only the inner value. The editor handles field validation, read-only access, stale targets, and undo centrally.

### SourceDefinition

Parser-neutral semantic codec; rendering is optional and never owns persistence.

```ts
export interface SourceDefinition<Attrs extends object, Resource = unknown> {
    readonly nodes: readonly string[];
    readonly syntax?: InlineSyntax;
    readonly placement?: "replace" | "before";
    readonly showWhenActive?: boolean;
    readonly measureHeight?: boolean;
    readonly read: (document: SourceDocument, node: SourceNode) => Attrs | null;
    readonly source?: (document: SourceDocument, node: SourceNode) => string;
    readonly patch: (document: SourceDocument, node: SourceNode, patch: Partial<Attrs>) => SourceEdit | readonly SourceEdit[] | null;
    readonly load?: (attrs: Readonly<Attrs>, signal: AbortSignal) => MsfeNodeViewResource<Resource> | Promise<MsfeNodeViewResource<Resource>>;
    readonly renderer?: MsfeNodeViewRenderer;
}
```

**nodes** — Confirmed syntax kinds, or the inline syntax name when syntax is specified.

**syntax** — Optional simple syntax contributed by this feature.

**placement** — Single-line replacement or a non-editable control before source-owned content.

**showWhenActive** — Whether the control stays visible while source is selected.

**measureHeight** — Preserve a settled replacement's measured height offscreen, including error fallback; ordinary inline controls leave this disabled.

**read** — @param document - Canonical source.
@param node - Confirmed syntax.
@returns Source-derived semantic fields or fallback.

**source** — Optional bounded fingerprint; must cover all patchable fields.

**patch** — @param document - Canonical source.
@param node - Current syntax.
@param patch - Semantic fields.
@returns Exact source edits or refusal.

**load** — Optional abortable resource loader shared by all renderers.

**renderer** — Native renderer, optional when addNodeView supplies a component renderer.

## Add commands

`addCommands()` defines `insertStatus()`. Module augmentation in `types.ts` provides type information for `editor.commands`, `editor.can()`, and `editor.chain()`. Commands use the supplied `tr` instead of dispatching a separate transaction.

```ts
editor.commands.insertStatus('todo')
editor.can().insertStatus('done')
editor.chain().insertStatus('todo').insertText(' ').run()
```

## Replace the component

The application uses `.extend({ addNodeView() { ... } })` to install `VueNodeViewRenderer()`. `status-view.vue` receives `nodeView.attrs` and `updateAttributes()`, so it does not calculate source offsets itself.

The component tree must include `VueNodeViewHost`. When moving to React, retain the same syntax extension and use the corresponding renderer and host.

## Add static reading

`MdProse` accepts third-party reading contributions through `withMdReading(extension, definition)`.
Finish `configure()` / `extend()` first, then wrap and install the extension in the same editor.
The public entry is `@quirekit/md-editor/vue`; no changes to built-in syntax branches are needed.

```ts
import { h } from 'vue'
import { withMdReading } from '@quirekit/md-editor/vue'
import { Status } from './status'
import type { StatusAttributes } from './types'

const ReadableStatus = withMdReading<StatusAttributes>(Status, {
  render({ attrs }) {
    return h('strong', attrs.value === 'done' ? 'Done' : 'Pending')
  },
})
```

`render(context, resource)` receives attrs from the installed extension's original codec, sharing
its defaults and validation. Context also provides `name`, `node`, `document` and `locale`, without
editing actions. `renderInline(from?, to?)` and `renderBlocks(omitted?)` delegate children to the
shared reader. Inline bounds must stay inside the node without cutting a child syntax node;
invalid bounds trigger local source fallback.

Pass the original editor's immutable snapshot to `MdProse.state`. The example uses
`const state = useMsfeState(editor)` with automatic subscription cleanup; see
[Vue state subscriptions](/en/guide/vue#observe-editor-state-in-vue). No Markdown serialization runs
on each input. Selection and focus changes do not repeat decoding or loading. Reading depends on source, syntax,
installed contributions, presentation configuration and locale; codecs should not derive static
content from selection or mutable external state. Document changes may rebuild the reading snapshot
conservatively; this API does not promise incremental node updates.

## Reading resources and fallback

For IO, declare `load(context, signal)` returning `ResourceLease<Resource>` (`{ value, release? }`)
or a Promise of it. `render` runs when the resource is ready. Snapshot replacement and unmount abort
the signal. Late resources still call `release()` even if the loader ignores cancellation. Reading
and editing NodeViews own separate lifetimes; the adapter does not automatically reuse `addSource().load`.

Pending or failed loads retain exact source, including during server rendering. SSR never starts
loading; synchronous renderers can render on the server. Vue children inherit host providers, and
client-side child errors stay local to the node. Renderers are trusted host code; raw Markdown is
never executed as HTML.

Listen with `<MdProse @error="reportReadingError" />` for `MdReadingError`, including owner,
`read` / `load` / `render` / `dispose` phase, original error and UTF-16 bounds in the captured snapshot.
These diagnostic bounds are not safe asynchronous write targets. Without an error listener, the Vue
entry preserves visible source; hosts can add logging. Disposal failures are reported without
replacing successfully rendered new content.

| Scenario | Editing / source | Static reading |
| --- | --- | --- |
| Valid custom fields | Original patch makes precise, undoable changes | Same codec defaults and validation |
| Invalid fields, unknown or unclosed syntax | Original text retained | Built-in rendering or source when no contribution claims it |
| Tokens in inline or fenced code | Remain code text | Not parsed again as custom nodes |
| Multiple semantic candidates for one syntax | Existing installation order | First codec returning non-null owns the node |
| `render` returns undefined / null | Source unchanged | Undefined retains exact source; null explicitly produces no content |
| Pending/rejected loads or render errors | Source unchanged, surrounding blocks intact | Only the failing node falls back; errors can be observed |
| Locked mode | Field and history writes rejected | Same content remains readable |
| Missing source codec / duplicate reading owner | Installation explicitly rejected | No invalid reading contribution |
| React or Word/PDF/OFD export | Framework-neutral semantics retained | This adapter serves Vue reading; exports need separate conversion contributions |

## Lifecycle constraints

A node component may unmount when it leaves the viewport, reveals source, switches documents, or is destroyed. Write persistent fields back to source and choose suitable lifetimes for transient UI state; do not assume the component stays mounted forever.

## Read, patch and validation contracts

`addAttributes()` admits writable fields. Defaults and validation do not replace source encoding. `read()` returning null means the confirmed syntax is not a supported semantic form; preserve it rather than normalizing it.

A patch receives partial fields, not necessarily every attribute. Return one edit, disjoint edits or null. Replace only intended field ranges and preserve spacing, quote style and delimiters. Encode or reject values containing delimiter syntax instead of interpolating them blindly.

Unknown or invalid fields are rejected. For several source spellings, define reversible writes first. Unsupported writable forms may remain readable; do not claim editing merely because rendering works.

## Source fingerprints

`source(document, node)` may define a smaller exact fingerprint, but it must include every patchable field. A code-language control can fingerprint the header; a body editor cannot use that same narrow fingerprint.

Fingerprints prevent stale components from editing different content. They are not durable business IDs. Neither are viewport view IDs. Persist required identifiers as explicitly validated source fields.

## Asynchronous components

`defineSource<Attrs, Resource>()` can add `load(attrs, signal)`. The view receives loading/ready/error status and an optional resource. Keep source writes behind guarded actions such as `updateAttributes()`, `deleteNode()` and `revealSource()`. Read [Resources](/en/extensions/resources) before adding uploads or server queries.

## Choosing block behavior

This example's literal syntax is a single-line, non-nested token. The helper is unsuitable for arbitrary multiline syntax or nested containers. Use native Markdown body ownership for [containers](/en/extensions/containers), block layers for [diagrams](/en/extensions/diagrams), and explicit grammar for [parser plugins](/en/extensions/markdown-plugins).

## Completion criteria

Cover valid, invalid, unclosed and escaped forms; exact field writes; undo; readonly rejection; obsolete resource cleanup; and actions after unmount. Check component baseline, native selection, focus and both themes at narrow widths.
