A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked findings (9 others triaged out as already-defended). Wrote a reproduction test per finding (each asserts the CORRECT behavior, so it fails on the bug), then fixed the production code so every repro goes green. All confirmed bugs: Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror): - #1 editor-ext node types silently dropped on export — ported the 8 missing canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed, status, pageEmbed, transclusionSource/Reference) into the git-sync schema mirror and added converter cases that emit their schema-matching HTML instead of flattening unknown nodes to '' (this was the critical data-loss flagged in review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated. - #2 top-level image lost width/height/align/attachmentId — now emits an HTML <img> (like video/diagrams) when it carries layout attrs; bare images stay . Image node parses width/height as strings so they re-import. - #3 code block containing a ``` fence corrupted on round-trip — outer fence is now widened to (longest-inner-backtick-run + 1). - #16 deep nesting threw RangeError (page never synced) — added a depth guard (MAX_NODE_DEPTH=400) so the converter never overflows the stack. Push/layout/cycle (engine): - #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent layout — deterministic, order-independent sibling disambiguation; suffix is stripped from a path-derived title ONLY when the new name is exactly the old title plus the suffix (never a genuine retitle ending in ' ~token'). - #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling — ambiguous (parent,title) is no longer adopted (falls back to fresh create). - #12 a new child under a new parent was created at ROOT — creates are ordered parent-before-child with an in-memory created-id map for parent resolution. - #13 git conflict markers could reach Docmost — bodies are scanned and the marker lines stripped (a '=======' line is only treated as a conflict separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe). - #15 a divergent `docmost` mirror was escalated by runPush but dropped by runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator. Server (merge / lock / provenance): - #9 3-way merge lost a human's block edit when git inserted an adjacent block — finer-grained diff3 region merge (via lcs) preserves non-overlapping human edits; genuine same-block conflicts still resolve git-wins. - #10 single-writer race — module-static liveLocks closes the same-process TOCTOU window, and a heartbeat refresh that cannot confirm the lock now aborts the cycle at its next write checkpoint (cooperative AbortSignal threaded through runCycle). Cross-process fencing tokens remain a follow-up. - #14 sticky-agent provenance overrode an explicit actor='git-sync' write, blinding the listener loop-guard — resolveSource now lets an explicit actor win over the sticky-agent fallback (explicit agent still wins). Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541 pass, server tsc clean. A review pass over the fixes caught and corrected a title-suffix over-strip, an inert abort signal, a document-wide conflict-marker strip, and two leaf-atom content-holes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1450 lines
44 KiB
TypeScript
1450 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,
|
|
parseHTML() {
|
|
return [{ tag: 'div[data-type="subpages"]' }];
|
|
},
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ["div", { "data-type": "subpages", ...HTMLAttributes }, 0];
|
|
},
|
|
});
|
|
|
|
/** 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,
|
|
];
|