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.
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.
@quirekit/msfe-coreParser-neutral semantic codec; rendering is optional and never owns persistence.
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;
}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.
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.
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. 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 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, block layers for diagrams, and explicit grammar for parser 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.