Markdown
ProseKit doesn't ship a Markdown serializer because different teams want different Markdown flavors, and ProseMirror's HTML representation is already lossless for the document tree. The recommended path is to serialize to HTML first, then convert HTML ↔ Markdown with remark and rehype.
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/react'
import { useCallback, useMemo, useState } from 'react'
import { htmlFromMarkdown, markdownFromHTML } from './markdown'
export default function Editor() {
// A list of saved documents, stored as Markdown strings
const [records, setRecords] = useState<string[]>([])
// Whether there are unsaved changes
const [hasUnsavedChange, setHasUnsavedChange] = useState(false)
// A key to force a re-render of the editor
const [key, setKey] = useState(1)
const editor = useMemo(() => {
const extension = defineBasicExtension()
return createEditor({ extension })
}, [])
const handleDocChange = useCallback(() => setHasUnsavedChange(true), [])
useDocChange(handleDocChange, { editor })
const handleSave = useCallback(() => {
const html = editor.getDocHTML()
const record = markdownFromHTML(html)
setRecords((prev) => [...prev, record])
setHasUnsavedChange(false)
}, [editor])
const handleLoad = useCallback(
(record: string) => {
const html = htmlFromMarkdown(record)
editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
setHasUnsavedChange(false)
setKey((prev) => prev + 1)
},
[editor],
)
return (
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<button
onClick={handleSave}
disabled={!hasUnsavedChange}
className="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
>
{hasUnsavedChange ? 'Save' : 'No changes to save'}
</button>
<ul className="border-b border-t border-solid text-sm">
{records.map((record, index) => (
<li key={index} className="m-1 flex gap-2">
<button
className="border border-solid bg-white px-2 py-1 text-black"
onClick={() => handleLoad(record)}
>
Load
</button>
<span className="flex-1 overflow-x-scroll p-2">
<pre>{record}</pre>
</span>
</li>
))}
</ul>
<ProseKit editor={editor} key={key}>
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</ProseKit>
</div>
)
}export { default as ExampleEditor } from './editor'import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export function markdownFromHTML(html: string): string {
return unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.processSync(html)
.toString()
}
export function htmlFromMarkdown(markdown: string): string {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkHtml)
.processSync(markdown)
.toString()
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useCallback, useMemo, useState } from 'preact/hooks'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/preact'
import { htmlFromMarkdown, markdownFromHTML } from './markdown'
export default function Editor() {
// A list of saved documents, stored as Markdown strings
const [records, setRecords] = useState<string[]>([])
// Whether there are unsaved changes
const [hasUnsavedChange, setHasUnsavedChange] = useState(false)
// A key to force a re-render of the editor
const [key, setKey] = useState(1)
const editor = useMemo(() => {
const extension = defineBasicExtension()
return createEditor({ extension })
}, [])
const handleDocChange = useCallback(() => setHasUnsavedChange(true), [])
useDocChange(handleDocChange, { editor })
const handleSave = useCallback(() => {
const html = editor.getDocHTML()
const record = markdownFromHTML(html)
setRecords((prev) => [...prev, record])
setHasUnsavedChange(false)
}, [editor])
const handleLoad = useCallback(
(record: string) => {
const html = htmlFromMarkdown(record)
editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
setHasUnsavedChange(false)
setKey((prev) => prev + 1)
},
[editor],
)
return (
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<button
onClick={handleSave}
disabled={!hasUnsavedChange}
className="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
>
{hasUnsavedChange ? 'Save' : 'No changes to save'}
</button>
<ul className="border-b border-t border-solid text-sm">
{records.map((record, index) => (
<li key={index} className="m-1 flex gap-2">
<button
className="border border-solid bg-white px-2 py-1 text-black"
onClick={() => handleLoad(record)}
>
Load
</button>
<span className="flex-1 overflow-x-scroll p-2">
<pre>{record}</pre>
</span>
</li>
))}
</ul>
<ProseKit editor={editor} key={key}>
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</ProseKit>
</div>
)
}export { default as ExampleEditor } from './editor'import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export function markdownFromHTML(html: string): string {
return unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.processSync(html)
.toString()
}
export function htmlFromMarkdown(markdown: string): string {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkHtml)
.processSync(markdown)
.toString()
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/solid'
import { createSignal, For, type JSX } from 'solid-js'
import { htmlFromMarkdown, markdownFromHTML } from './markdown'
export default function Editor(): JSX.Element {
const [records, setRecords] = createSignal<string[]>([])
const [hasUnsavedChange, setHasUnsavedChange] = createSignal(false)
const extension = defineBasicExtension()
const editor = createEditor({ extension })
const handleDocChange = () => setHasUnsavedChange(true)
useDocChange(handleDocChange, { editor })
const handleSave = () => {
const html = editor.getDocHTML()
const record = markdownFromHTML(html)
setRecords((prev) => [...prev, record])
setHasUnsavedChange(false)
}
const handleLoad = (record: string) => {
const html = htmlFromMarkdown(record)
editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
setHasUnsavedChange(false)
}
return (
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<button
onClick={handleSave}
disabled={!hasUnsavedChange()}
class="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
>
{hasUnsavedChange() ? 'Save' : 'No changes to save'}
</button>
<ul class="border-b border-t border-solid text-sm">
<For each={records()}>
{(record) => (
<li class="m-1 flex gap-2">
<button
class="border border-solid bg-white px-2 py-1 text-black"
onClick={() => handleLoad(record)}
>
Load
</button>
<span class="flex-1 overflow-x-scroll p-2">
<pre>{record}</pre>
</span>
</li>
)}
</For>
</ul>
<ProseKit editor={editor}>
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</ProseKit>
</div>
)
}export { default as ExampleEditor } from './editor'import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export function markdownFromHTML(html: string): string {
return unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.processSync(html)
.toString()
}
export function htmlFromMarkdown(markdown: string): string {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkHtml)
.processSync(markdown)
.toString()
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/svelte'
import { htmlFromMarkdown, markdownFromHTML } from './markdown'
// A list of saved documents, stored as Markdown strings
let records = $state<string[]>([])
// Whether there are unsaved changes
let hasUnsavedChange = $state(false)
// A key to force a re-render of the editor
let key = $state(1)
const extension = defineBasicExtension()
const editor = createEditor({ extension })
function handleDocChange() {
hasUnsavedChange = true
}
useDocChange(handleDocChange, { editor })
function handleSave() {
const html = editor.getDocHTML()
const record = markdownFromHTML(html)
records = [...records, record]
hasUnsavedChange = false
}
function handleLoad(record: string) {
const html = htmlFromMarkdown(record)
editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
hasUnsavedChange = false
key += 1
}
</script>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<button
disabled={!hasUnsavedChange}
class="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
onclick={handleSave}
>
{hasUnsavedChange ? 'Save' : 'No changes to save'}
</button>
<ul class="border-b border-t border-solid text-sm">
{#each records as record, index (index)}
<li class="m-1 flex gap-2">
<button
class="border border-solid bg-white px-2 py-1 text-black"
onclick={() => handleLoad(record)}
>
Load
</button>
<span class="flex-1 overflow-x-scroll p-2">
<pre>{record}</pre>
</span>
</li>
{/each}
</ul>
{#key key}
<ProseKit {editor}>
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</ProseKit>
{/key}
</div>export { default as ExampleEditor } from './editor.svelte'import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export function markdownFromHTML(html: string): string {
return unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.processSync(html)
.toString()
}
export function htmlFromMarkdown(markdown: string): string {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkHtml)
.processSync(markdown)
.toString()
}<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/vue'
import { ref } from 'vue'
import { htmlFromMarkdown, markdownFromHTML } from './markdown'
// A list of saved documents, stored as Markdown strings
const records = ref<string[]>([])
// Whether there are unsaved changes
const hasUnsavedChange = ref(false)
// A key to force a re-render of the editor
const key = ref(1)
const extension = defineBasicExtension()
const editor = createEditor({ extension })
function handleDocChange() {
hasUnsavedChange.value = true
}
useDocChange(handleDocChange, { editor })
function handleSave() {
const html = editor.getDocHTML()
const record = markdownFromHTML(html)
records.value = [...records.value, record]
hasUnsavedChange.value = false
}
function handleLoad(record: string) {
const html = htmlFromMarkdown(record)
editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
hasUnsavedChange.value = false
key.value += 1
}
</script>
<template>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<button
:disabled="!hasUnsavedChange"
class="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
@click="handleSave"
>
{{ hasUnsavedChange ? 'Save' : 'No changes to save' }}
</button>
<ul class="border-b border-t border-solid text-sm">
<li
v-for="(record, index) in records"
:key="index"
class="m-1 flex gap-2"
>
<button
class="border border-solid bg-white px-2 py-1 text-black"
@click="handleLoad(record)"
>
Load
</button>
<span class="flex-1 overflow-x-scroll p-2">
<pre>{{ record }}</pre>
</span>
</li>
</ul>
<ProseKit :key="key" :editor="editor">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</ProseKit>
</div>
</template>export { default as ExampleEditor } from './editor.vue'import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export function markdownFromHTML(html: string): string {
return unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.processSync(html)
.toString()
}
export function htmlFromMarkdown(markdown: string): string {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkHtml)
.processSync(markdown)
.toString()
}HTML → Markdown
Section titled “HTML → Markdown”import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
export async function htmlToMarkdown(html: string): Promise<string> {
const file = await unified()
.use(rehypeParse, { fragment: true })
.use(rehypeRemark)
.use(remarkStringify)
.process(html)
return String(file)
}Markdown → HTML
Section titled “Markdown → HTML”import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
export async function markdownToHtml(markdown: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown)
return String(file)
}Wiring it to the editor
Section titled “Wiring it to the editor”
// Save
async function save function save(): Promise<void> () {
const html const html: string = editor const editor: Editor<BasicExtension> .getDocHTML Editor<BasicExtension>.getDocHTML: (options?: getDocHTMLOptions) => stringReturn an HTML string representing the editor's current document. ()
const md const md: string = await htmlToMarkdown function htmlToMarkdown(html: string): Promise<string> (html const html: string )
localStorage var localStorage: Storage[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage) .setItem Storage.setItem(key: string, value: string): voidThe **`setItem()`** method of the Storage interface, when passed a key name and value, will add that key to the given Storage object, or update that key's value if it already exists.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem) ('doc.md', md const md: string )
}
// Load
async function load function load(): Promise<void> () {
const md const md: string = localStorage var localStorage: Storage[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage) .getItem Storage.getItem(key: string): string | nullThe **`getItem()`** method of the Storage interface, when passed a key name, will return that key's value, or null if the key does not exist, in the given Storage object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem) ('doc.md') ?? ''
const html const html: string = await markdownToHtml function markdownToHtml(md: string): Promise<string> (md const md: string )
editor const editor: Editor<BasicExtension> .setContent Editor<BasicExtension>.setContent: (content: Node | NodeJSON | string | Element, selection?: SelectionJSON | Selection | "start" | "end") => voidUpdate the editor's document and selection.@paramcontent - The new document to set. It can be one of the following:
- A ProseMirror node instance
- A ProseMirror node JSON object
- An HTML string
- A DOM element instance@paramselection - Optional. Specifies the new selection. It can be one of the following:
- A ProseMirror selection instance
- A ProseMirror selection JSON object
- The string "start" (to set selection at the beginning, default value)
- The string "end" (to set selection at the end) (html const html: string )
}