MSFE: A Source-First Markdown Editor Framework That Stops Format Drift
In the AI era, everyone is competing on note-taking apps: AI writing, AI notes, AI knowledge bases, each more dazzling than the last.
But if you open up the foundation, everyone is still using the same few pieces, and hitting the same old problems: content stored as JSON, rewritten when converting back and forth to Markdown; WYSIWYG and source mode as two separate editors, losing something each time you switch; and once collaboration is added, the structure starts misbehaving. No matter how fast AI generates, it still loses formatting and misplaces things when it lands in these frameworks.
I've been building ProseMirror-related products for three years, and I've stepped on most of these pits. This time I don't want to make another editor app; I want to get the editor framework right first. This article covers one line of that: MSFE (Markdown Source-First Editor), a source-first Markdown editor framework.
Here's the finished product, ready to use: https://msfe.quirekit.com
One: What Others Do
Let's look at what's under the hood of popular note-taking apps and editors from recent years. For open-source ones, I read the repository code; for closed-source, I only use official docs and engineering blogs; anything official didn't state is marked "undisclosed" (as of October 2026):
| Product | Editing method | What is edited | Editor foundation | Where content lives | Open source |
|---|---|---|---|---|---|
| Notion | Block editing (rich text) | Block data; Markdown only converted on import/export | Proprietary | Server-side database | No |
| Obsidian | Live preview: renders directly in source; also source mode, reading mode | Markdown source | CodeMirror 6 | Local Markdown files | No |
| Feishu Docs | Block editing (rich text) | Block tree; Markdown only on import/export | Proprietary | Cloud | No |
| SiYuan Notes | Block editing (rich text), can input Markdown syntax | Node tree; Markdown only on import/export | Proprietary Protyle | Per-note .sy file, node tree JSON | Yes |
| Typora | WYSIWYG, also source mode | Undisclosed | Proprietary, kernel undisclosed | Local Markdown files | No |
| AFFiNE | Block editing (rich text) + whiteboard | Block data | BlockSuite | Yjs | Yes |
| Logseq | Outline block editing: current block shows source, others render | Markdown source of current block | Each block a textarea | File version: Markdown/Org; DB version (Beta): SQLite | Yes |
| AppFlowy | Block editing (rich text) | Block data | Proprietary Flutter editor | yrs (Rust Yjs), local RocksDB | Yes |
| Memos | Short notes, block editing | Block structure, in/out as Markdown | Proprietary | Markdown text in database | Yes |
| MarkText | WYSIWYG + source mode, two sets | WYSIWYG mode edits rendered doc, saves back via turndown | Proprietary Muya + CodeMirror 5 | Local Markdown files | Yes |
| MiaoYan | Source editing + syntax highlighting + interface preview | Markdown source | System native text box + WebView preview | Local Markdown files | Yes |
| NoteGen | Rich text + source mode, two sets | Rich text mode edits Tiptap document tree, source mode edits source | Tiptap 3 + CodeMirror 6 | Local Markdown files | Yes |
| Blinko | Short notes, Vditor editor (supports WYSIWYG, instant render, split preview) | Depends on Vditor mode | Vditor | Markdown text in PostgreSQL | Yes |
| Flowix | Rich text + source mode, two sets | Rich text mode edits Tiptap document tree, converts to Markdown on save; source mode edits source | Tiptap 3 + CodeMirror 6 | Local Markdown files, plus SQLite index | Yes |
| Joplin (desktop) | Rich text + source, two sets | Rich text mode edits TinyMCE document, converts on switching | TinyMCE + CodeMirror | Markdown in SQLite | Yes |
The above only collects public information for comparing technical approaches, not judging product quality. Every product's trade-offs suit its use case.
By "what is edited," they roughly fall into three categories:
- Block editing / rich text: Notion, Feishu, SiYuan, AFFiNE, AppFlowy. They edit block data or document trees, with an experience closest to Word and Google Docs. Markdown is only an import/export format.
- Source school: Obsidian, MiaoYan, Logseq. They always edit the Markdown source, which is never rewritten. But they either stay at "source + highlighting + preview" or do "live preview," where marker symbols show through to some extent, falling short of true rich text.
- Dual editors: MarkText, NoteGen, Flowix, Joplin. One rich text editor plus one source editor, converting between them on switch. Rich text experience is there, but every switch and save runs a "document tree → Markdown" conversion.
Many products that emphasize local, offline storage keep content in a database or JSON, using Markdown mainly for import/export.
Two: Main Problem: Conversion Only Works as Specified, Your Source Is Not Preserved
The rich text school needs to read and write Markdown, so they have to convert back and forth between ProseMirror nodes, marks, and Markdown source. The problem is that this conversion only works "as specified."
Look at the most commonly used implementations (all from reading source):
prosemirror-markdown 1.13.4
- Italics and bold are hardcoded to
*and**; your_italics_and__bold__change on first save. - Unordered lists default to
*, losing your-. - Ordered lists are renumbered;
1. 1. 1.becomes1. 2. 3.after saving. - Line breaks via two trailing spaces are uniformly changed to
\. *,[,],~in text always get backslash escapes.- Unrecognized syntax throws an error. The default schema lacks tables, footnotes, strikethrough.
Tiptap's official @tiptap/markdown (3.30)
- Bold and italics are similarly hardcoded; unordered lists always output
-; ordered lists get renumbered. - Nodes without a corresponding renderer output an empty string, silently dropping content.
- The official docs themselves say: this is still an early version, annotations unsupported, and table cells can only hold one child node.
Community tiptap-markdown is no longer maintained. For nodes it can't express, it outputs a [nodeName] placeholder; tables with merged cells degrade to HTML, losing column alignment.
These aren't bugs; they're the inevitable result of that architecture: the JSON tree is the authoritative document, and Markdown is just one "print format" for it. With only one print format, the source you wrote is already gone after the first save.
For a single person writing notes, it might not matter. But it's critical in these scenarios:
- The document is in Git; changing one word yields a diff full of formatting changes.
- AI only edits one paragraph, but the entire document's style gets "normalized."
- The same file also needs to work in VS Code, static sites, and other Markdown tools.
Three: "Markdown Can't Do Complex Blocks" Is a Big Misconception
A common claim is that Markdown can't express complex structures like multi-dimensional tables and database views, so it must be stored as JSON. Logseq's co-founder also mentioned in 2024 why they made a database version, citing limited structured data support in Markdown, lacking persistent IDs, timestamps, and such.
Persistent IDs are indeed a real issue, discussed later. But "can't do complex blocks" is wrong.
Markdown already has a very useful container: code fences. In MSFE, multi-dimensional tables, flowcharts, mind maps, and whiteboards are stored as a code fence, with data inside that can be opened and edited repeatedly:
```quirekit-data-view
{ ...table fields, records, views... }
```
- In MSFE, it displays as an editable multi-dimensional table.
- In GitHub, VS Code, or any ordinary Markdown tool, it's just a code block, won't break, and won't lose data.
- The user clicks "OK" and the new data is written back to the same fence. Other lines don't change a single byte.
The complex-block problem isn't with Markdown; it's about designing a good landing spot for it.
Four: Where Markdown Really Limits
It's also dishonest to say Markdown can do everything. Its real limits are in the syntax itself, which I tested line by line with micromark 4.0.2 plus GFM extensions:
1. Table cells only accept inline content. Want a list or a code block inside a cell? The table breaks immediately:
| A | B |
| --- | --- |
| - 一
- 二 | x |
Parsing result: the first line becomes - 一 and an empty cell, - 二 | x | gets pushed outside the table, becoming a list item outside it. Putting a code block in a cell also messes up the whole table. To break lines inside a cell, you have to write <br>.
2. Formatting can't nest arbitrarily.
- Inline code can't contain bold:
`**加粗**`renders as the literal asterisks. - Links can't nest:
[外层 [内层](b) 文字](a)– the outer link becomes invalid, reverting to plain text. - Underline, text color, highlight, font size simply don't exist in Markdown; only HTML. Inline
<u>**粗**</u>works fine, both HTML and Markdown take effect; but as an HTML block like<div align="center">with**居中加粗**inside, the Markdown no longer parses and shows literal asterisks.
3. Chinese punctuation breaks bold, the pitfall that hurts Chinese users most.
这是**“加粗”**后面的字
注意**(重要)**这里
Neither line bolds; they render as literal asterisks. Reason: CommonMark decides whether ** can start or end emphasis by checking if either side is punctuation or whitespace. Chinese quotes and parentheses are punctuation, and they're immediately followed by a Han character, so the rule disallows it. English writing almost never hits this; Chinese writing hits it daily.
4. Some things simply don't exist in the syntax:
- Tables have no merged cells, no column width, no cell background color.
- Paragraphs have no alignment, indentation, line spacing, space before/after.
- Blocks have no stable IDs; "quote paragraph 3" fails once the paragraph moves.
- Annotations and revisions have nowhere to hang, no anchor points.
- Too many dialects: GFM, various extensions, private syntaxes from each software; the same file renders differently across tools.
So my stance:
- What syntax can't express, I don't pretend to express. MSFE leaves unknown syntax untouched in the source, neither losing nor altering it.
- Complex objects go into code fences as data.
- Layout needs like page, font, paragraph styles are handled by export templates and paginated documents, not crammed into Markdown.
Five: Dual Editors Are Heavy and Disrespect the Source
The Markdown school wanting WYSIWYG often goes the most common route: a rich text editor plus a source editor, converting between them on switch.
Joplin desktop uses TinyMCE plus CodeMirror. Its official help page is honest, listing the problems you get editing Markdown notes in the rich text editor: most plugin formatting is lost; tables missing a header get a blank one added; checkboxes can't mix with regular list items in the same list; HTML in Markdown can be lost; reference-style links get converted to inline.
MarkText uses its own Muya plus CodeMirror 5, reading via marked to HTML and writing via turndown back to Markdown.
Dual editors mean:
- Double the bundle size and memory, two sets of keyboard shortcuts, two undo histories, two plugin systems.
- Every view switch does a "Markdown → tree → Markdown" pass, exactly the lossy path from section two.
- Cursor, selection, and scroll position don't line up between the two, so you have to find your place again after switching.
Six: Collaboration: ProseMirror's Yjs Collaboration Has More Pits Than Expected
The rich text school for collaboration basically goes with Yjs plus y-prosemirror (Tiptap uses the same). We tested it carefully while building Word-level documents and went through upstream docs and issues:
Upstream acknowledged problems (y-prosemirror's CAVEATS and issues):
- When structure is invalid, it deletes nodes. Two people editing concurrently can produce a structure that doesn't match the schema, e.g., a paragraph inside a blockquote gets deleted by both. The bound process deletes the entire parent node, especially hitting users who've been offline long.
- Every remote update replaces the whole document. The author's words in the issue: "y-prosemirror replaces the complete document with every change." As a result, transaction position mapping fails, and local decorations and plugin state must be rebuilt using relative positions.
- Splitting, merging, and lifting paragraphs have no native representation. Splitting in Yjs is "delete the tail of the original paragraph, then insert a new node," which can shift semantics during concurrency.
- Undo has to be replaced. You can't use ProseMirror's built-in history; you must use Yjs's UndoManager. Tiptap docs also require disabling undo/redo in StarterKit when collaboration is on.
- Restoring selection on remote updates may throw, causing this update to be skipped on the local side, and the two states diverge (v1.3.7, issue still open).
- v1 and v2 data formats are incompatible; old and new clients can't edit the same document simultaneously.
Our own tests (Yjs 13.6.33 + y-prosemirror 1.3.7, Tiptap's y-tiptap 3.0.9, Yjs 14 pre-release, all three give the same results):
| Two people doing at the same time | Result |
|---|---|
| A splits a paragraph into two, B inserts text into the original second half | B's text moves to the end of the first half |
| A splits, B simultaneously bolds the second half | The bold is lost |
| A splits, B simultaneously deletes text from the second half | The deleted text comes back |
| A merges two paragraphs, B simultaneously inserts into the second paragraph | B's insertion disappears |
These aren't deployment issues; changing servers or adding more machines won't fix them. The root lies in "how to map a tree document to a CRDT."
Why MSFE's collaboration doesn't have these problems: MSFE collaborates on the Markdown source itself, a Yjs text. Any piece of text is valid Markdown, so there's no "invalid structure requiring deletion." Structure is reparsed from the text, so there's no "splitting/merging without representation." When two people edit the same spot, the result may need a human look, e.g., if they turn the same table row into different forms, but content won't vanish because of structural conflicts. Undo is collaborative undo, only reverting your own changes.
Seven: Why You Must Respect the Source
In the end, one sentence: the Markdown you wrote is the document itself.
- Git-friendly: change one word, the diff is one word.
- AI-friendly: LLMs read and write Markdown anyway; they only touch the paragraph they edit, not normalize the whole document along the way.
- Tool freedom: the same file can be edited in VS Code, rendered in a static site, or processed by other tools, not locked into any single editor.
- Long-term readable: ten years later, it's still plain text.
Eight: CodeMirror Can Also Do "Rich Text"
Many people think CodeMirror can only make source editors and you need ProseMirror for WYSIWYG. Actually, CodeMirror 6's decorations and widgets are enough to make it render "looks like rich text": headings become larger, marker symbols get hidden, tables render as tables, formulas render as formulas, and data in code fences renders as flowcharts and multi-dimensional tables. But the underlying text hasn't changed a single character.
MSFE itself offers three editable presentations: document view (marker symbols all hidden, looks like rich text), live preview (where you're editing, Markdown markers show up), and source view. They are different presentations on the same CodeMirror instance, not three editors. Switching does no conversion; selection and undo history are retained, and it's always the same Markdown being edited.
QuireKit's MD Editor is the finished product assembled on top of MSFE: add toolbar, slash menu, and a read-only reading view to render the same source.
Document view: all markers hidden, looks rich.
Live preview: where the cursor lives, that line shows Markdown source, e.g., here the formula line becomes LaTeX, other places render normally.
Source view: the same content, this is that Markdown.
Source-first also comes at a cost: every input must update the syntax tree, and large files get slow.
I've run into this problem before. I previously made an open-source project, Incremark, specifically to handle incremental rendering of Markdown from AI streaming output: each token the model emits doesn't reparse the whole document. When doing MSFE, I realized that the editor's incremental parsing and streaming rendering are the same problem, and it can be solved more thoroughly at the parser level. So I directly modified Lezer's incremental parsing and rewrote a new version of Incremark on top of it. Now MSFE editing, MD Editor's reading view, and AI streaming reply display all use the same parsing. The backstory of Incremark, and why I abandoned the first version, merits a separate article later.
The specific optimization is adding "reuse proof" to Lezer's incremental Markdown parsing: the parser records which ranges were reused as-is, and the editor uses that to skip the unchanged parts directly. For a 1 MiB document with 20 consecutive inputs:
| Work | Before optimization | After optimization |
|---|---|---|
| Syntax tree visits | 107,760 | 1,380 (98.7% less) |
| Syntax tree comparisons | 153,940 | 660 (99.6% less) |
Where the proof doesn't hold, it falls back to full comparison, so correctness is unaffected.
Nine: Why Package It as a Framework
Everything above could be done as a single editor app. I made it a framework because these problems face anyone building an editor; nobody should have to step on the same traps again.
MSFE's API is Tiptap-style: install extensions to edit what you want:
import { createMsfeEditor } from '@quirekit/msfe-core'
import { StarterKit } from '@quirekit/msfe-starter-kit'
const editor = createMsfeEditor({
parent,
documentId: 'note-1',
markdown: '# Hello\n\n**Markdown** stays the source.\n',
mode: 'live',
extensions: [StarterKit],
})
editor.getMarkdown() // always that source
- The core doesn't depend on Vue or React; Vue and React components are also provided.
- Ready-made extensions: headings, bold, links, images, code blocks, formulas and Mermaid-like extension syntax, HTML and HTML sandbox, flowcharts, mind maps, whiteboards, multi-dimensional tables.
- Collaboration (including Hocuspocus server) is also an extension.
These are all MIT open source. AI reading/writing documents, presenting changes as "pending review," and annotations are commercial components, free to develop and evaluate.
Ten: MD Editor and the Surrounding Ecosystem
If you don't want to assemble it yourself, just use MD Editor: toolbar, four views, slash menu, tables, formulas, charts all set up, and you can put it into a Vue project in a few lines.
<script setup lang="ts">
import { MdEditor } from '@quirekit/md-editor/vue'
import '@quirekit/md-editor/styles.css'
</script>
<template>
<MdEditor markdown="# Hello" label="Document" />
</template>
All surrounding editors are separate packages: formula editor (LaTeX to Word formula conversion), flowcharts, mind maps, whiteboards, Mermaid / PlantUML / Graphviz text drawing, multi-dimensional tables, and streaming Markdown rendering (Incremark) for AI incremental display. All UIs come from the same component library, with one-click light/dark switching.
Eleven: Professional Use: Word and PDF, Two Solutions
After writing Markdown, someone always needs Word, needs PDF. Here are two solutions.
Open-source solution (@quirekit/md-editor-basic-export, MIT): uses mature open-source libraries. Markdown to Word uses md-to-docx, Word to Markdown uses Mammoth plus Turndown, PDF uses pdfmake, all in-browser. MSFE-specific content degrades: flowcharts, mind maps, whiteboards export as preview images, other embedded blocks become placeholders. Every degradation is written into the export report, nothing silently lost.
Commercial solution: doesn't go the "Markdown to HTML to Word" path; instead converts Markdown into the same document model as our paginated document editor, Classic, and then Classic's typesetting engine outputs Word, PDF, and OFD:
- Templates: page, font, paragraph styles become export templates; the same Markdown can export with company templates, thesis templates, or official document templates.
- Formulas are native Word formulas, not images.
- Custom blocks roundtrip: flowcharts and mind maps display as images in Word while carrying the raw data into the file. Re-imported to Markdown, they restore as editable blocks.
- Loss is stated: each conversion gives a report; in strict mode, any loss means refusal.
- Interop with rich text: because it's the same model and layout, Markdown-exported PDF and OFD look exactly like what you see in the paginated document editor. Exported Word can also be page-edited in Classic.
Also: Classic
Classic is the paginated document editor mentioned above, another line of QuireKit: based on ProseMirror, with its own typesetting, truly paginated, drawn on Canvas or SVG. It solves a different class of problems, deserving its own article later. Its homepage shows it first, and you can try it directly: https://quirekit.com
Finally
QuireKit isn't a single editor, but a whole ecosystem: two Tiptap-style editor frameworks (the ProseMirror line and this CodeMirror line, MSFE), with paginated documents, block editors, Markdown editors, and a bunch of surrounding editors on top, sharing one UI component library. The open-source part is all MIT, and closed-source commercial use is allowed.
It's still alpha, the API will change. Welcome to try it and poke holes:
- MSFE: https://msfe.quirekit.com
- Online demo: https://quirekit.com
- Docs: https://docs.quirekit.com
- GitHub: https://github.com/quirekit/quirekit
If you think this direction is interesting, a Star on GitHub is the biggest support. And I'd love to hear: which pit got you stuck the longest when building a Markdown editor?