) =>
+ 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
found inside automatically.
@@ -1041,7 +1362,29 @@ export const docmostExtensions = [
heading: {},
link: { openOnClick: false },
}),
- Image.configure({ inline: 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;
+ 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
@@ -1094,5 +1437,13 @@ export const docmostExtensions = [
Audio,
Pdf,
PageBreak,
+ FootnoteReference,
+ FootnotesList,
+ FootnoteDefinition,
+ HtmlEmbed,
+ Status,
+ PageEmbed,
+ TransclusionSource,
+ TransclusionReference,
DocmostAttributes,
];
diff --git a/packages/git-sync/src/lib/markdown-converter.ts b/packages/git-sync/src/lib/markdown-converter.ts
index 7cd4b320..738e1d1b 100644
--- a/packages/git-sync/src/lib/markdown-converter.ts
+++ b/packages/git-sync/src/lib/markdown-converter.ts
@@ -1,3 +1,18 @@
+import { encodeHtmlEmbedSource } from "./docmost-schema.js";
+
+/**
+ * Hard cap on processNode recursion depth (see the depth guard below).
+ *
+ * Chosen well above any realistic document (the deepest legitimate nesting the
+ * editor can produce is far shallower) yet far below the point where the
+ * converter's own call stack overflows. The heaviest shape (deeply nested
+ * lists) costs ~5 JS frames per level and the runtime stack holds ~10k frames,
+ * so the measured overflow is around level ~650 (deeply nested lists); 400
+ * leaves a comfortable margin while still rendering pathological-but-bounded
+ * docs in full (the 200-level stress fixture reaches depth ~204).
+ */
+const MAX_NODE_DEPTH = 400;
+
/**
* Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions
@@ -43,7 +58,34 @@ export function convertProseMirrorToMarkdown(content: any): string {
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
+ // Recursion depth guard. processNode is mutually recursive (directly and via
+ // processListItem/processTaskItem/blockToHtml), and a pathologically nested
+ // document (e.g. tens of thousands of nested blockquotes) would otherwise
+ // overflow the call stack and throw a RangeError, which would abort the sync
+ // and prevent the page from ever being written. We track the live nesting
+ // depth in a closure counter (the wrapper below) so we NEVER throw: past the
+ // limit we stop recursing and emit the node's own text (or nothing) instead.
+ // Normal documents never approach MAX_NODE_DEPTH, so their output is byte-
+ // identical. NOTE: the wrapper signature is (node) only — several callers use
+ // `.map(processNode)`, which would otherwise pass the array index as a second
+ // argument; the wrapper ignores extra arguments so that is harmless.
+ let nodeDepth = 0;
const processNode = (node: any): string => {
+ if (nodeDepth >= MAX_NODE_DEPTH) {
+ // Bail out of deeper recursion without throwing. A text node still has
+ // its own content worth keeping; a container at the limit collapses to
+ // "" (its already-too-deep subtree is dropped) rather than overflowing.
+ return typeof node?.text === "string" ? node.text : "";
+ }
+ nodeDepth++;
+ try {
+ return processNodeInner(node);
+ } finally {
+ nodeDepth--;
+ }
+ };
+
+ const processNodeInner = (node: any): string => {
const type = node.type;
const nodeContent = node.content || [];
@@ -182,7 +224,16 @@ export function convertProseMirrorToMarkdown(content: any): string {
.map(processNode)
.join("")
.replace(/\n+$/, "");
- return "```" + language + "\n" + code + "\n```";
+ // CommonMark: an inner ``` run inside the code would prematurely close
+ // a 3-backtick fence (corrupting the block on re-import). Use an outer
+ // fence one backtick longer than the longest backtick run in the code
+ // (minimum 3) so the inner fence is always content.
+ const longestBacktickRun = (code.match(/`+/g) || []).reduce(
+ (max: number, run: string) => Math.max(max, run.length),
+ 0,
+ );
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
+ return fence + language + "\n" + code + "\n" + fence;
case "bulletList":
return nodeContent
@@ -228,16 +279,35 @@ export function convertProseMirrorToMarkdown(content: any): string {
// a bare "\n" would be reimported as a soft break and lost.
return " \n";
- case "image":
- const imgAlt = node.attrs?.alt || "";
+ case "image": {
+ const imgAttrs = node.attrs || {};
+ // A top-level image with layout/identity attrs beyond src/alt cannot be
+ // expressed by markdown `` — width/height/align/size/
+ // attachmentId/aspectRatio would be silently dropped on export and lost
+ // on re-import. Emit the SAME schema-matching
used inside columns
+ // (imageToHtml) so those attrs survive the round-trip. A bare image
+ // (only src/alt, optionally a title — which has no schema attr) keeps
+ // the lighter markdown form so existing image round-trip tests hold.
+ const hasLayoutAttrs =
+ imgAttrs.width != null ||
+ imgAttrs.height != null ||
+ imgAttrs.align ||
+ imgAttrs.size != null ||
+ imgAttrs.attachmentId ||
+ imgAttrs.aspectRatio != null;
+ if (hasLayoutAttrs) {
+ return imageToHtml(node);
+ }
+ const imgAlt = imgAttrs.alt || "";
// Neutralize characters that could break out of the markdown image
// URL: spaces/newlines and parentheses would terminate the (...) target
// and let a stored src inject following markdown/HTML. Percent-encode
// them so the URL stays a single inert token.
- const imgSrc = encodeMdUrl(node.attrs?.src);
+ const imgSrc = encodeMdUrl(imgAttrs.src);
// No "caption" attribute exists in the Docmost image schema, so we do
// not emit one (the previous caption branch was dead).
return ``;
+ }
case "video": {
// Emit the schema-matching