A Comark-aware Tiptap kit. Built on @tiptap/starter-kit + tables + image, it adds a thin layer that round-trips losslessly between Tiptap's ProseMirror schema, the Comark AST, and markdown — plus optional framework bindings.
A Comark-aware Tiptap kit. Built on @tiptap/starter-kit + tables + image, it adds a thin layer that round-trips losslessly between Tiptap’s ProseMirror schema, the Comark AST, and markdown — plus optional framework bindings.
comark-tiptap — the framework-agnostic core (ComarkKit, serializer, specs).comark-tiptap/vue — Vue 3 bindings (<ComarkEditor>, useComarkEditor, Vue NodeView helpers).comark-tiptap/react — React bindings (<ComarkEditor>, useComarkEditor, React NodeView helpers).More framework bindings are planned, following the frameworks Comark already supports. Each ships as its own subpath export with its framework as an optional peer dependency — so the core stays framework-agnostic and you install only what you use.
Discussion:
comarkdown/comark#164.
# core
pnpm add comark-tiptap comark @tiptap/core @tiptap/pm @tiptap/starter-kit \
@tiptap/extension-code-block @tiptap/extension-image @tiptap/extension-table
# + Vue bindings
pnpm add vue @tiptap/vue-3
# + React bindings
pnpm add react react-dom @tiptap/react
comark-tiptapComarkKit is a single Extension.create that registers StarterKit + tables + image + picture + the comark-specific nodes (ComarkComment, ComarkTemplate), the global htmlAttrs declaration, and the serializer. The schema is whatever Tiptap upstream ships — no per-extension reimplementations — so it stays drop-in compatible with the rest of the Tiptap ecosystem.
import { Editor } from "@tiptap/core";
import { ComarkKit, defineComarkComponent } from "comark-tiptap";
const Alert = defineComarkComponent({
name: "alert",
kind: "block",
props: {
type: { type: "string", default: "info" },
title: { type: "string" },
},
});
const editor = new Editor({
extensions: [ComarkKit.configure({ components: [Alert] })],
content: "# Hello\n\n::alert\nHi\n::", // markdown — parsed async, see below
});
editor.storage.comark.getAst(); // ComarkTree (sync)
await editor.storage.comark.getMarkdown(); // string (async — comark/render)
editor.commands.setComarkMarkdown("# Hi"); // markdown → comark.parse
editor.commands.setComarkAst(tree); // ComarkTree → serializer dispatch table
comark-tiptap is opinionated: strings are markdown — never HTML. setContent, insertContent, and insertContentAt route a string argument through comark.parse. Pre-parsed content (PM JSON, Fragment, ProseMirrorNode) passes through untouched; the empty string falls through too, so clearContent() keeps its sync semantics.
editor.commands.setContent("## Section\n\n- a\n- b"); // markdown
editor.commands.insertContent("**bold**", { inline: true }); // inline run at the cursor
Escape hatches for a single call (string input only):
editor.commands.setContent("<h1>Hi</h1>", { contentType: "html" }); // Tiptap's stock HTML pipeline, sync
editor.commands.setContent(JSON.stringify(pmDoc), { contentType: "json" }); // strict PM JSON, sync
editor.commands.setComarkAst('{"nodes":[["p",{},"Hi"]],"frontmatter":{},"meta":{}}'); // JSON-encoded AST
Object inputs are auto-detected — a ComarkTree (anything with a nodes array) routes through the AST path; plain PM JSON flows to the stock command.
comark.parse is async, so a markdown string seed (new Editor({ content }), setContent, insertContent) applies one microtask later — the command returns true synchronously but the content lands after the parse resolves. Don’t read editor.getJSON() immediately after a markdown seed; listen on editor.on('update', …) or wait a tick. Object paths (PM JSON, setComarkAst) stay synchronous.
ComarkKit.configure({
starterKit: { heading: { levels: [1, 2, 3] } }, // forwarded to StarterKit (codeBlock/underline always overridden)
table: { table: { resizable: true } }, // forwarded to TableKit; false to omit
image: { allowBase64: true }, // forwarded to ComarkImage (inline mode forced by default)
picture: false, // drop the `<picture>` node (its AST nodes are then dropped, sources included)
resolveSrc: (src) => cdnUrl(src), // display-only URL resolver, see below
comment: false, // drop the `<!-- … -->` node
template: false, // drop the `::template[name]` node
components: [Alert], // user components from defineComarkComponent
serializer: { injectStyles: true, injectNonce: "csp-token" }, // operational stylesheet auto-injection
});
Three input shapes are honored throughout — string (markdown), ComarkTree (AST), JSONContent (PM JSON) — and the same three read back out via getMarkdown() / getAst() / getJSON(). getHTML() is pure pass-through to Tiptap.
AST nodes whose kit extension is disabled (picture: false, comment: false, …) are dropped individually on the way in — the rest of the document survives — and each drop is reported through serializer.onError.
resolveSrcCMSs often store image sources as storage-relative keys (public/products/pump.webp) and resolve them to CDN URLs only at render time. Verbatim in an editor those keys 404 against the page origin. resolveSrc maps stored sources to display URLs without ever touching the stored content:
ComarkKit.configure({
resolveSrc: (src) => (src.startsWith("public/") ? `https://cdn.example/${src}` : undefined),
});
src and srcset (each candidate URL, descriptors preserved) and every <picture> source. Return undefined to leave a value untouched.context argument ({ attr: 'src' | 'srcset', node: 'image' | 'picture' }) is available; plain (src) => … mappers — e.g. wrapping @nuxt/image’s useImage() — plug in as-is.image: { resolveSrc } / picture: { resolveSrc } override the kit-level one.getHTML() caveat: display HTML is what Tiptap serializes, so getHTML() emits resolved URLs. The raw value rides along in data-comark-src / data-comark-srcset stash attributes — that’s also how internal copy-paste (PM’s clipboard serializes the display DOM) recovers raw values instead of baking CDN URLs into the document. Store the AST, not HTML.
<picture> elements round-trip losslessly through the editor as an opaque inline atom: sources and the inner img are preserved verbatim in attrs (selectable/deletable/draggable, not editable from within), and resolveSrc applies to their display. Block-level pictures come back paragraph-wrapped — same normalization as bare <img> elements.
[!WARNING]
Markdown output of pictures is not yet reliable upstream: comark 0.5.0 renders$-less elements as directives (whose inline form doesn’t reparse) and splits raw-HTML children with blank lines. AST round-trips are unaffected — store the AST for documents containing pictures.
comark-tiptap/vueNo UI-library dependency, no design-system opinions — just the editor primitives.
<script setup lang="ts">
import { ref } from "vue";
import { ComarkEditor, defineComarkVueComponent } from "comark-tiptap/vue";
import type { ComarkTree } from "comark-tiptap/vue";
import AlertNodeView from "./AlertNodeView.vue";
const Alert = defineComarkVueComponent({
name: "alert",
kind: "block",
props: { type: { type: "string", default: "info" }, title: { type: "string" } },
nodeView: AlertNodeView, // → real Vue NodeView via VueNodeViewRenderer
});
const tree = ref<ComarkTree>({ nodes: [], frontmatter: {}, meta: {} });
</script>
<template>
<ComarkEditor v-model.ast="tree" :components="[Alert]" />
</template>
The v-model modifier picks the flavor read back to the ref (input and output stay in the same flavor):
<ComarkEditor v-model="md" />
<!-- markdown (default) -->
<ComarkEditor v-model.markdown="md" />
<!-- markdown -->
<ComarkEditor v-model.html="html" />
<!-- HTML — Tiptap's stock pipeline -->
<ComarkEditor v-model.json="doc" />
<!-- PM JSON -->
<ComarkEditor v-model.ast="tree" />
<!-- Comark AST -->
:content is a non-reactive, mount-only seed; v-model is live two-way binding and wins when both are set. Markdown seeds resolve asynchronously (see above) — the wrapper handles the wait; ready / update events and the default slot’s is-ready flag fire when the seed lands.
const md = ref("# Hi\n");
const { editor, setContent, getAst, getMarkdown, getJson, getHtml } = useComarkEditor({
content: md, // ref/getter → live binding; plain value → mount-only seed
contentType: "markdown",
});
await setContent("## Replaced\n"); // single setter, dispatches by contentType
await setContent("<p>hi</p>", { contentType: "html" }); // per-call override
await setContent(({ content }) => `${content}\n\nappended`); // functional updater
const tree = getAst(); // ComarkTree | null
const markdown = await getMarkdown(); // string | null (async)
Pass kitOptions to either the component or the composable to forward configuration to ComarkKit.configure(...).
comark-tiptap/reactSame surface, React idioms. <ComarkEditor> is controlled via value / onChange (React has no v-model); the contentType prop selects the flavor for both input and output.
import { useState } from "react";
import { ComarkEditor, defineComarkReactComponent } from "comark-tiptap/react";
import type { ComarkTree } from "comark-tiptap/react";
import AlertNodeView from "./AlertNodeView";
const Alert = defineComarkReactComponent({
name: "alert",
kind: "block",
props: { type: { type: "string", default: "info" }, title: { type: "string" } },
nodeView: AlertNodeView, // → real React NodeView via ReactNodeViewRenderer
});
function Editor() {
const [tree, setTree] = useState<ComarkTree>({ nodes: [], frontmatter: {}, meta: {} });
return <ComarkEditor value={tree} onChange={setTree} contentType="ast" components={[Alert]} />;
}
Markdown/HTML/JSON/AST flavors work the same way — set contentType and bind value / onChange in that flavor. Markdown seeds resolve asynchronously (see above); onReady / onUpdate fire when the seed lands, and the fallback prop renders while the editor is being created.
const { editor, setContent, getAst, getMarkdown, getJson, getHtml } = useComarkEditor({
content: "# Hi\n", // mount-only seed
contentType: "markdown",
});
await setContent("## Replaced\n"); // single setter, dispatches by contentType
await setContent(({ content }) => `${content}\n\nappended`); // functional updater
const tree = getAst(); // ComarkTree | null
const markdown = await getMarkdown(); // string | null (async)
For full control, pass your own editor: <ComarkEditor editor={editor}> renders it and skips the internal one.
MIT © Sandro Circi