Files
gitmost/packages/git-sync/src/lib/docmost-schema.ts
claude code agent 227 f4651f554d fix(git-sync): preserve subpages.recursive and details.open on round trip
Found proactively by deepening the round-trip test from node-TYPE survival to
ATTRIBUTE fidelity (distinctive attr values per node). Two real losses (the
other 3 candidates — mathInline/mathBlock/pageEmbed — were verified to be
correct; the probe had used wrong attr names):

- subpages `recursive`: the converter emitted a bare div and the schema mirror
  didn't model the attr, so a recursive subpages reverted to non-recursive on a
  round trip. Now emits `data-recursive="true"` and the mirror parses it back
  (matching @docmost/editor-ext).
- details `open`: the `open` (collapsed/expanded) state lives on the details
  node, but the converter emitted the `<details>` wrapper from the summary case
  without it, so the state was dropped. The wrapper now carries `open`.

The round-trip test now also asserts attribute fidelity (12 cases) so these are
locked. Schema-surface snapshot updated for the new subpages attr.

git-sync vitest 671 (+1 expected-fail), §13.1 gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:52:59 +03:00

1461 lines
44 KiB
TypeScript

/**
* Full TipTap extension set matching the real Docmost document schema.
*
* The default StarterKit-only schema silently destroys Docmost-specific
* nodes (callout, table) and drops attributes it does not know about
* (node ids, image sizing, link targets). Every code path that converts
* to or from ProseMirror JSON must use THIS set, otherwise a round-trip
* loses content.
*
* PROVENANCE / KEEP IN SYNC: this file is a VENDORED MIRROR of the canonical
* Docmost document schema in `@docmost/editor-ext`. The node/mark/attribute
* surface MUST be kept in sync with editor-ext — anything present there but
* missing here is silently dropped on a round-trip (data loss). The exported
* `docmostExtensions` surface is guarded by `test/schema-surface-snapshot.test.ts`,
* which fails loudly on any drift; when it does, re-verify parity against
* `@docmost/editor-ext` before updating the snapshot.
*/
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Highlight from "@tiptap/extension-highlight";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import { Node, Extension, Mark } from "@tiptap/core";
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this
// package can stay on the same @tiptap/core version as the editor and avoid a
// duplicate-tiptap version split in the monorepo. Reads a single declaration
// from an element's inline `style` attribute, last-wins, case-insensitive.
function getStyleProperty(element: HTMLElement, propertyName: string): string | null {
const styleAttr = element.getAttribute("style");
if (!styleAttr) {
return null;
}
const decls = styleAttr.split(";").map((decl) => decl.trim()).filter(Boolean);
const target = propertyName.toLowerCase();
for (let i = decls.length - 1; i >= 0; i -= 1) {
const decl = decls[i];
const colonIndex = decl.indexOf(":");
if (colonIndex === -1) {
continue;
}
const prop = decl.slice(0, colonIndex).trim().toLowerCase();
if (prop === target) {
return decl.slice(colonIndex + 1).trim();
}
}
return null;
}
/** Allowed Docmost callout types; anything else falls back to "info". */
const CALLOUT_TYPES = ["info", "warning", "danger", "success"];
export const clampCalloutType = (value: string | null | undefined): string =>
value && CALLOUT_TYPES.includes(value.toLowerCase())
? value.toLowerCase()
: "info";
/**
* Allowlist guard for CSS color values imported from HTML.
*
* Docmost interpolates stored mark colors straight into an inline style
* attribute (e.g. style="background-color: ${color}" / "color: ${color}").
* An unsanitized value such as `red; --x: url(...)` or `red"><script>` would
* let a crafted document break out of the style attribute. We therefore only
* accept a narrow, well-formed subset of CSS <color> syntax and reject (-> null)
* anything else.
*
* Accepted forms:
* - named colors: letters only, e.g. "red", "rebeccapurple"
* - hex: #rgb, #rgba, #rrggbb, #rrggbbaa
* - functional notation: rgb()/rgba()/hsl()/hsla() containing only
* digits, %, ., commas, spaces and slashes
*/
const SAFE_COLOR_RE =
/^(?:[a-zA-Z]+|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|rgba|hsl|hsla)\([0-9.,%/\s]+\))$/;
export const sanitizeCssColor = (
value: string | null | undefined,
): string | null => {
if (typeof value !== "string") return null;
const color = value.trim();
return color && SAFE_COLOR_RE.test(color) ? color : null;
};
/** Docmost callout (info/warning/danger/success banner). */
const Callout = Node.create({
name: "callout",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
// Read the type from data-callout-type so generateJSON(html) preserves
// it; without an explicit parseHTML every imported callout became "info".
type: {
default: "info",
parseHTML: (el: HTMLElement) =>
clampCalloutType(el.getAttribute("data-callout-type")),
renderHTML: (attrs: Record<string, any>) => ({
"data-callout-type": clampCalloutType(attrs.type),
}),
},
icon: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-icon"),
renderHTML: (attrs: Record<string, any>) =>
attrs.icon ? { "data-icon": attrs.icon } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="callout"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "callout", ...HTMLAttributes }, 0];
},
});
/** Minimal table family: enough for schema round-trips and HTML parsing. */
const Table = Node.create({
name: "table",
group: "block",
content: "tableRow+",
isolating: true,
parseHTML() {
return [{ tag: "table" }];
},
renderHTML() {
return ["table", ["tbody", 0]];
},
});
const TableRow = Node.create({
name: "tableRow",
content: "(tableCell | tableHeader)*",
parseHTML() {
return [{ tag: "tr" }];
},
renderHTML() {
return ["tr", 0];
},
});
const cellAttributes = () => ({
colspan: { default: 1 },
rowspan: { default: 1 },
colwidth: { default: null },
backgroundColor: { default: null },
backgroundColorName: { default: null },
// Column alignment so GFM aligned tables (|:--|:-:|--:|) round-trip.
align: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("align") || el.style.textAlign || null,
renderHTML: (attrs: Record<string, any>) =>
attrs.align ? { align: attrs.align } : {},
},
});
const TableCell = Node.create({
name: "tableCell",
content: "block+",
isolating: true,
addAttributes: cellAttributes,
parseHTML() {
return [{ tag: "td" }];
},
renderHTML() {
return ["td", 0];
},
});
const TableHeader = Node.create({
name: "tableHeader",
content: "block+",
isolating: true,
addAttributes: cellAttributes,
parseHTML() {
return [{ tag: "th" }];
},
renderHTML() {
return ["th", 0];
},
});
/**
* Attributes Docmost stores on standard nodes that the stock extensions
* do not declare. Without these, Node.fromJSON silently drops them —
* including the block ids that heading anchors rely on.
*/
const DocmostAttributes = Extension.create({
name: "docmostAttributes",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph"],
attributes: {
id: { default: null },
indent: { default: null },
textAlign: { default: null },
},
},
{
types: ["image"],
attributes: {
align: { default: null },
// imageToHtml emits these Docmost-specific image attrs as data-*; map
// them back explicitly so a top-level image (or one inside a column)
// round-trips them. Without a parseHTML the default reads the bare
// attribute name (e.g. getAttribute("attachmentId") -> null) and the
// value — including the attachmentId that links the image to its
// stored file — is silently dropped on every round-trip (data loss).
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId
? { "data-attachment-id": attrs.attachmentId }
: {},
},
aspectRatio: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-aspect-ratio"),
renderHTML: (attrs: Record<string, any>) =>
attrs.aspectRatio != null
? { "data-aspect-ratio": attrs.aspectRatio }
: {},
},
height: { default: null },
placeholder: { default: null },
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-size": attrs.size } : {},
},
width: { default: null },
},
},
{
types: ["orderedList"],
attributes: { type: { default: null } },
},
{
types: ["link"],
attributes: { internal: { default: null }, title: { default: null } },
},
];
},
});
/**
* Docmost inline comment mark. Anchors a comment thread to a text range via
* `commentId`. Without it, any document containing comment highlights fails to
* round-trip through the schema ("There is no mark type comment in this schema"),
* which breaks update_page_json and edit_page_text on every commented page.
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
*/
const Comment = Mark.create({
name: "comment",
exitable: true,
inclusive: false,
addAttributes() {
return {
commentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-comment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.commentId ? { "data-comment-id": attrs.commentId } : {},
},
resolved: {
default: false,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-resolved") === "true",
renderHTML: (attrs: Record<string, any>) =>
attrs.resolved ? { "data-resolved": "true" } : {},
},
};
},
parseHTML() {
return [{ tag: "span[data-comment-id]" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", { class: "comment-mark", ...HTMLAttributes }, 0];
},
});
/**
* Text color mark. The markdown-converter emits colored text as
* <span style="color: ...">, but with no mark parsing it back the color was
* silently dropped on import. This mirrors TipTap's @tiptap/extension-text-style
* `textStyle` mark (the name Docmost expects) and carries a single `color`
* attribute. The parsed color is passed through the allowlist guard so a crafted
* style cannot break out of the attribute when Docmost re-renders it.
*/
const TextStyle = Mark.create({
name: "textStyle",
addAttributes() {
return {
color: {
default: null,
parseHTML: (el: HTMLElement) =>
sanitizeCssColor(
el.style.color || el.getAttribute("data-color"),
),
renderHTML: (attrs: Record<string, any>) => {
const color = sanitizeCssColor(attrs.color);
return color ? { style: `color: ${color}` } : {};
},
},
};
},
parseHTML() {
return [
{
tag: "span",
// Only claim a plain colored span. Do NOT match spans that are already a
// comment mark (data-comment-id) or a mention node (data-type=mention),
// otherwise importing such HTML would silently drop the comment/mention.
getAttrs: (el: HTMLElement) =>
el.style.color &&
!el.getAttribute("data-comment-id") &&
el.getAttribute("data-type") !== "mention"
? {}
: false,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["span", HTMLAttributes, 0];
},
});
/**
* Passthrough definitions for the remaining Docmost-specific nodes.
*
* TiptapTransformer.toYdoc (the write path every mutation uses) throws
* "Unknown node type: X" for any node not registered here, so editing ANY
* page that contains one of these nodes used to fail outright. The read path
* (fromYdoc) accepts them, which is why they appear in real documents.
*
* Each node below mirrors the real @docmost/editor-ext definition's name,
* group, content, inline/atom flags and attribute keys (with the same data-*
* HTML mapping) so that a fromYdoc -> transform -> toYdoc round-trip both
* validates and preserves attributes faithfully. Interactive concerns
* (node views, commands, keyboard shortcuts, input rules, suggestion plugins)
* are intentionally omitted: the MCP server never renders these nodes, it only
* needs the schema to accept and carry them. The Callout node above is the
* pattern these follow.
*/
/** Docmost @mention (user/page reference). Inline atom. */
const Mention = Node.create({
name: "mention",
group: "inline",
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.id ? { "data-id": attrs.id } : {},
},
label: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-label"),
renderHTML: (attrs: Record<string, any>) =>
attrs.label ? { "data-label": attrs.label } : {},
},
entityType: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-entity-type"),
renderHTML: (attrs: Record<string, any>) =>
attrs.entityType ? { "data-entity-type": attrs.entityType } : {},
},
entityId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-entity-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.entityId ? { "data-entity-id": attrs.entityId } : {},
},
slugId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-slug-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.slugId ? { "data-slug-id": attrs.slugId } : {},
},
creatorId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-creator-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.creatorId ? { "data-creator-id": attrs.creatorId } : {},
},
anchorId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-anchor-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.anchorId ? { "data-anchor-id": attrs.anchorId } : {},
},
};
},
parseHTML() {
return [{ tag: 'span[data-type="mention"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["span", { "data-type": "mention", ...HTMLAttributes }, 0];
},
});
/** Inline KaTeX expression. Carries the LaTeX source in `text`. */
const MathInline = Node.create({
name: "mathInline",
group: "inline",
inline: true,
atom: true,
addAttributes() {
return {
text: { default: "" },
};
},
parseHTML() {
return [{ tag: 'span[data-type="mathInline"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
{ "data-type": "mathInline", "data-katex": "true" },
`${HTMLAttributes.text ?? ""}`,
];
},
});
/** Block KaTeX expression. Carries the LaTeX source in `text`. */
const MathBlock = Node.create({
name: "mathBlock",
group: "block",
atom: true,
isolating: true,
addAttributes() {
return {
text: { default: "" },
};
},
parseHTML() {
return [{ tag: 'div[data-type="mathBlock"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
{ "data-type": "mathBlock", "data-katex": "true" },
`${HTMLAttributes.text ?? ""}`,
];
},
});
/** Collapsible <details> wrapper: summary + content children. */
const Details = Node.create({
name: "details",
group: "block",
content: "detailsSummary detailsContent",
defining: true,
isolating: true,
addAttributes() {
return {
open: {
default: false,
parseHTML: (el: HTMLElement) => el.getAttribute("open"),
renderHTML: (attrs: Record<string, any>) =>
attrs.open ? { open: "" } : {},
},
};
},
parseHTML() {
return [{ tag: "details" }];
},
renderHTML({ HTMLAttributes }) {
return ["details", { ...HTMLAttributes }, 0];
},
});
/** Clickable summary line of a <details> block. */
const DetailsSummary = Node.create({
name: "detailsSummary",
group: "block",
content: "inline*",
defining: true,
isolating: true,
selectable: false,
parseHTML() {
return [{ tag: "summary" }];
},
renderHTML({ HTMLAttributes }) {
return ["summary", { "data-type": "detailsSummary", ...HTMLAttributes }, 0];
},
});
/** Body of a <details> block. Permissive content so fromYdoc output validates. */
const DetailsContent = Node.create({
name: "detailsContent",
group: "block",
// Docmost declares block* (an empty details body is valid); block+ would
// reject a collapsed/empty details on round-trip.
content: "block*",
defining: true,
selectable: false,
parseHTML() {
return [{ tag: 'div[data-type="detailsContent"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "detailsContent", ...HTMLAttributes }, 0];
},
});
/** File attachment card (non-image upload). Block atom. */
const Attachment = Node.create({
name: "attachment",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
url: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-url"),
renderHTML: (attrs: Record<string, any>) => ({
"data-attachment-url": attrs.url ?? "",
}),
},
name: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-name"),
renderHTML: (attrs: Record<string, any>) =>
attrs.name ? { "data-attachment-name": attrs.name } : {},
},
mime: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-mime"),
renderHTML: (attrs: Record<string, any>) =>
attrs.mime ? { "data-attachment-mime": attrs.mime } : {},
},
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-attachment-size": attrs.size } : {},
},
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId
? { "data-attachment-id": attrs.attachmentId }
: {},
},
// Docmost declares `placeholder` (a transient upload key, not rendered
// to HTML). Carry it so a round-trip never hits "Unsupported attribute".
placeholder: { default: null },
};
},
parseHTML() {
return [{ tag: 'div[data-type="attachment"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "attachment", ...HTMLAttributes }, 0];
},
});
/** Uploaded <video> player. Block atom. */
const Video = Node.create({
name: "video",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("src"),
renderHTML: (attrs: Record<string, any>) => ({ src: attrs.src ?? "" }),
},
alt: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label"),
renderHTML: (attrs: Record<string, any>) =>
attrs.alt ? { "aria-label": attrs.alt } : {},
},
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId
? { "data-attachment-id": attrs.attachmentId }
: {},
},
width: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("width"),
renderHTML: (attrs: Record<string, any>) =>
attrs.width != null ? { width: attrs.width } : {},
},
height: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
renderHTML: (attrs: Record<string, any>) =>
attrs.height != null ? { height: attrs.height } : {},
},
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-size": attrs.size } : {},
},
align: {
default: "center",
parseHTML: (el: HTMLElement) => el.getAttribute("data-align"),
renderHTML: (attrs: Record<string, any>) =>
attrs.align ? { "data-align": attrs.align } : {},
},
aspectRatio: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-aspect-ratio"),
renderHTML: (attrs: Record<string, any>) =>
attrs.aspectRatio != null
? { "data-aspect-ratio": attrs.aspectRatio }
: {},
},
// Docmost declares `placeholder` (a transient upload key, not rendered
// to HTML). Carry it so a round-trip never hits "Unsupported attribute".
placeholder: { default: null },
};
},
parseHTML() {
return [{ tag: "video" }];
},
renderHTML({ HTMLAttributes }) {
return ["video", { controls: "true", ...HTMLAttributes }];
},
});
/**
* Defensive passthrough for a `youtube` node. Docmost itself has no dedicated
* youtube node (YouTube is handled via `embed`), but the converter read path
* references this type, so accept it as a generic block atom that preserves
* its src so legacy/external documents survive a round-trip.
*/
const Youtube = Node.create({
name: "youtube",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-src"),
renderHTML: (attrs: Record<string, any>) => ({
"data-src": attrs.src ?? "",
}),
},
width: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-width"),
renderHTML: (attrs: Record<string, any>) =>
attrs.width != null ? { "data-width": attrs.width } : {},
},
height: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-height"),
renderHTML: (attrs: Record<string, any>) =>
attrs.height != null ? { "data-height": attrs.height } : {},
},
align: {
default: "center",
parseHTML: (el: HTMLElement) => el.getAttribute("data-align"),
renderHTML: (attrs: Record<string, any>) =>
attrs.align ? { "data-align": attrs.align } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="youtube"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "youtube", ...HTMLAttributes }, 0];
},
});
/** Generic embed (provider iframe). Block atom. */
const Embed = Node.create({
name: "embed",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-src"),
renderHTML: (attrs: Record<string, any>) => ({
"data-src": attrs.src ?? "",
}),
},
provider: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-provider"),
renderHTML: (attrs: Record<string, any>) => ({
"data-provider": attrs.provider ?? "",
}),
},
align: {
default: "center",
parseHTML: (el: HTMLElement) => el.getAttribute("data-align"),
renderHTML: (attrs: Record<string, any>) => ({
"data-align": attrs.align ?? "center",
}),
},
width: {
default: 800,
parseHTML: (el: HTMLElement) => el.getAttribute("data-width"),
renderHTML: (attrs: Record<string, any>) => ({
"data-width": attrs.width,
}),
},
height: {
default: 600,
parseHTML: (el: HTMLElement) => el.getAttribute("data-height"),
renderHTML: (attrs: Record<string, any>) => ({
"data-height": attrs.height,
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="embed"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "embed", ...HTMLAttributes }, 0];
},
});
/** Shared attribute set for drawio/excalidraw diagram nodes. */
const diagramAttributes = () => ({
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-src"),
renderHTML: (attrs: Record<string, any>) => ({
"data-src": attrs.src ?? "",
}),
},
title: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-title"),
renderHTML: (attrs: Record<string, any>) =>
attrs.title ? { "data-title": attrs.title } : {},
},
alt: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt"),
renderHTML: (attrs: Record<string, any>) =>
attrs.alt ? { "data-alt": attrs.alt } : {},
},
width: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-width"),
renderHTML: (attrs: Record<string, any>) =>
attrs.width != null ? { "data-width": attrs.width } : {},
},
height: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-height"),
renderHTML: (attrs: Record<string, any>) =>
attrs.height != null ? { "data-height": attrs.height } : {},
},
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-size": attrs.size } : {},
},
aspectRatio: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-aspect-ratio"),
renderHTML: (attrs: Record<string, any>) =>
attrs.aspectRatio != null
? { "data-aspect-ratio": attrs.aspectRatio }
: {},
},
align: {
default: "center",
parseHTML: (el: HTMLElement) => el.getAttribute("data-align"),
renderHTML: (attrs: Record<string, any>) =>
attrs.align ? { "data-align": attrs.align } : {},
},
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId ? { "data-attachment-id": attrs.attachmentId } : {},
},
});
/** draw.io diagram. Block atom (image-backed). */
const Drawio = Node.create({
name: "drawio",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes: diagramAttributes,
parseHTML() {
return [{ tag: 'div[data-type="drawio"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "drawio", ...HTMLAttributes }, 0];
},
});
/** Excalidraw diagram. Block atom (image-backed). */
const Excalidraw = Node.create({
name: "excalidraw",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes: diagramAttributes,
parseHTML() {
return [{ tag: 'div[data-type="excalidraw"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "excalidraw", ...HTMLAttributes }, 0];
},
});
/** Multi-column layout container holding one or more `column` children. */
const Columns = Node.create({
name: "columns",
group: "block",
content: "column+",
defining: true,
isolating: true,
addAttributes() {
return {
layout: {
default: "two_equal",
parseHTML: (el: HTMLElement) => el.getAttribute("data-layout"),
renderHTML: (attrs: Record<string, any>) =>
attrs.layout ? { "data-layout": attrs.layout } : {},
},
widthMode: {
default: "normal",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-width-mode") || "normal",
renderHTML: (attrs: Record<string, any>) =>
attrs.widthMode && attrs.widthMode !== "normal"
? { "data-width-mode": attrs.widthMode }
: {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="columns"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "columns", ...HTMLAttributes }, 0];
},
});
/** Single column within a `columns` layout. */
const Column = Node.create({
name: "column",
group: "block",
content: "block+",
defining: true,
isolating: true,
selectable: false,
addAttributes() {
return {
width: {
default: null,
parseHTML: (el: HTMLElement) => {
const value = el.getAttribute("data-width");
return value ? parseFloat(value) : null;
},
renderHTML: (attrs: Record<string, any>) =>
attrs.width ? { "data-width": attrs.width } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="column"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "column", ...HTMLAttributes }, 0];
},
});
/**
* Subpages listing block (auto-generated index of child pages). Docmost
* declares no attributes; the markdown-converter has a `case "subpages"`, so
* the read path can emit it and toYdoc must accept it. Block atom.
*/
const Subpages = Node.create({
name: "subpages",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
recursive: {
default: false,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-recursive") === "true",
renderHTML: (attrs: Record<string, any>) =>
attrs.recursive ? { "data-recursive": "true" } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="subpages"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "subpages", ...HTMLAttributes }];
},
});
/** Uploaded <audio> player. Block atom. Mirrors Docmost audio attrs. */
const Audio = Node.create({
name: "audio",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("src"),
renderHTML: (attrs: Record<string, any>) => ({ src: attrs.src ?? "" }),
},
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId
? { "data-attachment-id": attrs.attachmentId }
: {},
},
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-size": attrs.size } : {},
},
// Transient upload key Docmost declares with rendered:false; carried so
// a round-trip never hits "Unsupported attribute".
placeholder: { default: null },
};
},
parseHTML() {
return [{ tag: "audio" }];
},
renderHTML({ HTMLAttributes }) {
return ["audio", { controls: "true", ...HTMLAttributes }];
},
});
/** Embedded PDF viewer. Block atom. Mirrors Docmost pdf attrs. */
const Pdf = Node.create({
name: "pdf",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
src: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("src"),
renderHTML: (attrs: Record<string, any>) => ({ src: attrs.src ?? "" }),
},
name: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-name"),
renderHTML: (attrs: Record<string, any>) =>
attrs.name ? { "data-name": attrs.name } : {},
},
attachmentId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.attachmentId
? { "data-attachment-id": attrs.attachmentId }
: {},
},
size: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-size"),
renderHTML: (attrs: Record<string, any>) =>
attrs.size != null ? { "data-size": attrs.size } : {},
},
width: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("width"),
renderHTML: (attrs: Record<string, any>) =>
attrs.width != null ? { width: attrs.width } : {},
},
height: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
renderHTML: (attrs: Record<string, any>) =>
attrs.height != null ? { height: attrs.height } : {},
},
// Transient upload key Docmost declares with rendered:false; carried so
// a round-trip never hits "Unsupported attribute".
placeholder: { default: null },
};
},
parseHTML() {
return [{ tag: 'div[data-type="pdf"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "pdf", ...HTMLAttributes }, 0];
},
});
/** Page break (print/export divider). Block atom; Docmost declares no attrs. */
const PageBreak = Node.create({
name: "pageBreak",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
parseHTML() {
return [{ tag: 'div[data-type="pageBreak"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "pageBreak", ...HTMLAttributes }];
},
});
/**
* Footnote feature (mirror of @docmost/editor-ext footnote, matching the MCP
* schema mirror). Three nodes connected by `id`:
* - FootnoteReference: inline atom marker in the body (<sup data-footnote-ref>);
* - FootnotesList: a single bottom container (<section data-footnotes>);
* - FootnoteDefinition: one editable note keyed by id (<div data-footnote-def>).
* The visible number is not stored; it is derived from reference order. The
* <sup> parse rule uses priority 100 so it beats the Superscript mark's <sup>
* rule (otherwise an empty reference parses as an empty superscript and drops).
*/
const FootnoteReference = Node.create({
name: "footnoteReference",
priority: 101,
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
id: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.id ? { "data-id": attrs.id } : {},
},
};
},
parseHTML() {
return [{ tag: "sup[data-footnote-ref]", priority: 100 }];
},
renderHTML({ HTMLAttributes }) {
return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }];
},
});
const FootnotesList = Node.create({
name: "footnotesList",
group: "block",
content: "footnoteDefinition+",
isolating: true,
selectable: false,
defining: true,
parseHTML() {
return [{ tag: "section[data-footnotes]" }];
},
renderHTML({ HTMLAttributes }) {
return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0];
},
});
const FootnoteDefinition = Node.create({
name: "footnoteDefinition",
content: "paragraph+",
defining: true,
isolating: true,
selectable: false,
addAttributes() {
return {
id: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.id ? { "data-id": attrs.id } : {},
},
};
},
parseHTML() {
return [{ tag: "div[data-footnote-def]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0];
},
});
/**
* Encode/decode the htmlEmbed `source` (arbitrary HTML/CSS/JS) to/from base64
* for the `data-source` attribute. Ported from @docmost/editor-ext so the
* markdown-converter HTML path (generateJSON via parseHTML) round-trips the
* raw source losslessly and keeps it inert while it sits in the attribute.
* `encodeURIComponent`/`decodeURIComponent` wrap btoa/atob so UTF-8 survives.
*/
export function encodeHtmlEmbedSource(source: string): string {
if (!source) return "";
try {
if (typeof btoa === "function") {
return btoa(encodeURIComponent(source));
}
return Buffer.from(encodeURIComponent(source), "utf-8").toString("base64");
} catch {
return "";
}
}
export function decodeHtmlEmbedSource(encoded: string): string {
if (!encoded) return "";
try {
if (typeof atob === "function") {
return decodeURIComponent(atob(encoded));
}
return decodeURIComponent(Buffer.from(encoded, "base64").toString("utf-8"));
} catch {
return "";
}
}
/**
* Docmost raw HTML embed. Block atom; the client renders `source` inside a
* sandboxed iframe. Mirrors the @docmost/editor-ext node — `source` rides the
* `data-source` attribute base64-encoded (this is an HTML/generateJSON path, so
* it MUST use base64 to avoid double-encoding / injection).
*/
const HtmlEmbed = Node.create({
name: "htmlEmbed",
group: "block",
inline: false,
isolating: true,
atom: true,
defining: true,
draggable: true,
addAttributes() {
return {
source: {
default: "",
parseHTML: (el: HTMLElement) =>
decodeHtmlEmbedSource(el.getAttribute("data-source") || ""),
renderHTML: (attrs: Record<string, any>) => ({
"data-source": encodeHtmlEmbedSource(attrs.source || ""),
}),
},
height: {
default: null,
parseHTML: (el: HTMLElement) => {
const v = el.getAttribute("data-height");
if (!v) return null;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
},
renderHTML: (attrs: Record<string, any>) =>
attrs.height != null ? { "data-height": String(attrs.height) } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="htmlEmbed"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "htmlEmbed", ...HTMLAttributes }];
},
});
/**
* Inline status pill. Mirrors @docmost/editor-ext status: the label rides in
* the element's TEXT content (not an attribute) and the color in data-color.
*/
const Status = Node.create({
name: "status",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
text: {
default: "",
parseHTML: (el: HTMLElement) => el.textContent || "",
},
color: {
default: "gray",
parseHTML: (el: HTMLElement) => el.getAttribute("data-color") || "gray",
renderHTML: (attrs: Record<string, any>) => ({
"data-color": attrs.color ?? "gray",
}),
},
};
},
parseHTML() {
return [{ tag: 'span[data-type="status"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
{ "data-type": "status", "data-color": HTMLAttributes["data-color"] },
`${HTMLAttributes.text ?? ""}`,
];
},
});
/**
* Whole-page live embed. Holds only a `sourcePageId` reference. Mirrors
* @docmost/editor-ext pageEmbed. Block atom.
*/
const PageEmbed = Node.create({
name: "pageEmbed",
group: "block",
atom: true,
isolating: true,
selectable: true,
draggable: true,
addAttributes() {
return {
sourcePageId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-source-page-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.sourcePageId
? { "data-source-page-id": attrs.sourcePageId }
: {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="pageEmbed"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "pageEmbed", ...HTMLAttributes }];
},
});
/**
* Block node types allowed inside a `transclusionSource` (mirrors
* @docmost/editor-ext transclusion constants). Excludes transclusion nodes
* (no nesting) and child-only nodes.
*/
const TRANSCLUSION_SOURCE_CONTENT_EXPRESSION =
"(paragraph | heading | blockquote | codeBlock | horizontalRule | bulletList" +
" | orderedList | taskList | image | video | audio | attachment | callout" +
" | details | embed | mathBlock | table | drawio | excalidraw | pdf" +
" | subpages | columns | youtube)+";
/** Sync-source block: editable content shared into transclusion references. */
const TransclusionSource = Node.create({
name: "transclusionSource",
group: "block",
content: TRANSCLUSION_SOURCE_CONTENT_EXPRESSION,
defining: true,
isolating: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.id ? { "data-id": attrs.id } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="transclusionSource"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": "transclusionSource", ...HTMLAttributes }, 0];
},
});
/** Live reference to a transcluded block/page. Block atom. */
const TransclusionReference = Node.create({
name: "transclusionReference",
group: "block",
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
sourcePageId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-source-page-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.sourcePageId
? { "data-source-page-id": attrs.sourcePageId }
: {},
},
transclusionId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-transclusion-id"),
renderHTML: (attrs: Record<string, any>) =>
attrs.transclusionId
? { "data-transclusion-id": attrs.transclusionId }
: {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="transclusionReference"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
{ "data-type": "transclusionReference", ...HTMLAttributes },
];
},
});
/**
* Full extension list. Image is block-level (matches Docmost); the
* ProseMirror DOM parser hoists <img> found inside <p> automatically.
* StarterKit v3 already bundles the link extension, configured here.
*/
export const docmostExtensions = [
StarterKit.configure({
codeBlock: {},
heading: {},
link: { openOnClick: false },
}),
// Preserve image width/height as the AUTHORED string. Without an explicit
// parseHTML the stock Image node attribute falls back to tiptap core's
// `fromString`, which coerces a numeric width like "320" into the number 320
// — changing the stored type on every markdown round-trip (Docmost stores
// these as strings, e.g. "320" or "50%", matching how video/audio/pdf are
// handled in this mirror). The node attribute is applied AFTER the global
// DocmostAttributes one, so the fix must live on the Image node itself.
Image.extend({
addAttributes() {
const parent = (this.parent?.() ?? {}) as Record<string, any>;
return {
...parent,
width: {
...parent.width,
parseHTML: (el: HTMLElement) => el.getAttribute("width"),
},
height: {
...parent.height,
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
},
};
},
}).configure({ inline: false }),
TaskList,
TaskItem.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into
// style="background-color: ${color}". Wrap the color attribute's parseHTML
// with the same allowlist guard used by textStyle so a crafted import color
// cannot break out of the style attribute. Multicolor behavior is preserved.
Highlight.extend({
addAttributes() {
const parent = this.parent?.() ?? {};
return {
...parent,
color: {
...(parent as Record<string, any>).color,
parseHTML: (el: HTMLElement) =>
sanitizeCssColor(
el.getAttribute("data-color") ||
getStyleProperty(el, "background-color") ||
el.style.backgroundColor,
),
},
};
},
}).configure({ multicolor: true }),
Subscript,
Superscript,
// StarterKit does not provide a textStyle mark, so register ours; without it
// generateJSON drops <span style="color: ...">, defeating the color import.
TextStyle,
Comment,
Callout,
Table,
TableRow,
TableCell,
TableHeader,
Mention,
MathInline,
MathBlock,
Details,
DetailsSummary,
DetailsContent,
Attachment,
Video,
Youtube,
Embed,
Drawio,
Excalidraw,
Columns,
Column,
Subpages,
Audio,
Pdf,
PageBreak,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
HtmlEmbed,
Status,
PageEmbed,
TransclusionSource,
TransclusionReference,
DocmostAttributes,
];