feat(prosemirror-markdown): new headless converter package seeded from git-sync (#293 stage 1)

Create @docmost/prosemirror-markdown — the single framework-free ProseMirror<->
Markdown converter + schema mirror that git-sync and mcp will both consume,
ending the three-hand-synced-copies drift (#293). This step only CREATES the
package (no consumer yet; git-sync untouched); the switch of git-sync and mcp
onto it, plus the canonical format decisions, come in later commits of this PR.

- packages/prosemirror-markdown/src/lib/: the 8 converter-core files copied
  VERBATIM from packages/git-sync/src/lib (docmost-schema, markdown-converter,
  markdown-to-prosemirror, canonicalize, markdown-document, node-ops, page-file,
  index). Confirmed byte-identical — no behavioral drift introduced.
- src/index.ts barrel; package.json (@tiptap/* + jsdom/marked/zod, editor-ext
  workspace devDep for the contract test); tsconfig/vitest configs.
- 24 converter-core test files + fixtures copied (engine-coupled layout/
  redteam-layout-title tests correctly excluded — they import ../src/engine).
- pnpm-lock importer added; build/ gitignored (CI-built).

Verified (clean checkout, no network): pnpm --frozen-lockfile EXIT 0; tsc EXIT 0;
vitest 23 files, 443 passed | 1 expected-fail (the same image-diagrams
known-limitation carried from git-sync) — faithful extraction. git-sync untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-04 07:10:04 +03:00
parent f5d19f9728
commit d6d7dd82f6
51 changed files with 12166 additions and 0 deletions
+4
View File
@@ -10,6 +10,10 @@ node_modules/
# so src/ and prod can never silently diverge).
packages/git-sync/build/
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
# never committed, so src/ and prod can never silently diverge).
packages/prosemirror-markdown/build/
# Logs
logs
*.log
@@ -0,0 +1,45 @@
{
"name": "@docmost/prosemirror-markdown",
"version": "0.1.0",
"description": "Pure ProseMirror <-> Markdown converter + schema mirror (headless, framework-free).",
"private": true,
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.20.4",
"@tiptap/extension-highlight": "3.20.4",
"@tiptap/extension-image": "3.20.4",
"@tiptap/extension-subscript": "3.20.4",
"@tiptap/extension-superscript": "3.20.4",
"@tiptap/extension-task-item": "3.20.4",
"@tiptap/extension-task-list": "3.20.4",
"@tiptap/html": "3.20.4",
"@tiptap/pm": "3.20.4",
"@tiptap/starter-kit": "3.20.4",
"jsdom": "25.0.0",
"marked": "17.0.5",
"zod": "4.3.6"
},
"devDependencies": {
"@docmost/editor-ext": "workspace:*",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.0.0",
"fast-check": "^4.8.0",
"typescript": "^5.0.0",
"vitest": "4.1.6"
}
}
@@ -0,0 +1,9 @@
/**
* Public surface of `@docmost/prosemirror-markdown`.
*
* A headless, framework-free ProseMirror <-> Markdown converter plus the
* Docmost schema mirror. Everything lives under `lib/` (the converter core);
* this top-level barrel simply re-exports that surface so the package entry is
* the converter surface.
*/
export * from "./lib/index.js";
@@ -0,0 +1,247 @@
/**
* Semantic canonicalization of ProseMirror/TipTap documents for the round-trip
* idempotency check (SPEC §11, "Task #0", option (b): compare a CANONICALIZED
* form rather than raw bytes).
*
* `markdownToProseMirror` reconstructs schema DEFAULT attributes (e.g.
* `indent: null` where the source omitted it) and regenerates per-block ids on
* every import. A raw deep-equal of the source doc against the re-imported doc
* therefore diverges even when the two are semantically identical. This module
* normalizes a document so that two semantically-equal docs compare deep-equal
* regardless of block ids and absent-vs-explicit-default-null attributes.
*
* It is a self-contained module with no external dependencies.
*/
/**
* Known NON-NULL schema defaults that `markdownToProseMirror` materializes on
* import, keyed by node/mark type → { attr: defaultValue }.
*
* Why this exists: `canonicalizeAttrs` already treats an absent attr as
* equivalent to an explicit `null`/`undefined`. But several Docmost schema
* attributes default to a NON-null value, so import fills them in even when the
* source omitted them — making "attr absent" diverge from "attr at its default
* value" under a raw deep-equal. To keep "absent ≡ explicit-default", we ALSO
* drop any attr whose value equals its known schema default. A non-default
* value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT.
*
* Every entry below was read from `packages/docmost-client/src/lib/
* docmost-schema.ts` (the line refs are the exact `default:` declarations) and
* confirmed to be materialized by an export→import→export round-trip:
* - mark `link` target / rel — DocmostAttributes + StarterKit link.
* StarterKit's link extension defaults `target: "_blank"` and
* `rel: "noopener noreferrer nofollow"`; both materialize on import
* (empirically confirmed) even when the source had only `href`.
* - mark `comment` resolved — docmost-schema.ts L213-214 (`default: false`).
* - node `orderedList` start — provided by StarterKit's orderedList
* (`default: 1`); materializes on import (empirically confirmed).
* - node `drawio`/`excalidraw`/`video`/`youtube`/`embed` align — the diagram
* attribute set and the media nodes declare `align: { default: "center" }`
* (docmost-schema.ts L745-750 diagramAttributes; L564 video; L626 youtube;
* L667 embed). The diagram `align` is the one the round-trip materializes
* (docmost-schema.ts L745); the media/embed entries normalize the SAME
* `align` default for consistency. Note: this only normalizes `align` —
* full canonical stability of `embed` is separately limited by the
* converter coercing numeric `width`/`height` to strings, which is outside
* canonicalize's scope.
*
* NOTE: `image` has NO non-null align default — its `align` defaults to `null`
* (docmost-schema.ts L174), so it is already handled by the null-drop rule and
* is intentionally NOT listed here.
*/
const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
// mark types
link: {
target: "_blank",
rel: "noopener noreferrer nofollow",
},
comment: {
resolved: false,
},
// node types
orderedList: {
start: 1,
},
drawio: {
align: "center",
},
excalidraw: {
align: "center",
},
video: {
align: "center",
},
youtube: {
align: "center",
},
embed: {
align: "center",
},
};
/**
* Prune an `attrs` object in place on a fresh copy: drop keys whose value is
* `null` or `undefined` (an absent attribute and an explicit default of `null`
* are semantically equivalent here). Optionally also drop a node-level `id`
* (block ids are regenerated on import, SPEC §11). ALSO drop any attr whose
* value equals the node/mark `type`'s known NON-null schema default
* (`KNOWN_DEFAULTS`), so "attr absent" ≡ "attr at its default value" — without
* this, the import-materialized `link.target`/`comment.resolved`/
* `orderedList.start`/diagram `align` defaults would be a phantom diff. Every
* non-default attribute value is KEPT (level, language, src, href, commentId,
* width, a non-default `start`/`align`, ...).
*
* Returns the pruned attrs object, or `undefined` if nothing meaningful is
* left (so the caller can drop the `attrs` key entirely: `{attrs:{}}` ≡ no
* attrs).
*/
function canonicalizeAttrs(
attrs: Record<string, unknown>,
dropId: boolean,
type: string | undefined,
): Record<string, unknown> | undefined {
const defaults = type ? KNOWN_DEFAULTS[type] : undefined;
const out: Record<string, unknown> = {};
// Stable key order so a JSON.stringify of the canonical form is comparable
// regardless of the input's key order.
for (const key of Object.keys(attrs).sort()) {
// Block ids are regenerated on import; drop them on NODE attrs only.
if (dropId && key === "id") continue;
const value = attrs[key];
// Absent ≡ explicit-default-null/undefined.
if (value === null || value === undefined) continue;
// Absent ≡ explicit known non-null default (e.g. link.target="_blank").
// A non-default value (e.g. orderedList.start=5) does NOT match, so it is
// kept. The `comment` mark's `commentId` is never a default, so it always
// survives (SPEC §3); only its `resolved: false` default is normalized away.
if (defaults && key in defaults && value === defaults[key]) continue;
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Return a DEEP COPY of a ProseMirror node tree, canonicalized so that two
* semantically-equal documents compare deep-equal. Rules (applied recursively
* to the node, its `content`, and its `marks`):
*
* 1. Remove node-level `attrs.id` (regenerated on import). Mark attrs are NOT
* touched for `id` (marks carry no block id; only their meaningful attrs).
* 2. In any `attrs` object (node OR mark) drop keys whose value is `null`/
* `undefined` (absent ≡ explicit default null) OR equals that node/mark
* type's known non-null schema default (absent ≡ explicit default).
* Keep every non-default value. The type is passed into the attrs
* normalizer so it can look up `KNOWN_DEFAULTS`.
* 3. If an `attrs` object becomes empty after pruning, drop the `attrs` key.
* 4. Preserve `marks` (including the `comment` mark and its `commentId` — a
* meaningful anchor per SPEC §3; never strip it).
* 5. Preserve `text`, `type`, and `content` order exactly.
* 6. Never mutate the input.
*/
export function canonicalizeContent(node: any): any {
if (Array.isArray(node)) {
return node.map((child) => canonicalizeContent(child));
}
if (node === null || typeof node !== "object") {
// Primitive leaf (string/number/boolean/null): returned as-is.
return node;
}
// A node is a mark when it has a `type` but never carries block `content`
// and lives inside a `marks` array. We cannot tell from the node alone, so
// we distinguish at the recursion site: node `attrs` drop `id`, mark `attrs`
// do not. This is handled by passing a `dropId` flag down for the `attrs`
// key specifically (nodes) vs the `marks[].attrs` path (marks).
const out: Record<string, unknown> = {};
for (const key of Object.keys(node)) {
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
// Node-level attrs: drop the block id, null/undefined attrs, and any
// attr at this node type's known non-null schema default.
const canon = canonicalizeAttrs(
node.attrs as Record<string, unknown>,
true,
typeof node.type === "string" ? node.type : undefined,
);
if (canon !== undefined) out.attrs = canon;
// else: drop the `attrs` key entirely (rule 3).
} else if (key === "marks" && Array.isArray(node.marks)) {
// Marks: keep them all (incl. comment); canonicalize their attrs but do
// NOT drop `id` (a mark's `id` would be a meaningful attr, not a block
// id). An empty marks array is dropped so `marks:[]` ≡ no marks.
const marks = (node.marks as any[]).map((mark) => canonicalizeMark(mark));
if (marks.length > 0) out.marks = marks;
} else {
out[key] = canonicalizeContent(node[key]);
}
}
return out;
}
/**
* Canonicalize a single mark: keep `type`, prune its `attrs` (null/undefined
* AND known non-null defaults dropped, empty attrs removed) but NEVER drop a
* mark's attribute as a "block id" — marks have no block id, only meaningful
* attrs (href, commentId, color, level, ...). Meaningful NON-default attrs
* survive (the `comment` mark's `commentId` is never a default, so it always
* survives — SPEC §3); only known defaults like `link.target="_blank"`,
* `link.rel="noopener…"` and `comment.resolved=false` are normalized away.
*/
function canonicalizeMark(mark: any): any {
if (mark === null || typeof mark !== "object") return mark;
const out: Record<string, unknown> = {};
for (const key of Object.keys(mark)) {
if (key === "attrs" && mark.attrs && typeof mark.attrs === "object") {
const canon = canonicalizeAttrs(
mark.attrs as Record<string, unknown>,
false,
typeof mark.type === "string" ? mark.type : undefined,
);
if (canon !== undefined) out.attrs = canon;
} else {
out[key] = canonicalizeContent(mark[key]);
}
}
return out;
}
/**
* Deep structural equality of two values that is key-order-insensitive.
* Used to compare canonical forms. (`canonicalizeContent` already emits
* `attrs` in a stable key order, but the top-level node keys preserve input
* order, so we compare structurally rather than by string.)
*/
function deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return a === b;
if (typeof a !== "object") return false;
const aIsArr = Array.isArray(a);
const bIsArr = Array.isArray(b);
if (aIsArr !== bIsArr) return false;
if (aIsArr) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
/**
* True when two ProseMirror documents are semantically equal: equal after
* canonicalization (block ids stripped, absent-vs-default-null normalized).
*/
export function docsCanonicallyEqual(a: any, b: any): boolean {
return deepEqual(canonicalizeContent(a), canonicalizeContent(b));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
/**
* Public surface of the pure converter (`lib/`). This barrel re-exports the
* PURE, IO-free pieces the sync engine needs: the self-contained markdown
* (de)serializers, the lossless ProseMirror <-> Markdown converter, the
* markdown -> ProseMirror import path, and semantic canonicalization for the
* round-trip idempotency check (SPEC §11).
*
* There is no REST client, websocket/collab write-path, auth-utils or page-lock
* here — the gitmost server writes natively.
*/
export {
serializeDocmostMarkdown,
parseDocmostMarkdown,
serializeDocmostMarkdownBody,
} from "./markdown-document.js";
export type { DocmostMdMeta } from "./markdown-document.js";
export { convertProseMirrorToMarkdown } from "./markdown-converter.js";
export { markdownToProseMirror } from "./markdown-to-prosemirror.js";
export {
canonicalizeContent,
docsCanonicallyEqual,
} from "./canonicalize.js";
export { parsePageFile, serializePageFile } from "./page-file.js";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
/**
* Self-contained Docmost-flavoured Markdown document (custom extensions).
*
* A single `.md` file that packages everything needed to losslessly round-trip
* a page through "download -> edit body -> re-upload":
* - a leading `docmost:meta` block: a one-line JSON object with page identity;
* - the Markdown body (carrying inline comment anchors and diagrams as HTML);
* - a trailing `docmost:comments` block: a one-line JSON array of comment
* threads.
*
* Both metadata blocks are HTML comments on purpose: `marked`/`generateJSON`
* drop HTML comments, so even if the WHOLE file were ever fed straight to the
* importer without first stripping the blocks, the metadata cannot leak into the
* document. (A fenced ```docmost-comments``` block would WRONGLY become a
* codeBlock node, so a fenced block is deliberately NOT used.)
*
* The delimiter literals may legitimately appear in the BODY too (e.g. a user
* re-pastes an exported `.md` into a page, or a page documents this very
* format). To stay robust, parsing treats only the FINAL, document-ending
* `docmost:comments` block as metadata: it is the last `<!-- docmost:comments`
* opener whose closing `-->` sits at the very end of the file. Any earlier
* literal occurrence is left in the body untouched.
*
* NOTE on comments: in this version the comment THREAD records are preserved in
* the file but are NOT pushed back to the server on import — only the inline
* comment marks (anchors) embedded in the body are restored. Managing comment
* records stays with the comment tools/UI.
*/
export interface DocmostMdMeta {
version: number;
pageId?: string;
slugId?: string;
title?: string;
spaceId?: string;
parentPageId?: string | null;
}
// Match the leading meta block (allow leading whitespace). Capture group 1 is
// the JSON text between the markers.
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
// Match a `docmost:comments` opener. Used globally to scan for the LAST opener
// rather than end-anchoring a single regex (which would mis-capture across a
// literal opener that appears earlier in the body).
const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
/**
* Assemble the full self-contained markdown file: meta block, body, and the
* comments block. The meta block is always emitted; the comments block is always
* emitted too (with `[]` when there are no comments) so the format stays uniform
* and parsing stays simple.
*/
export function serializeDocmostMarkdown(
meta: DocmostMdMeta,
body: string,
comments: any[],
): string {
const metaJson = JSON.stringify(meta);
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
const trimmedBody = (body ?? "").trim();
return (
`<!-- docmost:meta\n${metaJson}\n-->\n\n` +
`${trimmedBody}\n\n` +
`<!-- docmost:comments\n${commentsJson}\n-->\n`
);
}
/**
* Split a self-contained file back into its parts. Tolerant: if the meta or
* comments block is missing (e.g. a hand-written plain-markdown file), the
* corresponding value is returned as `null` and the whole input is treated as
* the body. This never throws on a MISSING block; only a `JSON.parse` failure
* inside a block that IS present is surfaced as a thrown Error with a clear
* message. Robust to `\r\n` line endings.
*/
export function parseDocmostMarkdown(full: string): {
meta: DocmostMdMeta | null;
body: string;
comments: any[] | null;
} {
// Normalize line endings so the anchored regexes work regardless of CRLF.
const normalized = (full ?? "").replace(/\r\n/g, "\n");
// Extract the leading meta block (start-anchored — already unambiguous).
let meta: DocmostMdMeta | null = null;
let metaEnd = 0;
const metaMatch = normalized.match(META_RE);
if (metaMatch) {
try {
meta = JSON.parse(metaMatch[1]);
} catch (e) {
throw new Error(
`Invalid docmost:meta JSON block: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
// Body starts right after the matched meta block.
metaEnd = (metaMatch.index ?? 0) + metaMatch[0].length;
}
// Find the LAST `<!-- docmost:comments` opener; the real file-level block is
// the final one whose closing `-->` ends the document. Any earlier literal
// occurrence inside the body (e.g. a re-pasted export) is left in the body.
let lastOpenStart = -1;
let lastOpenEnd = -1;
let m: RegExpExecArray | null;
COMMENTS_OPEN_RE.lastIndex = 0;
while ((m = COMMENTS_OPEN_RE.exec(normalized)) !== null) {
lastOpenStart = m.index;
lastOpenEnd = m.index + m[0].length;
}
let comments: any[] | null = null;
let bodyEnd = normalized.length;
if (lastOpenStart !== -1) {
const rest = normalized.slice(lastOpenEnd);
const close = rest.match(/\r?\n-->[ \t]*\r?\n?\s*$/); // closer must end the doc
if (close) {
const jsonText = rest.slice(0, close.index);
try {
comments = JSON.parse(jsonText);
} catch (e) {
throw new Error(
`Invalid docmost:comments JSON block: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
bodyEnd = lastOpenStart; // strip from the opener to end of document
}
}
const body = normalized.slice(metaEnd, bodyEnd).trim();
return { meta, body, comments };
}
/**
* Serialize a self-contained markdown file with the meta block + body ONLY —
* NO trailing `docmost:comments` block. The sync engine never touches
* `/comments` (SPEC §3): the synced file carries just page identity (meta) and
* the body, where comment threads survive only as inline `<span
* data-comment-id>` anchor marks inside the body.
*
* `parseDocmostMarkdown` already tolerates a missing comments block (it returns
* `comments: null` and treats the rest as body), so a file produced here
* round-trips cleanly through the parser.
*/
export function serializeDocmostMarkdownBody(
meta: DocmostMdMeta,
body: string,
): string {
return `<!-- docmost:meta\n${JSON.stringify(meta)}\n-->\n\n${(body ?? "").trim()}\n`;
}
@@ -0,0 +1,365 @@
/**
* Pure markdown -> ProseMirror conversion.
*
* The converter path is `markdownToProseMirror` (marked -> HTML ->
* generateJSON) plus the two pre/post processors it needs (`preprocessCallouts`,
* `bridgeTaskLists`). The gitmost server writes the resulting page bodies
* natively through the collab gateway, so no websocket/Yjs write-path lives
* here.
*/
import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom";
import { marked } from "marked";
import { docmostExtensions } from "./docmost-schema.js";
// Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
global.window = dom.window as any;
global.document = dom.window.document;
// @ts-ignore
global.Element = dom.window.Element;
/**
* Hard ceiling above which we skip callout preprocessing entirely. The linear
* scanner below has no quadratic blow-up, but we still cap input defensively so
* a pathological multi-megabyte payload cannot tie up the event loop; in that
* case the markdown is passed through verbatim (callouts are simply not
* detected) rather than risking a slow scan.
*/
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
/** Matches a bare closing callout fence: `:::`. */
const CALLOUT_CLOSE_RE = /^:::\s*$/;
/**
* Matches an Obsidian-native callout opener: `> [!type]` (type captured). An
* optional title after the type is allowed but ignored (the Docmost callout
* schema has no title). The body is the following contiguous blockquote lines.
*/
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
/** Matches any blockquote continuation line (`>` … ). */
const BLOCKQUOTE_LINE_RE = /^>/;
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
/**
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
* callout blocks (the syntax our markdown export produces) into HTML
* divs that the callout extension parses. The inner content is rendered
* through marked as regular markdown.
*
* Implemented as a single linear pass over the lines (no quadratic regex
* rescan). It:
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
* `:::` line that lives inside a code fence as a callout delimiter, so a
* callout body that itself contains a fenced code block with a `:::` line is
* no longer corrupted;
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
* nesting level, supporting NESTED callouts via a depth counter (an inner
* `:::type` opens a deeper level and consumes a matching `:::`);
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
* (inner rendered through marked) as the previous regex implementation.
*/
async function preprocessCallouts(markdown: string): Promise<string> {
// Defensive cap: skip preprocessing for pathologically large inputs.
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown;
}
// Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function.
const transform = async (lines: string[]): Promise<string> => {
const out: string[] = [];
let inCodeFence = false;
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Inside a code fence, only its matching closing fence is significant;
// everything else (including `:::` lines) is copied through verbatim.
if (inCodeFence) {
out.push(line);
const fence = line.match(CODE_FENCE_RE);
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
fence[2].length >= codeFenceMarker.length) {
inCodeFence = false;
codeFenceMarker = "";
}
i++;
continue;
}
// A code fence opening outside any callout body: enter code-fence mode.
const fenceOpen = line.match(CODE_FENCE_RE);
if (fenceOpen) {
inCodeFence = true;
codeFenceMarker = fenceOpen[2];
out.push(line);
i++;
continue;
}
// An opening callout fence: scan forward (with code-fence and nested
// callout awareness) for its matching closing `:::` at the same level.
const open = line.match(CALLOUT_OPEN_RE);
if (open) {
const type = open[1].toLowerCase();
const bodyLines: string[] = [];
let depth = 1;
let innerInCodeFence = false;
let innerCodeFenceMarker = "";
let j = i + 1;
for (; j < lines.length; j++) {
const bl = lines[j];
if (innerInCodeFence) {
const f = bl.match(CODE_FENCE_RE);
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
f[2].length >= innerCodeFenceMarker.length) {
innerInCodeFence = false;
innerCodeFenceMarker = "";
}
bodyLines.push(bl);
continue;
}
const innerFence = bl.match(CODE_FENCE_RE);
if (innerFence) {
innerInCodeFence = true;
innerCodeFenceMarker = innerFence[2];
bodyLines.push(bl);
continue;
}
if (CALLOUT_OPEN_RE.test(bl)) {
depth++;
bodyLines.push(bl);
continue;
}
if (CALLOUT_CLOSE_RE.test(bl)) {
depth--;
if (depth === 0) break; // matching close for THIS callout
bodyLines.push(bl);
continue;
}
bodyLines.push(bl);
}
if (j < lines.length) {
// Found the matching closing fence: render the body (recursively, so
// nested callouts are handled) and emit the callout div.
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j + 1; // skip past the closing `:::`
continue;
}
// No matching close (unterminated callout): treat the opener as a
// literal line and continue, preserving the original text.
out.push(line);
i++;
continue;
}
// An Obsidian-native callout: `> [!type]` opener; the body is the following
// CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
// its first line) does not match and stays a blockquote.
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
if (bqOpen) {
const type = bqOpen[1].toLowerCase();
const bodyLines: string[] = [];
let j = i + 1;
for (; j < lines.length; j++) {
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
bodyLines.push(lines[j].replace(/^>\s?/, ""));
}
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j;
continue;
}
out.push(line);
i++;
}
return out.join("\n");
};
return transform(markdown.split("\n"));
}
/**
* Bridge marked's checkbox lists to TipTap task lists.
*
* marked renders GitHub task list items (`- [x] done`) as a plain
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
* into the shape those extensions expect:
* TaskList parseHTML matches `ul[data-type="taskList"]`,
* TaskItem matches `li[data-type="taskItem"]`,
* the checked state is read from `data-checked === "true"`.
*
* A list is only converted when it has at least one `<li>` and EVERY direct
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
*/
function bridgeTaskLists(html: string): string {
// Cheap early-out: if the markup contains no checkbox input at all there is
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
// common case (most pages have no task lists).
if (!/type=["']?checkbox/i.test(html)) {
return html;
}
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
// pathologically large inputs rather than running a second expensive JSDOM
// parse on a multi-megabyte payload. The markup is passed through verbatim.
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return html;
}
const dom = new JSDOM(html);
const document = dom.window.document;
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
// bullet <li> that merely contains a nested task sublist is not misdetected.
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
// ALL of them so none survive into the converted item.
const directCheckboxes = (li: Element): Element[] => {
const found: Element[] = [];
for (const child of Array.from(li.children)) {
if (
child.tagName === "INPUT" &&
child.getAttribute("type") === "checkbox"
) {
found.push(child);
continue;
}
if (child.tagName === "P") {
for (const inp of Array.from(
child.querySelectorAll(":scope > input[type='checkbox']"),
)) {
found.push(inp);
}
}
}
return found;
};
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
// its own checkbox is a numbered checklist that must also become a taskList.
const lists = Array.from(document.querySelectorAll("ul, ol"));
for (const list of lists) {
// Only consider DIRECT child <li> elements; nested lists are handled by
// their own iteration of the outer loop.
const items = Array.from(list.children).filter(
(child) => child.tagName === "LI",
);
if (items.length === 0) continue;
const itemCheckboxes = items.map((li) => directCheckboxes(li));
// Convert only when every direct <li> carries at least one OWN checkbox.
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
// <ol> while tagging it data-type="taskList": generateJSON would then match
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
// emitting a phantom empty orderedList beside the real taskList. So rename a
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
let target: Element = list;
if (list.tagName === "OL") {
const ul = document.createElement("ul");
// Carry over existing attributes (e.g. class) so nothing is silently lost.
for (const attr of Array.from(list.attributes)) {
ul.setAttribute(attr.name, attr.value);
}
// Move every child node (including the <li>s we collected) into the <ul>.
while (list.firstChild) {
ul.appendChild(list.firstChild);
}
list.replaceWith(ul);
target = ul;
}
target.setAttribute("data-type", "taskList");
items.forEach((li, index) => {
const boxes = itemCheckboxes[index];
// The first checkbox determines the checked state (matches the previous
// single-checkbox behaviour); any extras only need removing.
const input = boxes[0] ?? null;
li.setAttribute("data-type", "taskItem");
const checked =
input != null &&
(input.hasAttribute("checked") || (input as any).checked);
li.setAttribute("data-checked", checked ? "true" : "false");
// Remove ALL direct checkbox inputs so none survive into the content
// (a raw-inline-HTML <li> may carry more than one).
for (const box of boxes) {
box.remove();
}
});
}
return document.body.innerHTML;
}
/**
* Recursively strip content-less paragraph nodes from a generated doc.
*
* A block-level atom whose markdown form is INLINE (e.g. the block `image`'s
* `![](url)`, or a bare media element) is wrapped by marked in a <p>; the schema
* then HOISTS the block atom out of that paragraph, leaving an EMPTY paragraph
* sibling. On the next export that empty `<p>` renders to "" and the doc "\n\n"
* join injects a phantom blank gap, so the markdown is not byte-stable.
*
* Markdown blank lines are separators, never content, so generateJSON only ever
* produces an empty paragraph as such a hoist artifact — removing them is safe
* and general (it also subsumes the <div>-wrapper workaround the `video` case
* uses). We remove ONLY `type === 'paragraph'` nodes whose `content` is absent
* or an empty array; every other node (including atoms without `content`) is
* preserved, and we recurse into the content of any node that has children.
*/
function stripEmptyParagraphs(node: any): any {
if (!node || !Array.isArray(node.content)) {
// Atom / leaf node (no children to recurse into): keep as-is.
return node;
}
const mapped = node.content.map((child: any) => stripEmptyParagraphs(child));
const isEmptyParagraph = (child: any): boolean =>
!!child &&
child.type === "paragraph" &&
(!Array.isArray(child.content) || child.content.length === 0);
const filtered = mapped.filter((child: any) => !isEmptyParagraph(child));
// Schema-validity guard: several nodes require NON-empty block content
// (`content: "block+"` — tableCell, tableHeader, blockquote, column, callout,
// and the doc root). For an empty one of those, generateJSON materializes a
// single empty paragraph as its OBLIGATORY content — that is not a hoist
// artifact. If stripping would empty the container, keep ONE empty paragraph
// so the result stays schema-valid (an empty cell/quote must not become `[]`).
const cleaned =
filtered.length === 0 && mapped.length > 0 ? [mapped[0]] : filtered;
return { ...node, content: cleaned };
}
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
export async function markdownToProseMirror(
markdownContent: string,
): Promise<any> {
const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts);
const bridged = bridgeTaskLists(html);
const doc = generateJSON(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
}
@@ -0,0 +1,897 @@
/**
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
* tree by node id.
*
* A ProseMirror node here is a plain JSON object of the shape produced by
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
* table cells hold their children in `content` just like any other block, so a
* single recursive walk reaches them all.
*
* Every exported function operates on a DEEP CLONE of the input document and
* returns the new document. The input doc and any `newNode`/`node` argument are
* never mutated. All functions are defensively null-safe: missing/!Array
* `content`, non-object nodes, and absent `attrs` are tolerated.
*/
/** Deep-clone a JSON-serializable value without mutating the original. */
function clone<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
// Fallback for environments without structuredClone.
return JSON.parse(JSON.stringify(value)) as T;
}
/** True if `value` is a non-null object (and not an array). */
function isObject(value: any): value is Record<string, any> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/** True if `node` carries the given id in `node.attrs.id`. */
function matchesId(node: any, nodeId: string): boolean {
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
}
/**
* Recursively concatenate all text contained in a node.
*
* Text nodes contribute their `text` string; container nodes contribute the
* joined `blockPlainText` of their `content` children. Returns "" for nullish
* or non-object inputs.
*/
export function blockPlainText(node: any): string {
if (!isObject(node)) return "";
let out = "";
if (typeof node.text === "string") {
out += node.text;
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
out += blockPlainText(child);
}
}
return out;
}
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
function truncate(text: string, n: number): string {
return text.length > n ? text.slice(0, n) + "…" : text;
}
/** One compact outline entry for a single top-level block. */
export interface OutlineEntry {
index: number;
type: string | undefined;
id: string | null;
firstText: string;
/** Present for headings only. */
level?: number | null;
/** Present for tables only. */
rows?: number;
cols?: number;
header?: string[];
/** Present for list blocks only (bulletList/orderedList/taskList). */
items?: number;
}
/**
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
* table cells — compactness is the point; use `getNodeByRef` to drill into a
* specific block.
*
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
* cell texts as `header`; list blocks (types ending in "List") add `items`.
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
* a missing or non-object doc/content yields `[]`.
*/
export function buildOutline(doc: any): OutlineEntry[] {
if (!isObject(doc) || !Array.isArray(doc.content)) return [];
const out: OutlineEntry[] = [];
for (let i = 0; i < doc.content.length; i++) {
const block = doc.content[i];
const type = isObject(block) ? block.type : undefined;
const entry: OutlineEntry = {
index: i,
type,
id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null,
firstText: truncate(blockPlainText(block), 100),
};
if (type === "heading") {
entry.level = isObject(block.attrs) ? block.attrs.level ?? null : null;
} else if (type === "table") {
const headerRow = block.content?.[0]?.content ?? [];
entry.rows = block.content?.length ?? 0;
entry.cols = block.content?.[0]?.content?.length ?? 0;
entry.header = headerRow.map((cell: any) =>
truncate(blockPlainText(cell), 40),
);
} else if (typeof type === "string" && type.endsWith("List")) {
entry.items = block.content?.length ?? 0;
}
out.push(entry);
}
return out;
}
/**
* Resolve a single node by reference and return `{ node, path, type }`, or
* `null` when nothing matches.
*
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
* `n` in `doc.content`. This is the only way to address table/tableRow/
* tableCell nodes, which carry no `attrs.id`.
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
* tree with `attrs.id === ref` is returned.
*
* `path` is the array of child indices from the doc root down to the node
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
* so callers can mutate it without touching the input doc. Null-safe.
*/
export function getNodeByRef(
doc: any,
ref: string,
): { node: any; path: number[]; type: string | undefined } | null {
if (!isObject(doc)) return null;
// "#<n>": index into the top-level content array.
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
if (!isObject(block)) return null;
return { node: clone(block), path: [index], type: block.type };
}
// Otherwise: depth-first search for the first node with attrs.id === ref.
const search = (
node: any,
trail: number[],
): { node: any; path: number[]; type: string } | null => {
if (!isObject(node)) return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const path = [...trail, i];
if (matchesId(child, ref)) {
return { node: clone(child), path, type: child.type };
}
const hit = search(child, path);
if (hit != null) return hit;
}
}
return null;
};
return search(doc, []);
}
/**
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
* `newNode`, anywhere in the tree (including inside callouts and table cells).
*
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
* is the number of nodes substituted. A fresh clone of `newNode` is used for
* each match so they do not share references.
*/
export function replaceNodeById(
doc: any,
nodeId: string,
newNode: any,
): { doc: any; replaced: number } {
const out = clone(doc);
let replaced = 0;
// Walk a content array, replacing direct matches and recursing into the
// (possibly new) children of non-matching nodes.
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, nodeId)) {
content[i] = clone(newNode);
replaced++;
// Do not recurse into a freshly substituted node.
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, replaced };
}
/**
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
* array, anywhere in the tree (recursive, including callouts and tables).
*
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
* the number of nodes removed.
*/
export function deleteNodeById(
doc: any,
nodeId: string,
): { doc: any; deleted: number } {
const out = clone(doc);
let deleted = 0;
// Filter a content array in place, dropping matches and recursing into the
// surviving children.
const walkContent = (content: any[]): any[] => {
const kept: any[] = [];
for (const child of content) {
if (matchesId(child, nodeId)) {
deleted++;
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
child.content = walkContent(child.content);
}
kept.push(child);
}
return kept;
};
if (isObject(out) && Array.isArray(out.content)) {
out.content = walkContent(out.content);
}
return { doc: out, deleted };
}
/**
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
* "Unexpected content type" when asked to store an `undefined` attribute value).
*
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
* legitimate JSON-storable values and are preserved. Operates on a clone and
* returns it; the input is never mutated. Defensively null-safe like the rest
* of the file.
*/
export function sanitizeForYjs(doc: any): any {
const out = clone(doc);
// Drop every key whose value is strictly `undefined` from an attrs object.
const stripUndefined = (attrs: any): void => {
if (!isObject(attrs)) return;
for (const key of Object.keys(attrs)) {
if (attrs[key] === undefined) {
delete attrs[key];
}
}
};
const walk = (node: any): void => {
if (!isObject(node)) return;
stripUndefined(node.attrs);
if (Array.isArray(node.marks)) {
for (const mark of node.marks) {
if (isObject(mark)) stripUndefined(mark.attrs);
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
};
walk(out);
return out;
}
/**
* Diagnostics helper: walk the tree and return a human-readable path string for
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
* cannot store — i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
* every attribute is storable. Null-safe.
*/
export function findUnstorableAttr(doc: any): string | null {
const isUnstorable = (value: any): string | null => {
if (value === undefined) return "undefined";
const t = typeof value;
if (t === "function") return "function";
if (t === "symbol") return "symbol";
if (t === "bigint") return "bigint";
return null;
};
// Check an attrs object; return the offending sub-path or null.
const checkAttrs = (attrs: any, basePath: string): string | null => {
if (!isObject(attrs)) return null;
for (const key of Object.keys(attrs)) {
const kind = isUnstorable(attrs[key]);
if (kind != null) return `${basePath}.${key} (${kind})`;
}
return null;
};
const walk = (node: any, path: string): string | null => {
if (!isObject(node)) return null;
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
if (attrHit != null) return attrHit;
if (Array.isArray(node.marks)) {
for (let i = 0; i < node.marks.length; i++) {
const markHit = checkAttrs(
node.marks[i]?.attrs,
`${path}.marks[${i}].attrs`,
);
if (markHit != null) return markHit;
}
}
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const childHit = walk(node.content[i], `${path}.content[${i}]`);
if (childHit != null) return childHit;
}
}
return null;
};
// The root doc node carries no useful index, so start the path at "doc".
if (!isObject(doc)) return null;
const attrHit = checkAttrs(doc.attrs, "attrs");
if (attrHit != null) return attrHit;
if (Array.isArray(doc.content)) {
for (let i = 0; i < doc.content.length; i++) {
const childHit = walk(doc.content[i], `content[${i}]`);
if (childHit != null) return childHit;
}
}
return null;
}
/**
* Table structural node types and the container each must live directly inside.
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
* rather than blindly into the anchor's direct parent (which would corrupt the
* table's nesting).
*/
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
const REQUIRED_CONTAINER: Record<string, string> = {
tableRow: "table",
tableCell: "tableRow",
tableHeader: "tableRow",
};
/**
* Locate an anchor and return its ancestor chain (from `doc` down to and
* including the matched node). Each chain entry is `{ node, index }` where
* `index` is the node's position inside its parent's `content` array (the root
* doc has index -1). Returns `null` when the anchor cannot be resolved.
*/
function findAnchorChain(
doc: any,
opts: InsertOptions,
): { node: any; index: number }[] | null {
if (!isObject(doc)) return null;
// DFS by id anywhere in the tree, accumulating the path.
if (opts.anchorNodeId != null) {
const targetId = opts.anchorNodeId;
const search = (
node: any,
index: number,
trail: { node: any; index: number }[],
): { node: any; index: number }[] | null => {
if (!isObject(node)) return null;
const here = [...trail, { node, index }];
if (matchesId(node, targetId)) return here;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const hit = search(node.content[i], i, here);
if (hit != null) return hit;
}
}
return null;
};
return search(doc, -1, []);
}
// By text: only top-level blocks are scanned (same rule as the JSON path).
if (opts.anchorText != null && Array.isArray(doc.content)) {
for (let i = 0; i < doc.content.length; i++) {
if (blockPlainText(doc.content[i]).includes(opts.anchorText)) {
return [
{ node: doc, index: -1 },
{ node: doc.content[i], index: i },
];
}
}
}
return null;
}
/** Options controlling where `insertNodeRelative` places the new node. */
export interface InsertOptions {
position: "before" | "after" | "append";
/** Resolve the anchor by node id anywhere in the tree (preferred). */
anchorNodeId?: string;
/** Fallback: first TOP-LEVEL block whose plain text includes this string. */
anchorText?: string;
}
/**
* Insert a deep clone of `node` relative to an anchor.
*
* - position "append": push the node onto the top-level `doc.content`.
* - position "before"/"after": locate the anchor and splice the node into the
* anchor's parent `content` array immediately before / after it.
*
* Anchor resolution for before/after:
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
* anywhere in the tree (recursive);
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
* false when the anchor could not be resolved (the doc is returned unchanged
* apart from being cloned).
*/
export function insertNodeRelative(
doc: any,
node: any,
opts: InsertOptions,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const fresh = clone(node);
// Defensive: stay null-safe like the other exports — a missing opts means
// there is nothing actionable to do.
if (!isObject(opts)) return { doc: out, inserted: false };
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
// "append": top-level push.
if (opts.position === "append") {
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
// top level — appending one would produce invalid nesting.
if (isStructural) {
throw new Error(
`insert_node: cannot append a ${node.type} at the top level; use ` +
`position before/after with an anchor inside the target table`,
);
}
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
out.content.push(fresh);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
}
const offset = opts.position === "after" ? 1 : 0;
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
// into the nearest enclosing table/tableRow rather than the anchor's direct
// parent, so the row/cell lands at the correct level of the table.
if (isStructural) {
const containerType = REQUIRED_CONTAINER[node.type];
const chain = findAnchorChain(out, opts);
// Anchor not resolved at all — keep the existing "anchor not found" path.
if (chain == null) return { doc: out, inserted: false };
// Find the DEEPEST ancestor (including the anchor itself) of the required
// container type.
let containerIdx = -1;
for (let i = chain.length - 1; i >= 0; i--) {
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
containerIdx = i;
break;
}
}
if (containerIdx === -1) {
throw new Error(
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
`that lives inside the target table.`,
);
}
const container = chain[containerIdx].node;
if (!Array.isArray(container.content)) container.content = [];
if (containerIdx === chain.length - 1) {
// The matched container IS the anchor node itself (e.g. anchorText
// resolved to the table block): append/prepend within it.
const at = opts.position === "after" ? container.content.length : 0;
container.content.splice(at, 0, fresh);
} else {
// The immediate child on the path leading to the anchor is the row/cell
// to splice next to.
const enclosingChildIndex = chain[containerIdx + 1].index;
container.content.splice(enclosingChildIndex + offset, 0, fresh);
}
return { doc: out, inserted: true };
}
// Resolve by id anywhere in the tree: splice into the parent content array.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
for (let i = 0; i < out.content.length; i++) {
if (blockPlainText(out.content[i]).includes(opts.anchorText)) {
out.content.splice(i + offset, 0, fresh);
return { doc: out, inserted: true };
}
}
}
return { doc: out, inserted: false };
}
// ===========================================================================
// Table editing helpers
//
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
// table -> { type:"table", content:[tableRow...] }
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
// content:[paragraph...] }
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
// of the input doc (via `clone`) and never mutate their inputs.
// ===========================================================================
/**
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
* `makeFreshId` so generated paragraph ids never collide with existing ones.
*/
function collectIds(node: any, used: Set<string>): void {
if (!isObject(node)) return;
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
used.add(node.attrs.id);
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectIds(child, used);
}
}
/**
* Fresh-id generator: returns a random Docmost-style id (12 chars from
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
* exact string, so randomness is fine — and unlike a module-local counter it
* needs no reset and cannot become predictable across calls.
*/
function makeFreshId(used: Set<string>): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let id: string;
do {
id = "";
for (let i = 0; i < 12; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
} while (used.has(id) || id === "");
used.add(id);
return id;
}
/**
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
* its index path. Returns null when no table matches.
*
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
* ancestor chain to the nearest `type === "table"` ancestor.
*/
function locateTable(
rootClone: any,
tableRef: string,
): { table: any; path: number[] } | null {
if (!isObject(rootClone)) return null;
// "#<n>": index into the top-level content array; must be a table.
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(rootClone.content)
? rootClone.content[index]
: undefined;
if (isObject(block) && block.type === "table") {
return { table: block, path: [index] };
}
return null;
}
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
// climb to the nearest enclosing table.
const search = (
node: any,
trail: { node: any; index: number }[],
): { table: any; path: number[] } | null => {
if (!isObject(node)) return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const here = [...trail, { node: child, index: i }];
if (matchesId(child, tableRef)) {
// Walk UP to the nearest table ancestor (including the match itself).
for (let j = here.length - 1; j >= 0; j--) {
if (isObject(here[j].node) && here[j].node.type === "table") {
return {
table: here[j].node,
path: here.slice(0, j + 1).map((e) => e.index),
};
}
}
return null; // id found but no enclosing table
}
const hit = search(child, here);
if (hit != null) return hit;
}
}
return null;
};
return search(rootClone, []);
}
/** Build the plain-text → single-paragraph cell content used by all writers. */
function makeCellParagraph(id: string, text: string): any {
return {
type: "paragraph",
attrs: { id, indent: 0 },
// Empty string → a paragraph with an empty content array.
content: text ? [{ type: "text", text }] : [],
};
}
/**
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
*
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
* Tables may be ragged (rows of differing length), so `cols` reflects only
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
* width.
* - `cells`: `string[][]` of each cell's `blockPlainText`.
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
* so callers can `patch_node` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc.
*/
export function readTable(
doc: any,
tableRef: string,
): {
rows: number;
cols: number;
cells: string[][];
cellIds: (string | null)[][];
path: number[];
} | null {
const root = clone(doc);
const located = locateTable(root, tableRef);
if (located == null) return null;
const { table, path } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const cols = rowNodes[0]?.content?.length ?? 0;
const cells: string[][] = [];
const cellIds: (string | null)[][] = [];
for (const rowNode of rowNodes) {
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
const rowText: string[] = [];
const rowIds: (string | null)[] = [];
for (const cellNode of cellNodes) {
rowText.push(blockPlainText(cellNode));
// The cell's first paragraph carries the id used for patch_node.
const firstPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
const id =
isObject(firstPara) && isObject(firstPara.attrs)
? firstPara.attrs.id ?? null
: null;
rowIds.push(id);
}
cells.push(rowText);
cellIds.push(rowIds);
}
return { rows, cols, cells, cellIds, path };
}
/**
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
*
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
* MORE cells than columns throws. Each new cell copies `colwidth` for its
* column from the header row when present, gets a fresh-id paragraph, and a
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
* the row there; otherwise the row is appended at the end.
*/
export function insertTableRow(
doc: any,
tableRef: string,
cells: string[],
index?: number,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, inserted: false };
const { table } = located;
if (!Array.isArray(table.content)) table.content = [];
const rows = table.content.length;
const headerRow = table.content[0];
const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : [];
// Column count is the WIDEST existing row, so the guard below stays
// meaningful for ragged tables and the new row matches the table's width.
// Fall back to the supplied cell count only when the table has no rows.
let colCount = 0;
for (const r of table.content) {
if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length);
}
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
if (Array.isArray(cells) && cells.length > colCount) {
throw new Error(
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
);
}
// Resolve the landing index up front so the cell-type decision and the splice
// below agree: a valid integer in [0, rows] splices there, else we append.
const landingIndex =
typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows
? index
: rows;
// Seed the id generator with every id already in the doc so the new cell
// paragraph ids are unique within the whole document.
const used = new Set<string>();
collectIds(out, used);
const newCells: any[] = [];
for (let i = 0; i < colCount; i++) {
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
const attrs: Record<string, any> = { colspan: 1, rowspan: 1 };
// Copy this column's colwidth from the header row's cell when present.
const colwidth = headerCells[i]?.attrs?.colwidth;
if (colwidth !== undefined) attrs.colwidth = colwidth;
// A row landing at index 0 becomes the new header row, so inherit the
// current header cell's type per column (Docmost uses "tableHeader" there);
// every other position is a plain data cell.
const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell";
newCells.push({
type: cellType,
attrs,
content: [makeCellParagraph(makeFreshId(used), text)],
});
}
const newRow = { type: "tableRow", content: newCells };
// Splice at the resolved landing index (append when index was omitted/invalid).
table.content.splice(landingIndex, 0, newRow);
return { doc: out, inserted: true };
}
/**
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
* `deleted` is false only when the table cannot be located. Throws on an
* out-of-range index, and refuses to delete the table's only row.
*/
export function deleteTableRow(
doc: any,
tableRef: string,
index: number,
): { doc: any; deleted: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, deleted: false };
const { table } = located;
if (!Array.isArray(table.content)) table.content = [];
const rows = table.content.length;
if (!Number.isInteger(index) || index < 0 || index >= rows) {
throw new Error(
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
);
}
if (rows <= 1) {
throw new Error(
"table_delete_row: refusing to delete the only row of the table",
);
}
table.content.splice(index, 1);
return { doc: out, deleted: true };
}
/**
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
* that reuses the cell's existing first-paragraph id when present, else a fresh
* one.
*/
export function updateTableCell(
doc: any,
tableRef: string,
row: number,
col: number,
text: string,
): { doc: any; updated: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, updated: false };
const { table } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const rowNode = rowNodes[row];
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
? rowNode.content.length
: 0;
if (
!Number.isInteger(row) ||
row < 0 ||
row >= rows ||
!Number.isInteger(col) ||
col < 0 ||
col >= cols
) {
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
}
const cellNode = rowNode.content[col];
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
const existingPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
let id =
isObject(existingPara) && isObject(existingPara.attrs)
? existingPara.attrs.id
: undefined;
if (typeof id !== "string" || id.length === 0) {
const used = new Set<string>();
collectIds(out, used);
id = makeFreshId(used);
}
cellNode.content = [makeCellParagraph(id, text)];
return { doc: out, updated: true };
}
@@ -0,0 +1,81 @@
/**
* The native-Obsidian page-file format (design: docs/backlog/git-sync-thin-meta.md).
* A page file is CLEAN markdown with a minimal YAML frontmatter carrying ONLY the
* page's durable identity:
*
* ---
* gitmost_id: 019ef6fc-2638-7ce1-9ce3-2756ce038480
* ---
* <clean markdown body>
*
* Everything else is derived (title = filename, parentPageId = enclosing folder,
* spaceId = the vault, updatedAt = git). `gitmost_id` (a Docmost pageId) is the
* only non-derivable bit and travels WITH the file so identity survives any move,
* even one git's rename detection misses. Third-party editors (Obsidian, …) see
* clean markdown; the frontmatter is hidden in their preview.
*
* No backward-compat with the old `docmost:meta` format: vaults are a cache, wiped
* and rebuilt native. A file WITHOUT a `gitmost_id` frontmatter is an un-tracked
* (e.g. hand-written) file -> the caller ADOPTS it (creates a page, writes the id).
*/
/**
* The frontmatter key carrying the Docmost pageId. NAMESPACED (not a bare `id`)
* so it never collides with a user's own frontmatter fields.
*/
export const ID_KEY = "gitmost_id";
/** Leading YAML frontmatter block: `---\n…\n---` at the very start of the file. */
const FRONTMATTER_RE = /^?---\n([\s\S]*?)\n---\n?/;
/** The top-level `<ID_KEY>: <value>` line inside the frontmatter (quotes optional). */
function readIdFromYaml(yaml: string): string | null {
const re = new RegExp(`^${ID_KEY}:\\s*(.+?)\\s*$`);
for (const line of yaml.split("\n")) {
const m = line.match(re);
if (m) {
const v = m[1].trim().replace(/^["']|["']$/g, "");
return v === "" ? null : v;
}
}
return null;
}
/**
* Parse a page file into its identity (`id`) and clean markdown `body`. Tolerant:
* a file with no frontmatter (a hand-written third-party file) returns `id: null`
* and the whole text as the body — the caller then ADOPTS it (creates a page,
* writes the id back).
*
* KNOWN LIMITATION (phase 4 — adoption, see docs/backlog/git-sync-thin-meta.md):
* a leading frontmatter block is stripped from `body` even when it carries NO
* `gitmost_id` but DOES carry the user's own Obsidian properties (`tags:` etc.).
* On adoption those fields are not yet round-tripped — `serializePageFile`
* write-back persists only `gitmost_id`. Preserving arbitrary user frontmatter
* across the Docmost round-trip (BOTH adoption write-back AND the next pull's
* re-serialize) is deferred to the adoption phase; until then, do NOT roll the
* native format onto a real Obsidian vault whose notes carry properties.
*/
export function parsePageFile(full: string): {
id: string | null;
body: string;
} {
const text = (full ?? "").replace(/\r\n/g, "\n");
// Native format: a `gitmost_id` YAML frontmatter. Anything else (no frontmatter,
// or frontmatter without the key) is an un-tracked file -> adopt.
const fm = text.match(FRONTMATTER_RE);
if (fm) {
return { id: readIdFromYaml(fm[1]), body: text.slice(fm[0].length).trim() };
}
return { id: null, body: text.trim() };
}
/**
* Serialize a page into the thin format: `id` frontmatter + a blank line + the
* clean body + a trailing newline. Deterministic so an unchanged page re-syncs to
* byte-identical output (no churn — the loop-guard relies on it).
*/
export function serializePageFile(id: string, body: string): string {
return `---\n${ID_KEY}: ${id}\n---\n\n${body.trim()}\n`;
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
// Barrel import (R-Infra alias resolves this to packages/docmost-client/src so
// coverage measures the real source, not stale dist).
import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client';
// ---------------------------------------------------------------------------
// Gaps NOT covered by canonicalize.test.ts (test-strategy report §2 diff):
// - the *.align family (drawio/excalidraw/video/youtube/embed): a "center"
// default is dropped, a non-default value is kept;
// - comment.resolved: TRUE is PRESERVED (only resolved:false is normalized);
// - link.target / link.rel NON-default values are kept;
// - property: canonicalizeContent is a fixpoint, docsCanonicallyEqual is
// reflexive and symmetric.
// The base file already covers id-stripping, null-drop, link/comment/orderedList
// default-drop, key-order insensitivity, and a real-diff negative — not re-added.
// ---------------------------------------------------------------------------
describe('canonicalizeContent — *.align default family', () => {
// Every diagram/media node whose schema `align` defaults to "center".
const alignTypes = ['drawio', 'excalidraw', 'video', 'youtube', 'embed'];
for (const type of alignTypes) {
it(`${type}: align "center" (the schema default) is dropped`, () => {
const out = canonicalizeContent({
type,
attrs: { id: 'n-1', src: '/x', align: 'center' },
});
// align==default removed; the meaningful src survives.
expect(out.attrs).toEqual({ src: '/x' });
});
it(`${type}: a NON-default align (e.g. "right") is kept`, () => {
const out = canonicalizeContent({
type,
attrs: { id: 'n-1', src: '/x', align: 'right' },
});
expect(out.attrs).toEqual({ src: '/x', align: 'right' });
});
}
it('image align is NOT in KNOWN_DEFAULTS: a non-null align survives, null is dropped', () => {
// image.align defaults to null, so it is handled by the null-drop rule and
// a real value ("left") must be kept (no spurious default match).
const kept = canonicalizeContent({
type: 'image',
attrs: { id: 'i-1', src: '/a.png', align: 'left' },
});
expect(kept.attrs).toEqual({ src: '/a.png', align: 'left' });
// An image with align:"center" must KEEP it (center is NOT a default for
// image, only for the diagram/media family) — guards against over-matching.
const center = canonicalizeContent({
type: 'image',
attrs: { id: 'i-2', src: '/b.png', align: 'center' },
});
expect(center.attrs).toEqual({ src: '/b.png', align: 'center' });
});
});
describe('canonicalizeContent — comment.resolved:true preserved (SPEC §11 L66)', () => {
it('keeps resolved:true (a legitimate change, not a default to normalize away)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'anchored',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } }],
});
// resolved:true is NON-default; it must survive alongside the commentId so a
// resolve-vs-unresolved divergence is not falsely reported as equal.
expect(out.marks).toEqual([
{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } },
]);
});
it('a resolved:true comment is NOT canonically equal to an unresolved one', () => {
const resolved = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'c', resolved: true } }],
};
const open = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'c' } }],
};
expect(docsCanonicallyEqual(resolved, open)).toBe(false);
});
});
describe('canonicalizeContent — link non-default target/rel kept', () => {
it('keeps a NON-default link.target (e.g. "_self")', () => {
const out = canonicalizeContent({
type: 'text',
text: 'l',
marks: [{ type: 'link', attrs: { href: 'https://e.com', target: '_self' } }],
});
// _self != the "_blank" default, so target must survive.
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://e.com', target: '_self' } },
]);
});
it('keeps a NON-default link.rel', () => {
const out = canonicalizeContent({
type: 'text',
text: 'l',
marks: [{ type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } }],
});
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } },
]);
});
});
// ---------------------------------------------------------------------------
// Property-based oracle checks (SPEC §11). The generated trees mix node/mark
// types, ids, null attrs, known-default attrs and meaningful attrs, so the
// invariants are exercised across the whole canonicalization surface.
// ---------------------------------------------------------------------------
// An attribute value: a meaningful value, a null/undefined, a block id, or a
// known schema default — so pruning, id-drop, null-drop and default-drop all
// fire during shrinking.
const attrValueArb = fc.oneof(
fc.string({ minLength: 1, maxLength: 6 }),
fc.integer({ min: 0, max: 9 }),
fc.boolean(),
fc.constant(null),
);
// A recursive ProseMirror-ish node arbitrary (bounded depth) with type, attrs
// (incl. an id and possibly a known default), optional marks and content.
const nodeArb: fc.Arbitrary<any> = fc.letrec((tie) => ({
node: fc.record(
{
type: fc.constantFrom(
'paragraph',
'heading',
'orderedList',
'drawio',
'video',
'text',
),
text: fc.option(fc.string({ minLength: 0, maxLength: 5 }), { nil: undefined }),
attrs: fc.option(
fc.dictionary(
fc.constantFrom('id', 'level', 'start', 'align', 'src', 'indent', 'keep'),
attrValueArb,
{ maxKeys: 4 },
),
{ nil: undefined },
),
marks: fc.option(
fc.array(
fc.record({
type: fc.constantFrom('bold', 'link', 'comment'),
attrs: fc.option(
fc.dictionary(
fc.constantFrom('href', 'target', 'rel', 'commentId', 'resolved'),
fc.oneof(attrValueArb, fc.constant('_blank')),
{ maxKeys: 3 },
),
{ nil: undefined },
),
}),
{ maxLength: 2 },
),
{ nil: undefined },
),
content: fc.option(fc.array(tie('node'), { maxLength: 2 }), { nil: undefined }),
},
{ requiredKeys: ['type'] },
),
})).node;
describe('canonicalizeContent — property invariants (SPEC §11 oracle)', () => {
it('is a fixpoint: f(f(x)) === f(x)', () => {
fc.assert(
fc.property(nodeArb, (node) => {
const once = canonicalizeContent(node);
const twice = canonicalizeContent(once);
// The canonical form must already be stable under a second pass.
expect(twice).toEqual(once);
}),
{ numRuns: 300 },
);
});
it('docsCanonicallyEqual is reflexive: equal(x, x) is always true', () => {
fc.assert(
fc.property(nodeArb, (node) => {
expect(docsCanonicallyEqual(node, node)).toBe(true);
}),
{ numRuns: 300 },
);
});
it('docsCanonicallyEqual is symmetric: equal(a, b) === equal(b, a)', () => {
fc.assert(
fc.property(nodeArb, nodeArb, (a, b) => {
expect(docsCanonicallyEqual(a, b)).toBe(docsCanonicallyEqual(b, a));
}),
{ numRuns: 300 },
);
});
});
@@ -0,0 +1,302 @@
import { describe, expect, it } from 'vitest';
// Import via the package barrel to also assert the symbols are re-exported.
import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client';
describe('canonicalizeContent', () => {
it('strips node-level attrs.id, recursively', () => {
const input = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { id: 'h-1', level: 2 },
content: [{ type: 'text', text: 'Title' }],
},
],
};
const out = canonicalizeContent(input);
expect(out.content[0].attrs).toEqual({ level: 2 });
// No `id` survives anywhere in the canonical tree.
expect(JSON.stringify(out)).not.toContain('"id"');
});
it('drops null/undefined attrs but keeps every non-null attr', () => {
const out = canonicalizeContent({
type: 'paragraph',
attrs: {
id: 'p-1',
indent: null,
textAlign: undefined,
level: 0,
keep: 'yes',
},
content: [],
});
// null/undefined gone; non-null values (incl. 0 and false) kept.
expect(out.attrs).toEqual({ keep: 'yes', level: 0 });
});
it('removes an attrs object that becomes empty after pruning', () => {
const out = canonicalizeContent({
type: 'paragraph',
attrs: { id: 'p-1', indent: null, textAlign: null },
content: [{ type: 'text', text: 'x' }],
});
// attrs had only an id + null defaults -> the whole attrs key is dropped.
expect('attrs' in out).toBe(false);
expect(out).toEqual({
type: 'paragraph',
content: [{ type: 'text', text: 'x' }],
});
});
it('treats {attrs:{}} as equivalent to no attrs', () => {
const withEmpty = canonicalizeContent({ type: 'paragraph', attrs: {} });
const without = canonicalizeContent({ type: 'paragraph' });
expect(withEmpty).toEqual(without);
});
it('keeps comment marks + commentId but normalizes resolved:false default (SPEC §3 anchor)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'anchored',
marks: [
{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: false } },
],
});
// The comment mark is preserved; commentId (a meaningful anchor) survives,
// but the `resolved: false` schema default is normalized away.
expect(out.marks).toEqual([
{ type: 'comment', attrs: { commentId: 'cmt-1' } },
]);
});
it('drops known non-null schema defaults (link target/rel, comment resolved)', () => {
const out = canonicalizeContent({
type: 'text',
text: 'a link',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com/page',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
});
// href (non-default) kept; target/rel (schema defaults) dropped.
expect(out.marks).toEqual([
{ type: 'link', attrs: { href: 'https://example.com/page' } },
]);
});
it('keeps a NON-default value that happens to share an attr name (orderedList start:5)', () => {
const out = canonicalizeContent({
type: 'orderedList',
attrs: { id: 'ol-1', start: 5 },
content: [],
});
// start:5 is NOT the default (1), so it must survive.
expect(out.attrs).toEqual({ start: 5 });
});
it('keeps meaningful node/mark attrs (level, language, href, src, width)', () => {
const out = canonicalizeContent({
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { id: 'c-1', language: 'js' },
content: [{ type: 'text', text: 'x' }],
},
{
type: 'image',
attrs: { id: 'i-1', src: '/a.png', width: 100, height: null },
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'link',
marks: [{ type: 'link', attrs: { href: 'https://e.com' } }],
},
],
},
],
});
expect(out.content[0].attrs).toEqual({ language: 'js' });
expect(out.content[1].attrs).toEqual({ src: '/a.png', width: 100 });
expect(out.content[2].content[0].marks[0].attrs).toEqual({
href: 'https://e.com',
});
});
it('preserves text, type and content order exactly', () => {
const input = {
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'three' },
],
};
const out = canonicalizeContent(input);
expect(out.content.map((n: any) => n.text)).toEqual([
'one',
'two',
'three',
]);
expect(out.content[1].marks).toEqual([{ type: 'bold' }]);
});
it('drops an empty marks array (marks:[] === no marks)', () => {
const out = canonicalizeContent({ type: 'text', text: 'x', marks: [] });
expect('marks' in out).toBe(false);
});
it('does not mutate its input (frozen tree passes through unchanged)', () => {
const input = Object.freeze({
type: 'doc',
content: Object.freeze([
Object.freeze({
type: 'paragraph',
attrs: Object.freeze({ id: 'p-1', indent: null }),
content: Object.freeze([Object.freeze({ type: 'text', text: 'x' })]),
}),
]),
});
const before = JSON.stringify(input);
const out = canonicalizeContent(input);
// Input is structurally identical after the call.
expect(JSON.stringify(input)).toBe(before);
// A fresh tree is returned.
expect(out).not.toBe(input);
expect('attrs' in out.content[0]).toBe(false);
});
});
describe('docsCanonicallyEqual', () => {
it('is true when docs differ only by block ids', () => {
const a = {
type: 'doc',
content: [
{ type: 'heading', attrs: { id: 'h-1', level: 1 }, content: [] },
],
};
const b = {
type: 'doc',
content: [
{ type: 'heading', attrs: { id: 'h-DIFFERENT', level: 1 }, content: [] },
],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is true when one side omits an attr the other sets to default null', () => {
const a = {
type: 'paragraph',
attrs: { id: 'p-1' },
content: [{ type: 'text', text: 'x' }],
};
const b = {
type: 'paragraph',
attrs: { id: 'p-2', indent: null, textAlign: null },
content: [{ type: 'text', text: 'x' }],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is key-order-insensitive for attrs', () => {
const a = { type: 'image', attrs: { src: '/a.png', width: 10 } };
const b = { type: 'image', attrs: { width: 10, src: '/a.png' } };
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false for a real text difference', () => {
const a = { type: 'text', text: 'hello' };
const b = { type: 'text', text: 'world' };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is false for a real attr difference (different level)', () => {
const a = { type: 'heading', attrs: { id: 'x', level: 1 } };
const b = { type: 'heading', attrs: { id: 'y', level: 2 } };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is false when a meaningful mark attr differs (commentId)', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-2' } }],
};
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is true when a link has only href vs one with the schema-default target/rel', () => {
const a = {
type: 'text',
text: 'link',
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
};
const b = {
type: 'text',
text: 'link',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is true when an orderedList omits start vs one with the default start:1', () => {
const a = { type: 'orderedList', content: [] };
const b = { type: 'orderedList', attrs: { start: 1 }, content: [] };
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false when an orderedList has a non-default start (5 vs absent)', () => {
const a = { type: 'orderedList', content: [] };
const b = { type: 'orderedList', attrs: { start: 5 }, content: [] };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
it('is true when a comment mark omits resolved vs one with the default false', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: false } }],
};
expect(docsCanonicallyEqual(a, b)).toBe(true);
});
it('is false when a comment mark is dropped entirely', () => {
const a = {
type: 'text',
text: 'x',
marks: [{ type: 'comment', attrs: { commentId: 'cmt-1' } }],
};
const b = { type: 'text', text: 'x' };
expect(docsCanonicallyEqual(a, b)).toBe(false);
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
docsCanonicallyEqual,
} from 'docmost-client';
// Helper mirroring the convention in markdown-converter.test.ts: wrap atoms in
// a top-level doc node so convertProseMirrorToMarkdown (which requires
// content.content) walks them.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
// SPEC case 1: drawio carrying the full numeric-attr surface
// (data-width/data-height/data-size/data-aspect-ratio) that it shares with
// audio/video/pdf but which no fixture exercises on a diagram node.
it('drawio round-trips numeric attrs, coercing number -> string via getAttribute', async () => {
const input = doc({
type: 'drawio',
attrs: {
src: '/d.drawio',
attachmentId: 'att-1',
width: 640,
height: 480,
size: 1234,
aspectRatio: 1.777,
align: 'center',
},
});
const md1 = convertProseMirrorToMarkdown(input);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// Exact serialized form: numbers render as bare data-* values; attribute
// order follows the converter's emit order (src, then width/height/size/
// aspect-ratio/align, then attachment-id).
expect(md1).toBe(
'<div data-type="drawio" data-src="/d.drawio" data-width="640" data-height="480" data-size="1234" data-aspect-ratio="1.777" data-align="center" data-attachment-id="att-1"></div>',
);
// A second export reproduces the first byte-for-byte (drawio align default
// is already "center", so nothing new materializes on import).
expect(md2).toBe(md1);
// Re-import coerces every numeric attr to a STRING because parseHTML reads
// them via getAttribute(). This is the gap the reviewer flagged: the
// number -> string coercion on a diagram node is otherwise untested.
const attrs2 = doc2.content[0].attrs;
expect(attrs2.width).toBe('640');
expect(attrs2.height).toBe('480');
expect(attrs2.size).toBe('1234');
expect(attrs2.aspectRatio).toBe('1.777');
expect(typeof attrs2.width).toBe('string');
expect(typeof attrs2.aspectRatio).toBe('string');
// String attrs pass through unchanged.
expect(attrs2.align).toBe('center');
expect(attrs2.attachmentId).toBe('att-1');
// Canonically NOT equal: the numeric -> string coercion survives
// canonicalization (only align='center' is normalized away via
// KNOWN_DEFAULTS.drawio), so 640 !== '640' makes the docs differ.
expect(docsCanonicallyEqual(input, doc2)).toBe(false);
});
// SPEC case 2: minimal excalidraw atom with ONLY string attrs (no align, no
// numeric attrs). Locks the one-time export divergence (align='center'
// default materializes only on import) plus escapeAttr of title/alt through
// the data-title/data-alt path.
it('excalidraw materializes align default only on import and escapes title/alt', async () => {
const input = doc({
type: 'excalidraw',
attrs: {
src: '/e.excalidraw',
title: 'My "Diagram"',
alt: 'a&b',
},
});
const md1 = convertProseMirrorToMarkdown(input);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// First export: no align emitted (the input doc carries no align), and the
// " in title becomes &quot;, the & in alt becomes &amp; via escapeAttr.
expect(md1).toBe(
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My &quot;Diagram&quot;" data-alt="a&amp;b"></div>',
);
// Second export: align='center' has now materialized (the schema's
// diagramAttributes default), so md2 gains a data-align="center" suffix and
// is NOT byte-equal to md1. This one-time divergence is the diagram quirk.
expect(md2).toBe(
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My &quot;Diagram&quot;" data-alt="a&amp;b" data-align="center"></div>',
);
expect(md2).not.toBe(md1);
// Re-import decodes the escaped entities back to the original characters.
const attrs2 = doc2.content[0].attrs;
expect(attrs2.title).toBe('My "Diagram"');
expect(attrs2.alt).toBe('a&b');
expect(attrs2.align).toBe('center');
// Canonically EQUAL: align='center' is normalized away via
// KNOWN_DEFAULTS.excalidraw, and title/alt are non-default strings that
// survive on both sides, so the docs are semantically equal.
expect(docsCanonicallyEqual(input, doc2)).toBe(true);
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
sanitizeCssColor,
clampCalloutType,
encodeHtmlEmbedSource,
decodeHtmlEmbedSource,
} from '../src/lib/docmost-schema.js';
// These tests pin the two security/normalization helpers that Docmost
// interpolates into inline style and the callout banner type on re-render.
// They are the allowlist guard (XSS/style-breakout boundary) and the
// case-insensitive callout normalizer, both otherwise only exercised
// indirectly through parseHTML/renderHTML.
describe('sanitizeCssColor', () => {
it('accepts a plain named color unchanged', () => {
expect(sanitizeCssColor('red')).toBe('red');
});
it('accepts 3-digit and 6-digit hex colors unchanged', () => {
expect(sanitizeCssColor('#abc')).toBe('#abc');
expect(sanitizeCssColor('#aabbcc')).toBe('#aabbcc');
});
it('accepts well-formed functional notation unchanged', () => {
expect(sanitizeCssColor('rgb(1,2,3)')).toBe('rgb(1,2,3)');
expect(sanitizeCssColor('rgba(0,0,0,0.5)')).toBe('rgba(0,0,0,0.5)');
expect(sanitizeCssColor('hsl(120,50%,50%)')).toBe('hsl(120,50%,50%)');
});
it('trims surrounding whitespace before matching', () => {
// ' blue ' trims to 'blue', which is a valid named color.
expect(sanitizeCssColor(' blue ')).toBe('blue');
});
it('rejects a style-injection payload (returns null)', () => {
expect(sanitizeCssColor('red; --x: url(x)')).toBeNull();
});
it('rejects an attribute-breakout payload (returns null)', () => {
expect(sanitizeCssColor('red"><script>')).toBeNull();
});
it('rejects the empty string (returns null)', () => {
expect(sanitizeCssColor('')).toBeNull();
});
it('rejects non-string input via the typeof guard (returns null)', () => {
// @ts-expect-error deliberately passing a non-string to exercise the guard
expect(sanitizeCssColor(123)).toBeNull();
});
});
describe('clampCalloutType', () => {
it('lowercases an uppercase valid type', () => {
expect(clampCalloutType('INFO')).toBe('info');
});
it('lowercases a mixed-case valid type', () => {
expect(clampCalloutType('Warning')).toBe('warning');
});
it('passes through already-lowercase valid types', () => {
expect(clampCalloutType('danger')).toBe('danger');
expect(clampCalloutType('success')).toBe('success');
});
it('PRESERVES every editor-canonical type (note/default no longer flattened)', () => {
// Regression for the QA "callout type -> [!info]" fidelity loss: `note` and
// `default` are valid editor callout types and must survive the git
// round-trip, not collapse to `info`.
expect(clampCalloutType('note')).toBe('note');
expect(clampCalloutType('default')).toBe('default');
expect(clampCalloutType('info')).toBe('info');
expect(clampCalloutType('warning')).toBe('warning');
expect(clampCalloutType('danger')).toBe('danger');
expect(clampCalloutType('success')).toBe('success');
});
it('maps GitHub/Obsidian alert ALIASES to the editor banner (not flatly info)', () => {
// The editor schema has no tip/caution/important callout node — they are input
// aliases the editor's own paste path maps onto the supported set
// (GITHUB_ALERT_TYPE_MAP in editor-ext). git-sync mirrors that aliasing so an
// ingested `> [!tip]` / `> [!caution]` lands on the closest real banner instead
// of collapsing everything to `info`.
expect(clampCalloutType('tip')).toBe('success');
expect(clampCalloutType('TIP')).toBe('success');
expect(clampCalloutType('caution')).toBe('danger');
expect(clampCalloutType('important')).toBe('info');
});
it('falls back to "info" for genuinely unknown types', () => {
expect(clampCalloutType('question')).toBe('info');
expect(clampCalloutType('banana')).toBe('info');
});
it('falls back to "info" for empty string and null', () => {
expect(clampCalloutType('')).toBe('info');
expect(clampCalloutType(null)).toBe('info');
});
});
// The htmlEmbed `source` rides the data-source attribute base64-encoded so the
// raw HTML/CSS/JS stays inert and double-encoding-free across a round trip.
// Encode/decode MUST be exact inverses (incl. UTF-8) or the embed body corrupts.
describe('encode/decodeHtmlEmbedSource', () => {
it('round-trips ASCII HTML losslessly', () => {
const src = '<b>hi</b>';
expect(decodeHtmlEmbedSource(encodeHtmlEmbedSource(src))).toBe(src);
});
it('round-trips multi-byte UTF-8 (Cyrillic + emoji) losslessly', () => {
const src = '<p>Привет, мир 🌍 — café</p>';
const encoded = encodeHtmlEmbedSource(src);
// It is actually encoded (not passed through verbatim).
expect(encoded).not.toBe(src);
expect(decodeHtmlEmbedSource(encoded)).toBe(src);
});
it('maps empty string to empty string both ways', () => {
expect(encodeHtmlEmbedSource('')).toBe('');
expect(decodeHtmlEmbedSource('')).toBe('');
});
});
@@ -0,0 +1,36 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "Level one heading" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "A plain paragraph of text." }]
},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Level two heading" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "First line of a paragraph" },
{ "type": "hardBreak" },
{ "type": "text", "text": "second line after a hard break." }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Level three heading" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Closing paragraph." }]
}
]
}
@@ -0,0 +1,62 @@
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "marks": [{ "type": "bold" }], "text": "bold" },
{ "type": "text", "text": " " },
{ "type": "text", "marks": [{ "type": "italic" }], "text": "italic" },
{ "type": "text", "text": " " },
{ "type": "text", "marks": [{ "type": "code" }], "text": "code" },
{ "type": "text", "text": " " },
{ "type": "text", "marks": [{ "type": "strike" }], "text": "strike" }
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://example.com/page"
}
}
],
"text": "a link"
},
{ "type": "text", "text": ", " },
{
"type": "text",
"marks": [{ "type": "highlight" }],
"text": "highlighted"
},
{ "type": "text", "text": ", base" },
{ "type": "text", "marks": [{ "type": "subscript" }], "text": "sub" },
{ "type": "text", "text": " and base" },
{ "type": "text", "marks": [{ "type": "superscript" }], "text": "sup" },
{ "type": "text", "text": "." }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Here is a " },
{
"type": "text",
"marks": [
{
"type": "comment",
"attrs": { "commentId": "cmt-xyz789" }
}
],
"text": "commented anchor span"
},
{ "type": "text", "text": " that must survive (SPEC §3)." }
]
}
]
}
@@ -0,0 +1,113 @@
{
"type": "doc",
"content": [
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "First bullet" }]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Second bullet with a nested list" }]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Nested bullet A" }]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Nested bullet B" }]
}
]
}
]
}
]
}
]
},
{
"type": "orderedList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "First ordered item" }]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Second ordered item" }]
},
{
"type": "orderedList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Nested ordered one" }]
}
]
}
]
}
]
}
]
},
{
"type": "taskList",
"content": [
{
"type": "taskItem",
"attrs": { "checked": true },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Done task" }]
}
]
},
{
"type": "taskItem",
"attrs": { "checked": false },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Pending task" }]
}
]
}
]
}
]
}
@@ -0,0 +1,38 @@
{
"type": "doc",
"content": [
{
"type": "blockquote",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "A quoted line." }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "A second quoted paragraph." }]
}
]
},
{
"type": "horizontalRule"
},
{
"type": "codeBlock",
"attrs": { "language": "js" },
"content": [
{ "type": "text", "text": "const a = 1;\nconsole.log(a);\n" }
]
},
{
"type": "callout",
"attrs": { "type": "warning" },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "This is a warning callout." }]
}
]
}
]
}
@@ -0,0 +1,85 @@
{
"type": "doc",
"content": [
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Name" }]
}
]
},
{
"type": "tableHeader",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Value" }]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "alpha" }]
}
]
},
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "1" }]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "beta" }]
}
]
},
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "2" }]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,17 @@
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "A drawio and an excalidraw diagram follow." }]
},
{
"type": "drawio",
"attrs": { "src": "/api/files/def/flow.drawio", "align": "center", "attachmentId": "att-1" }
},
{
"type": "excalidraw",
"attrs": { "src": "/api/files/ghi/sketch.excalidraw", "align": "center", "attachmentId": "att-2" }
}
]
}
@@ -0,0 +1,35 @@
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Some " },
{
"type": "text",
"marks": [{ "type": "textStyle", "attrs": { "color": "#ff0000" } }],
"text": "red colored"
},
{ "type": "text", "text": " text." }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Ping " },
{
"type": "mention",
"attrs": {
"id": "m-1",
"label": "Alice",
"entityType": "user",
"entityId": "u-1",
"slugId": "s-1",
"creatorId": "c-1"
}
},
{ "type": "text", "text": " please." }
]
}
]
}
@@ -0,0 +1,15 @@
{
"type": "doc",
"content": [
{
"type": "details",
"attrs": { "open": false },
"content": [
{ "type": "detailsSummary", "content": [{ "type": "text", "text": "Click to expand" }] },
{ "type": "detailsContent", "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hidden body paragraph." }] }
]}
]
}
]
}
@@ -0,0 +1,17 @@
{
"type": "doc",
"content": [
{
"type": "columns",
"attrs": { "layout": "two", "widthMode": "normal" },
"content": [
{ "type": "column", "attrs": { "width": 50 }, "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Left column." }] }
]},
{ "type": "column", "attrs": { "width": 50 }, "content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Right column." }] }
]}
]
}
]
}
@@ -0,0 +1,13 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{ "type": "text", "text": "Notes for " },
{ "type": "mention", "attrs": { "id": "m-2", "label": "Bob", "entityType": "user", "entityId": "u-2", "slugId": "s-2", "creatorId": "c-2" } }
]
}
]
}
@@ -0,0 +1,21 @@
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "An image followed by two diagrams." }]
},
{
"type": "image",
"attrs": { "src": "/api/files/abc/diagram.png", "alt": "A picture" }
},
{
"type": "drawio",
"attrs": { "src": "/api/files/def/flow.drawio", "attachmentId": "att-1" }
},
{
"type": "excalidraw",
"attrs": { "src": "/api/files/ghi/sketch.excalidraw", "attachmentId": "att-2" }
}
]
}
@@ -0,0 +1,151 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1, "id": "h-1" },
"content": [{ "type": "text", "text": "Round-trip sample" }]
},
{
"type": "paragraph",
"attrs": { "id": "p-1" },
"content": [
{ "type": "text", "text": "This paragraph has " },
{ "type": "text", "marks": [{ "type": "bold" }], "text": "bold" },
{ "type": "text", "text": ", " },
{ "type": "text", "marks": [{ "type": "italic" }], "text": "italic" },
{ "type": "text", "text": " and a " },
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://example.com"
}
}
],
"text": "link"
},
{ "type": "text", "text": "." }
]
},
{
"type": "paragraph",
"attrs": { "id": "p-2" },
"content": [
{ "type": "text", "text": "Here is a " },
{
"type": "text",
"marks": [
{ "type": "comment", "attrs": { "commentId": "cmt-abc123", "resolved": false } }
],
"text": "commented span"
},
{ "type": "text", "text": " that must survive the round-trip." }
]
},
{
"type": "bulletList",
"attrs": { "id": "ul-1" },
"content": [
{
"type": "listItem",
"attrs": { "id": "li-1" },
"content": [
{
"type": "paragraph",
"attrs": { "id": "p-3" },
"content": [{ "type": "text", "text": "First bullet" }]
}
]
},
{
"type": "listItem",
"attrs": { "id": "li-2" },
"content": [
{
"type": "paragraph",
"attrs": { "id": "p-4" },
"content": [{ "type": "text", "text": "Second bullet" }]
}
]
}
]
},
{
"type": "table",
"attrs": { "id": "tbl-1" },
"content": [
{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Name" }]
}
]
},
{
"type": "tableHeader",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Value" }]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "alpha" }]
}
]
},
{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1 },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "1" }]
}
]
}
]
}
]
},
{
"type": "callout",
"attrs": { "type": "info", "id": "callout-1" },
"content": [
{
"type": "paragraph",
"attrs": { "id": "p-5" },
"content": [{ "type": "text", "text": "This is an info callout." }]
}
]
},
{
"type": "codeBlock",
"attrs": { "language": "js", "id": "code-1" },
"content": [
{ "type": "text", "text": "const a = 1;\nconsole.log(a);\n" }
]
}
]
}
@@ -0,0 +1,845 @@
import { describe, expect, it } from 'vitest';
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
// pulls in collaboration.ts and mutates the global DOM at import time), matching
// the other converter unit tests. markdownToProseMirror is imported for the
// round-trip cases; loading it mutates the global DOM via jsdom (required for
// @tiptap/html's generateJSON under Node) — this is expected.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
// Wrap one or more nodes in a minimal ProseMirror doc. The top-level converter
// joins doc children with "\n\n" then .trim()s, so a single-node doc yields
// exactly that node's rendered (trimmed) string.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string) => ({ type: 'text', text: t });
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
// Run a full export -> import -> export cycle and return both markdown strings
// plus the intermediate ProseMirror doc (mirrors the property test's helper).
async function roundTrip(node: any): Promise<{ md1: string; doc2: any; md2: string }> {
const md1 = convertProseMirrorToMarkdown(doc(node));
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, doc2, md2 };
}
// ---------------------------------------------------------------------------
// 1. pageBreak DATA LOSS (markdown-converter.ts has NO `case "pageBreak"`).
//
// The schema declares a `pageBreak` block atom (docmost-schema.ts ~L1009), so a
// real document CAN legally contain one. The converter's switch has no branch
// for it, so it falls through to `default`, which renders only the node's
// children — and a pageBreak atom has NONE. It therefore exports to "" and the
// node silently disappears: an exported markdown file can never carry a page
// break, and a round-trip cannot reconstruct it. We pin this as a known
// divergence with an `it.fails` round-trip repro (mirroring the package's two
// existing documented `it.fails` bugs in markdown-roundtrip.property.test.ts).
// ---------------------------------------------------------------------------
describe('pageBreak data loss (no converter case — SPEC §11 divergence)', () => {
it('exports a pageBreak node to the schema-matching block div', () => {
// FIXED: a standalone pageBreak now emits the block-level HTML div so the
// node survives instead of being erased to "".
expect(convertProseMirrorToMarkdown(doc({ type: 'pageBreak' }))).toBe(
'<div data-type="pageBreak"></div>',
);
});
it('keeps a pageBreak sitting BETWEEN two paragraphs on export', () => {
// FIXED: with surrounding content the divider is emitted as its own block
// between the two paragraphs (joined by the doc "\n\n"), no longer dropped.
const out = convertProseMirrorToMarkdown(
doc(para(text('before')), { type: 'pageBreak' }, para(text('after'))),
);
expect(out).toBe(
'before\n\n<div data-type="pageBreak"></div>\n\nafter',
);
expect(out).toContain('pageBreak');
});
// FIXED: a pageBreak node now survives an export -> import -> export cycle
// because the FIRST export emits the schema-matching block div, which marked
// passes through and generateJSON rebuilds into a pageBreak node again.
it('a pageBreak node round-trips (export -> import yields a pageBreak)', async () => {
const { md1, doc2 } = await roundTrip({ type: 'pageBreak' });
expect(md1).not.toBe('');
const types = (doc2.content || []).map((n: any) => n.type);
expect(types).toContain('pageBreak');
});
});
// ---------------------------------------------------------------------------
// 2. subpages round-trip (`case "subpages"` emits the schema-matching div).
//
// It used to emit the literal `{{SUBPAGES}}`, which has no markdown/HTML meaning,
// so on re-import the subpages BLOCK came back as a plain PARAGRAPH carrying the
// literal string (the embed rendered as visible "{{SUBPAGES}}" text on the page
// after a sync — data loss). It now emits `<div data-type="subpages">` like the
// other embed nodes, so the schema's parseHTML rebuilds the subpages node.
// ---------------------------------------------------------------------------
describe('subpages round-trip (schema-matching div)', () => {
it('emits the subpages div and re-imports as a subpages node (no literal leak)', async () => {
const { md1, doc2 } = await roundTrip({ type: 'subpages' });
expect(md1).toBe('<div data-type="subpages"></div>');
const collect = (n: any): string[] => [
n.type,
...((n.content || []) as any[]).flatMap(collect),
];
const allTypes = (doc2.content || []).flatMap(collect);
// The subpages node survives, and no literal {{SUBPAGES}} text leaked back.
expect(allTypes).toContain('subpages');
expect(JSON.stringify(doc2)).not.toContain('{{SUBPAGES}}');
});
});
// ---------------------------------------------------------------------------
// 3. column.width number<->string drift (`case "column"` + width parseHTML).
//
// The converter emits the width verbatim into `data-width="..."` (a STRING in
// the HTML, as all HTML attributes are). On import the schema's `column.width`
// parseHTML does `parseFloat(value)`, so the attribute always comes back as a
// NUMBER. A document authored/stored with a STRING fractional width therefore
// DRIFTS to a number across a round-trip at the ProseMirror-doc level — even
// though the emitted MARKDOWN stays byte-stable (the number prints the same).
// Pinned here as a documented attribute-type divergence (SPEC §11).
// ---------------------------------------------------------------------------
describe('column.width number<->string drift (schema parseFloat — SPEC §11)', () => {
const columnsWith = (width: any) => ({
type: 'columns',
attrs: { layout: 'two' },
content: [
{ type: 'column', attrs: { width }, content: [para(text('L'))] },
{ type: 'column', content: [para(text('R'))] },
],
});
it('a STRING fractional width drifts to a NUMBER across the round-trip', async () => {
const { md1, doc2, md2 } = await roundTrip(columnsWith('33.3'));
// The emitted markdown carries the value as an HTML attribute string and is
// byte-stable across the cycle (the divergence is at the doc level only).
expect(md1).toContain('data-width="33.3"');
expect(md2).toBe(md1);
// But the doc attribute type changed: authored as string "33.3", it comes
// back as the number 33.3 (schema's parseFloat). This is the drift.
const rtWidth = doc2.content?.[0]?.content?.[0]?.attrs?.width;
expect(typeof rtWidth).toBe('number');
expect(rtWidth).toBe(33.3);
});
it('a NUMBER fractional width keeps its value (no precision loss) and is byte-stable', async () => {
const { md1, doc2, md2 } = await roundTrip(columnsWith(33.333333));
expect(md1).toContain('data-width="33.333333"');
expect(md2).toBe(md1);
const rtWidth = doc2.content?.[0]?.content?.[0]?.attrs?.width;
expect(typeof rtWidth).toBe('number');
expect(rtWidth).toBe(33.333333);
});
});
// ---------------------------------------------------------------------------
// 5b. EMPTY detailsContent (`case "details"` with an empty body).
//
// detailsContent's schema content is `block*` (docmost-schema.ts ~L474), so an
// empty details body is legal. The converter must handle a `detailsContent`
// with no children without crashing and without emitting invalid output that
// breaks the round-trip. This pins that an empty details body exports cleanly
// and re-imports as a valid `details` whose body is an empty `detailsContent`.
// ---------------------------------------------------------------------------
describe('empty detailsContent (schema allows block*)', () => {
const emptyDetails = doc({
type: 'details',
content: [
{ type: 'detailsSummary', content: [text('Summary')] },
{ type: 'detailsContent', content: [] },
],
});
it('exports an empty details body without crashing or producing junk', () => {
const md = convertProseMirrorToMarkdown(emptyDetails);
// The summary survives and the <details> wrapper closes; the empty body adds
// no content of its own.
expect(md).toContain('<summary>Summary</summary>');
expect(md).toContain('</details>');
expect(md).not.toContain('undefined');
expect(md).not.toContain('null');
});
it('round-trips to a valid details with an empty detailsContent body', async () => {
const md1 = convertProseMirrorToMarkdown(emptyDetails);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// Export is byte-stable (no growth / no junk on the second pass).
expect(md2).toBe(md1);
// The re-imported tree is a details with summary + an empty content body.
const details = doc2.content?.[0];
expect(details?.type).toBe('details');
const childTypes = (details?.content || []).map((c: any) => c.type);
expect(childTypes).toEqual(['detailsSummary', 'detailsContent']);
const detailsContent = details.content.find(
(c: any) => c.type === 'detailsContent',
);
// block* — an empty body has no (or empty) content, which is valid.
expect(detailsContent.content == null || detailsContent.content.length === 0).toBe(
true,
);
});
});
// ===========================================================================
// CONVERTER GAP COVERAGE (specs 1–29)
//
// These describe the converter's exact emission for under-tested branches and,
// for the round-trip cases, pin export byte-stability and/or documented data
// loss. docsCanonicallyEqual is imported here (not at the top) to keep the
// existing block's imports untouched. heading/col are local helpers; doc/text/
// para are reused from the top of the file.
// ===========================================================================
import { docsCanonicallyEqual } from '../src/lib/canonicalize.js';
const heading = (level: number, ...inline: any[]) => ({
type: 'heading',
attrs: { level },
content: inline,
});
// A two-layout columns block carrying a single column with exactly one child —
// the shared shape for the raw-HTML-container round-trip specs (15, 17–29).
const oneColumn = (child: any) => ({
type: 'columns',
attrs: { layout: 'two' },
content: [{ type: 'column', content: [child] }],
});
// Extract the single column's single child node from a round-tripped doc.
const colChildOf = (doc2: any) =>
doc2?.content?.[0]?.content?.[0]?.content?.[0];
describe('converter gap coverage — emission branches (specs 1–11)', () => {
// 1. orderedList renders index+1 and DROPS the start attribute.
it('orderedList start:5 restarts numbering at 1 (start attr ignored)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'orderedList',
attrs: { start: 5 },
content: [
{ type: 'listItem', content: [para(text('a'))] },
{ type: 'listItem', content: [para(text('b'))] },
],
}),
);
expect(out).toBe('1. a\n2. b');
});
// 2. An empty paragraph contributes an empty segment between two "\n\n" joins.
it('an empty paragraph between two paragraphs yields doubled blank lines', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('a')), { type: 'paragraph' }, para(text('b'))),
);
expect(out).toBe('a\n\n\n\nb');
});
// 3. A code block inside a blockquote: every physical line gets "> ".
it('a codeBlock inside a blockquote prefixes every fence/code line with "> "', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [text('a\nb')],
},
],
}),
);
expect(out).toBe('> ```js\n> a\n> b\n> ```');
});
// 4. A body cell with TWO block children (paragraph + bulletList) cannot be a
// GFM pipe row (inline-only). #8 emits the WHOLE table as HTML <table> so
// the paragraph and the list each survive as their own block instead of
// being lossily flattened into one "p1 - a" pipe cell.
it('a table cell with paragraph+list emits an HTML <table> (blocks preserved)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [{ type: 'tableHeader', content: [para(text('h'))] }],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [
para(text('p1')),
{
type: 'bulletList',
content: [{ type: 'listItem', content: [para(text('a'))] }],
},
],
},
],
},
],
}),
);
expect(out).toBe(
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>p1</p><ul><li><p>a</p></li></ul></td></tr></tbody></table>',
);
});
// 5. code + link co-occur: the schema's `code` mark excludes all other marks
// (including link), so the link cannot survive import. The lossless,
// byte-stable behavior is to emit ONLY the backtick code span (code wins).
it('a code+link run emits the backtick code form (code wins, link dropped)', () => {
const out = convertProseMirrorToMarkdown(
doc(
para({
type: 'text',
text: 'x',
marks: [
{ type: 'code' },
{ type: 'link', attrs: { href: 'http://a?b&c"d' } },
],
}),
),
);
expect(out).toBe('`x`');
});
// 6. hardBreak inside a heading: prefix applied once, " \n" between a and b.
it('a hardBreak inside an h2 heading produces "## a \\nb"', () => {
const out = convertProseMirrorToMarkdown(
doc(heading(2, text('a'), { type: 'hardBreak' }, text('b'))),
);
expect(out).toBe('## a \nb');
});
// 7. encodeMdUrl's non-space whitespace sub-path: a newline -> %0A.
it('an image src containing a newline percent-encodes it to %0A', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'image', attrs: { alt: 'cap', src: '/a\nb.png' } }),
);
expect(out).toBe('![cap](/a%0Ab.png)');
});
// 8. spanned-table HTML fallback: rowspan>1 AND align cell-attr branches, <td>.
it('a spanned cell with rowspan+align emits <td rowspan align> in that order', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { rowspan: 2, align: 'center' },
content: [para(text('m'))],
},
],
},
],
}),
);
expect(out).toBe(
'<table><tbody><tr><td rowspan="2" align="center"><p>m</p></td></tr></tbody></table>',
);
});
// 9. taskItem fixed indent width of 2 (NOT prefix.length+1) for a nested sublist.
it('a task item with a nested bullet sublist indents the sublist by 2 columns', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
para(text('top')),
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('child'))] },
],
},
],
},
],
}),
);
expect(out).toBe('- [ ] top\n - child');
});
// 10. A bulletList inside a blockquote: each list line independently prefixed.
it('a bulletList inside a blockquote prefixes every list line with "> "', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('x'))] },
{ type: 'listItem', content: [para(text('y'))] },
],
},
],
}),
);
expect(out).toBe('> - x\n> - y');
});
// 11. A non-spanned cell with TWO block paragraphs: #8 emits the whole table
// as HTML <table>, so each paragraph stays its own <p> and the literal
// pipe needs no escaping inside HTML text (the old GFM path space-joined
// the blocks into one line and escaped the pipe to \|).
it('a table cell with two paragraphs emits an HTML <table> (blocks kept, no pipe-escape)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [{ type: 'tableHeader', content: [para(text('h'))] }],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [para(text('a|b')), para(text('c'))],
},
],
},
],
}),
);
expect(out).toBe(
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
);
});
});
describe('converter gap coverage — documented round-trip data loss (specs 12–14)', () => {
// 12. A 3-backtick fence inside a codeBlock body is now lengthened: the outer
// fence widens to (longest inner run + 1) backticks per CommonMark, so the
// inner ``` is treated as content and the block survives as ONE node.
it('a triple-backtick fence inside a codeBlock body round-trips via a widened fence', async () => {
const d = doc({
type: 'codeBlock',
attrs: { language: 'js' },
content: [{ type: 'text', text: '```\ninner\n```' }],
});
const md1 = convertProseMirrorToMarkdown(d);
// Outer fence widened to 4 backticks; the inner 3-backtick fence is content.
expect(md1).toBe('````js\n```\ninner\n```\n````');
const doc2 = await markdownToProseMirror(md1);
// The block survives as a SINGLE code block (no premature split).
const top = doc2.content || [];
expect(top).toHaveLength(1);
expect(top[0].type).toBe('codeBlock');
expect(top[0].attrs?.language).toBe('js');
expect(top[0].content?.[0]?.text).toContain('```\ninner\n```');
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1); // byte-stable
// Canonically the re-imported code text gains a single trailing newline
// (marked re-adds it; the exporter strips it back, hence byte stability).
// The fence is no longer lossy: the inner fence and content fully survive.
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
// 13. A leading ordered-list marker in paragraph text is NOT escaped, so a
// plain paragraph silently becomes an orderedList on re-import.
it('a paragraph starting with "1. " is promoted to an orderedList on re-import', async () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'text', text: '1. not a list' }],
});
const md1 = convertProseMirrorToMarkdown(d);
expect(md1).toBe('1. not a list'); // no backslash escape
const doc2 = await markdownToProseMirror(md1);
expect(doc2.content?.[0]?.type).toBe('orderedList');
const li = doc2.content[0].content?.[0];
expect(li?.type).toBe('listItem');
expect(li.content?.[0]?.content?.[0]).toMatchObject({
type: 'text',
text: 'not a list', // the "1. " was consumed as a list marker
});
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
// 14. The image emitter drops the title attribute (silently lost on round-trip).
it('an image title attribute is dropped on export and lost on re-import', async () => {
const d = doc({
type: 'image',
attrs: { src: '/i.png', alt: 'a', title: 't"q' },
});
const md1 = convertProseMirrorToMarkdown(d);
expect(md1).toBe('![a](/i.png)'); // no title, no quotes
const doc2 = await markdownToProseMirror(md1);
const img = (doc2.content || []).find((n: any) => n.type === 'image');
expect(img).toBeTruthy();
expect(img.attrs?.title).toBeNull(); // the original 't"q' was dropped
expect(img.attrs?.src).toBe('/i.png');
expect(img.attrs?.alt).toBe('a');
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
});
describe('converter gap coverage — raw-HTML container round-trips (specs 15–29)', () => {
// 15. image inside a column: imageToHtml width+align arms; byte-stable; no
// literal-markdown text node leaks.
it('an image in a column emits <img> (width/align arms) and round-trips byte-stable', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({
type: 'image',
attrs: { src: '/i.png', alt: 'cap', width: 320, align: 'center' },
}),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><img src="/i.png" alt="cap" width="320" align="center"></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('image');
});
// 16. image inside a SPANNED table cell (the other raw-HTML container).
it('an image in a spanned table cell emits <img> (width arm) and round-trips byte-stable', async () => {
const { md1, md2 } = await roundTrip({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 2 },
content: [
{
type: 'image',
attrs: { src: '/i.png', alt: 'x', width: 100 },
},
],
},
],
},
],
});
expect(md1).toBe(
'<table><tbody><tr><td colspan="2"><img src="/i.png" alt="x" width="100"></td></tr></tbody></table>',
);
expect(md2).toBe(md1);
});
// 17. callout inside a column: calloutToHtml lower-cases the type; byte-stable.
it('a callout in a column emits the HTML div (type lower-cased) and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({
type: 'callout',
attrs: { type: 'WARNING' },
content: [para(text('a'))],
}),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><div data-type="callout" data-callout-type="warning"><p>a</p></div></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('callout');
});
// 18. details tree inside a column: summary via inlineToHtml, content via blockToHtml.
it('a details tree in a column emits <details>/<summary>/<div detailsContent> and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({
type: 'details',
content: [
{ type: 'detailsSummary', content: [text('S')] },
{ type: 'detailsContent', content: [para(text('body'))] },
],
}),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><details><summary data-type="detailsSummary">S</summary><div data-type="detailsContent"><p>body</p></div></details></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('details');
});
// 19. taskList inside a column: BOTH checked:true and checked:false arms.
it('a taskList in a column emits both data-checked arms and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: true },
content: [para(text('done'))],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [para(text('todo'))],
},
],
}),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>done</p></li><li data-type="taskItem" data-checked="false"><p>todo</p></li></ul></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('taskList');
});
// 20. bare taskItem (no wrapping taskList) inside a column self-wraps.
it('a bare taskItem in a column self-wraps in a single-item taskList and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({
type: 'taskItem',
attrs: { checked: false },
content: [para(text('lone'))],
}),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>lone</p></li></ul></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('taskList');
});
// 21. blockquote inside a column: real <blockquote>, not markdown "> q".
it('a blockquote in a column emits <blockquote> and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({ type: 'blockquote', content: [para(text('q'))] }),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><blockquote><p>q</p></blockquote></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('blockquote');
});
// 22. horizontalRule inside a column: literal <hr>, not markdown "---".
it('a horizontalRule in a column emits <hr> and round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({ type: 'horizontalRule' }),
);
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><hr></div></div>',
);
expect(md2).toBe(md1);
expect(colChildOf(doc2)?.type).toBe('horizontalRule');
});
// 23. Unknown block type with NON-text block children -> <div>-wrap of children.
it('an unknown block with block children wraps them in <div> (no markdown leak)', () => {
const md1 = convertProseMirrorToMarkdown(
doc(
oneColumn({
type: 'someFutureBlock',
content: [para(text('a')), para(text('b'))],
}),
),
);
expect(md1).toContain('<div><p>a</p><p>b</p></div>');
// No markdown paragraph separator survives inside the raw-HTML column.
expect(md1).toBe(
'<div data-type="columns" data-layout="two"><div data-type="column"><div><p>a</p><p>b</p></div></div></div>',
);
});
// 24. Unknown block with ONLY inline/text children -> <div>inlineToHtml</div>.
it('an unknown block with only inline children renders inline as HTML (marks not markdown)', () => {
const md1 = convertProseMirrorToMarkdown(
doc(
oneColumn({
type: 'someInlineOnlyBlock',
content: [text('hi'), { type: 'text', text: '!', marks: [{ type: 'bold' }] }],
}),
),
);
expect(md1).toContain('<div>hi<strong>!</strong></div>');
});
// 25. mathBlock inside a column delegates through processNode (NOT $$ fence).
it('a mathBlock in a column delegates to processNode (HTML div, no $$ fence)', () => {
const md1 = convertProseMirrorToMarkdown(
doc(oneColumn({ type: 'mathBlock', attrs: { text: 'a^2+b^2' } })),
);
expect(md1).toContain(
'<div data-type="mathBlock" data-katex="true" text="a^2+b^2"></div>',
);
expect(md1).not.toContain('$$');
});
// 26. SPANNED table inside a column delegates to processNode -> raw <table>.
it('a spanned table in a column delegates to raw <table> HTML (no GFM pipes)', () => {
const md1 = convertProseMirrorToMarkdown(
doc(
oneColumn({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 2 },
content: [para(text('x'))],
},
],
},
],
}),
),
);
expect(md1).toContain('<table');
expect(md1).toContain('colspan="2"');
// No GFM pipe-table separator leaked into the raw-HTML column.
expect(md1).not.toContain('| --- |');
});
// 27. list item with TWO block children (paragraph + codeBlock) -> blockChildrenToHtml.
it('a list item with paragraph+codeBlock in a column emits both blocks as HTML', () => {
const md1 = convertProseMirrorToMarkdown(
doc(
oneColumn({
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
para(text('p')),
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [text('a\nb')],
},
],
},
],
}),
),
);
expect(md1).toContain('<p>p</p>');
expect(md1).toContain('<pre><code class="language-js">a\nb</code></pre>');
// The two blocks appear sequentially inside the same <li>.
expect(md1).toContain(
'<li><p>p</p><pre><code class="language-js">a\nb</code></pre></li>',
);
});
// 28. ordered list item whose 2nd block child is a NESTED bulletList.
it('an ordered list item with a nested bulletList in a column emits nested <ul> HTML', () => {
const md1 = convertProseMirrorToMarkdown(
doc(
oneColumn({
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
para(text('p1')),
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('nested'))] },
],
},
],
},
],
}),
),
);
// NOTE(review): the spec's expected literal said '<ul><li>nested</li></ul>',
// but blockChildrenToHtml renders the nested listItem's paragraph child as a
// real <p>, so the actual (correct) emission is '<ul><li><p>nested</p></li></ul>'.
expect(md1).toContain(
'<ol><li><p>p1</p><ul><li><p>nested</p></li></ul></li></ol>',
);
// No markdown list markers leaked into the raw-HTML column.
expect(md1).not.toContain('1. ');
expect(md1).not.toContain('- nested');
});
// 29. mathInline atom inside a column paragraph -> inlineToHtml delegates via processNode.
it('a mathInline atom in a column paragraph emits schema HTML (no $...$ fence)', () => {
const md1 = convertProseMirrorToMarkdown(
doc(oneColumn(para(text('eq: '), { type: 'mathInline', attrs: { text: 'x_i' } }))),
);
expect(md1).toContain(
'<p>eq: <span data-type="mathInline" data-katex="true" text="x_i"></span></p>',
);
expect(md1).not.toContain('$x_i$');
});
});
// ===========================================================================
// 30. heading.textAlign round-trip (A1). The paragraph case already exports a
// non-default alignment as a styled `<p style="text-align:…">` that re-parses
// losslessly; headings used to emit only the bare `## text` form, silently
// DROPPING textAlign on export. The heading case is now symmetric: an aligned
// heading exports as `<hN style="text-align:…">` and re-parses back to a heading
// carrying BOTH the level and the textAlign, so the round-trip is lossless; an
// UNaligned heading still emits the bare `## text` markdown form (no churn).
// ===========================================================================
const alignedHeading = (level: number, align: string, ...inline: any[]) => ({
type: 'heading',
attrs: { level, textAlign: align },
content: inline,
});
describe('heading.textAlign round-trip (A1)', () => {
it('an aligned heading exports as <hN style="text-align:…"> (not bare ##)', () => {
expect(convertProseMirrorToMarkdown(doc(alignedHeading(2, 'center', text('Title'))))).toBe(
'<h2 style="text-align:center">Title</h2>',
);
});
it('survives export -> import -> export losslessly (level AND textAlign preserved)', async () => {
const input = alignedHeading(2, 'center', text('Title'));
const { md1, doc2, md2 } = await roundTrip(input);
// Export direction: a styled <hN>, injection-safe via escapeAttr.
expect(md1).toBe('<h2 style="text-align:center">Title</h2>');
// Import direction: re-parses to a heading node with the level AND textAlign
// (the raw <hN style> HTML block flows through marked -> generateJSON, where
// the heading parse rule matches and the textAlign global attr reads the
// style back). Byte-stable second export closes the loop.
const h = doc2.content[0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(2);
expect(h.attrs.textAlign).toBe('center');
expect(md2).toBe(md1);
// Canonical equality of the re-parsed doc against the original input doc.
expect(docsCanonicallyEqual(doc2, doc(input))).toBe(true);
});
it('a right-aligned h3 round-trips its level and alignment', async () => {
const { doc2 } = await roundTrip(alignedHeading(3, 'right', text('Head')));
const h = doc2.content[0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(3);
expect(h.attrs.textAlign).toBe('right');
});
it('an UNaligned heading still emits the bare "## text" form (no HTML churn)', () => {
const bare = convertProseMirrorToMarkdown(doc(heading(2, text('Plain'))));
expect(bare).toBe('## Plain');
expect(bare).not.toContain('<h2');
// The default "left" alignment is likewise NOT wrapped.
expect(
convertProseMirrorToMarkdown(doc(alignedHeading(2, 'left', text('Plain')))),
).toBe('## Plain');
});
});
@@ -0,0 +1,402 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// markdown-converter.ts is the weakest pure module (report §2). These golden
// tests close the gaps the base markdown-converter.test.ts leaves open:
// columns/column wrapper, embed/audio/pdf (used to emit nothing), drawio/
// excalidraw data-align presence rule, the remaining inline-mark matrix,
// paragraph.textAlign, subpages + unknown-in-container fallback, escaping
// idempotence, table-cell pipe/newline sanitization, and empty/single-column
// tables. Cases already asserted in the base file are NOT repeated.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const c = (node: any) => convertProseMirrorToMarkdown(doc(node));
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
describe('columns / column (raw-HTML layout wrapper)', () => {
it('wraps a multi-column layout as nested data-type divs with the children inside (regression: children unwrapped)', () => {
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{ type: 'column', attrs: { width: 50 }, content: [para(text('L'))] },
{ type: 'column', content: [para(text('R'))] },
],
});
expect(out).toBe(
'<div data-type="columns" data-layout="two">' +
'<div data-type="column" data-width="50"><p>L</p></div>' +
'<div data-type="column"><p>R</p></div>' +
'</div>',
);
});
it('omits the default widthMode "normal" but emits a non-default one', () => {
const normal = c({
type: 'columns',
attrs: { layout: 'two', widthMode: 'normal' },
content: [{ type: 'column', content: [para(text('x'))] }],
});
expect(normal).not.toContain('data-width-mode');
const wide = c({
type: 'columns',
attrs: { layout: 'two', widthMode: 'full' },
content: [{ type: 'column', content: [para(text('x'))] }],
});
expect(wide).toContain('data-width-mode="full"');
});
});
describe('embed / audio / pdf (previously emitted nothing — invisible regression)', () => {
it('embed emits div[data-type="embed"] with src/provider', () => {
expect(c({ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } })).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe"></div>',
);
});
it('audio emits a div-wrapped <audio> with src', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe(
'<div><audio src="/a.mp3"></audio></div>',
);
});
it('pdf emits div[data-type="pdf"] with src and name', () => {
expect(c({ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf' } })).toBe(
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf"></div>',
);
});
});
describe('drawio / excalidraw data-align asymmetry (SPEC §11)', () => {
it('drawio: data-align is ABSENT when align is unset', () => {
const out = c({ type: 'drawio', attrs: { src: '/d.drawio' } });
expect(out).toBe('<div data-type="drawio" data-src="/d.drawio"></div>');
expect(out).not.toContain('data-align');
});
it('drawio: data-align is PRESENT for a non-default align', () => {
expect(c({ type: 'drawio', attrs: { src: '/d.drawio', align: 'right' } })).toBe(
'<div data-type="drawio" data-src="/d.drawio" data-align="right"></div>',
);
});
it('excalidraw: data-align is ABSENT when align is unset', () => {
const out = c({ type: 'excalidraw', attrs: { src: '/e.excalidraw' } });
expect(out).toBe('<div data-type="excalidraw" data-src="/e.excalidraw"></div>');
expect(out).not.toContain('data-align');
});
});
describe('inline-mark matrix (underline/sub/sup/highlight±color/textStyle/comment)', () => {
it('emits the schema HTML for each remaining inline mark in one matrix', () => {
const cases: [any[], string][] = [
[[{ type: 'underline' }], '<u>m</u>'],
[[{ type: 'subscript' }], '<sub>m</sub>'],
[[{ type: 'superscript' }], '<sup>m</sup>'],
[[{ type: 'highlight' }], '<mark>m</mark>'],
[
[{ type: 'highlight', attrs: { color: '#ff0000' } }],
'<mark style="background-color: #ff0000">m</mark>',
],
[
[{ type: 'textStyle', attrs: { color: '#00ff00' } }],
'<span style="color: #00ff00">m</span>',
],
[
[{ type: 'comment', attrs: { commentId: 'cid-1' } }],
'<span data-comment-id="cid-1">m</span>',
],
[
[{ type: 'comment', attrs: { commentId: 'cid-1', resolved: true } }],
'<span data-comment-id="cid-1" data-resolved="true">m</span>',
],
];
for (const [marks, expected] of cases) {
expect(c(para(text('m', marks)))).toBe(expected);
}
});
it('a textStyle mark with no color emits nothing (plain text passes through)', () => {
expect(c(para(text('plain', [{ type: 'textStyle', attrs: {} }])))).toBe('plain');
});
it('a comment mark with no commentId emits nothing (plain text)', () => {
expect(c(para(text('plain', [{ type: 'comment', attrs: {} }])))).toBe('plain');
});
});
describe('paragraph.textAlign -> <p style="text-align:...">', () => {
it('non-default alignment emits an HTML <p style="text-align:...">', () => {
// #7 fix: a non-default paragraph alignment now round-trips. It is exported
// as an HTML `<p style="text-align:center">` (the schema's paragraph
// parseHTML reads `style="text-align"` back onto `textAlign` on import), so
// the alignment survives instead of collapsing to bare text. (The old
// `<div align="center">` form was NOT re-parsed onto the paragraph and was
// therefore lossy.)
expect(c({ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('x')] })).toBe(
'<p style="text-align:center">x</p>',
);
});
it('textAlign "left" (the default) is NOT wrapped', () => {
expect(c({ type: 'paragraph', attrs: { textAlign: 'left' }, content: [text('x')] })).toBe('x');
});
});
describe('subpages token + unknown-in-container fallback', () => {
it('subpages emits the schema-matching div (round-trips, unlike the old {{SUBPAGES}} literal)', () => {
expect(c({ type: 'subpages' })).toBe('<div data-type="subpages"></div>');
});
it('an unknown block inside a raw-HTML container is wrapped in <div> (never markdown)', () => {
// Inside columns the children are rendered as HTML; an unknown block type
// must NOT fall back to markdown (which would land as literal text on
// re-import). It is wrapped in a <div> so its children survive.
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{ type: 'column', content: [{ type: 'weirdBlock', content: [para(text('kept'))] }] },
],
});
expect(out).toBe(
'<div data-type="columns" data-layout="two">' +
'<div data-type="column"><div><p>kept</p></div></div>' +
'</div>',
);
});
it('an unknown TOP-LEVEL block falls back to its children only (markdown context)', () => {
expect(c({ type: 'totallyUnknown', content: [text('inner')] })).toBe('inner');
});
});
describe('escaping idempotence (SPEC §11 phantom-diff guard)', () => {
it('escapeAttr escapes ONLY & and " in an attribute context, and is idempotent', () => {
// The mathBlock `text` attr goes through escapeAttr. & -> &amp;, " -> &quot;.
const once = c({ type: 'mathBlock', attrs: { text: 'a & "b"' } });
expect(once).toBe(
'<div data-type="mathBlock" data-katex="true" text="a &amp; &quot;b&quot;"></div>',
);
// < and > are deliberately NOT escaped (would accumulate on round-trips).
const angled = c({ type: 'mathBlock', attrs: { text: 'a < b > c' } });
expect(angled).toContain('text="a < b > c"');
expect(angled).not.toContain('&lt;');
expect(angled).not.toContain('&gt;');
});
it('encodeMdUrl turns a space into %20 in an image src (single inert URL token)', () => {
expect(c({ type: 'image', attrs: { alt: 'c', src: '/my pic.png' } })).toBe(
'![c](/my%20pic.png)',
);
});
});
describe('multi-block table cell -> HTML <table> (#8: GFM pipes cannot hold block content)', () => {
it('emits the whole table as HTML <table> so a multi-paragraph cell survives', () => {
// A cell holding TWO block paragraphs cannot be represented by a GFM pipe
// row (one inline line only) — the old GFM path collapsed the two blocks
// into one line ("a\|b c"), losing the block boundary and forcing a fragile
// pipe-escape. #8 emits the WHOLE table as raw HTML <table> instead: the
// schema's table-family parseHTML round-trips it, each paragraph stays its
// own <p>, and the literal pipe needs no escaping inside HTML text.
const out = c({
type: 'table',
content: [
{ type: 'tableRow', content: [
{ type: 'tableHeader', content: [para(text('H'))] },
]},
{ type: 'tableRow', content: [
{ type: 'tableCell', content: [para(text('a|b')), para(text('c'))] },
]},
],
});
expect(out).toBe(
'<table><tbody><tr><th><p>H</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
);
});
});
describe('empty / single-column tables', () => {
it('a table with no rows renders as the empty string', () => {
expect(c({ type: 'table', content: [] })).toBe('');
});
it('a single-column GFM table emits one column with a "---" separator', () => {
const out = c({
type: 'table',
content: [
{ type: 'tableRow', content: [{ type: 'tableHeader', content: [para(text('Only'))] }] },
{ type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('v'))] }] },
],
});
expect(out).toBe('| Only |\n| --- |\n| v |');
});
});
// ---------------------------------------------------------------------------
// Media / attachment / container full-attribute coverage. The base golden file
// only sets the minimal attrs for each media node (src, or src+name), so the
// optional-attribute emission branches and their exact ORDERING are uncovered.
// These cases pin the full ordered attribute string for video/youtube/embed/
// audio/pdf/attachment plus the all-absent side of every optional guard, and
// the distinct HTML-container (blockToHtml / inlineToHtml) paths for an
// orderedList and a hardBreak inside a column.
// ---------------------------------------------------------------------------
describe('media / attachment / container full-attribute golden coverage', () => {
it('video: emits all optional attrs in source order (alt->aria-label, attachmentId/size/align/aspectRatio->data-*)', () => {
expect(
c({
type: 'video',
attrs: {
src: '/v.mp4',
alt: 'clip',
attachmentId: 'att-1',
width: 640,
height: 480,
size: 1234,
align: 'center',
aspectRatio: 1.777,
},
}),
).toBe(
'<div><video src="/v.mp4" aria-label="clip" data-attachment-id="att-1" width="640" height="480" data-size="1234" data-align="center" data-aspect-ratio="1.777"></video></div>',
);
});
it('video: with only src, every optional guard takes its false branch (src-only <video>, no data-type on wrapper)', () => {
expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe(
'<div><video src="/v.mp4"></video></div>',
);
});
it('youtube + embed: each emits its full optional attr set in source order', () => {
// (a) youtube: width/height/align all present -> data-* in order.
expect(
c({
type: 'youtube',
attrs: { src: 'https://youtu.be/abc', width: 560, height: 315, align: 'right' },
}),
).toBe(
'<div data-type="youtube" data-src="https://youtu.be/abc" data-width="560" data-height="315" data-align="right"></div>',
);
// (b) embed: align/width/height optional branches after src+provider.
expect(
c({
type: 'embed',
attrs: { src: 'https://x.com/e', provider: 'iframe', align: 'left', width: 600, height: 400 },
}),
).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="left" data-width="600" data-height="400"></div>',
);
});
it('audio: emits data-attachment-id then data-size after src when both are set', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7', size: 9001 } })).toBe(
'<div><audio src="/a.mp3" data-attachment-id="att-7" data-size="9001"></audio></div>',
);
});
it('audio: with attachmentId but no size, data-size is suppressed (size != null false branch)', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7' } })).toBe(
'<div><audio src="/a.mp3" data-attachment-id="att-7"></audio></div>',
);
});
it('pdf: emits the full optional attr set in order (data-name, data-attachment-id, data-size, width, height)', () => {
expect(
c({
type: 'pdf',
attrs: {
src: '/d.pdf',
name: 'd.pdf',
attachmentId: 'att-9',
size: 2048,
width: 800,
height: 600,
},
}),
).toBe(
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf" data-attachment-id="att-9" data-size="2048" width="800" height="600"></div>',
);
});
it('attachment: emits data-attachment-name/mime/size/id in order after the always-present url', () => {
expect(
c({
type: 'attachment',
attrs: {
url: '/f.zip',
name: 'f.zip',
mime: 'application/zip',
size: 512,
attachmentId: 'att-3',
},
}),
).toBe(
'<div data-type="attachment" data-attachment-url="/f.zip" data-attachment-name="f.zip" data-attachment-mime="application/zip" data-attachment-size="512" data-attachment-id="att-3"></div>',
);
});
it('attachment: with only a url, no spurious data-attachment-name/mime/size/id appear (all guards false)', () => {
expect(c({ type: 'attachment', attrs: { url: '/f.zip' } })).toBe(
'<div data-type="attachment" data-attachment-url="/f.zip"></div>',
);
});
it('orderedList inside a column renders via blockToHtml as <ol> (start attr DROPPED) with bold->strong, code->code', () => {
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{
type: 'column',
content: [
{
type: 'orderedList',
attrs: { start: 3 },
content: [
{
type: 'listItem',
content: [para(text('a', [{ type: 'bold' }]))],
},
{
type: 'listItem',
content: [para(text('b', [{ type: 'code' }]))],
},
],
},
],
},
],
});
// blockToHtml orderedList path emits a plain <ol> with no start attribute,
// and inlineToHtml maps bold->strong, code->code.
expect(out).toContain(
'<ol><li><p><strong>a</strong></p></li><li><p><code>b</code></p></li></ol>',
);
// The start:3 attr is NOT preserved in the HTML/column container path.
expect(out).not.toContain('start=');
});
it('hardBreak inside a column renders as <br> via inlineToHtml (not the markdown two-space form)', () => {
const out = c({
type: 'columns',
attrs: { layout: 'two' },
content: [
{
type: 'column',
content: [para(text('a'), { type: 'hardBreak' }, text('b'))],
},
],
});
expect(out).toContain('<p>a<br>b</p>');
// The processNode markdown " \n" hard-break form must NOT appear in the
// raw-HTML column container path.
expect(out).not.toContain(' \n');
});
});
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest';
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
// pulls in collaboration.ts and mutates the global DOM at import time), matching
// the other converter unit tests (see markdown-converter-gaps.test.ts).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// Minimal ProseMirror builders. The top-level converter joins doc children with
// "\n\n" then .trim()s, so a single-node doc yields exactly that node's rendered
// (trimmed) string.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
// A columns node carrying a SINGLE column, whose content is the supplied block
// children. columns/column are raw-HTML containers, so their children render via
// blockToHtml -> inlineToHtml (the HTML-mirroring path under test).
const oneColumn = (...blocks: any[]) => ({
type: 'columns',
attrs: { layout: 'two' },
content: [{ type: 'column', content: blocks }],
});
// Extract the inner HTML of the single column from a rendered columns string.
// Output shape is:
// <div data-type="columns" data-layout="two"><div data-type="column">INNER</div></div>
const COLUMN_PREFIX =
'<div data-type="columns" data-layout="two"><div data-type="column">';
const COLUMN_SUFFIX = '</div></div>';
const columnInner = (rendered: string): string => {
expect(rendered.startsWith(COLUMN_PREFIX)).toBe(true);
expect(rendered.endsWith(COLUMN_SUFFIX)).toBe(true);
return rendered.slice(COLUMN_PREFIX.length, rendered.length - COLUMN_SUFFIX.length);
};
// ---------------------------------------------------------------------------
// 1. inlineToHtml mark-mirroring INSIDE a raw-HTML container (columns).
//
// At the TOP level the `text` case emits markdown markers (**, *, ``, ~~) for
// bold/italic/code/strike. But inside columns (and spanned table cells) the
// content is raw HTML that marked will NOT re-parse, so inlineToHtml
// (markdown-converter.ts lines 599-619) MUST mirror each mark to HTML instead:
// bold-><strong>, italic-><em>, code-><code>, strike-><s>, underline-><u>. This
// is a DISTINCT branch from the top-level mark path; if it leaked markdown, the
// literal ** / `` would survive as text on re-import.
// ---------------------------------------------------------------------------
describe('inlineToHtml: bold/italic/code/strike/underline -> HTML inside columns', () => {
it('mirrors each single-mark run to its schema HTML tag (not markdown markers)', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('b', [{ type: 'bold' }]),
text('i', [{ type: 'italic' }]),
text('c', [{ type: 'code' }]),
text('s', [{ type: 'strike' }]),
text('u', [{ type: 'underline' }]),
),
),
),
);
expect(out).toBe(
'<div data-type="columns" data-layout="two">' +
'<div data-type="column">' +
'<p><strong>b</strong><em>i</em><code>c</code><s>s</s><u>u</u></p>' +
'</div></div>',
);
// Belt-and-suspenders: none of the top-level markdown markers leaked.
expect(out).not.toContain('**');
expect(out).not.toContain('~~');
expect(out).not.toContain('`');
});
});
// ---------------------------------------------------------------------------
// 2. inlineToHtml: link/hardBreak/highlight/textStyle/comment inside columns.
//
// Exercises the remaining inlineToHtml branches that are uncovered inside a
// raw-HTML container: link href escaping via escapeAttr (line 621; & -> &amp;,
// " -> &quot;), hardBreak -> <br> (line 591), highlight WITH vs WITHOUT color
// (624-626), textStyle color (628-630), and comment with data-resolved (632-638).
// ---------------------------------------------------------------------------
describe('inlineToHtml: link/hardBreak/highlight/textStyle/comment inside columns', () => {
it('escapes link hrefs, emits <br>, plain/colored <mark>, span color, and resolved comment', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('lnk', [{ type: 'link', attrs: { href: 'http://a?b&c"d' } }]),
{ type: 'hardBreak' },
text('hl', [{ type: 'highlight', attrs: { color: '#ff0000' } }]),
text('plain', [{ type: 'highlight' }]),
text('clr', [{ type: 'textStyle', attrs: { color: 'red' } }]),
text('cm', [
{ type: 'comment', attrs: { commentId: 'c1', resolved: true } },
]),
),
),
),
);
expect(columnInner(out)).toBe(
'<p>' +
'<a href="http://a?b&amp;c&quot;d">lnk</a>' +
'<br>' +
'<mark style="background-color: #ff0000">hl</mark>' +
'<mark>plain</mark>' +
'<span style="color: red">clr</span>' +
'<span data-comment-id="c1" data-resolved="true">cm</span>' +
'</p>',
);
});
it('omits data-resolved when the comment is not resolved', () => {
// The resolved sub-branch (632-638) is load-bearing: an unresolved comment
// must emit a bare data-comment-id span with NO data-resolved attribute.
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('cm', [
{ type: 'comment', attrs: { commentId: 'c1', resolved: false } },
]),
),
),
),
);
expect(columnInner(out)).toBe('<p><span data-comment-id="c1">cm</span></p>');
expect(out).not.toContain('data-resolved');
});
});
// ---------------------------------------------------------------------------
// 3. blockToHtml non-paragraph branches inside columns: heading / codeBlock /
// bulletList.
//
// heading -> <hN> (718-721), codeBlock with-language vs no-language class fork
// (730-742; the no-language `cls = ''` branch at 741 yields a BARE <code> with
// no class), and bulletList -> <ul><li><p>...</p></li></ul> (722-725). Code text
// is element TEXT content, so it is escapeHtmlText-escaped (not the attr escaper),
// and embedded newlines are preserved verbatim.
// ---------------------------------------------------------------------------
describe('blockToHtml: heading / codeBlock(lang & no-lang) / bulletList inside columns', () => {
it('emits <hN>, language vs bare <pre><code>, and <ul><li><p>..</p></li>', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
{ type: 'heading', attrs: { level: 2 }, content: [text('H')] },
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [text('a\nb')],
},
{ type: 'codeBlock', content: [text('plain')] },
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('item'))] },
],
},
),
),
);
expect(columnInner(out)).toBe(
'<h2>H</h2>' +
'<pre><code class="language-js">a\nb</code></pre>' +
'<pre><code>plain</code></pre>' +
'<ul><li><p>item</p></li></ul>',
);
// The no-language codeBlock must NOT carry a class attribute (the cls=''
// fork at line 741): its <code> opens bare.
expect(out).toContain('<pre><code>plain</code></pre>');
});
});
// ---------------------------------------------------------------------------
// 4. Spanned-table renderHtmlCell + orderedList block child (HTML fallback).
//
// A colspan>1 cell forces the WHOLE table to the raw-<table> HTML fallback
// (markdown-converter.ts ~287-331). renderHtmlCell emits colspan + align attrs
// (312-316) and renders each block child via blockToHtml. An orderedList child
// hits the blockToHtml orderedList branch (726-729), which emits
// <ol><li><p>..</p></li>..</ol> — the schema's `start` attr is NOT emitted by
// this HTML <ol> branch.
// ---------------------------------------------------------------------------
describe('spanned table: renderHtmlCell colspan/align + orderedList block child', () => {
it('renders the colspan/align cell with an <ol> (start attr is dropped)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 2, align: 'center' },
content: [
{
type: 'orderedList',
attrs: { start: 3 },
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
},
],
},
],
},
],
}),
);
expect(out).toBe(
'<table><tbody><tr>' +
'<td colspan="2" align="center">' +
'<ol><li><p>one</p></li><li><p>two</p></li></ol>' +
'</td>' +
'</tr></tbody></table>',
);
// The HTML <ol> branch does not propagate the ProseMirror `start` attribute.
expect(out).not.toContain('start');
});
});
@@ -0,0 +1,645 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// Wrap a single node in a minimal ProseMirror doc. The top-level converter
// joins doc children with "\n\n" and then .trim()s the whole output, so a
// single-node doc yields exactly that node's rendered (and trimmed) string.
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
// Convenience: a text node, optionally with marks.
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
// Convenience: a paragraph wrapping inline children.
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
describe('convertProseMirrorToMarkdown', () => {
// ---------------------------------------------------------------------------
describe('headings', () => {
it('emits the right number of "#" for levels 1-6', () => {
for (let level = 1; level <= 6; level++) {
const out = convertProseMirrorToMarkdown(
doc({ type: 'heading', attrs: { level }, content: [text('H')] }),
);
expect(out).toBe('#'.repeat(level) + ' H');
}
});
it('defaults to level 1 when level is missing', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'heading', content: [text('NoLevel')] }),
);
expect(out).toBe('# NoLevel');
});
});
// ---------------------------------------------------------------------------
describe('text marks', () => {
it('bold', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'bold' }])))),
).toBe('**x**');
});
it('italic', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'italic' }])))),
).toBe('*x*');
});
it('strike', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'strike' }])))),
).toBe('~~x~~');
});
it('inline code (sole mark) uses backtick span', () => {
expect(
convertProseMirrorToMarkdown(doc(para(text('x', [{ type: 'code' }])))),
).toBe('`x`');
});
it('code + another mark emits the backtick code form (code wins)', () => {
// The schema's `code` mark excludes all other marks, so the editor can
// never produce code+bold on one run and import always drops the co-mark.
// The lossless, byte-stable behavior is to emit ONLY the backtick code
// span and ignore the co-occurring mark.
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
);
expect(out).toBe('`x`');
});
it('code + strike combo emits the backtick code form (code wins)', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
);
expect(out).toBe('`x`');
});
});
// ---------------------------------------------------------------------------
describe('links', () => {
it('href only', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('site', [{ type: 'link', attrs: { href: 'https://e.com' } }]))),
);
expect(out).toBe('[site](https://e.com)');
});
it('href + title with an embedded double quote is escaped', () => {
const out = convertProseMirrorToMarkdown(
doc(
para(
text('site', [
{ type: 'link', attrs: { href: 'https://e.com', title: 'a "b" c' } },
]),
),
),
);
// The markdown link-title form escapes the inner " as \".
expect(out).toBe('[site](https://e.com "a \\"b\\" c")');
});
});
// ---------------------------------------------------------------------------
describe('image', () => {
it('percent-encodes spaces and parentheses in src', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'image',
attrs: { alt: 'cap', src: '/files/my pic (1).png' },
}),
);
// space -> %20, ( -> %28, ) -> %29
expect(out).toBe('![cap](/files/my%20pic%20%281%29.png)');
});
it('empty alt and missing src render harmlessly', () => {
const out = convertProseMirrorToMarkdown(doc({ type: 'image', attrs: {} }));
expect(out).toBe('![]()');
});
});
// ---------------------------------------------------------------------------
describe('codeBlock', () => {
it('with language', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'codeBlock',
attrs: { language: 'ts' },
content: [text('const a = 1;')],
}),
);
expect(out).toBe('```ts\nconst a = 1;\n```');
});
it('without language emits empty info string', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'codeBlock', content: [text('plain')] }),
);
expect(out).toBe('```\nplain\n```');
});
it('strips ALL trailing newlines for idempotency', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'codeBlock', content: [text('a\n\n\n')] }),
);
// Every trailing "\n" is removed, then exactly one is re-added by the fence.
expect(out).toBe('```\na\n```');
});
});
// ---------------------------------------------------------------------------
describe('lists', () => {
it('bullet list', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
}),
);
expect(out).toBe('- one\n- two');
});
it('ordered list numbers items sequentially', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'orderedList',
content: [
{ type: 'listItem', content: [para(text('a'))] },
{ type: 'listItem', content: [para(text('b'))] },
{ type: 'listItem', content: [para(text('c'))] },
],
}),
);
expect(out).toBe('1. a\n2. b\n3. c');
});
it('nested bullet list indents the child by the 2-col marker width', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
para(text('parent')),
{
type: 'bulletList',
content: [{ type: 'listItem', content: [para(text('child'))] }],
},
],
},
],
}),
);
// First line carries the marker; the nested list is indented 2 columns.
expect(out).toBe('- parent\n - child');
});
it('nested ordered list indents by the wider 3-col marker width', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
para(text('parent')),
{
type: 'orderedList',
content: [{ type: 'listItem', content: [para(text('child'))] }],
},
],
},
],
}),
);
// "1. " is 3 columns wide, so the continuation indent is 3 spaces.
expect(out).toBe('1. parent\n 1. child');
});
});
// ---------------------------------------------------------------------------
describe('task list', () => {
it('unchecked and checked items', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'taskList',
content: [
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
],
}),
);
expect(out).toBe('- [ ] todo\n- [x] done');
});
it('empty task item keeps its marker', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'taskList',
content: [{ type: 'taskItem', attrs: { checked: false }, content: [] }],
}),
);
expect(out).toBe('- [ ]');
});
});
// ---------------------------------------------------------------------------
describe('blockquote', () => {
it('single paragraph quote prefixes the line', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'blockquote', content: [para(text('quoted'))] }),
);
expect(out).toBe('> quoted');
});
it('multi-paragraph quote separates blocks with a bare ">" line', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [para(text('first')), para(text('second'))],
}),
);
expect(out).toBe('> first\n>\n> second');
});
});
// ---------------------------------------------------------------------------
describe('breaks and rules', () => {
it('horizontal rule', () => {
expect(
convertProseMirrorToMarkdown(doc({ type: 'horizontalRule' })),
).toBe('---');
});
it('hard break emits two trailing spaces then newline', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('a'), { type: 'hardBreak' }, text('b'))),
);
expect(out).toBe('a \nb');
});
});
// ---------------------------------------------------------------------------
describe('tables', () => {
it('GFM table emits alignment markers derived from header cells', () => {
const headerRow = {
type: 'tableRow',
content: [
{ type: 'tableHeader', attrs: { align: 'left' }, content: [para(text('L'))] },
{ type: 'tableHeader', attrs: { align: 'center' }, content: [para(text('C'))] },
{ type: 'tableHeader', attrs: { align: 'right' }, content: [para(text('R'))] },
{ type: 'tableHeader', content: [para(text('N'))] },
],
};
const bodyRow = {
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('1'))] },
{ type: 'tableCell', content: [para(text('2'))] },
{ type: 'tableCell', content: [para(text('3'))] },
{ type: 'tableCell', content: [para(text('4'))] },
],
};
const out = convertProseMirrorToMarkdown(
doc({ type: 'table', content: [headerRow, bodyRow] }),
);
expect(out).toBe(
[
'| L | C | R | N |',
'| :-- | :-: | --: | --- |',
'| 1 | 2 | 3 | 4 |',
].join('\n'),
);
});
it('spanned table (colspan/rowspan) emits raw <table> HTML', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 2 },
content: [para(text('wide'))],
},
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('a'))] },
{ type: 'tableCell', content: [para(text('b'))] },
],
},
],
}),
);
expect(out).toBe(
'<table><tbody>' +
'<tr><th colspan="2"><p>wide</p></th></tr>' +
'<tr><td><p>a</p></td><td><p>b</p></td></tr>' +
'</tbody></table>',
);
});
});
// ---------------------------------------------------------------------------
describe('callout and details', () => {
it('callout uses lowercased type fence', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'callout',
attrs: { type: 'WARNING' },
content: [para(text('beware'))],
}),
);
expect(out).toBe('> [!warning]\n> beware');
});
it('callout defaults to info', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'callout', content: [para(text('hi'))] }),
);
expect(out).toBe('> [!info]\n> hi');
});
it('details emits summary + content wrapped in <details>', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'details',
content: [
{ type: 'detailsSummary', content: [text('Title')] },
{ type: 'detailsContent', content: [para(text('Body'))] },
],
}),
);
// details joins its children with "\n"; summary opens, content closes.
expect(out).toBe('<details>\n<summary>Title</summary>\n\nBody\n</details>');
});
});
// ---------------------------------------------------------------------------
describe('math', () => {
it('inline math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
const out = convertProseMirrorToMarkdown(
doc(para({ type: 'mathInline', attrs: { text: 'a < b' } })),
);
// < and > must NOT be HTML-escaped (idempotency); only & and " would be.
expect(out).toBe(
'<span data-type="mathInline" data-katex="true" text="a < b"></span>',
);
expect(out).not.toContain('&lt;');
});
it('block math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'mathBlock', attrs: { text: 'x > y & z' } }),
);
// & IS escaped (entity-significant), but < and > are NOT.
expect(out).toBe(
'<div data-type="mathBlock" data-katex="true" text="x > y &amp; z"></div>',
);
expect(out).not.toContain('&lt;');
expect(out).not.toContain('&gt;');
});
});
// ---------------------------------------------------------------------------
describe('inline atoms and media', () => {
it('mention emits schema span with data-* attrs and visible label', () => {
const out = convertProseMirrorToMarkdown(
doc(
para({
type: 'mention',
attrs: { id: 'u1', label: 'Alice', entityType: 'user' },
}),
),
);
expect(out).toBe(
'<span data-type="mention" data-id="u1" data-label="Alice" data-entity-type="user">@Alice</span>',
);
});
it('attachment emits div with schema data-attachment-* attrs', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'attachment',
attrs: { url: '/files/x.zip', name: 'x.zip', mime: 'application/zip', size: 99 },
}),
);
expect(out).toBe(
'<div data-type="attachment" data-attachment-url="/files/x.zip" ' +
'data-attachment-name="x.zip" data-attachment-mime="application/zip" ' +
'data-attachment-size="99"></div>',
);
});
it('video emits a <div>-wrapped <video> with schema attrs', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'video',
attrs: { src: '/v.mp4', alt: 'clip', width: 640 },
}),
);
expect(out).toBe(
'<div><video src="/v.mp4" aria-label="clip" width="640"></video></div>',
);
});
it('youtube emits a div[data-type="youtube"] with data-src', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'youtube',
attrs: { src: 'https://youtu.be/abc', width: 560, height: 315 },
}),
);
expect(out).toBe(
'<div data-type="youtube" data-src="https://youtu.be/abc" ' +
'data-width="560" data-height="315"></div>',
);
});
});
// ---------------------------------------------------------------------------
describe('edge cases', () => {
it('null content returns ""', () => {
expect(convertProseMirrorToMarkdown(null)).toBe('');
});
it('empty object returns ""', () => {
expect(convertProseMirrorToMarkdown({})).toBe('');
});
it('doc with no content returns ""', () => {
expect(convertProseMirrorToMarkdown({ type: 'doc' })).toBe('');
});
it('unknown node type falls back to children-only (no throw, text preserved)', () => {
const out = convertProseMirrorToMarkdown(
doc({ type: 'totallyUnknownType', content: [text('kept')] }),
);
expect(out).toBe('kept');
});
it('deeply nested structure does not stack-overflow', () => {
// Build a deeply nested bullet list (each level holds one nested list).
let node: any = { type: 'listItem', content: [para(text('leaf'))] };
for (let i = 0; i < 200; i++) {
node = {
type: 'listItem',
content: [para(text('lvl')), { type: 'bulletList', content: [node] }],
};
}
const root = doc({ type: 'bulletList', content: [node] });
expect(() => convertProseMirrorToMarkdown(root)).not.toThrow();
const out = convertProseMirrorToMarkdown(root);
expect(out).toContain('leaf');
expect(out.startsWith('- lvl')).toBe(true);
});
});
// ===========================================================================
// Targeted coverage for marker-width-scaled list indent, the markdown
// link-title escape branch, the markdown callout fence, and the blockquote
// per-line prefixer over a multi-line nested-block child. Grounded against
// the real converter output (verified empirically) — see processListItem /
// indentItemChildren (src 812-843), the link mark branch (src 117-121), the
// callout case (src 373-376), and the blockquote prefixer (src 210-221).
describe('marker-width / link-title / callout / blockquote-nested', () => {
// Spec 1 — two-digit ordered marker scales the continuation indent to 4.
it('indents a nested ordered sublist under item 10 by 4 spaces (marker "10. ")', () => {
// Items 1..10 ("a".."j"); the 10th additionally holds a nested
// orderedList with one paragraph "x".
const items: any[] = [];
for (let i = 0; i < 9; i++) {
items.push({
type: 'listItem',
content: [para(text(String.fromCharCode(97 + i)))], // 'a'..'i'
});
}
items.push({
type: 'listItem',
content: [
para(text('j')),
{
type: 'orderedList',
content: [{ type: 'listItem', content: [para(text('x'))] }],
},
],
});
const out = convertProseMirrorToMarkdown(
doc({ type: 'orderedList', content: items }),
);
// The 10th marker is the 4-column "10. "; the nested sublist line must be
// indented exactly 4 spaces (prefix.length 3 + 1), NOT 3.
expect(out).toContain('10. j\n 1. x');
// Guard against the off-by-one (3-space) regression that would re-parse
// the sublist as loose/sibling content on import.
expect(out).not.toContain('10. j\n 1. x');
// And the single-digit items keep the narrower 3-column marker (no body
// continuation here, but the marker itself must stay "1. ".."9. ").
expect(out.startsWith('1. a\n2. b\n')).toBe(true);
expect(out).toContain('\n9. i\n10. j');
});
// Spec 2 — markdown link-title branch escapes an embedded double quote and
// emits the href raw.
it('escapes an embedded double-quote in a markdown link title and emits href raw', () => {
const out = convertProseMirrorToMarkdown(
doc(
para(
text('lbl', [
{
type: 'link',
attrs: { href: 'http://a', title: 'he said "hi"' },
},
]),
),
),
);
// The title's " is backslash-escaped (.replace(/"/g,'\\"')) so it cannot
// terminate the (url "title") syntax early; the href is RAW (not escaped).
expect(out).toBe('[lbl](http://a "he said \\"hi\\"")');
});
// Spec 3 — markdown callout fence lowercases the type and joins multiple
// paragraph children.
it('lowercases an uppercase callout type and joins its paragraphs', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'callout',
attrs: { type: 'WARNING' },
content: [para(text('line1')), para(text('line2'))],
}),
);
// NOTE(review): the spec predicted ':::warning\nline1\n\nline2\n:::' (a
// The converter joins the callout's rendered children with a single '\n'
// and emits an Obsidian-native callout: a `> [!type]` opener plus one
// `>`-prefixed body line per content line. We pin the lowercasing
// (WARNING -> warning) and the multi-child join.
expect(out).toBe('> [!warning]\n> line1\n> line2');
// The type is lowercased (an uppercase `[!WARNING]` would not re-import).
expect(out.startsWith('> [!warning]\n')).toBe(true);
expect(out).not.toContain('[!WARNING]');
// Both paragraph children are present, each blockquote-prefixed.
expect(out).toContain('> line1\n> line2');
});
// Spec 4 — blockquote per-line prefixer over a multi-line nested callout.
it('prefixes every line of a nested callout child with "> "', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [
{
type: 'callout',
attrs: { type: 'INFO' },
content: [para(text('a')), para(text('b'))],
},
],
}),
);
// NOTE(review): the spec predicted '> :::info\n> a\n>\n> b\n> :::',
// assuming the nested callout body contains a blank line between 'a' and
// The nested callout renders as an Obsidian callout '> [!info]\n> a\n> b'
// (single-'\n' join, no blank line). The outer blockquote prefixer then
// prefixes each of those lines with '> ' again, yielding a doubly-nested
// blockquote — the realistic per-line-prefix loop over a multi-line child.
expect(out).toBe('> > [!info]\n> > a\n> > b');
// Every produced line carries the '> ' prefix (no line escapes to col 0).
for (const line of out.split('\n')) {
expect(line.startsWith('>')).toBe(true);
}
});
// The empty-line '>' branch from Spec 4's intent IS reachable — just not via
// the nested callout (whose body has no blank line). A two-paragraph
// blockquote DOES separate its block children with a bare '>' line, which is
// the branch the spec wanted to protect. Pin it directly so the
// (line.length ? '> ' : '>') empty-line path stays covered.
it('maps an internal blank line to a bare ">" (not "> ") in a multi-block quote', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'blockquote',
content: [para(text('p1')), para(text('p2'))],
}),
);
expect(out).toBe('> p1\n>\n> p2');
// The separator line is exactly '>' with NO trailing space.
expect(out.split('\n')).toContain('>');
expect(out).not.toContain('> \n');
});
});
});
@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import {
serializeDocmostMarkdown,
parseDocmostMarkdown,
serializeDocmostMarkdownBody,
type DocmostMdMeta,
} from '../src/lib/markdown-document.js';
const meta: DocmostMdMeta = {
version: 1,
pageId: 'p1',
slugId: 's1',
title: 'Hello',
spaceId: 'sp1',
parentPageId: null,
};
describe('serializeDocmostMarkdown / parseDocmostMarkdown', () => {
// ---------------------------------------------------------------------------
describe('round-trip', () => {
it('round-trips meta, body, and comments', () => {
const body = '# Title\n\nSome **body** text.';
const comments = [{ id: 'c1', text: 'a note' }];
const full = serializeDocmostMarkdown(meta, body, comments);
const parsed = parseDocmostMarkdown(full);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe(body);
expect(parsed.comments).toEqual(comments);
});
it('emits a comments block with [] even when there are no comments', () => {
const full = serializeDocmostMarkdown(meta, 'body', []);
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
const parsed = parseDocmostMarkdown(full);
expect(parsed.comments).toEqual([]);
expect(parsed.body).toBe('body');
});
it('non-array comments arg is normalized to [] in the serialized output', () => {
const full = serializeDocmostMarkdown(meta, 'body', null as any);
expect(full).toContain('<!-- docmost:comments\n[]\n-->');
});
it('trims surrounding whitespace from the body on serialize', () => {
const full = serializeDocmostMarkdown(meta, '\n\n body \n\n', []);
const parsed = parseDocmostMarkdown(full);
expect(parsed.body).toBe('body');
});
});
// ---------------------------------------------------------------------------
describe('missing blocks (tolerant parsing)', () => {
it('missing meta block yields meta:null', () => {
const input = 'Just a body.\n\n<!-- docmost:comments\n[]\n-->\n';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toBeNull();
expect(parsed.body).toBe('Just a body.');
expect(parsed.comments).toEqual([]);
});
it('missing comments block yields comments:null and treats all as body', () => {
const input =
'<!-- docmost:meta\n' + JSON.stringify(meta) + '\n-->\n\nbody only';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toEqual(meta);
expect(parsed.comments).toBeNull();
expect(parsed.body).toBe('body only');
});
it('plain markdown with neither block: meta and comments null, whole input is body', () => {
const input = '# Plain\n\nNo envelope here.';
const parsed = parseDocmostMarkdown(input);
expect(parsed.meta).toBeNull();
expect(parsed.comments).toBeNull();
expect(parsed.body).toBe(input);
});
});
// ---------------------------------------------------------------------------
describe('CRLF normalization', () => {
it('parses a CRLF-encoded document the same as LF', () => {
const lf = serializeDocmostMarkdown(meta, 'line one\nline two', [
{ id: 'c1' },
]);
const crlf = lf.replace(/\n/g, '\r\n');
const parsed = parseDocmostMarkdown(crlf);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe('line one\nline two');
expect(parsed.comments).toEqual([{ id: 'c1' }]);
});
});
// ---------------------------------------------------------------------------
describe('only the final document-ending comments block is captured', () => {
it('an earlier literal docmost:comments opener inside the body stays in the body', () => {
// The body documents the format and contains a literal opener that does
// NOT end the document. Only the trailing block is treated as metadata.
const bodyWithLiteral =
'Here is how the format looks:\n\n<!-- docmost:comments\n[{"fake":true}]\n-->\n\nand more prose after it.';
const full = serializeDocmostMarkdown(meta, bodyWithLiteral, [
{ id: 'real' },
]);
const parsed = parseDocmostMarkdown(full);
// The real (final) block parses into the comments...
expect(parsed.comments).toEqual([{ id: 'real' }]);
// ...and the earlier literal opener is preserved verbatim in the body.
expect(parsed.body).toContain(
'<!-- docmost:comments\n[{"fake":true}]\n-->',
);
expect(parsed.body).toContain('and more prose after it.');
});
it('a literal opener whose closer does NOT end the doc is left entirely in the body', () => {
// No real trailing block: the opener is not document-ending, so comments
// stays null and nothing is stripped.
const input =
'<!-- docmost:meta\n' +
JSON.stringify(meta) +
'\n-->\n\nbody start\n\n<!-- docmost:comments\n[]\n-->\n\ntrailing text not ending the doc';
const parsed = parseDocmostMarkdown(input);
expect(parsed.comments).toBeNull();
expect(parsed.body).toContain('<!-- docmost:comments');
expect(parsed.body).toContain('trailing text not ending the doc');
});
});
// ---------------------------------------------------------------------------
describe('end-anchored comments closer tolerates CRLF + trailing whitespace', () => {
it('captures the final comments block when its "-->" closer has CRLF and trailing spaces', () => {
// The closer regex is /\r?\n-->[ \t]*\r?\n?\s*$/. Build a document whose
// trailing comments block uses CRLF line endings AND has trailing spaces
// after the "-->" closer, then assert it is still recognised as the
// document-ending block (and the body is not polluted by it).
const metaLine = JSON.stringify(meta);
const crlfDoc =
`<!-- docmost:meta\r\n${metaLine}\r\n-->\r\n\r\n` +
`the body line\r\n\r\n` +
`<!-- docmost:comments\r\n[{"id":"c-crlf"}]\r\n--> \r\n`;
const parsed = parseDocmostMarkdown(crlfDoc);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe('the body line');
expect(parsed.comments).toEqual([{ id: 'c-crlf' }]);
});
});
// ---------------------------------------------------------------------------
describe('malformed JSON throws a clear error', () => {
it('throws on malformed meta JSON', () => {
const input = '<!-- docmost:meta\n{not valid json}\n-->\n\nbody';
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:meta JSON/);
});
it('throws on malformed comments JSON', () => {
const input = 'body\n\n<!-- docmost:comments\n[not, valid]\n-->\n';
expect(() => parseDocmostMarkdown(input)).toThrow(/docmost:comments JSON/);
});
});
});
describe('serializeDocmostMarkdownBody', () => {
it('emits NO comments block', () => {
const out = serializeDocmostMarkdownBody(meta, 'just the body');
expect(out).not.toContain('docmost:comments');
expect(out).toContain('<!-- docmost:meta');
});
it('serialize -> parse preserves meta and the trimmed body, comments null (SPEC §3)', () => {
const fullMeta: DocmostMdMeta = {
version: 1,
pageId: 'page-123',
slugId: 'slug-abc',
title: 'My Page',
spaceId: 'space-1',
parentPageId: 'parent-9',
};
const body = 'Hello\n\nWorld';
const out = serializeDocmostMarkdownBody(fullMeta, body);
const parsed = parseDocmostMarkdown(out);
expect(parsed.meta).toEqual(fullMeta);
expect(parsed.body).toBe(body);
expect(parsed.comments).toBeNull();
});
it('preserves a null parentPageId for a root page', () => {
const out = serializeDocmostMarkdownBody(meta, 'body text');
const parsed = parseDocmostMarkdown(out);
expect(parsed.meta).toEqual(meta);
expect(parsed.comments).toBeNull();
});
it('produces a parseable file for an empty or missing body', () => {
const minimal: DocmostMdMeta = { version: 1, pageId: 'p-empty' };
const emptyFile = serializeDocmostMarkdownBody(minimal, '');
const parsedEmpty = parseDocmostMarkdown(emptyFile);
expect(parsedEmpty.meta).toEqual(minimal);
expect(parsedEmpty.body).toBe('');
expect(parsedEmpty.comments).toBeNull();
// Missing body (undefined) — serializer coalesces to "".
const missingFile = serializeDocmostMarkdownBody(
minimal,
undefined as unknown as string,
);
const parsedMissing = parseDocmostMarkdown(missingFile);
expect(parsedMissing.meta).toEqual(minimal);
expect(parsedMissing.body).toBe('');
expect(parsedMissing.comments).toBeNull();
});
it('trims the body', () => {
const out = serializeDocmostMarkdownBody(meta, '\n\n hi \n');
const parsed = parseDocmostMarkdown(out);
expect(parsed.body).toBe('hi');
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
serializeDocmostMarkdownBody,
parseDocmostMarkdown,
type DocmostMdMeta,
} from 'docmost-client';
describe('serializeDocmostMarkdownBody round-trip (SPEC §3)', () => {
it('serialize -> parse preserves meta and the trimmed body, with no comments block', () => {
const meta: DocmostMdMeta = {
version: 1,
pageId: 'page-123',
slugId: 'slug-abc',
title: 'My Page',
spaceId: 'space-1',
parentPageId: 'parent-9',
};
const body = 'Hello\n\nWorld';
const file = serializeDocmostMarkdownBody(meta, body);
const parsed = parseDocmostMarkdown(file);
expect(parsed.meta).toEqual(meta);
expect(parsed.body).toBe(body);
// No trailing docmost:comments block was emitted (SPEC §3).
expect(parsed.comments).toBeNull();
});
it('preserves a null parentPageId for a root page', () => {
const meta: DocmostMdMeta = {
version: 1,
pageId: 'root-1',
slugId: 'root-slug',
title: 'Root',
spaceId: 'space-1',
parentPageId: null,
};
const file = serializeDocmostMarkdownBody(meta, 'body text');
const parsed = parseDocmostMarkdown(file);
expect(parsed.meta).toEqual(meta);
expect(parsed.comments).toBeNull();
});
it('produces a parseable file for an empty/missing body', () => {
const meta: DocmostMdMeta = { version: 1, pageId: 'p-empty' };
// Empty string body.
const emptyFile = serializeDocmostMarkdownBody(meta, '');
expect(() => parseDocmostMarkdown(emptyFile)).not.toThrow();
const parsedEmpty = parseDocmostMarkdown(emptyFile);
expect(parsedEmpty.meta).toEqual(meta);
expect(parsedEmpty.body).toBe('');
expect(parsedEmpty.comments).toBeNull();
// Missing body (undefined) — serializer coalesces to "".
const missingFile = serializeDocmostMarkdownBody(
meta,
undefined as unknown as string,
);
expect(() => parseDocmostMarkdown(missingFile)).not.toThrow();
const parsedMissing = parseDocmostMarkdown(missingFile);
expect(parsedMissing.meta).toEqual(meta);
expect(parsedMissing.body).toBe('');
expect(parsedMissing.comments).toBeNull();
});
});
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
} from "docmost-client";
// Round-trip coverage for the two editor features git-sync's converter
// predated and must now preserve losslessly:
// - the `spoiler` inline mark (issue #259), emitted as raw inline HTML
// `<span data-spoiler="true">…</span>` (Markdown has no native syntax);
// - the image `caption` attribute (issue #221), emitted as `data-caption`
// on the raw <img> (Markdown `![](src)` cannot carry it).
// We exercise the real export -> import -> export cycle: a PM doc must survive
// PM -> MD -> PM unchanged, and the raw-HTML forms in incoming Markdown must
// parse back to the mark/attribute.
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
const text = (t: string, marks?: any[]) =>
marks ? { type: "text", text: t, marks } : { type: "text", text: t };
const para = (...inline: any[]) => ({ type: "paragraph", content: inline });
// Count text nodes carrying a `spoiler` mark anywhere in a PM JSON doc.
function countSpoilerMarks(node: any): number {
let count = 0;
const walk = (n: any) => {
if (!n || typeof n !== "object") return;
if (Array.isArray(n.marks)) {
for (const mark of n.marks) if (mark?.type === "spoiler") count++;
}
if (Array.isArray(n.content)) n.content.forEach(walk);
};
walk(node);
return count;
}
// Find the first image node anywhere in a PM JSON doc.
function findImage(node: any): any | null {
if (!node || typeof node !== "object") return null;
if (node.type === "image") return node;
if (Array.isArray(node.content)) {
for (const child of node.content) {
const hit = findImage(child);
if (hit) return hit;
}
}
return null;
}
describe("spoiler mark round-trip (#259)", () => {
it("survives export -> import -> export unchanged", async () => {
const source = doc(
para(
text("before "),
text("hidden", [{ type: "spoiler" }]),
text(" after"),
),
);
const md1 = convertProseMirrorToMarkdown(source);
// Lossless raw inline HTML form.
expect(md1).toContain('<span data-spoiler="true">hidden</span>');
const doc2 = await markdownToProseMirror(md1);
// The spoiler mark was recovered on import.
expect(countSpoilerMarks(doc2)).toBe(1);
expect(JSON.stringify(doc2)).toContain("hidden");
// Byte-stable: a second export reproduces the first exactly.
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1);
});
it("keeps the spoiler intact when it intersects a bold mark", async () => {
const source = doc(
para(text("secret", [{ type: "bold" }, { type: "spoiler" }])),
);
const md1 = convertProseMirrorToMarkdown(source);
expect(md1).toContain('data-spoiler="true"');
const doc2 = await markdownToProseMirror(md1);
expect(countSpoilerMarks(doc2)).toBe(1);
// Bold survives alongside the spoiler.
expect(JSON.stringify(doc2)).toContain('"bold"');
});
it("parses a raw <span data-spoiler> in incoming Markdown back to the mark", async () => {
const incoming = 'before <span data-spoiler="true">hidden</span> after';
const parsed = await markdownToProseMirror(incoming);
expect(countSpoilerMarks(parsed)).toBe(1);
});
});
describe("image caption round-trip (#221)", () => {
it("survives export -> import -> export with the caption preserved", async () => {
const source = doc({
type: "image",
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
});
const md1 = convertProseMirrorToMarkdown(source);
// A captioned image takes the raw <img> form so data-caption can ride along.
expect(md1).toContain('data-caption="A grey cat"');
const doc2 = await markdownToProseMirror(md1);
const img = findImage(doc2);
expect(img).toBeTruthy();
expect(img.attrs?.caption).toBe("A grey cat");
// Byte-stable: a second export reproduces the first exactly.
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1);
});
it("parses a raw <img data-caption> in incoming Markdown back to the caption", async () => {
const incoming = '<img src="/files/a.png" alt="cat" data-caption="A grey cat">';
const parsed = await markdownToProseMirror(incoming);
const img = findImage(parsed);
expect(img).toBeTruthy();
expect(img.attrs?.caption).toBe("A grey cat");
});
it("leaves a caption-less image on the lighter markdown form", () => {
const md = convertProseMirrorToMarkdown(
doc({ type: "image", attrs: { src: "/files/a.png", alt: "cat" } }),
);
expect(md).toBe("![cat](/files/a.png)");
});
});
@@ -0,0 +1,698 @@
import { describe, expect, it, vi } from 'vitest';
import fc from 'fast-check';
// These property tests run real ProseMirror<->Markdown conversion × NUM_RUNS, so
// each takes ~4–5s. Inputs are DETERMINISTIC (fixed SEED below) — the only source
// of flakiness is wall-clock: under the full suite's parallel worker load they can
// exceed vitest's default 5000ms per-test timeout. Give them ample headroom so CI
// (which gates the docker build, AGENTS.md) is deterministic regardless of load.
vi.setConfig({ testTimeout: 30000 });
// Import the converter DIRECTLY from src (NOT the docmost-client barrel) so we
// match the path used by the other converter unit tests.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
// global DOM via jsdom at module load time — this is expected and required for
// @tiptap/html's generateJSON to run under Node.
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import { stripBlockIds } from './roundtrip-helpers.js';
// ---------------------------------------------------------------------------
// WHY THIS TEST EXISTS (SPEC §11 / "Задача №0")
//
// git is the state store, and git diffs byte-for-byte. The sync daemon does
// `export(markdown) -> import(ProseMirror) -> export(markdown)` on every pull,
// so if the *second* export differs from the first by even one byte, every
// pull produces a phantom diff -> endless commits/conflicts. The single
// property git actually needs is therefore MARKDOWN BYTE-STABILITY:
//
// md2 := export(import(export(doc))) MUST equal md1 := export(doc)
//
// This file fuzzes that invariant with fast-check over randomly generated,
// representative Docmost ProseMirror documents.
//
// ---------------------------------------------------------------------------
// THE "SUPPORTED SPACE" PROBLEM
//
// A NAIVE generator surfaces two different kinds of `md2 !== md1`:
//
// (a) GENUINE converter limitations — documented below as `it.fails` repros.
// (b) Inputs the converter LEGITIMATELY normalizes, i.e. markdown that is
// ambiguous or that the schema rewrites to a canonical form. These are
// NOT byte-stable by construction and are NOT bugs; the fix is to keep
// the generator inside the byte-stable / supported space.
//
// The following were all empirically confirmed (by probing the live converter)
// and are EXCLUDED from / canonicalized by the byte-stable arbitrary. Each is a
// markdown ambiguity or a schema/ProseMirror normalization, NOT a converter bug.
//
// * Text that re-triggers block/inline markdown syntax on re-parse:
// - a leading `>`/`*`/`-`/`#`/`1.` turns a paragraph into a blockquote/
// list/heading;
// - `a b` (2+ spaces) collapses to `a b`;
// - `<b>` / `</div>` parse as real HTML tags (and run-concatenation can
// form `<word>` across a run boundary);
// - `&amp;` / `&lt;` decode back to `&` / `<`;
// - a lone backtick is a code-span delimiter and re-pairs globally.
// -> The text arbitrary emits space-joined tokens that BEGIN and END with an
// alphanumeric word, with any single special char confined to the middle
// (space-flanked). Every char the task requires (* _ [ ] ( ) | < > &, and
// more) is covered this way; the backtick is exercised via code spans.
// * A purely numeric image `alt` ("0") or link `title` ("0") is parsed back as
// a NUMBER and dropped by the converter's `value || ""` -> alt/title always
// carry at least one letter.
// * Callout types other than info/success/warning/danger normalize to `info`
// (schema only knows those four) -> generator restricts to those four.
// * A list item / callout / blockquote with MULTIPLE block children: the
// converter joins them with a single "\n", which marked re-parses as ONE
// merged paragraph ("- p1\n p2" -> "- p1 p2"). -> container bodies hold a
// SINGLE paragraph, optionally plus ONE nested list for lists.
// * `orderedList.start` / `1)` markers normalize to `1.` -> not emitted.
// * Two sibling lists sharing a marker family (bullet/task use "-", ordered
// uses "1.") MERGE into one list -> no two list blocks are adjacent.
// * TWO consecutive hard breaks render a blank line that marked eats as a
// paragraph break, and a trailing hard break is trimmed -> consecutive/
// trailing hard breaks are collapsed/removed.
// * Adjacent text runs with IDENTICAL marks ("**a****b****c**" -> "**abc**").
// A real ProseMirror doc never stores split same-mark runs (the editor
// coalesces them) -> the generator merges them too (normalizeInline).
//
// The GENUINE, real-but-intentional non-roundtrip limitations are kept HONEST as
// `it.fails` blocks below (so the suite stays green only because they are marked
// expected-to-fail, never by hiding them):
//
// 1. The `code` mark COMBINED with any other mark. The converter emits nested
// HTML (`<strong><code>x</code></strong>`), but the schema's `code` mark
// declares `excludes: "_"`, so on import every co-occurring mark is dropped
// and the run comes back as `code` only -> md2 == "`x`". Acknowledged in
// markdown-converter.ts (the long comment above the marks switch);
// impossible to round-trip both while `code` excludes them.
// 2. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
// is block-level but `![](url)` is inline; marked wraps it in a <p>, the
// schema hoists the <img> out and leaves an empty paragraph sibling, which
// injects an extra blank gap on the second export. An image IS byte-stable
// as the sole block (edge artifacts get trimmed) — covered by a green test.
// ---------------------------------------------------------------------------
// Run a full export -> import -> export cycle and return both markdown strings.
async function roundTrip(doc: unknown): Promise<{ md1: string; md2: string; doc2: any }> {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, md2, doc2 };
}
const SEED = 42;
const NUM_RUNS = 100;
// ---------------------------------------------------------------------------
// Inline text arbitraries
// ---------------------------------------------------------------------------
// Alphanumeric "word" (no markdown-significant characters). Length 1..6.
const wordArb = fc
.stringMatching(/^[A-Za-z0-9]{1,6}$/)
.filter((w) => w.length > 0);
// A SINGLE markdown-significant character, emitted only as an isolated,
// space-flanked token. Every char the task calls out plus a few more; each was
// verified byte-stable in this position.
//
// NOTE: the backtick (`) is DELIBERATELY excluded from free-floating plain
// text. A lone backtick is a markdown code-span DELIMITER, so its round-trip
// depends on GLOBAL backtick pairing: a stray backtick in running text adjacent
// to a real code span ("A ` " + `code`) re-pairs into a different code span and
// loses a space — genuinely outside the byte-stable space. The backtick is
// still fully exercised as the `code`-mark delimiter and inside code blocks.
const specialCharArb = fc.constantFrom(
'*', '_', '[', ']', '(', ')', '{', '}', '|', '<', '>', '&', '#', '!', '~', '=', '+', '-',
);
// Build a "safe special" text string: a space-joined sequence of tokens that
// always BEGINS and ENDS with an alphanumeric word, with any isolated special
// chars confined to the MIDDLE (each space-flanked by words).
//
// Both boundary guarantees matter:
// * Leading word: the line never opens with a block/inline trigger
// (">", "*", "-", "#", "1." ...).
// * Trailing word: adjacent text runs CONCATENATE with no separator, so a run
// ending in a bare "<" beside a run starting with a letter would form a fake
// HTML tag ("...0 <" + "A >" -> "0 <A >"), which marked/jsdom strips. Ending
// every run with an alphanumeric word keeps every special internal and
// space-flanked even after concatenation.
const safeTextArb: fc.Arbitrary<string> = fc
.tuple(
wordArb,
fc.array(fc.oneof(wordArb, specialCharArb), { minLength: 0, maxLength: 3 }),
wordArb,
)
.map(([first, middle, last]) => [first, ...middle, last].join(' '));
// A plain alphanumeric phrase (1..3 words) for places where even isolated
// specials are not wanted (e.g. code-block language, mention labels).
const phraseArb: fc.Arbitrary<string> = fc
.array(wordArb, { minLength: 1, maxLength: 3 })
.map((ws) => ws.join(' '));
// A phrase guaranteed to contain at least one letter. Used for image alt text:
// a PURELY numeric alt (e.g. "0", "00") is parsed back by the schema as a
// NUMBER, and the converter's `alt || ""` then treats the number 0 as falsy and
// DROPS the alt ("![0](u)" -> "![](u)") — not byte-stable. A letter anywhere in
// the alt keeps it a string and avoids the coercion.
const letterPhraseArb: fc.Arbitrary<string> = fc
.tuple(
fc.stringMatching(/^[A-Za-z]{1,4}$/),
fc.array(wordArb, { minLength: 0, maxLength: 2 }),
)
.map(([head, rest]) => [head, ...rest].join(' '));
// A text run with an OPTIONAL single non-code mark (bold/italic/strike), or a
// SOLE `code` mark, or a link. `code` is never combined with another mark in
// the byte-stable arbitrary (that combination is the known bug, exercised
// separately in the it.fails block). Marks wrap safe text, which stays stable
// even when it contains isolated specials.
const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
// Plain text.
safeTextArb.map((t) => ({ type: 'text', text: t })),
// Single formatting mark.
fc
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
.map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }] })),
// Sole code mark (backtick span). safeTextArb is already backtick-free, so the
// code span content cannot contain an inner backtick (which would be
// ambiguous to re-parse).
safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })),
// Link with safe text and a paren/space-free href, optionally with a title.
// The title rides in a markdown link-title attribute; a purely numeric title
// is coerced to a number and dropped on re-import (same class of quirk as the
// image alt), so the title always carries at least one letter.
fc
.tuple(
phraseArb,
fc.webUrl().filter((u) => !/[()\s]/.test(u)),
fc.option(letterPhraseArb, { nil: undefined }),
)
.map(([t, href, title]) => ({
type: 'text',
text: t,
marks: [{ type: 'link', attrs: title ? { href, title } : { href } }],
})),
// Inline COMMENT anchor (SPEC §3): a span[data-comment-id] that must survive
// the round-trip byte-for-byte. The commentId is an alphanumeric token (no
// attribute-breaking chars), and `resolved` rides as data-resolved="true"
// only when true — both forms were verified byte-stable.
fc
.tuple(safeTextArb, fc.stringMatching(/^[A-Za-z0-9]{4,10}$/), fc.boolean())
.map(([t, commentId, resolved]) => ({
type: 'text',
text: t,
marks: [
{
type: 'comment',
attrs: resolved ? { commentId, resolved: true } : { commentId },
},
],
})),
);
// Inline math node carrying LaTeX that includes the `a < b` the task asks for.
const mathInlineArb: fc.Arbitrary<any> = fc
.constantFrom('a < b', 'x^2 + y^2', 'a < b < c', '\\frac{1}{2}', 'E = mc^2')
.map((text) => ({ type: 'mathInline', attrs: { text } }));
// Mention node (schema attrs); label/id are plain phrases.
const mentionArb: fc.Arbitrary<any> = fc
.tuple(phraseArb, fc.uuid(), fc.uuid())
.map(([label, id, entityId]) => ({
type: 'mention',
attrs: { id, label, entityType: 'user', entityId },
}));
const hardBreakArb: fc.Arbitrary<any> = fc.constant({ type: 'hardBreak' });
// Canonicalize a generated inline-content array the way ProseMirror itself
// stores inline content, then trim the markdown-fragile edges. Applied to both
// paragraph and heading inline content.
//
// 1) MERGE adjacent `text` runs that carry IDENTICAL marks. A real
// ProseMirror document never stores two neighbouring runs with the same
// mark set — the editor coalesces them into one. A naive generator that
// leaves them split produces UNREALISTIC docs AND breaks byte-stability:
// three adjacent bold runs export as "**a****b****c**", whose inner
// "****" boundaries are ambiguous and re-parse as a single "**abc**".
// Merging makes the generated doc canonical and the markdown stable.
// 2) Collapse CONSECUTIVE hard breaks. Two in a row render as " \n \n",
// whose middle whitespace-only line marked treats as a paragraph break, so
// "a \n \nb" re-parses to "a\n\nb". A SINGLE hard break round-trips.
// 3) Drop a TRAILING hard break: "... \n" sits at the paragraph edge and is
// removed by the converter's .trim().
const sameMarks = (a: any[] | undefined, b: any[] | undefined): boolean =>
JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
function normalizeInline(nodes: any[]): any[] {
const out: any[] = [];
for (const node of nodes) {
const prev = out[out.length - 1];
// Collapse a second consecutive hard break.
if (node.type === 'hardBreak' && prev && prev.type === 'hardBreak') {
continue;
}
// Merge an adjacent text run with the same marks.
if (
node.type === 'text' &&
prev &&
prev.type === 'text' &&
sameMarks(prev.marks, node.marks)
) {
prev.text += node.text;
continue;
}
// Clone text nodes so the in-place merge above never mutates a shared value.
out.push(node.type === 'text' ? { ...node } : node);
}
while (out.length > 1 && out[out.length - 1].type === 'hardBreak') {
out.pop();
}
return out;
}
// Inline content for a paragraph: at least one marked text run, optionally with
// inline atoms (math/mention) and hard breaks interspersed. Always starts with a
// text run so the paragraph never opens with a block trigger.
const inlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
markedTextRunArb,
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: mathInlineArb },
{ weight: 1, arbitrary: mentionArb },
{ weight: 1, arbitrary: hardBreakArb },
),
{ minLength: 0, maxLength: 4 },
),
)
.map(([first, rest]) => normalizeInline([first, ...rest]));
// Inline content for a HEADING — identical to a paragraph's, but WITHOUT hard
// breaks. A hard break inside an ATX heading ("# a \nb") is NOT byte-stable:
// marked does not honour a hard break inside a heading, so it re-parses as the
// heading "# a" plus a separate paragraph "b" (md2 = "# a\n\nb"). math/mention/
// link inside a heading are fine (verified) and stay in the menu.
const headingInlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
markedTextRunArb,
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: mathInlineArb },
{ weight: 1, arbitrary: mentionArb },
),
{ minLength: 0, maxLength: 4 },
),
)
.map(([first, rest]) => normalizeInline([first, ...rest]));
// ---------------------------------------------------------------------------
// Block arbitraries
// ---------------------------------------------------------------------------
const paragraphArb: fc.Arbitrary<any> = inlineContentArb.map((content) => ({
type: 'paragraph',
content,
}));
const headingArb: fc.Arbitrary<any> = fc
.tuple(fc.integer({ min: 1, max: 6 }), headingInlineContentArb)
.map(([level, content]) => ({ type: 'heading', attrs: { level }, content }));
// Code block content: 1..4 lines of plain phrases (may contain specials inline,
// which are inert inside a fenced block). Language is optional and is a single
// lowercase token.
const codeBlockArb: fc.Arbitrary<any> = fc
.tuple(
fc.option(fc.constantFrom('js', 'ts', 'python', 'go', 'rust', 'bash'), {
nil: '',
}),
fc
.array(safeTextArb, { minLength: 1, maxLength: 4 })
.map((lines) => lines.join('\n')),
)
.map(([language, code]) => ({
type: 'codeBlock',
attrs: { language },
content: [{ type: 'text', text: code }],
}));
const blockquoteArb: fc.Arbitrary<any> = paragraphArb.map((p) => ({
type: 'blockquote',
content: [p],
}));
const horizontalRuleArb: fc.Arbitrary<any> = fc.constant({
type: 'horizontalRule',
});
// Callout: ONE paragraph child; type restricted to the four the schema knows.
const calloutArb: fc.Arbitrary<any> = fc
.tuple(
fc.constantFrom('info', 'success', 'warning', 'danger'),
paragraphArb,
)
.map(([type, p]) => ({ type: 'callout', attrs: { type }, content: [p] }));
const mathBlockArb: fc.Arbitrary<any> = fc
.constantFrom('a < b', 'a < b < c', '\\sum_{i=0}^{n} i', 'x = \\frac{-b}{2a}', '')
.map((text) => ({ type: 'mathBlock', attrs: { text } }));
const imageArb: fc.Arbitrary<any> = fc
.tuple(
fc.webUrl(),
// alt is a letter-bearing phrase OR empty. Brackets/parens leak into the
// markdown image syntax (not byte-stable) so they are excluded, and a purely
// numeric alt is coerced to a number and dropped (see letterPhraseArb), so
// alt always carries at least one letter when non-empty.
fc.option(letterPhraseArb, { nil: '' }),
)
.map(([src, alt]) => ({ type: 'image', attrs: { src, alt } }));
// A simple list item: ONE paragraph, optionally followed by ONE nested bullet
// list (single level of nesting). depth controls whether nesting is allowed.
function listItemArb(allowNest: boolean): fc.Arbitrary<any> {
if (!allowNest) {
return paragraphArb.map((p) => ({ type: 'listItem', content: [p] }));
}
return fc
.tuple(
paragraphArb,
fc.option(
fc.array(
paragraphArb.map((p) => ({ type: 'listItem', content: [p] })),
{ minLength: 1, maxLength: 3 },
),
{ nil: undefined },
),
)
.map(([p, nested]) => ({
type: 'listItem',
content: nested
? [p, { type: 'bulletList', content: nested }]
: [p],
}));
}
const bulletListArb: fc.Arbitrary<any> = fc
.array(listItemArb(true), { minLength: 1, maxLength: 4 })
.map((items) => ({ type: 'bulletList', content: items }));
const orderedListArb: fc.Arbitrary<any> = fc
.array(listItemArb(true), { minLength: 1, maxLength: 4 })
.map((items) => ({ type: 'orderedList', content: items }));
// Task item: ONE paragraph, optional ONE nested bullet list.
const taskItemArb: fc.Arbitrary<any> = fc
.tuple(
fc.boolean(),
paragraphArb,
fc.option(
fc.array(listItemArb(false), { minLength: 1, maxLength: 2 }),
{ nil: undefined },
),
)
.map(([checked, p, nested]) => ({
type: 'taskItem',
attrs: { checked },
content: nested ? [p, { type: 'bulletList', content: nested }] : [p],
}));
const taskListArb: fc.Arbitrary<any> = fc
.array(taskItemArb, { minLength: 1, maxLength: 4 })
.map((items) => ({ type: 'taskList', content: items }));
// GFM table: a header row + 1..3 body rows, with a fixed column count (1..3) and
// per-column alignment. Cells hold a single short paragraph of safe text.
const tableArb: fc.Arbitrary<any> = fc
.integer({ min: 1, max: 3 })
.chain((cols) => {
const cellArb = (header: boolean, align?: string) =>
phraseArb.map((t) => ({
type: header ? 'tableHeader' : 'tableCell',
attrs: align ? { align } : {},
content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }],
}));
const alignsArb = fc.array(
fc.constantFrom(undefined, 'left', 'center', 'right'),
{ minLength: cols, maxLength: cols },
);
return fc
.tuple(
alignsArb,
fc.array(
fc.constant(null), // body-row placeholders; cells filled below
{ minLength: 1, maxLength: 3 },
),
)
.chain(([aligns, bodyRows]) => {
const headerRow = fc
.tuple(...aligns.map((a) => cellArb(true, a)))
.map((cells) => ({ type: 'tableRow', content: cells }));
const bodyRowArbs = bodyRows.map(() =>
fc
.tuple(...aligns.map(() => cellArb(false)))
.map((cells) => ({ type: 'tableRow', content: cells })),
);
return fc
.tuple(headerRow, fc.tuple(...bodyRowArbs))
.map(([h, body]) => ({ type: 'table', content: [h, ...body] }));
});
});
// ---------------------------------------------------------------------------
// Top-level document arbitrary
// ---------------------------------------------------------------------------
// The full menu of block nodes that are byte-stable when SEQUENCED with other
// blocks. NOTE: `image` is deliberately NOT in this menu — see the dedicated
// image tests below. The Docmost `image` node is BLOCK-level, but its markdown
// form `![](url)` is INLINE; marked wraps it in a <p>, the schema then hoists
// the block <img> out and leaves an EMPTY paragraph beside it, so on the second
// export the stray empty paragraph injects extra blank lines between siblings
// ("p\n\n![](u)\n\nq" -> "p\n\n\n\n![](u)\n\nq"). An image is only byte-stable
// when it is the SOLE block (the edge artifacts get .trim()'d away). It is
// therefore covered by its own targeted tests, not mixed into multi-block docs.
const blockArb: fc.Arbitrary<any> = fc.oneof(
{ weight: 6, arbitrary: paragraphArb },
{ weight: 3, arbitrary: headingArb },
{ weight: 2, arbitrary: codeBlockArb },
{ weight: 2, arbitrary: bulletListArb },
{ weight: 2, arbitrary: orderedListArb },
{ weight: 2, arbitrary: taskListArb },
{ weight: 2, arbitrary: blockquoteArb },
{ weight: 2, arbitrary: tableArb },
{ weight: 2, arbitrary: calloutArb },
{ weight: 1, arbitrary: horizontalRuleArb },
{ weight: 1, arbitrary: mathBlockArb },
);
const LIST_TYPES = new Set(['bulletList', 'orderedList', 'taskList']);
// A bounded document: 1..8 block nodes. Kept small so each run is cheap (each
// run does a real marked + jsdom parse) and shrinking stays fast.
//
// Post-process: never let two LIST blocks sit directly adjacent. Two sibling
// lists that share a marker family — bullet/task both use "-", ordered uses
// "1." — are MERGED by markdown into a single list when only a blank line
// separates them ("- a\n\n- b" -> one list -> "- a\n- b"), which is not
// byte-stable. (A non-list block between two lists separates them fine, as does
// a different marker family, but dropping every back-to-back list is the clean,
// always-correct rule.) We drop a list block whenever the previously kept block
// is also a list.
const docArb: fc.Arbitrary<any> = fc
.array(blockArb, { minLength: 1, maxLength: 8 })
.map((content) => {
const out: any[] = [];
for (const block of content) {
const prev = out[out.length - 1];
if (
prev &&
LIST_TYPES.has(prev.type) &&
LIST_TYPES.has(block.type)
) {
continue; // skip a list that would sit right after another list
}
out.push(block);
}
// Guarantee a non-empty document even if filtering removed everything but a
// single dropped block (cannot happen here since the first block is always
// kept, but keep the invariant explicit).
return { type: 'doc', content: out.length ? out : content.slice(0, 1) };
});
// ---------------------------------------------------------------------------
// The properties
// ---------------------------------------------------------------------------
describe('markdown <-> ProseMirror round-trip (property-based)', () => {
it('the generator covers every targeted node type at least once', () => {
// A sanity check that the arbitrary actually exercises the intended node
// variety within NUM_RUNS — not a correctness property, just coverage.
const seen = new Set<string>();
const collect = (node: any) => {
if (!node || typeof node !== 'object') return;
if (node.type) seen.add(node.type);
for (const m of node.marks ?? []) seen.add(`mark:${m.type}`);
for (const c of node.content ?? []) collect(c);
};
fc.assert(
fc.property(docArb, (doc) => {
collect(doc);
return true;
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
// Core block types and marks we expect to appear.
for (const t of [
'paragraph',
'heading',
'codeBlock',
'bulletList',
'orderedList',
'taskList',
'blockquote',
'table',
'callout',
'horizontalRule',
'mathBlock',
// 'image' is covered by its own dedicated tests, not docArb.
'mention',
'mathInline',
'hardBreak',
'mark:bold',
'mark:italic',
'mark:strike',
'mark:code',
'mark:link',
'mark:comment',
]) {
expect(seen, `expected the generator to produce ${t}`).toContain(t);
}
});
it('markdown is byte-stable across export -> import -> export', async () => {
// The property git needs: a second export reproduces the first byte-for-byte.
await fc.assert(
fc.asyncProperty(docArb, async (doc) => {
const { md1, md2 } = await roundTrip(doc);
expect(md2).toBe(md1);
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it('the document is semantically stable on a second cycle (ids stripped)', async () => {
// Optional, stronger-feeling property. We do NOT compare doc vs doc2: the
// converter reconstructs schema default attrs on the FIRST import (a known
// SPEC §11 divergence). But once the markdown is byte-stable, importing the
// SAME markdown twice must yield structurally identical docs (modulo the
// regenerated block ids). So we compare doc2 (import of md1) with doc3
// (import of md2 == md1) after stripping ids.
await fc.assert(
fc.asyncProperty(docArb, async (doc) => {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// Guard: this property only makes sense when md is byte-stable.
expect(md2).toBe(md1);
const doc3 = await markdownToProseMirror(md2);
expect(stripBlockIds(doc3)).toEqual(stripBlockIds(doc2));
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it('a SOLE image block is byte-stable', async () => {
// An image is byte-stable when it is the only block in the document: the
// stray empty paragraph the schema leaves beside the hoisted block <img>
// sits at a document edge and is removed by the converter's final .trim().
await fc.assert(
fc.asyncProperty(imageArb, async (image) => {
const doc = { type: 'doc', content: [image] };
const { md1, md2 } = await roundTrip(doc);
expect(md2).toBe(md1);
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
// -------------------------------------------------------------------------
// KNOWN, DOCUMENTED non-roundtrip bug #2 (kept honest as it.fails).
//
// BUG: a block-level `image` placed BETWEEN other blocks is not byte-stable.
// The Docmost image node is BLOCK-level but its markdown form `![](url)` is
// INLINE. marked wraps the inline image in a <p>; the schema then hoists the
// block <img> out of that <p>, leaving an EMPTY paragraph as a sibling. On the
// second export that empty paragraph renders as "" and the "\n\n" doc join
// injects an extra blank gap:
// "p\n\n![x](u)\n\nq" -> "p\n\n\n\n![x](u)\n\nq" (=> md2 !== md1).
// Minimal repro doc:
// { type:'doc', content:[
// { type:'paragraph', content:[{type:'text',text:'p'}] },
// { type:'image', attrs:{ src:'http://a.aa', alt:'x' } },
// { type:'paragraph', content:[{type:'text',text:'q'}] } ] }
// Not "fixed" — the source must not change; documented and exercised here.
// -------------------------------------------------------------------------
it('a block image between other blocks is byte-stable', async () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'p' }] },
{ type: 'image', attrs: { src: 'http://a.aa', alt: 'x' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'q' }] },
],
};
const { md1, md2 } = await roundTrip(doc);
expect(md2).toBe(md1);
});
// -------------------------------------------------------------------------
// KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
//
// BUG: the `code` mark combined with ANY other mark does NOT round-trip.
// The converter emits nested HTML so the output is well-formed, e.g.
// marks [code, bold] -> md1 = "<strong><code>x</code></strong>"
// but the schema's `code` mark declares `excludes: "_"`, so on import the
// co-occurring mark is dropped and the run comes back as code-only:
// md2 = "`x`" (=> md2 !== md1).
// Minimal repro doc:
// { type:'doc', content:[ { type:'paragraph', content:[
// { type:'text', text:'x', marks:[{type:'code'},{type:'bold'}] } ] } ] }
// This is acknowledged in markdown-converter.ts (the long comment above the
// marks switch): preserving both marks is impossible while `code` excludes
// them. Documented here, not "fixed", because the source must not change.
// -------------------------------------------------------------------------
it(
'code mark combined with another mark is byte-stable',
async () => {
const codeComboArb = fc
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
.map(([t, other]) => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: t, marks: [{ type: 'code' }, { type: other }] },
],
},
],
}));
await fc.assert(
fc.asyncProperty(codeComboArb, async (doc) => {
const { md1, md2 } = await roundTrip(doc);
expect(md2).toBe(md1);
}),
{ numRuns: 20, seed: SEED },
);
},
);
});
@@ -0,0 +1,535 @@
import { describe, expect, it } from 'vitest';
// markdownToProseMirror lives next to the markdown->HTML preprocessors
// (preprocessCallouts, bridgeTaskLists). Those helpers are NOT exported, so we
// exercise them through the public entry point, which runs the full
// markdown -> preprocessCallouts -> marked -> bridgeTaskLists -> generateJSON
// pipeline. Importing this module mutates the global DOM via jsdom (required for
// @tiptap/html under Node) — expected, same as the property test.
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
// The export side (ProseMirror -> markdown) is pulled in for the round-trip
// specs below (underline/sub/sup marks, heading levels, link title). Imported
// directly from src/lib (not the barrel) like the other converter unit tests.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// Find every node of a given type anywhere in a ProseMirror doc tree.
const findAll = (node: any, type: string, acc: any[] = []): any[] => {
if (node && node.type === type) acc.push(node);
for (const child of node?.content || []) findAll(child, type, acc);
return acc;
};
// Concatenate all text within a subtree (order-preserving).
const allText = (node: any): string => {
if (node?.type === 'text') return node.text || '';
return (node?.content || []).map(allText).join('');
};
// ---------------------------------------------------------------------------
// Obsidian-native callouts: the export emits `> [!type]` (a blockquote callout,
// which renders as a callout in Obsidian) and the importer parses it back —
// alongside the legacy `:::type` fence so existing vaults keep working.
// ---------------------------------------------------------------------------
describe('preprocessCallouts: Obsidian `> [!type]` callouts', () => {
it('imports `> [!type]` as a callout node (not a plain blockquote)', async () => {
const md = ['> [!warning]', '> be careful', '> second line'].join('\n');
const docNode = await markdownToProseMirror(md);
const callouts = findAll(docNode, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs?.type).toBe('warning');
expect(findAll(docNode, 'blockquote')).toHaveLength(0);
expect(allText(callouts[0])).toContain('be careful');
});
it('imports a nested `> > [!type]` callout inside another', async () => {
const md = ['> [!info]', '> outer', '> > [!danger]', '> > inner'].join('\n');
const docNode = await markdownToProseMirror(md);
const outer = docNode.content?.[0];
expect(outer?.type).toBe('callout');
expect(outer?.attrs?.type).toBe('info');
const inner = (outer?.content || []).filter(
(n: any) => n.type === 'callout',
);
expect(inner).toHaveLength(1);
expect(inner[0].attrs?.type).toBe('danger');
expect(allText(inner[0])).toContain('inner');
});
it('round-trips a callout: export -> `> [!type]` -> import keeps type + body', async () => {
const original = {
type: 'doc',
content: [
{
type: 'callout',
attrs: { type: 'success' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }],
},
],
};
const md = convertProseMirrorToMarkdown(original);
expect(md).toBe('> [!success]\n> done');
const back = await markdownToProseMirror(md);
const callouts = findAll(back, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs?.type).toBe('success');
expect(allText(callouts[0])).toContain('done');
});
it('a plain blockquote (no `[!type]`) stays a blockquote', async () => {
const back = await markdownToProseMirror('> just a quote\n> more');
expect(findAll(back, 'callout')).toHaveLength(0);
expect(findAll(back, 'blockquote')).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// 3. preprocessCallouts — two uncovered branches.
//
// (a) NESTED callouts: an inner `:::type ... :::` inside an outer callout body
// must be matched at its own nesting level (the depth counter) and emerge as
// a callout NESTED inside the outer callout — not flattened or mis-closed.
// (b) A `:::` line INSIDE a fenced code block must NOT be treated as a callout
// delimiter: the scanner tracks code fences and copies their lines verbatim,
// so the outer callout's matching `:::` is the one AFTER the fence closes.
// ---------------------------------------------------------------------------
describe('preprocessCallouts: nested callouts + code-fenced ":::"', () => {
it('(a) parses a callout nested inside another callout', async () => {
const md = [
':::info',
'outer text',
':::warning',
'inner text',
':::',
':::',
].join('\n');
const docNode = await markdownToProseMirror(md);
// Exactly two callouts, and one is nested inside the other.
const callouts = findAll(docNode, 'callout');
expect(callouts).toHaveLength(2);
const outer = docNode.content?.[0];
expect(outer?.type).toBe('callout');
expect(outer?.attrs?.type).toBe('info');
// The inner callout is a CHILD of the outer one (not a sibling at doc level).
const innerCallouts = (outer?.content || []).filter(
(n: any) => n.type === 'callout',
);
expect(innerCallouts).toHaveLength(1);
expect(innerCallouts[0].attrs?.type).toBe('warning');
// Both bodies kept their text.
expect(allText(outer)).toContain('outer text');
expect(allText(innerCallouts[0])).toContain('inner text');
});
it('(b) a ":::" line inside a fenced code block is NOT a callout delimiter', async () => {
// The inner ``` ... ``` fence contains a `:::` line. If preprocessCallouts
// treated it as the closing fence, the callout would terminate early and the
// code text would leak out. The correct behavior: the fence content survives
// verbatim in a codeBlock, and the callout closes at the LAST ":::".
const md = [
':::info',
'before code',
'```',
':::',
'still inside the code fence',
'```',
'after code',
':::',
].join('\n');
const docNode = await markdownToProseMirror(md);
// One callout wrapping everything (it did not close early on the fenced ":::")
const callouts = findAll(docNode, 'callout');
expect(callouts).toHaveLength(1);
const callout = callouts[0];
// The code block is a CHILD of the callout and still contains the ":::" line.
const codeBlocks = findAll(callout, 'codeBlock');
expect(codeBlocks).toHaveLength(1);
expect(allText(codeBlocks[0])).toContain(':::');
expect(allText(codeBlocks[0])).toContain('still inside the code fence');
// The text before and after the fence is part of the callout, not a stray
// top-level paragraph created by an early close.
expect(allText(callout)).toContain('before code');
expect(allText(callout)).toContain('after code');
});
it('(c) an UNCLOSED ":::" opener is treated as a literal line, not a callout', async () => {
// Realistic input: a hand-edited vault file with a `:::info` opener and no
// matching closing `:::`. The fallback emits the opener as a LITERAL line
// rather than swallowing the rest of the document into a phantom callout —
// previously uncovered (markdown-to-prosemirror.ts).
const md = [':::info', 'orphan body line', 'another line'].join('\n');
const docNode = await markdownToProseMirror(md);
// No callout node was created (the opener never closed).
expect(findAll(docNode, 'callout')).toHaveLength(0);
// The opener survives as literal text and the body lines are preserved (the
// rest of the document was NOT eaten by an unterminated callout).
const text = allText(docNode);
expect(text).toContain(':::info');
expect(text).toContain('orphan body line');
expect(text).toContain('another line');
});
});
// ---------------------------------------------------------------------------
// 4. bridgeTaskLists — numbered checklist + mixed-list negative.
//
// (a) A NUMBERED checklist (`1. [x] ...`) is rendered by marked as an <ol> of
// checkbox <li>s. The bridge must convert it to a taskList AND rename the
// <ol> to a <ul> so generateJSON does NOT also match the orderedList rule
// and emit a phantom empty orderedList beside the real taskList.
// (b) NEGATIVE: a MIXED list (some items have checkboxes, some don't) must NOT
// be converted — it stays an ordinary bullet/numbered list.
// ---------------------------------------------------------------------------
describe('bridgeTaskLists: numbered checklist + mixed-list negative', () => {
it('(a) a numbered <ol> checklist becomes a taskList with NO phantom orderedList', async () => {
const md = ['1. [x] done', '2. [ ] todo'].join('\n');
const docNode = await markdownToProseMirror(md);
// It became a taskList...
const taskLists = findAll(docNode, 'taskList');
expect(taskLists).toHaveLength(1);
const items = (taskLists[0].content || []).filter(
(n: any) => n.type === 'taskItem',
);
expect(items).toHaveLength(2);
expect(items[0].attrs?.checked).toBe(true);
expect(items[1].attrs?.checked).toBe(false);
expect(allText(items[0])).toContain('done');
expect(allText(items[1])).toContain('todo');
// ...and NO phantom (empty) orderedList survived the <ol> -> <ul> rename.
const orderedLists = findAll(docNode, 'orderedList');
expect(orderedLists).toHaveLength(0);
});
it('(b) a MIXED list (some items checkboxed, some not) is NOT converted to a taskList', async () => {
const md = ['- [x] checked item', '- plain item'].join('\n');
const docNode = await markdownToProseMirror(md);
// The bridge requires EVERY direct <li> to carry its own checkbox; one plain
// item disqualifies the whole list, so it stays a bulletList.
expect(findAll(docNode, 'taskList')).toHaveLength(0);
expect(findAll(docNode, 'taskItem')).toHaveLength(0);
const bulletLists = findAll(docNode, 'bulletList');
expect(bulletLists).toHaveLength(1);
const listItems = findAll(bulletLists[0], 'listItem');
expect(listItems).toHaveLength(2);
// Both items survive as ordinary list items (text preserved).
expect(allText(bulletLists[0])).toContain('checked item');
expect(allText(bulletLists[0])).toContain('plain item');
});
});
// Find the first mark of a given type on a text node anywhere in the tree.
const firstMark = (node: any, markType: string): any => {
if (node?.type === 'text') {
for (const m of node.marks || []) if (m.type === markType) return m;
}
for (const child of node?.content || []) {
const found = firstMark(child, markType);
if (found) return found;
}
return null;
};
// ---------------------------------------------------------------------------
// Spec 1. IMPORT-side color sanitization for the highlight + textStyle marks.
//
// The Highlight.extend / TextStyle parseHTML run attacker-controlled colors
// through sanitizeCssColor when generateJSON re-parses stored HTML. This is the
// real defense that strips a crafted color on IMPORT (the export-side emission
// is tested elsewhere; the parse path was not).
// ---------------------------------------------------------------------------
describe('import: highlight/textStyle color sanitization (parseHTML)', () => {
it('strips the unsafe "--x:1" declaration but keeps the safe "red" background-color', async () => {
const doc = await markdownToProseMirror(
'<mark style="background-color: red; --x:1">x</mark>',
);
const mark = firstMark(doc, 'highlight');
// The highlight mark IS present on the text run.
expect(mark).not.toBeNull();
expect(allText(doc)).toContain('x');
// NOTE(review): Spec 1 expected attrs.color === null for this input. The
// ACTUAL behavior is attrs.color === 'red': the schema's Highlight.extend
// reads the color via getStyleProperty(el, 'background-color'), which
// isolates the `background-color: red` declaration and DROPS the separate
// unsafe `--x:1` declaration. sanitizeCssColor('red') then accepts the bare
// named color. So the injection ('--x:1') is stripped (the defense holds)
// but the legitimate 'red' survives — color is 'red', not null. The
// color-dropped-to-null path is exercised by the data-color variant below,
// where the whole "red; --x:1" string reaches sanitizeCssColor and fails.
expect(mark.attrs.color).toBe('red');
});
it('drops a crafted color carried whole in data-color (sanitizeCssColor -> null)', async () => {
// Here the entire unsafe string is the candidate color (no per-declaration
// splitting), so sanitizeCssColor rejects it and the highlight color is null
// while the highlight mark itself is still applied.
const doc = await markdownToProseMirror(
'<mark data-color="red; --x:1">x</mark>',
);
const mark = firstMark(doc, 'highlight');
expect(mark).not.toBeNull();
expect(mark.attrs.color).toBeNull();
});
it("imports '#ff0000' as the highlight mark color verbatim", async () => {
const doc = await markdownToProseMirror(
'<mark style="background-color: #ff0000">x</mark>',
);
const mark = firstMark(doc, 'highlight');
expect(mark).not.toBeNull();
expect(mark.attrs.color).toBe('#ff0000');
});
it("imports a colored span as a textStyle mark with the sanitized color", async () => {
const doc = await markdownToProseMirror(
'<span style="color: rebeccapurple">y</span>',
);
const mark = firstMark(doc, 'textStyle');
expect(mark).not.toBeNull();
expect(mark.attrs.color).toBe('rebeccapurple');
// It is carried on a real text node containing the span's text.
expect(allText(doc)).toContain('y');
});
});
// ---------------------------------------------------------------------------
// Spec 2. Importing a non-schema callout fence resolves the type via the editor's
// alias map (known GitHub/Obsidian aliases) or clamps to 'info' (unknown).
//
// preprocessCallouts emits div[data-type=callout][data-callout-type=<type>]; the
// schema's Callout.type parseHTML pipes it through clampCalloutType. A known alias
// (`tip`) maps to the editor's banner (`success`); a genuinely unknown type
// (`banana`) clamps to the 'info' default. End-to-end import-side resolution.
// ---------------------------------------------------------------------------
describe('import: non-schema callout fence resolves via alias map / clamps to info', () => {
it("imports ':::tip' as a callout whose attrs.type === 'success' (alias)", async () => {
const doc = await markdownToProseMirror(':::tip\nhello\n:::');
const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('success');
// The body paragraph survived inside the callout.
expect(allText(callouts[0])).toContain('hello');
const paras = findAll(callouts[0], 'paragraph');
expect(paras.length).toBeGreaterThanOrEqual(1);
});
it("imports ':::banana' (unknown) as a callout whose attrs.type === 'info'", async () => {
const doc = await markdownToProseMirror(':::banana\nhello\n:::');
const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('info');
expect(allText(callouts[0])).toContain('hello');
});
});
// ---------------------------------------------------------------------------
// Spec 3. Importing a columns layout with a string data-width yields a numeric
// column width, and the columns wrapper carries its default layout/widthMode.
// ---------------------------------------------------------------------------
describe('import: columns layout with string data-width -> numeric width', () => {
it('parses data-width="33.5" to the number 33.5 and populates columns defaults', async () => {
const doc = await markdownToProseMirror(
'<div data-type="columns"><div data-type="column" data-width="33.5"><p>a</p></div></div>',
);
const columns = findAll(doc, 'columns');
expect(columns).toHaveLength(1);
// Columns default attrs are populated (not undefined).
expect(columns[0].attrs.widthMode).toBe('normal');
expect(columns[0].attrs.layout).not.toBeNull();
expect(columns[0].attrs.layout).toBe('two_equal');
const cols = findAll(columns[0], 'column');
expect(cols).toHaveLength(1);
// parseFloat('33.5') -> 33.5 as a NUMBER, not the string '33.5'.
expect(cols[0].attrs.width).toBe(33.5);
expect(typeof cols[0].attrs.width).toBe('number');
expect(allText(cols[0])).toContain('a');
});
});
// ---------------------------------------------------------------------------
// Spec 4. Comment mark resolved-attribute boolean coercion on import.
//
// The comment mark's resolved attr parseHTML compares
// el.getAttribute('data-resolved') === 'true', so a missing attribute yields
// false (default) and the literal 'true' yields boolean true.
// ---------------------------------------------------------------------------
describe('import: comment mark commentId + resolved boolean coercion', () => {
it("data-resolved='true' -> resolved:true with the parsed commentId", async () => {
const doc = await markdownToProseMirror(
'<span data-comment-id="c1" data-resolved="true">x</span>',
);
const mark = firstMark(doc, 'comment');
expect(mark).not.toBeNull();
expect(mark.attrs.commentId).toBe('c1');
expect(mark.attrs.resolved).toBe(true);
});
it('a missing data-resolved -> resolved:false (default)', async () => {
const doc = await markdownToProseMirror(
'<span data-comment-id="c2">y</span>',
);
const mark = firstMark(doc, 'comment');
expect(mark).not.toBeNull();
expect(mark.attrs.commentId).toBe('c2');
expect(mark.attrs.resolved).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Spec 5. A NON-numeric truthy data-width reaches parseFloat and yields NaN.
//
// Column.width parseHTML is `value ? parseFloat(value) : null`; 'abc' is truthy
// so parseFloat('abc') -> NaN leaks through as the raw attribute value rather
// than falling back to the null default. (JSON.stringify would serialize NaN to
// null — see the assertion below — so the leak is invisible in serialized JSON.)
// ---------------------------------------------------------------------------
describe('import: malformed non-numeric data-width leaks NaN', () => {
it("data-width='abc' -> column width is NaN (typeof number), not null", async () => {
const doc = await markdownToProseMirror(
'<div data-type="columns"><div data-type="column" data-width="abc"><p>x</p></div></div>',
);
const width = doc.content[0].content[0].attrs.width;
expect(typeof width).toBe('number');
expect(Number.isNaN(width)).toBe(true);
// Document that the leak is masked by JSON serialization: NaN -> null.
expect(JSON.parse(JSON.stringify(doc)).content[0].content[0].attrs.width).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Spec 6. A column with NO data-width attribute lands on the null default.
//
// The else branch of `value ? parseFloat(value) : null` (getAttribute -> null)
// must yield exactly null (not NaN/undefined), and the columns wrapper carries
// its layout/widthMode defaults.
// ---------------------------------------------------------------------------
describe('import: width-less column lands on null default', () => {
it('no data-width -> column width === null, columns defaults populated', async () => {
const doc = await markdownToProseMirror(
'<div data-type="columns"><div data-type="column"><p>y</p></div></div>',
);
expect(doc.content[0].content[0].attrs.width).toBe(null);
expect(doc.content[0].attrs.layout).toBe('two_equal');
expect(doc.content[0].attrs.widthMode).toBe('normal');
});
});
// ---------------------------------------------------------------------------
// Spec 7. A structural callout div with missing/empty data-callout-type clamps
// to 'info' via clampCalloutType (the parseHTML getAttrs fallback), with no icon.
// ---------------------------------------------------------------------------
describe('import: callout div with missing/empty data-callout-type clamps to info', () => {
it('a callout div with NO data-callout-type -> type:info, icon:null', async () => {
const doc = await markdownToProseMirror(
'<div data-type="callout"><p>z</p></div>',
);
expect(doc.content[0].type).toBe('callout');
expect(doc.content[0].attrs.type).toBe('info');
expect(doc.content[0].attrs.icon).toBeNull();
});
it('a callout div with EMPTY data-callout-type -> type:info, icon:null', async () => {
const doc = await markdownToProseMirror(
'<div data-type="callout" data-callout-type=""><p>w</p></div>',
);
expect(doc.content[0].type).toBe('callout');
expect(doc.content[0].attrs.type).toBe('info');
expect(doc.content[0].attrs.icon).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Spec 8. A plain <td> with no align/colspan/rowspan/colwidth lands on the
// schema defaults (align null via the `||` fallback arm; spans default to 1).
// ---------------------------------------------------------------------------
describe('import: span/align-less table cell lands on defaults', () => {
it('a bare td -> align:null, colspan:1, rowspan:1, colwidth:null', async () => {
const doc = await markdownToProseMirror(
'<table><tbody><tr><td><p>c</p></td></tr></tbody></table>',
);
const cells = findAll(doc, 'tableCell');
expect(cells).toHaveLength(1);
const attrs = cells[0].attrs;
expect(attrs.align).toBeNull();
expect(attrs.colspan).toBe(1);
expect(attrs.rowspan).toBe(1);
expect(attrs.colwidth).toBeNull();
expect(allText(cells[0])).toContain('c');
});
});
// ---------------------------------------------------------------------------
// Spec 9. underline/subscript/superscript marks survive import and re-export.
// (inlineToHtml src 611-619 renders them back to <u>/<sub>/<sup>.)
// ---------------------------------------------------------------------------
describe('import+export: underline/subscript/superscript marks round-trip', () => {
it('<u>/<sub>/<sup> import to the right marks and re-export unchanged', async () => {
const doc = await markdownToProseMirror('<p><u>a</u><sub>b</sub><sup>c</sup></p>');
const para = findAll(doc, 'paragraph')[0];
const texts = (para.content || []).filter((n: any) => n.type === 'text');
expect(texts).toHaveLength(3);
expect(texts[0].text).toBe('a');
expect((texts[0].marks || []).map((m: any) => m.type)).toEqual(['underline']);
expect(texts[1].text).toBe('b');
expect((texts[1].marks || []).map((m: any) => m.type)).toEqual(['subscript']);
expect(texts[2].text).toBe('c');
expect((texts[2].marks || []).map((m: any) => m.type)).toEqual(['superscript']);
const md = convertProseMirrorToMarkdown(doc);
expect(md).toContain('<u>a</u>');
expect(md).toContain('<sub>b</sub>');
expect(md).toContain('<sup>c</sup>');
});
});
// ---------------------------------------------------------------------------
// Spec 10. Heading level attribute fidelity (h1/h2/h6) on import and re-export.
// ---------------------------------------------------------------------------
describe('import+export: heading levels 1/2/6 round-trip', () => {
it('parses # / ## / ###### to level 1/2/6 and re-emits them', async () => {
const doc = await markdownToProseMirror('# H1\n\n## H2\n\n###### H6');
const headings = findAll(doc, 'heading');
expect(headings).toHaveLength(3);
expect(headings[0].attrs.level).toBe(1);
expect(headings[1].attrs.level).toBe(2);
expect(headings[2].attrs.level).toBe(6);
const md = convertProseMirrorToMarkdown(doc);
const blocks = md.split('\n\n');
expect(blocks).toContain('# H1');
expect(blocks).toContain('## H2');
expect(blocks).toContain('###### H6');
});
});
// ---------------------------------------------------------------------------
// Spec 11. Link mark recovers BOTH href and title on import and round-trips.
// ---------------------------------------------------------------------------
describe('import+export: link mark href + title round-trip', () => {
it('parses [lbl](http://a "the title") with href+title and re-emits it', async () => {
const doc = await markdownToProseMirror('[lbl](http://a "the title")');
const mark = firstMark(doc, 'link');
expect(mark).not.toBeNull();
expect(mark.attrs.href).toBe('http://a');
expect(mark.attrs.title).toBe('the title');
expect(allText(doc)).toContain('lbl');
const md = convertProseMirrorToMarkdown(doc);
expect(md).toContain('[lbl](http://a "the title")');
});
});
@@ -0,0 +1,275 @@
import { describe, expect, it } from 'vitest';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
docsCanonicallyEqual,
} from 'docmost-client';
// ---------------------------------------------------------------------------
// Media / atom node round-trip coverage (audio, video, pdf, attachment, embed,
// youtube). The existing specs (corpus + property test) exercise the EXPORT
// direction of these nodes only; their parseHTML branches (the INVERSE parse of
// the exported HTML) are otherwise unprotected. Each test runs the full
// export -> import -> export pipeline and pins:
// - the exact md1 byte string the converter emits,
// - whether md2 is byte-stable (md2 === md1) or grows by a materialized
// schema default on the first import,
// - the re-parsed doc2 attrs (NOTE: parseHTML reads via getAttribute and so
// returns STRINGS for numeric attrs, which is what breaks naive canonical
// equality), and
// - docsCanonicallyEqual(doc, doc2) where the spec asserts a specific result.
//
// `convertProseMirrorToMarkdown` requires a full doc ({type:'doc', content:[]}),
// so each spec's `doc=[...]` content array is wrapped via mkDoc().
// ---------------------------------------------------------------------------
/** Wrap a content array (as the specs express `doc`) into a real PM doc. */
const mkDoc = (content: any[]) => ({ type: 'doc', content });
/** export -> import -> export, returning both markdowns and the re-parsed doc. */
async function roundTrip(doc: any) {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, md2, doc2 };
}
/** Find the first node of a given type anywhere in a PM doc tree. */
const findFirst = (node: any, type: string): any => {
if (node && node.type === type) return node;
for (const child of node?.content || []) {
const hit = findFirst(child, type);
if (hit) return hit;
}
return null;
};
describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', () => {
// 1. audio with ALL optional attrs ---------------------------------------
it('audio with src+attachmentId+size: byte-stable, size re-parses to the STRING "9001"', async () => {
const doc = mkDoc([
{ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7', size: 9001 } },
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div><audio src="/a.mp3" data-attachment-id="att-7" data-size="9001"></audio></div>',
);
// Byte-stable: a second export reproduces the first exactly.
expect(md2).toBe(md1);
const audio = findFirst(doc2, 'audio');
expect(audio).not.toBeNull();
expect(audio.type).toBe('audio');
expect(audio.attrs.src).toBe('/a.mp3');
expect(audio.attrs.attachmentId).toBe('att-7');
// NOTE: the schema's data-size parseHTML returns getAttribute() -> a STRING,
// so the number 9001 comes back as the string '9001'.
expect(audio.attrs.size).toBe('9001');
});
// 2. fully-populated video -----------------------------------------------
it('video with all attrs: byte-stable; numeric attrs re-parse to STRINGS; canonical equality FALSE', async () => {
const doc = mkDoc([
{
type: 'video',
attrs: {
src: '/v.mp4',
alt: 'clip',
attachmentId: 'att-1',
width: 640,
height: 480,
size: 1234,
align: 'center',
aspectRatio: 1.777,
},
},
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div><video src="/v.mp4" aria-label="clip" data-attachment-id="att-1" width="640" height="480" data-size="1234" data-align="center" data-aspect-ratio="1.777"></video></div>',
);
expect(md2).toBe(md1);
const video = findFirst(doc2, 'video');
expect(video).not.toBeNull();
expect(video.attrs.alt).toBe('clip');
// All numeric attrs come back as STRINGS via getAttribute().
expect(video.attrs.width).toBe('640');
expect(video.attrs.height).toBe('480');
expect(video.attrs.size).toBe('1234');
expect(video.attrs.aspectRatio).toBe('1.777');
// Byte-stable export but NOT canonically equal: the numeric width/height/
// size/aspectRatio came back as strings, so deep-equal of the canonical
// forms fails (align:'center' is normalized away, the numbers are not).
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
});
// 3. minimal video (only src) --------------------------------------------
it('minimal video (src only): NOT byte-stable (gains data-align="center") but canonically equal', async () => {
const doc = mkDoc([{ type: 'video', attrs: { src: '/v.mp4' } }]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe('<div><video src="/v.mp4"></video></div>');
// video.align has a non-null schema default 'center' that materializes on
// import; the converter only emits data-align when set, so export #2 grows
// by data-align="center" exactly once (the documented one-time asymmetry).
expect(md2).toBe('<div><video src="/v.mp4" data-align="center"></video></div>');
expect(md2).not.toBe(md1);
// align:'center' is normalized away via KNOWN_DEFAULTS.video, so despite the
// byte growth the documents ARE canonically equal.
expect(docsCanonicallyEqual(doc, doc2)).toBe(true);
});
// 4. pdf with no numeric attrs (positive control) -------------------------
it('pdf with src+name+attachmentId (no numerics): byte- AND canonically-stable', async () => {
const doc = mkDoc([
{ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf', attachmentId: 'att-9' } },
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf" data-attachment-id="att-9"></div>',
);
expect(md2).toBe(md1);
const pdf = findFirst(doc2, 'pdf');
expect(pdf).not.toBeNull();
expect(pdf.attrs.src).toBe('/d.pdf');
expect(pdf.attrs.name).toBe('d.pdf');
expect(pdf.attrs.attachmentId).toBe('att-9');
// No numeric attrs to coerce to strings, so the round-trip is BOTH byte- and
// canonically-stable (the positive control vs. the numeric-divergence cases).
expect(docsCanonicallyEqual(doc, doc2)).toBe(true);
});
// 5. attachment with numeric size ----------------------------------------
it('attachment with url+name+mime+size+attachmentId: byte-stable; size STRING; canonical FALSE', async () => {
const doc = mkDoc([
{
type: 'attachment',
attrs: {
url: '/f.zip',
name: 'f.zip',
mime: 'application/zip',
size: 512,
attachmentId: 'att-3',
},
},
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div data-type="attachment" data-attachment-url="/f.zip" data-attachment-name="f.zip" data-attachment-mime="application/zip" data-attachment-size="512" data-attachment-id="att-3"></div>',
);
expect(md2).toBe(md1);
const att = findFirst(doc2, 'attachment');
expect(att).not.toBeNull();
expect(att.attrs.url).toBe('/f.zip');
expect(att.attrs.name).toBe('f.zip');
expect(att.attrs.mime).toBe('application/zip');
expect(att.attrs.attachmentId).toBe('att-3');
// data-attachment-size parseHTML -> getAttribute() -> STRING.
expect(att.attrs.size).toBe('512');
// The numeric size coerced to a string breaks canonical equality.
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
});
// 6. embed WITH explicit width/height/align (byte-stable) ----------------
it('embed with explicit src+provider+align+width+height: byte-stable; width/height STRINGS', async () => {
const doc = mkDoc([
{
type: 'embed',
attrs: {
src: 'https://x.com/e',
provider: 'iframe',
align: 'left',
width: 600,
height: 400,
},
},
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="left" data-width="600" data-height="400"></div>',
);
expect(md2).toBe(md1);
const embed = findFirst(doc2, 'embed');
expect(embed).not.toBeNull();
expect(embed.attrs.src).toBe('https://x.com/e');
expect(embed.attrs.provider).toBe('iframe');
expect(embed.attrs.align).toBe('left');
// data-width / data-height parseHTML -> getAttribute() -> STRINGS.
expect(embed.attrs.width).toBe('600');
expect(embed.attrs.height).toBe('400');
});
// 7. minimal embed (only src+provider) -----------------------------------
it('minimal embed (src+provider): NOT byte-stable; defaults width/height materialize as NUMBERS 800/600', async () => {
const doc = mkDoc([
{ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } },
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe"></div>',
);
// embed has non-null schema defaults align='center', width=800, height=600
// that the converter never emits on export #1 but materialize on import, so
// export #2 grows by three data-* attrs (a one-time divergence).
expect(md2).toBe(
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="center" data-width="800" data-height="600"></div>',
);
expect(md2).not.toBe(md1);
const embed = findFirst(doc2, 'embed');
expect(embed).not.toBeNull();
expect(embed.attrs.align).toBe('center');
// NOTE: these come from the addAttributes default (NOT parseHTML), so on the
// FIRST import they are the NUMBERS 800/600, not strings — parseHTML only
// runs when the attribute is actually present on the imported element.
expect(embed.attrs.width).toBe(800);
expect(embed.attrs.height).toBe(600);
});
// 8. youtube with src+width+height+align ---------------------------------
it('youtube with src+width+height+align(right): byte-stable; width/height STRINGS; canonical FALSE', async () => {
const doc = mkDoc([
{
type: 'youtube',
attrs: {
src: 'https://youtu.be/abc',
width: 560,
height: 315,
align: 'right',
},
},
]);
const { md1, md2, doc2 } = await roundTrip(doc);
expect(md1).toBe(
'<div data-type="youtube" data-src="https://youtu.be/abc" data-width="560" data-height="315" data-align="right"></div>',
);
expect(md2).toBe(md1);
const yt = findFirst(doc2, 'youtube');
expect(yt).not.toBeNull();
expect(yt.attrs.src).toBe('https://youtu.be/abc');
expect(yt.attrs.align).toBe('right');
// data-width / data-height parseHTML -> getAttribute() -> STRINGS.
expect(yt.attrs.width).toBe('560');
expect(yt.attrs.height).toBe('315');
// Numeric width/height coerced to strings; align='right' is non-default so
// it is kept (not in KNOWN_DEFAULTS.youtube's normalization). Canonical FALSE.
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
});
});
@@ -0,0 +1,268 @@
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
getNodeByRef,
replaceNodeById,
insertNodeRelative,
insertTableRow,
updateTableCell,
sanitizeForYjs,
findUnstorableAttr,
buildOutline,
} from '../src/lib/node-ops.js';
// Gaps NOT covered by node-ops.test.ts (test-strategy report §2). The base file
// is comprehensive; these add only the missing edges: newNode-arg immutability,
// anchor-is-container routing, malformed opts, ragged/empty/no-colwidth/non-int
// insertTableRow, getNodeByRef non-object/#-1, updateTableCell empty-id refresh,
// outline 100/40 boundary, malformed marks, and the makeFreshId property.
const text = (value: string, marks?: any[]): any => {
const node: any = { type: 'text', text: value };
if (marks) node.marks = marks;
return node;
};
const para = (id: string, value = ''): any => ({
type: 'paragraph',
attrs: { id, indent: 0 },
content: value ? [text(value)] : [],
});
const cell = (
type: 'tableCell' | 'tableHeader',
paraId: string | null,
value = '',
extraAttrs: Record<string, any> = {},
): any => ({
type,
attrs: { colspan: 1, rowspan: 1, ...extraAttrs },
content: paraId == null ? [] : [para(paraId, value)],
});
const row = (cells: any[]): any => ({ type: 'tableRow', content: cells });
const doc = (...content: any[]): any => ({ type: 'doc', content });
// ===========================================================================
describe('replaceNodeById — newNode ARGUMENT immutability', () => {
it('does not mutate the caller-supplied newNode after replacement', () => {
// The doc-argument immutability is covered in the base file; this pins the
// OTHER input — the replacement node must be deep-cloned, so mutating the
// result never reaches the caller's newNode (and vice versa).
const d = doc(para('p0', 'old'), para('p1', 'old2'));
const newNode = { type: 'paragraph', attrs: { id: 'new' }, content: [text('NEW')] };
const snapshot = structuredClone(newNode);
const res = replaceNodeById(d, 'p0', newNode);
// Mutating the inserted copy must not touch the argument...
res.doc.content[0].content.push(text('mutated'));
expect(newNode).toEqual(snapshot);
// ...and mutating the argument afterwards must not touch the inserted copy.
newNode.content.push(text('later'));
expect(res.doc.content[0].content).toEqual([text('NEW'), text('mutated')]);
});
});
// ===========================================================================
describe('insertNodeRelative — container routing and malformed opts', () => {
it('routes a structural row when anchorText resolves to the TABLE block itself', () => {
// anchorText only scans top-level blocks, so it resolves to the whole table;
// the matched container IS the anchor (containerIdx === chain.length-1), so
// a row "after" must be appended inside the table, not spliced beside a row.
const table = { type: 'table', content: [row([cell('tableCell', 'r0', 'hello cell')])] };
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
const res = insertNodeRelative(doc(table), newRow, {
position: 'after',
anchorText: 'hello cell',
});
expect(res.inserted).toBe(true);
const firstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(firstCellId)).toEqual(['r0', 'rNew']);
});
it('prepends a structural row when anchorText resolves to the table and position is "before"', () => {
const table = { type: 'table', content: [row([cell('tableCell', 'r0', 'hello cell')])] };
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
const res = insertNodeRelative(doc(table), newRow, {
position: 'before',
anchorText: 'hello cell',
});
const firstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(firstCellId)).toEqual(['rNew', 'r0']);
});
it('is a no-op (inserted:false) for a malformed opts object', () => {
const d = doc(para('p0'));
const res = insertNodeRelative(d, para('n'), null as any);
expect(res.inserted).toBe(false);
expect(res.doc).toEqual(d);
});
});
// ===========================================================================
describe('insertTableRow — column count and index edge cases', () => {
const ragged = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0')]), // 1 col
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]), // 2 cols
],
});
it('derives the column count from the WIDEST row (ragged table)', () => {
// The guard counts against the widest row (2), so 3 cells throws...
expect(() => insertTableRow(doc(ragged()), '#0', ['X', 'Y', 'Z'])).toThrow(
/got 3 cell\(s\) but the table has 2 column\(s\)/,
);
// ...and a 2-cell row is padded to the widest width (2), not the header's 1.
const res = insertTableRow(doc(ragged()), '#0', ['X', 'Y']);
expect(res.doc.content[0].content[2].content).toHaveLength(2);
});
it('an EMPTY table falls back to the supplied cell count', () => {
const res = insertTableRow(doc({ type: 'table', content: [] }), '#0', ['A', 'B']);
expect(res.inserted).toBe(true);
expect(res.doc.content[0].content[0].content).toHaveLength(2);
});
it('omits colwidth entirely when the header cell has none (no undefined leak)', () => {
const noColwidth = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
],
};
const res = insertTableRow(doc(noColwidth), '#0', ['X']);
const newCellAttrs = res.doc.content[0].content[2].content[0].attrs;
expect('colwidth' in newCellAttrs).toBe(false); // not colwidth:undefined
});
it('APPENDS for a non-integer or negative index (does not throw)', () => {
const t = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
],
};
const frac = insertTableRow(doc(t), '#0', ['X'], 1.5);
expect(frac.inserted).toBe(true);
expect(frac.doc.content[0].content).toHaveLength(3); // appended at the end
const neg = insertTableRow(doc(t), '#0', ['X'], -1);
expect(neg.doc.content[0].content).toHaveLength(3);
});
});
// ===========================================================================
describe('getNodeByRef — malformed refs', () => {
it('returns null for a non-object block at a valid #n index', () => {
const d = { type: 'doc', content: [null] };
expect(getNodeByRef(d, '#0')).toBeNull();
});
it('returns null for "#-1" (the index regex does not match a negative)', () => {
const d = doc(para('p0'));
// "#-1" matches neither the "#<digits>" form nor any block id -> null.
expect(getNodeByRef(d, '#-1')).toBeNull();
});
});
// ===========================================================================
describe('updateTableCell — fresh id when the first paragraph has an empty id', () => {
it('mints a fresh id when the existing first paragraph id is the empty string', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1 },
content: [{ type: 'paragraph', attrs: { id: '' }, content: [text('old')] }],
},
]),
],
};
const res = updateTableCell(doc(table), '#0', 1, 0, 'new');
const newId = res.doc.content[0].content[1].content[0].content[0].attrs.id;
// An empty id is treated as missing -> a fresh Docmost-style id is minted.
expect(newId).toMatch(/^[a-z0-9]{12}$/);
expect(newId).not.toBe('');
});
});
// ===========================================================================
describe('buildOutline — exact 100 / 40 char truncation boundaries', () => {
it('does NOT truncate firstText at exactly 100 chars but DOES at 101', () => {
const at100 = buildOutline(doc(para('p', 'x'.repeat(100))));
expect(at100[0].firstText).toBe('x'.repeat(100)); // boundary: not cut
expect(at100[0].firstText.endsWith('…')).toBe(false);
const at101 = buildOutline(doc(para('p', 'x'.repeat(101))));
expect(at101[0].firstText).toBe('x'.repeat(100) + '…'); // first char over the cap
});
it('does NOT truncate a header cell at exactly 40 chars but DOES at 41', () => {
const tableAt40 = {
type: 'table',
content: [row([cell('tableHeader', 'h', 'y'.repeat(40))])],
};
expect(buildOutline(doc(tableAt40))[0].header).toEqual(['y'.repeat(40)]);
const tableAt41 = {
type: 'table',
content: [row([cell('tableHeader', 'h', 'y'.repeat(41))])],
};
expect(buildOutline(doc(tableAt41))[0].header).toEqual(['y'.repeat(40) + '…']);
});
});
// ===========================================================================
describe('sanitizeForYjs / findUnstorableAttr — malformed marks array', () => {
const malformed = () =>
doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [
text('x', [null, { type: 'link', attrs: { href: 'u', gone: undefined } }]),
],
});
it('sanitizeForYjs skips a null mark and strips undefined on the real one', () => {
const res = sanitizeForYjs(malformed());
const marks = res.content[0].content[0].marks;
expect(marks[0]).toBeNull(); // the null mark is left untouched, not crashed on
expect(marks[1].attrs).toEqual({ href: 'u' }); // undefined dropped
});
it('findUnstorableAttr skips a null mark and reports the real undefined attr path', () => {
expect(findUnstorableAttr(malformed())).toBe(
'content[0].content[0].marks[1].attrs.gone (undefined)',
);
});
});
// ===========================================================================
describe('makeFreshId — format and uniqueness (property, via insertTableRow)', () => {
it('every minted cell-paragraph id matches ^[a-z0-9]{12}$ and is globally unique', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 5 }), (cols) => {
// Build an empty-id table of `cols` columns; the inserted row mints a
// fresh id per cell. The doc carries one pre-existing id to also assert
// the new ids never collide with it.
const headerCells = Array.from({ length: cols }, (_, i) =>
cell('tableHeader', `pre-${i}`, `H${i}`),
);
const d = doc({ type: 'table', content: [row(headerCells)] });
const res = insertTableRow(d, '#0', Array.from({ length: cols }, () => 'v'), 1);
const ids = res.doc.content[0].content[1].content.map(
(c: any) => c.content[0].attrs.id,
);
for (const id of ids) {
expect(id).toMatch(/^[a-z0-9]{12}$/);
}
// Unique within the new row AND distinct from the pre-existing ids.
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) {
expect(id.startsWith('pre-')).toBe(false);
}
}),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,908 @@
import { describe, expect, it } from 'vitest';
import {
blockPlainText,
buildOutline,
getNodeByRef,
replaceNodeById,
deleteNodeById,
sanitizeForYjs,
findUnstorableAttr,
insertNodeRelative,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
} from '../src/lib/node-ops.js';
// ---------------------------------------------------------------------------
// Tiny ProseMirror/TipTap JSON fixture builders. These produce the exact plain
// JSON shape Docmost uses: { type, attrs?, content?, text?, marks? }.
// ---------------------------------------------------------------------------
/** A text leaf node, optionally carrying marks. */
function text(value: string, marks?: any[]): any {
const node: any = { type: 'text', text: value };
if (marks) node.marks = marks;
return node;
}
/** A paragraph block with an id and a single text child (or empty). */
function para(id: string, value = ''): any {
return {
type: 'paragraph',
attrs: { id, indent: 0 },
content: value ? [text(value)] : [],
};
}
/** A heading block. */
function heading(id: string, level: number, value: string): any {
return {
type: 'heading',
attrs: { id, level },
content: [text(value)],
};
}
/** A table cell (or header) wrapping a single paragraph; extra attrs merged in. */
function cell(
type: 'tableCell' | 'tableHeader',
paraId: string | null,
value = '',
extraAttrs: Record<string, any> = {},
): any {
const attrs = { colspan: 1, rowspan: 1, ...extraAttrs };
return {
type,
attrs,
content: paraId == null ? [] : [para(paraId, value)],
};
}
/** A table row. */
function row(cells: any[]): any {
return { type: 'tableRow', content: cells };
}
/** A doc root with the given top-level blocks. */
function doc(...content: any[]): any {
return { type: 'doc', content };
}
// ===========================================================================
// blockPlainText
// ===========================================================================
describe('blockPlainText', () => {
it('returns the text of a plain text node', () => {
expect(blockPlainText(text('hello'))).toBe('hello');
});
it('concatenates text from nested containers', () => {
const node = {
type: 'paragraph',
content: [text('foo'), text('bar'), { type: 'span', content: [text('baz')] }],
};
expect(blockPlainText(node)).toBe('foobarbaz');
});
it('returns "" for nullish or non-object inputs', () => {
expect(blockPlainText(null)).toBe('');
expect(blockPlainText(undefined)).toBe('');
expect(blockPlainText('a string')).toBe('');
expect(blockPlainText(42)).toBe('');
expect(blockPlainText([text('x')])).toBe(''); // arrays are not objects here
});
it('uses BOTH text and nested content of a node, text first', () => {
const node = { type: 'weird', text: 'A', content: [text('B'), text('C')] };
expect(blockPlainText(node)).toBe('ABC');
});
});
// ===========================================================================
// buildOutline
// ===========================================================================
describe('buildOutline', () => {
it('captures heading level, id and firstText', () => {
const outline = buildOutline(doc(heading('h1', 2, 'Title')));
expect(outline).toEqual([
{ index: 0, type: 'heading', id: 'h1', firstText: 'Title', level: 2 },
]);
});
it('reports table rows/cols and header texts (cols from row 0)', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'a', 'H1'), cell('tableHeader', 'b', 'H2')]),
row([cell('tableCell', 'c', 'x'), cell('tableCell', 'd', 'y')]),
],
};
const [entry] = buildOutline(doc(table));
expect(entry.type).toBe('table');
expect(entry.rows).toBe(2);
expect(entry.cols).toBe(2);
expect(entry.header).toEqual(['H1', 'H2']);
});
it('derives cols from row 0 for a ragged table', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'a', 'H1')]), // row 0 has 1 col
row([cell('tableCell', 'b', 'x'), cell('tableCell', 'c', 'y')]), // 2 cols
],
};
const [entry] = buildOutline(doc(table));
expect(entry.rows).toBe(2);
expect(entry.cols).toBe(1); // cols reflect ONLY row 0
expect(entry.header).toEqual(['H1']);
});
it('reports item count for any *List block', () => {
const list = {
type: 'bulletList',
attrs: { id: 'l1' },
content: [{ type: 'listItem' }, { type: 'listItem' }, { type: 'listItem' }],
};
const [entry] = buildOutline(doc(list));
expect(entry.type).toBe('bulletList');
expect(entry.items).toBe(3);
});
it('returns [] for an empty or non-object doc', () => {
expect(buildOutline(null)).toEqual([]);
expect(buildOutline({ type: 'doc' })).toEqual([]); // no content array
expect(buildOutline({ type: 'doc', content: [] })).toEqual([]);
expect(buildOutline('nope')).toEqual([]);
});
it('falls back to null id when a block has no attrs.id', () => {
const [entry] = buildOutline(doc({ type: 'paragraph', content: [text('hi')] }));
expect(entry.id).toBeNull();
expect(entry.firstText).toBe('hi');
});
it('truncates firstText to 100 chars with an ellipsis', () => {
const long = 'x'.repeat(150);
const [entry] = buildOutline(doc(para('p', long)));
expect(entry.firstText).toBe('x'.repeat(100) + '…');
expect(entry.firstText.length).toBe(101); // 100 chars + ellipsis
});
it('truncates table header cell text to 40 chars', () => {
const long = 'y'.repeat(60);
const table = {
type: 'table',
content: [row([cell('tableHeader', 'a', long)])],
};
const [entry] = buildOutline(doc(table));
expect(entry.header).toEqual(['y'.repeat(40) + '…']);
});
});
// ===========================================================================
// getNodeByRef
// ===========================================================================
describe('getNodeByRef', () => {
it('resolves a top-level block by #n', () => {
const d = doc(para('p0', 'zero'), para('p1', 'one'));
const hit = getNodeByRef(d, '#1');
expect(hit).not.toBeNull();
expect(hit!.path).toEqual([1]);
expect(hit!.type).toBe('paragraph');
expect(hit!.node.attrs.id).toBe('p1');
});
it('returns null for #n out of range', () => {
const d = doc(para('p0'));
expect(getNodeByRef(d, '#5')).toBeNull();
expect(getNodeByRef(d, '#1')).toBeNull();
});
it('finds a nested node by id with the correct path', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'deep', 'found me')])],
};
const d = doc(para('p0'), table);
const hit = getNodeByRef(d, 'deep');
expect(hit).not.toBeNull();
// doc.content[1] -> table.content[0] -> row.content[0] -> cell.content[0]
expect(hit!.path).toEqual([1, 0, 0, 0]);
expect(hit!.type).toBe('paragraph');
});
it('returns null when the id is not found', () => {
const d = doc(para('p0'));
expect(getNodeByRef(d, 'missing')).toBeNull();
});
it('returns the FIRST node for a duplicate id', () => {
const d = doc(para('dup', 'first'), para('dup', 'second'));
const hit = getNodeByRef(d, 'dup');
expect(hit!.path).toEqual([0]);
expect(blockPlainText(hit!.node)).toBe('first');
});
it('returns null for a non-object doc', () => {
expect(getNodeByRef(null, '#0')).toBeNull();
expect(getNodeByRef('x', 'id')).toBeNull();
});
it('returns a CLONE — mutating it does not touch the input doc', () => {
const d = doc(para('p0', 'orig'));
const snapshot = structuredClone(d);
const hit = getNodeByRef(d, 'p0');
hit!.node.attrs.id = 'mutated';
hit!.node.content.push(text('extra'));
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// replaceNodeById
// ===========================================================================
describe('replaceNodeById', () => {
const newNode = () => ({ type: 'paragraph', attrs: { id: 'new' }, content: [text('NEW')] });
it('reports replaced:0 when nothing matches', () => {
const d = doc(para('p0'));
const res = replaceNodeById(d, 'missing', newNode());
expect(res.replaced).toBe(0);
expect(res.doc).toEqual(d);
});
it('replaces a single match', () => {
const d = doc(para('p0', 'old'), para('p1'));
const res = replaceNodeById(d, 'p0', newNode());
expect(res.replaced).toBe(1);
expect(res.doc.content[0]).toEqual(newNode());
expect(res.doc.content[1].attrs.id).toBe('p1');
});
it('replaces N matches', () => {
const d = doc(para('dup', 'a'), para('keep'), para('dup', 'b'));
const res = replaceNodeById(d, 'dup', newNode());
expect(res.replaced).toBe(2);
expect(res.doc.content[0]).toEqual(newNode());
expect(res.doc.content[1].attrs.id).toBe('keep');
expect(res.doc.content[2]).toEqual(newNode());
});
it('replaces a nested match inside a table cell', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'inner', 'x')])],
};
const d = doc(table);
const res = replaceNodeById(d, 'inner', newNode());
expect(res.replaced).toBe(1);
expect(res.doc.content[0].content[0].content[0].content[0]).toEqual(newNode());
});
it('does NOT recurse into the substituted node', () => {
// The replacement itself carries the same id; it must not be re-replaced.
const d = doc(para('target'));
const replacement = { type: 'paragraph', attrs: { id: 'target' }, content: [text('R')] };
const res = replaceNodeById(d, 'target', replacement);
expect(res.replaced).toBe(1); // not 2 — no recursion into the new node
});
it('gives each match a SEPARATE clone', () => {
const d = doc(para('dup'), para('dup'));
const res = replaceNodeById(d, 'dup', newNode());
res.doc.content[0].content.push(text('mutated'));
// The second replacement must be untouched.
expect(res.doc.content[1]).toEqual(newNode());
});
it('does not mutate the input doc', () => {
const d = doc(para('p0', 'old'));
const snapshot = structuredClone(d);
replaceNodeById(d, 'p0', newNode());
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// deleteNodeById
// ===========================================================================
describe('deleteNodeById', () => {
it('reports deleted:0 when nothing matches', () => {
const d = doc(para('p0'));
const res = deleteNodeById(d, 'missing');
expect(res.deleted).toBe(0);
expect(res.doc).toEqual(d);
});
it('deletes a single match', () => {
const d = doc(para('p0'), para('p1'), para('p2'));
const res = deleteNodeById(d, 'p1');
expect(res.deleted).toBe(1);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'p2']);
});
it('deletes N matches', () => {
const d = doc(para('dup'), para('keep'), para('dup'));
const res = deleteNodeById(d, 'dup');
expect(res.deleted).toBe(2);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['keep']);
});
it('deletes a nested node and preserves sibling order', () => {
// A callout-style container holding three paragraph children; deleting the
// middle one must leave the outer siblings in order.
const callout = {
type: 'callout',
attrs: { id: 'cal' },
content: [para('a', 'A'), para('b', 'B'), para('c', 'C')],
};
const d = doc(para('outer0'), callout, para('outer1'));
const res = deleteNodeById(d, 'b');
expect(res.deleted).toBe(1);
// Inner siblings keep their order.
const innerIds = res.doc.content[1].content.map((cl: any) => cl.attrs.id);
expect(innerIds).toEqual(['a', 'c']);
// Outer siblings are untouched and in order.
const outerIds = res.doc.content.map((cl: any) => cl.attrs.id);
expect(outerIds).toEqual(['outer0', 'cal', 'outer1']);
});
it('does not mutate the input doc (deep-equal before/after)', () => {
const d = doc(para('p0'), para('p1'));
const snapshot = structuredClone(d);
deleteNodeById(d, 'p0');
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// sanitizeForYjs
// ===========================================================================
describe('sanitizeForYjs', () => {
it('strips undefined keys from node.attrs', () => {
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined, kept: 1 } });
const res = sanitizeForYjs(d);
expect('gone' in res.content[0].attrs).toBe(false);
expect(res.content[0].attrs).toEqual({ id: 'p', kept: 1 });
});
it('strips undefined keys from mark.attrs', () => {
const d = doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [text('hi', [{ type: 'link', attrs: { href: 'u', gone: undefined } }])],
});
const res = sanitizeForYjs(d);
expect('gone' in res.content[0].content[0].marks[0].attrs).toBe(false);
expect(res.content[0].content[0].marks[0].attrs).toEqual({ href: 'u' });
});
it('PRESERVES null, false, 0 and "" (only undefined is dropped)', () => {
const d = doc({
type: 'paragraph',
attrs: { a: null, b: false, c: 0, d: '', e: undefined },
});
const res = sanitizeForYjs(d);
expect(res.content[0].attrs).toEqual({ a: null, b: false, c: 0, d: '' });
});
it('recurses into nested content', () => {
const d = doc({
type: 'table',
content: [row([cell('tableCell', null, '', { gone: undefined, colwidth: null })])],
});
const res = sanitizeForYjs(d);
const cellAttrs = res.content[0].content[0].content[0].attrs;
expect('gone' in cellAttrs).toBe(false);
expect(cellAttrs.colwidth).toBeNull();
});
it('does not mutate the input doc', () => {
const d = doc({ type: 'paragraph', attrs: { id: 'p', gone: undefined } });
// structuredClone preserves an explicit `undefined` value key, so snapshot it.
const snapshot = structuredClone(d);
sanitizeForYjs(d);
expect(d).toEqual(snapshot);
expect('gone' in d.content[0].attrs).toBe(true); // still present on the input
});
});
// ===========================================================================
// findUnstorableAttr
// ===========================================================================
describe('findUnstorableAttr', () => {
it('returns null for a fully storable doc', () => {
const d = doc(para('p0', 'clean'));
expect(findUnstorableAttr(d)).toBeNull();
});
it('detects an undefined node attr with its path and kind', () => {
const d = doc(para('a'), para('b'), { type: 'paragraph', attrs: { id: 'c', x: undefined } });
expect(findUnstorableAttr(d)).toBe('content[2].attrs.x (undefined)');
});
it('detects a function attr', () => {
const d = doc({ type: 'paragraph', attrs: { fn: () => 1 } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.fn (function)');
});
it('detects a symbol attr', () => {
const d = doc({ type: 'paragraph', attrs: { s: Symbol('x') } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.s (symbol)');
});
it('detects a bigint attr', () => {
const d = doc({ type: 'paragraph', attrs: { big: 10n } });
expect(findUnstorableAttr(d)).toBe('content[0].attrs.big (bigint)');
});
it('detects an unstorable mark attr with the marks[i] path', () => {
const d = doc({
type: 'paragraph',
attrs: { id: 'p' },
content: [text('hi'), text('yo', [{ type: 'link', attrs: { x: undefined } }])],
});
expect(findUnstorableAttr(d)).toBe('content[0].content[1].marks[0].attrs.x (undefined)');
});
it('returns the FIRST hit only', () => {
const d = doc(
{ type: 'paragraph', attrs: { first: undefined } },
{ type: 'paragraph', attrs: { second: undefined } },
);
expect(findUnstorableAttr(d)).toBe('content[0].attrs.first (undefined)');
});
it('returns null for a non-object doc', () => {
expect(findUnstorableAttr(null)).toBeNull();
expect(findUnstorableAttr('x')).toBeNull();
});
});
// ===========================================================================
// insertNodeRelative
// ===========================================================================
describe('insertNodeRelative', () => {
const block = (id: string, value = '') => para(id, value);
it('appends a node to top-level content', () => {
const d = doc(para('p0'));
const res = insertNodeRelative(d, block('new', 'N'), { position: 'append' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new']);
});
it('creates a content array when appending to a doc without one', () => {
const res = insertNodeRelative({ type: 'doc' }, block('new'), { position: 'append' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['new']);
});
it('inserts before a node by id (top level)', () => {
const d = doc(para('p0'), para('p1'));
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'p1' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('inserts after a node by id (top level)', () => {
const d = doc(para('p0'), para('p1'));
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'p0' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('inserts before a NESTED anchor by id, into its own parent content', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', 'inner', 'x')])],
};
const d = doc(table);
const res = insertNodeRelative(d, block('new'), { position: 'before', anchorNodeId: 'inner' });
expect(res.inserted).toBe(true);
// The new (non-structural) node is spliced into the cell's content before the paragraph.
const cellContent = res.doc.content[0].content[0].content[0].content;
expect(cellContent.map((c: any) => c.attrs.id)).toEqual(['new', 'inner']);
});
it('inserts by anchorText against top-level blocks (substring match)', () => {
const d = doc(para('p0', 'hello world'), para('p1', 'other'));
const res = insertNodeRelative(d, block('new'), { position: 'after', anchorText: 'world' });
expect(res.inserted).toBe(true);
expect(res.doc.content.map((c: any) => c.attrs.id)).toEqual(['p0', 'new', 'p1']);
});
it('returns inserted:false when the anchor cannot be resolved', () => {
const d = doc(para('p0'));
const byId = insertNodeRelative(d, block('new'), { position: 'after', anchorNodeId: 'nope' });
expect(byId.inserted).toBe(false);
expect(byId.doc).toEqual(d);
const byText = insertNodeRelative(d, block('new'), { position: 'before', anchorText: 'zzz' });
expect(byText.inserted).toBe(false);
expect(byText.doc).toEqual(d);
});
it('routes a structural tableRow to the nearest table container', () => {
const table = {
type: 'table',
content: [
row([cell('tableCell', 'r0c0', 'A')]),
row([cell('tableCell', 'r1c0', 'B')]),
],
};
const d = doc(table);
const newRow = row([cell('tableCell', 'rNew', 'NEW')]);
// Anchor on a cell paragraph inside row 0; "after" should put the row after row 0.
const res = insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'r0c0' });
expect(res.inserted).toBe(true);
const rowFirstCellId = (r: any) => r.content[0].content[0].attrs.id;
expect(res.doc.content[0].content.map(rowFirstCellId)).toEqual(['r0c0', 'rNew', 'r1c0']);
});
it('throws when appending a structural node at the top level', () => {
const d = doc(para('p0'));
const newRow = row([cell('tableCell', 'x', 'X')]);
expect(() => insertNodeRelative(d, newRow, { position: 'append' })).toThrow(
/cannot append a tableRow at the top level/,
);
});
it('throws when a structural anchor is not inside the required container', () => {
// Anchor resolves to a top-level paragraph that is not inside any table.
const d = doc(para('p0', 'loose'));
const newRow = row([cell('tableCell', 'x', 'X')]);
expect(() =>
insertNodeRelative(d, newRow, { position: 'after', anchorNodeId: 'p0' }),
).toThrow(/the anchor is not inside a table/);
});
it('honours offset: before vs after place the node on the correct side', () => {
const d = doc(para('a'), para('b'), para('c'));
const before = insertNodeRelative(d, block('N'), { position: 'before', anchorNodeId: 'b' });
expect(before.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'N', 'b', 'c']);
const after = insertNodeRelative(d, block('N'), { position: 'after', anchorNodeId: 'b' });
expect(after.doc.content.map((c: any) => c.attrs.id)).toEqual(['a', 'b', 'N', 'c']);
});
it('does not mutate the input doc or the node argument', () => {
const d = doc(para('p0'));
const dSnapshot = structuredClone(d);
const node = block('new', 'N');
const nodeSnapshot = structuredClone(node);
insertNodeRelative(d, node, { position: 'append' });
expect(d).toEqual(dSnapshot);
expect(node).toEqual(nodeSnapshot);
});
});
// ===========================================================================
// readTable
// ===========================================================================
describe('readTable', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
});
it('reads a table by #n', () => {
const d = doc(para('p0'), makeTable());
const res = readTable(d, '#1');
expect(res).not.toBeNull();
expect(res!.rows).toBe(2);
expect(res!.cols).toBe(2);
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
expect(res!.cellIds).toEqual([['h0', 'h1'], ['c0', 'c1']]);
expect(res!.path).toEqual([1]);
});
it('climbs from an inner paragraph id up to the table', () => {
const d = doc(makeTable());
const res = readTable(d, 'c1'); // id of a paragraph inside a data cell
expect(res).not.toBeNull();
expect(res!.path).toEqual([0]);
expect(res!.cells).toEqual([['H0', 'H1'], ['A', 'B']]);
});
it('reports per-row widths via cells for a ragged table', () => {
const table = {
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0')]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
};
const res = readTable(doc(table), '#0');
expect(res!.cols).toBe(1); // cols comes from row 0
expect(res!.cells).toEqual([['H0'], ['A', 'B']]); // actual per-row widths preserved
expect(res!.cellIds).toEqual([['h0'], ['c0', 'c1']]);
});
it('reports null cellId for an empty cell with no paragraph', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
};
const res = readTable(doc(table), '#0');
expect(res!.cells).toEqual([['', 'B']]);
expect(res!.cellIds).toEqual([[null, 'c1']]);
});
it('returns null when the ref matches no table', () => {
const d = doc(para('p0'));
expect(readTable(d, '#0')).toBeNull(); // #0 is a paragraph, not a table
expect(readTable(d, 'missing')).toBeNull();
expect(readTable(d, 'p0')).toBeNull(); // id found but no enclosing table
});
});
// ===========================================================================
// insertTableRow
// ===========================================================================
describe('insertTableRow', () => {
const makeTable = () => ({
type: 'table',
content: [
row([
cell('tableHeader', 'h0', 'H0', { colwidth: [120] }),
cell('tableHeader', 'h1', 'H1', { colwidth: [240] }),
]),
row([cell('tableCell', 'c0', 'A'), cell('tableCell', 'c1', 'B')]),
],
});
/** First-paragraph ids of every cell in a row, for ordering assertions. */
const rowCellParaIds = (r: any): (string | undefined)[] =>
r.content.map((c: any) => c.content[0]?.attrs?.id);
/** Cell text of a row. */
const rowTexts = (r: any): string[] =>
r.content.map((c: any) => blockPlainText(c));
it('appends a row when index is omitted', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y']);
expect(res.inserted).toBe(true);
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
});
it('splices at a middle index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 1);
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[1])).toEqual(['X', 'Y']); // new row at index 1
expect(rowTexts(rows[2])).toEqual(['A', 'B']); // old data row pushed down
});
it('splices at the end index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 2); // rows == 2, valid end index
const rows = res.doc.content[0].content;
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']);
});
it('APPENDS (does not throw) for an out-of-range index', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 99);
const rows = res.doc.content[0].content;
expect(res.inserted).toBe(true);
expect(rows.length).toBe(3);
expect(rowTexts(rows[2])).toEqual(['X', 'Y']); // appended at the end
});
it('throws when given more cells than columns', () => {
const d = doc(makeTable());
expect(() => insertTableRow(d, '#0', ['X', 'Y', 'Z'])).toThrow(
/got 3 cell\(s\) but the table has 2 column\(s\)/,
);
});
it('pads a short row to the column count', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['only']);
const rows = res.doc.content[0].content;
expect(rowTexts(rows[2])).toEqual(['only', '']); // padded with empty cell
});
it('copies colwidth from the header row for each column', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y']);
const newRow = res.doc.content[0].content[2];
expect(newRow.content[0].attrs.colwidth).toEqual([120]);
expect(newRow.content[1].attrs.colwidth).toEqual([240]);
expect(newRow.content[0].attrs).toMatchObject({ colspan: 1, rowspan: 1 });
});
it('index 0 inherits the header cell TYPE', () => {
const d = doc(makeTable());
const res = insertTableRow(d, '#0', ['X', 'Y'], 0);
const newRow = res.doc.content[0].content[0];
expect(newRow.content.every((c: any) => c.type === 'tableHeader')).toBe(true);
// A non-zero index produces plain data cells instead.
const res2 = insertTableRow(d, '#0', ['X', 'Y'], 1);
const dataRow = res2.doc.content[0].content[1];
expect(dataRow.content.every((c: any) => c.type === 'tableCell')).toBe(true);
});
it('mints unique, well-formed paragraph ids for new cells', () => {
const d = doc(makeTable());
const existing = new Set(['h0', 'h1', 'c0', 'c1']);
const res = insertTableRow(d, '#0', ['X', 'Y']);
const newRow = res.doc.content[0].content[2];
const ids = rowCellParaIds(newRow) as string[];
for (const id of ids) {
expect(typeof id).toBe('string');
expect(id).toMatch(/^[a-z0-9]{12}$/); // Docmost-style 12-char id
expect(existing.has(id)).toBe(false); // unique vs pre-existing ids
}
expect(new Set(ids).size).toBe(ids.length); // unique within the row
});
it('returns inserted:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = insertTableRow(d, 'missing', ['X']);
expect(res.inserted).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
insertTableRow(d, '#0', ['X', 'Y'], 1);
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// deleteTableRow
// ===========================================================================
describe('deleteTableRow', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H')]),
row([cell('tableCell', 'c0', 'A')]),
row([cell('tableCell', 'c1', 'B')]),
],
});
const firstId = (r: any) => r.content[0].content[0].attrs.id;
it('deletes a middle row and preserves siblings', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 1);
expect(res.deleted).toBe(true);
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c1']);
});
it('deletes the first row', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 0);
expect(res.doc.content[0].content.map(firstId)).toEqual(['c0', 'c1']);
});
it('deletes the last row', () => {
const d = doc(makeTable());
const res = deleteTableRow(d, '#0', 2);
expect(res.doc.content[0].content.map(firstId)).toEqual(['h0', 'c0']);
});
it('throws on an out-of-range index', () => {
const d = doc(makeTable());
expect(() => deleteTableRow(d, '#0', 99)).toThrow(/out of range/);
expect(() => deleteTableRow(d, '#0', -1)).toThrow(/out of range/);
});
it('throws when asked to delete the only row', () => {
const single = {
type: 'table',
content: [row([cell('tableCell', 'c0', 'A')])],
};
expect(() => deleteTableRow(doc(single), '#0', 0)).toThrow(
/refusing to delete the only row/,
);
});
it('returns deleted:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = deleteTableRow(d, 'missing', 0);
expect(res.deleted).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
deleteTableRow(d, '#0', 1);
expect(d).toEqual(snapshot);
});
});
// ===========================================================================
// updateTableCell
// ===========================================================================
describe('updateTableCell', () => {
const makeTable = () => ({
type: 'table',
content: [
row([cell('tableHeader', 'h0', 'H0'), cell('tableHeader', 'h1', 'H1')]),
row([
cell('tableCell', 'c0', 'A', { colspan: 2, rowspan: 3, colwidth: [200] }),
cell('tableCell', 'c1', 'B'),
]),
],
});
it('sets the cell text', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 1, 'NEW');
expect(res.updated).toBe(true);
expect(blockPlainText(res.doc.content[0].content[1].content[1])).toBe('NEW');
});
it('REUSES the existing first-paragraph id', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 0, 'changed');
const para0 = res.doc.content[0].content[1].content[0].content[0];
expect(para0.attrs.id).toBe('c0'); // critical: id reused, not regenerated
expect(para0.content[0].text).toBe('changed');
});
it('mints a fresh id when the cell had no paragraph', () => {
const table = {
type: 'table',
content: [row([cell('tableCell', null), cell('tableCell', 'c1', 'B')])],
};
const d = doc(table);
const res = updateTableCell(d, '#0', 0, 0, 'now has text');
const newPara = res.doc.content[0].content[0].content[0].content[0];
expect(typeof newPara.attrs.id).toBe('string');
expect(newPara.attrs.id).toMatch(/^[a-z0-9]{12}$/);
expect(newPara.attrs.id).not.toBe('c1'); // unique vs existing ids
expect(newPara.content[0].text).toBe('now has text');
});
it('PRESERVES the cell colspan/rowspan/colwidth (only content replaced)', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 0, 'x');
const cellNode = res.doc.content[0].content[1].content[0];
expect(cellNode.attrs).toEqual({ colspan: 2, rowspan: 3, colwidth: [200] });
});
it('throws when row or col is out of range', () => {
const d = doc(makeTable());
expect(() => updateTableCell(d, '#0', 5, 0, 'x')).toThrow(/out of range/);
expect(() => updateTableCell(d, '#0', 0, 5, 'x')).toThrow(/out of range/);
expect(() => updateTableCell(d, '#0', -1, 0, 'x')).toThrow(/out of range/);
});
it('an empty string yields an empty paragraph content array', () => {
const d = doc(makeTable());
const res = updateTableCell(d, '#0', 1, 1, '');
const cellPara = res.doc.content[0].content[1].content[1].content[0];
expect(cellPara.type).toBe('paragraph');
expect(cellPara.content).toEqual([]); // empty string -> empty content
expect(cellPara.attrs.id).toBe('c1'); // id still reused
});
it('returns updated:false when the table cannot be located', () => {
const d = doc(para('p0'));
const res = updateTableCell(d, 'missing', 0, 0, 'x');
expect(res.updated).toBe(false);
expect(res.doc).toEqual(d);
});
it('does not mutate the input doc', () => {
const d = doc(makeTable());
const snapshot = structuredClone(d);
updateTableCell(d, '#0', 1, 1, 'NEW');
expect(d).toEqual(snapshot);
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { parsePageFile, serializePageFile } from "../src/lib/page-file";
describe("page-file thin format", () => {
it("round-trips id frontmatter + clean body", () => {
const text = serializePageFile("019ef6fc-2638", "# Hello\n\nbody text");
expect(text.startsWith("---\ngitmost_id: 019ef6fc-2638\n---\n")).toBe(true);
const { id, body } = parsePageFile(text);
expect(id).toBe("019ef6fc-2638");
expect(body).toBe("# Hello\n\nbody text");
});
it("serialization is deterministic (byte-identical for the same input)", () => {
expect(serializePageFile("p", "x")).toBe(serializePageFile("p", "x"));
});
it("reads id from frontmatter with quotes / extra fields", () => {
expect(parsePageFile('---\ngitmost_id: "abc"\ntitle: ignored\n---\nbody').id).toBe("abc");
expect(parsePageFile("---\ngitmost_id: 'xyz'\n---\nbody").id).toBe("xyz");
});
it("ADOPT: a plain hand-written file has no id and keeps its whole body", () => {
const { id, body } = parsePageFile("# Just a note\n\nwritten in Obsidian");
expect(id).toBeNull();
expect(body).toBe("# Just a note\n\nwritten in Obsidian");
});
it("tolerates empty / whitespace input", () => {
expect(parsePageFile("").id).toBeNull();
expect(parsePageFile(" \n ").body).toBe("");
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
// pulls in collaboration.ts and mutates the global DOM at import time), matching
// the other converter unit tests. markdownToProseMirror is imported for the
// round-trip cases; loading it mutates the global DOM via jsdom (required for
// @tiptap/html's generateJSON under Node) — this is expected.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
// ---------------------------------------------------------------------------
// #1 editor-ext atoms dropped: the `default` branch (markdown-converter.ts
// ~584-586) collapses unknown atoms to "" by mapping their (empty) children.
// ---------------------------------------------------------------------------
describe('#1 editor-ext atoms dropped', () => {
it('preserves an inline status atom text', () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'status', attrs: { text: 'Done' } }],
});
expect(convertProseMirrorToMarkdown(d)).toContain('Done');
});
it('preserves a block htmlEmbed atom', () => {
const d = doc({ type: 'htmlEmbed', attrs: { source: '<b>hi</b>' } });
expect(convertProseMirrorToMarkdown(d)).not.toBe('');
});
it('preserves a footnoteReference atom', () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'footnoteReference', attrs: { id: 'fn1', referenceNumber: 1 } }],
});
expect(convertProseMirrorToMarkdown(d)).not.toBe('');
});
});
// ---------------------------------------------------------------------------
// #2 top-level image attrs lost: a top-level image emits markdown ![](src),
// which carries no width/height/align/attachmentId.
// ---------------------------------------------------------------------------
describe('#2 top-level image attrs lost', () => {
it('keeps width through export and re-import', async () => {
const d = doc({
type: 'image',
attrs: { src: '/files/x.png', width: '320', height: '200', align: 'right', attachmentId: 'a1' },
});
const md = convertProseMirrorToMarkdown(d);
expect(md).toContain('320');
const back = await markdownToProseMirror(md);
expect(back.content[0].attrs.width).toBe('320');
});
});
// ---------------------------------------------------------------------------
// #3 code-fence corruption: a code block whose TEXT contains a ``` fence must
// be emitted with a wider outer fence so the inner fence survives.
// ---------------------------------------------------------------------------
describe('#3 code-fence corruption', () => {
it('round-trips a code block containing an inner fence', async () => {
const code = '```js\nfoo()\n```';
const d = doc({
type: 'codeBlock',
attrs: { language: '' },
content: [{ type: 'text', text: code }],
});
const md1 = convertProseMirrorToMarkdown(d);
const back = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(back);
expect(md2).toBe(md1);
});
});
// ---------------------------------------------------------------------------
// #16 depth guard: deep recursion in processNode overflows the stack (today a
// RangeError) instead of being guarded.
// ---------------------------------------------------------------------------
describe('#16 depth guard', () => {
it('does not throw on a deeply nested blockquote doc', () => {
const DEPTH = 50000;
let node: any = { type: 'paragraph', content: [{ type: 'text', text: 'x' }] };
for (let i = 0; i < DEPTH; i++) {
node = { type: 'blockquote', content: [node] };
}
const d = doc(node);
expect(() => convertProseMirrorToMarkdown(d)).not.toThrow();
});
});
@@ -0,0 +1,297 @@
import { describe, expect, it } from 'vitest';
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
/**
* Exhaustive serialize -> deserialize round trip for EVERY node and mark type the
* Docmost document schema supports. The git-sync converter exports a page body to
* Markdown and imports it back; any node type that has no parseHTML inverse (or is
* serialized to a literal that never re-parses) silently degrades to plain text on
* a round trip — e.g. `subpages` used to export as the literal `{{SUBPAGES}}` and
* came back as the visible text "{{SUBPAGES}}" instead of the embed.
*
* This guards the whole class: for one representative fixture per type, the node
* (or mark) MUST still be present after convert -> import, and the exported
* Markdown must not contain a `{{...}}` template literal (the old lossy form).
*/
const T = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const P = (...c: any[]) => ({ type: 'paragraph', content: c });
const doc = (...c: any[]) => ({ type: 'doc', content: c });
// `primary` is the node/mark type that must survive the round trip.
const FIXTURES: Record<string, { doc: any; primary: string }> = {
paragraph: { doc: doc(P(T('hello'))), primary: 'paragraph' },
heading: { doc: doc({ type: 'heading', attrs: { level: 2 }, content: [T('H2')] }), primary: 'heading' },
blockquote: { doc: doc({ type: 'blockquote', content: [P(T('q'))] }), primary: 'blockquote' },
codeBlock: { doc: doc({ type: 'codeBlock', attrs: { language: 'js' }, content: [T('foo()')] }), primary: 'codeBlock' },
bulletList: { doc: doc({ type: 'bulletList', content: [{ type: 'listItem', content: [P(T('a'))] }] }), primary: 'bulletList' },
orderedList: { doc: doc({ type: 'orderedList', attrs: { start: 1 }, content: [{ type: 'listItem', content: [P(T('a'))] }] }), primary: 'orderedList' },
taskList: { doc: doc({ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: true }, content: [P(T('done'))] }] }), primary: 'taskList' },
horizontalRule: { doc: doc({ type: 'horizontalRule' }), primary: 'horizontalRule' },
image: { doc: doc({ type: 'image', attrs: { src: '/f/x.png', width: '320', align: 'center' } }), primary: 'image' },
hardBreak: { doc: doc(P(T('a'), { type: 'hardBreak' }, T('b'))), primary: 'hardBreak' },
callout: { doc: doc({ type: 'callout', attrs: { type: 'info' }, content: [P(T('note'))] }), primary: 'callout' },
columns: {
doc: doc({ type: 'columns', content: [
{ type: 'column', attrs: { width: '50%' }, content: [P(T('L'))] },
{ type: 'column', attrs: { width: '50%' }, content: [P(T('R'))] }] }),
primary: 'column',
},
details: {
doc: doc({ type: 'details', content: [
{ type: 'detailsSummary', content: [T('Sum')] },
{ type: 'detailsContent', content: [P(T('body'))] }] }),
primary: 'details',
},
table: {
doc: doc({ type: 'table', content: [
{ type: 'tableRow', content: [{ type: 'tableHeader', content: [P(T('H1'))] }, { type: 'tableHeader', content: [P(T('H2'))] }] },
{ type: 'tableRow', content: [{ type: 'tableCell', content: [P(T('C1'))] }, { type: 'tableCell', content: [P(T('C2'))] }] }] }),
primary: 'tableCell',
},
mathBlock: { doc: doc({ type: 'mathBlock', attrs: { math: 'x^2' } }), primary: 'mathBlock' },
mathInline: { doc: doc(P({ type: 'mathInline', attrs: { math: 'x^2' } })), primary: 'mathInline' },
mention: { doc: doc(P({ type: 'mention', attrs: { id: 'u1', label: 'Bob', entityType: 'user', entityId: 'u1' } })), primary: 'mention' },
drawio: { doc: doc({ type: 'drawio', attrs: { src: '/f/d.drawio', attachmentId: 'a1' } }), primary: 'drawio' },
excalidraw: { doc: doc({ type: 'excalidraw', attrs: { src: '/f/e.excalidraw', attachmentId: 'a1' } }), primary: 'excalidraw' },
embed: { doc: doc({ type: 'embed', attrs: { src: 'https://youtube.com/x', provider: 'iframe' } }), primary: 'embed' },
pdf: { doc: doc({ type: 'pdf', attrs: { src: '/f/x.pdf', attachmentId: 'a1' } }), primary: 'pdf' },
video: { doc: doc({ type: 'video', attrs: { src: '/f/v.mp4', width: '640' } }), primary: 'video' },
audio: { doc: doc({ type: 'audio', attrs: { src: '/f/a.mp3' } }), primary: 'audio' },
attachment: { doc: doc({ type: 'attachment', attrs: { url: '/f/x.zip', name: 'x.zip', attachmentId: 'a1' } }), primary: 'attachment' },
youtube: { doc: doc({ type: 'youtube', attrs: { src: 'https://youtube.com/watch?v=x' } }), primary: 'youtube' },
subpages: { doc: doc({ type: 'subpages' }), primary: 'subpages' },
pageBreak: { doc: doc({ type: 'pageBreak' }), primary: 'pageBreak' },
htmlEmbed: { doc: doc({ type: 'htmlEmbed', attrs: { source: '<b>hi</b>' } }), primary: 'htmlEmbed' },
pageEmbed: { doc: doc({ type: 'pageEmbed', attrs: { pageId: 'p1' } }), primary: 'pageEmbed' },
// transclusionSource: the schema reads `id` (NOT `pageId`) and its content is
// `+` (at least one block child), so give it both or it never re-parses.
transclusion: { doc: doc({ type: 'transclusionSource', attrs: { id: 't1' }, content: [P(T('shared'))] }), primary: 'transclusionSource' },
transclusionReference: { doc: doc({ type: 'transclusionReference', attrs: { sourcePageId: 'p1', transclusionId: 't1' } }), primary: 'transclusionReference' },
footnote: {
doc: doc(
P(T('x'), { type: 'footnoteReference', attrs: { id: 'fn1' } }),
{ type: 'footnotesList', content: [{ type: 'footnoteDefinition', attrs: { id: 'fn1' }, content: [P(T('note'))] }] }),
primary: 'footnoteReference',
},
status: { doc: doc(P({ type: 'status', attrs: { text: 'Done', color: 'green' } })), primary: 'status' },
// marks
bold: { doc: doc(P(T('b', [{ type: 'bold' }]))), primary: 'bold' },
italic: { doc: doc(P(T('i', [{ type: 'italic' }]))), primary: 'italic' },
strike: { doc: doc(P(T('s', [{ type: 'strike' }]))), primary: 'strike' },
code: { doc: doc(P(T('c', [{ type: 'code' }]))), primary: 'code' },
underline: { doc: doc(P(T('u', [{ type: 'underline' }]))), primary: 'underline' },
superscript: { doc: doc(P(T('x', [{ type: 'superscript' }]))), primary: 'superscript' },
subscript: { doc: doc(P(T('x', [{ type: 'subscript' }]))), primary: 'subscript' },
highlight: { doc: doc(P(T('h', [{ type: 'highlight', attrs: { color: 'yellow' } }]))), primary: 'highlight' },
link: { doc: doc(P(T('l', [{ type: 'link', attrs: { href: 'https://x.com' } }]))), primary: 'link' },
};
function collectTypes(n: any, set = new Set<string>()): Set<string> {
if (!n || typeof n !== 'object') return set;
if (n.type) set.add(n.type);
if (Array.isArray(n.content)) n.content.forEach((c: any) => collectTypes(c, set));
if (Array.isArray(n.marks)) n.marks.forEach((m: any) => m?.type && set.add(m.type));
return set;
}
describe('git-sync converter: every node/mark type survives a Markdown round trip', () => {
for (const [name, { doc: original, primary }] of Object.entries(FIXTURES)) {
it(`round-trips ${name} (keeps the ${primary} node/mark, no literal leak)`, async () => {
const md = convertProseMirrorToMarkdown(original);
// The lossy old form serialized embeds to `{{...}}` literals that never
// re-parsed; no node may export to one.
expect(md).not.toMatch(/\{\{.*\}\}/);
const back = await markdownToProseMirror(md);
const types = collectTypes(back);
expect(types.has(primary)).toBe(true);
});
}
});
// A node surviving as the right TYPE is necessary but not sufficient — its
// attributes must survive too. Each case carries a DISTINCTIVE attribute value
// (real attr names, verified against the schema) that must reappear after a
// round trip. This caught `subpages.recursive` and `details.open` being dropped.
describe('git-sync converter: node ATTRIBUTES survive a Markdown round trip', () => {
const ATTR_CASES: Array<{ name: string; doc: any; needles: string[] }> = [
{ name: 'callout type', doc: doc({ type: 'callout', attrs: { type: 'warning' }, content: [P(T('x'))] }), needles: ['warning'] },
{ name: 'image dimensions/align/attachmentId', doc: doc({ type: 'image', attrs: { src: '/f/x.png', width: '777', height: '555', align: 'right', attachmentId: 'ATT777' } }), needles: ['777', '555', 'right', 'ATT777'] },
{ name: 'subpages recursive', doc: doc({ type: 'subpages', attrs: { recursive: true } }), needles: ['"recursive":true'] },
{ name: 'details open', doc: doc({ type: 'details', attrs: { open: true }, content: [{ type: 'detailsSummary', content: [T('S')] }, { type: 'detailsContent', content: [P(T('b'))] }] }), needles: ['"open":'] },
{ name: 'mathInline formula', doc: doc(P({ type: 'mathInline', attrs: { text: 'E=mc^7' } })), needles: ['E=mc^7'] },
{ name: 'mathBlock formula', doc: doc({ type: 'mathBlock', attrs: { text: '\\sum_7' } }), needles: ['sum_7'] },
{ name: 'pageEmbed sourcePageId', doc: doc({ type: 'pageEmbed', attrs: { sourcePageId: 'PAGE777' } }), needles: ['PAGE777'] },
{ name: 'video dimensions/attachmentId', doc: doc({ type: 'video', attrs: { src: '/f/v.mp4', width: '888', attachmentId: 'VID888' } }), needles: ['888', 'VID888'] },
{ name: 'status text/color', doc: doc(P({ type: 'status', attrs: { text: 'InProgress777', color: 'orange' } })), needles: ['InProgress777', 'orange'] },
{ name: 'mention entityId/label', doc: doc(P({ type: 'mention', attrs: { id: 'M1', label: 'Alice', entityType: 'user', entityId: 'ENT777' } })), needles: ['Alice', 'ENT777'] },
{ name: 'columns widths', doc: doc({ type: 'columns', content: [{ type: 'column', attrs: { width: '37%' }, content: [P(T('L'))] }, { type: 'column', attrs: { width: '63%' }, content: [P(T('R'))] }] }), needles: ['37%', '63%'] },
{ name: 'highlight color', doc: doc(P(T('x', [{ type: 'highlight', attrs: { color: '#abcdef' } }]))), needles: ['#abcdef'] },
];
for (const { name, doc: original, needles } of ATTR_CASES) {
it(`preserves ${name}`, async () => {
const md = convertProseMirrorToMarkdown(original);
const back = JSON.stringify(await markdownToProseMirror(md));
for (const needle of needles) {
// The value must survive in the re-imported doc (or in the markdown the
// schema parses it back from).
expect(`${back} ${md}`).toContain(needle);
}
});
}
});
// Find the FIRST node of a given type anywhere in a ProseMirror tree (depth
// first). Used by the structural round-trip assertions below that need the
// re-imported node's concrete attrs/content, not just "the type is present".
function findNode(n: any, type: string): any {
if (!n || typeof n !== 'object') return undefined;
if (n.type === type) return n;
if (Array.isArray(n.content)) {
for (const c of n.content) {
const hit = findNode(c, type);
if (hit) return hit;
}
}
return undefined;
}
// Collect every text run reachable under a node (concatenated). Lets a test
// assert a footnote definition's note BODY survived, not just the wrapper.
function allText(n: any): string {
if (!n || typeof n !== 'object') return '';
if (n.type === 'text') return n.text || '';
if (Array.isArray(n.content)) return n.content.map(allText).join('');
return '';
}
// Attributes survive as the TYPE-correct value, not just as a substring of the
// serialized blob. These re-import and assert on the concrete re-parsed node.
describe('git-sync converter: lose-prone atoms keep their VALUES across a round trip', () => {
it('A: a NESTED details (inside columns) keeps open:true', async () => {
// The raw-HTML path (detailsToHtml) is used for a details nested in a
// column/spanned cell — distinct from the top-level details case. Before the
// fix it emitted a bare <details>, dropping open every round trip.
const original = doc({
type: 'columns',
content: [
{
type: 'column',
attrs: { width: '100%' },
content: [
{
type: 'details',
attrs: { open: true },
content: [
{ type: 'detailsSummary', content: [T('S')] },
{ type: 'detailsContent', content: [P(T('b'))] },
],
},
],
},
],
});
const md = convertProseMirrorToMarkdown(original);
// detailsToHtml must emit the `open` attribute (RED before the fix: it
// emitted a bare <details> inside the column).
expect(md).toContain('<details open>');
const back = await markdownToProseMirror(md);
const details = findNode(back, 'details');
expect(details).toBeDefined();
// `open` must round-trip as a STRICT boolean `true` — not "" (the old raw
// getAttribute value) and not the default `false` (a dropped attribute).
// Before the schema parseHTML fix (hasAttribute), `<details open>` parsed to
// "" — falsy, so it rendered as a bare <details> and collapsed. RED before
// the fix (open was "" or false, never === true).
expect(details.attrs?.open).toBe(true);
});
it('B: a TOP-LEVEL details keeps open as strict boolean true', async () => {
const original = doc({
type: 'details',
attrs: { open: true },
content: [
{ type: 'detailsSummary', content: [T('S')] },
{ type: 'detailsContent', content: [P(T('b'))] },
],
});
const md = convertProseMirrorToMarkdown(original);
const back = await markdownToProseMirror(md);
const details = findNode(back, 'details');
expect(details).toBeDefined();
// Strict boolean, proving the value survives as `true` (not ""/false).
// RED before the fix: parseHTML returned getAttribute("open") === "".
expect(details.attrs?.open).toBe(true);
});
it('D: htmlEmbed source VALUE and height survive', async () => {
const original = doc({
type: 'htmlEmbed',
attrs: { source: '<b>hi</b>', height: 300 },
});
const md = convertProseMirrorToMarkdown(original);
const back = await markdownToProseMirror(md);
const embed = findNode(back, 'htmlEmbed');
expect(embed).toBeDefined();
// The exact raw source must decode back identically (base64 round trip).
expect(embed.attrs?.source).toBe('<b>hi</b>');
expect(embed.attrs?.height).toBe(300);
});
it('E: footnote definition BODY survives and its id matches the reference', async () => {
const original = doc(
P(T('x'), { type: 'footnoteReference', attrs: { id: 'fn1' } }),
{
type: 'footnotesList',
content: [
{
type: 'footnoteDefinition',
attrs: { id: 'fn1' },
content: [P(T('note'))],
},
],
},
);
const md = convertProseMirrorToMarkdown(original);
const back = await markdownToProseMirror(md);
const list = findNode(back, 'footnotesList');
const def = findNode(back, 'footnoteDefinition');
const ref = findNode(back, 'footnoteReference');
expect(list).toBeDefined();
expect(def).toBeDefined();
expect(ref).toBeDefined();
// The note text rode along, not just the empty wrapper.
expect(allText(def)).toContain('note');
// The reference still points at the matching definition.
expect(ref.attrs?.id).toBe(def.attrs?.id);
});
it('F: transclusionReference keeps BOTH sourcePageId and transclusionId', async () => {
const original = doc({
type: 'transclusionReference',
attrs: { sourcePageId: 'PAGE_X', transclusionId: 'TR_Y' },
});
const md = convertProseMirrorToMarkdown(original);
const back = await markdownToProseMirror(md);
const ref = findNode(back, 'transclusionReference');
expect(ref).toBeDefined();
expect(ref.attrs?.sourcePageId).toBe('PAGE_X');
expect(ref.attrs?.transclusionId).toBe('TR_Y');
});
it('F: transclusionSource keeps its id and re-parses its child body', async () => {
const original = doc({
type: 'transclusionSource',
attrs: { id: 'SRC_Z' },
content: [P(T('shared body'))],
});
const md = convertProseMirrorToMarkdown(original);
const back = await markdownToProseMirror(md);
const src = findNode(back, 'transclusionSource');
expect(src).toBeDefined();
expect(src.attrs?.id).toBe('SRC_Z');
expect(allText(src)).toContain('shared body');
});
});
@@ -0,0 +1,104 @@
import { readFile } from 'node:fs/promises';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
docsCanonicallyEqual,
} from 'docmost-client';
// Resolve fixtures relative to this test file so the test is CWD-independent.
const here = dirname(fileURLToPath(import.meta.url));
const CORPUS_DIR = join(here, 'fixtures', 'corpus');
const KNOWN_LIMITATIONS_DIR = join(here, 'fixtures', 'known-limitations');
/** Run a single document through export -> import -> export. */
async function roundTrip(doc: any) {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, md2, doc2 };
}
describe('round-trip corpus (SPEC §11)', () => {
// Discover the corpus synchronously at collection time so each fixture gets
// its own `it` with the file name in the test title.
const files = readdirSync(CORPUS_DIR)
.filter((name) => name.endsWith('.json'))
.sort();
it('has a non-empty corpus', () => {
expect(files.length).toBeGreaterThan(0);
});
for (const name of files) {
it(`${name}: markdown byte-stable AND canonically stable`, async () => {
const doc = JSON.parse(await readFile(join(CORPUS_DIR, name), 'utf8'));
const { md1, md2, doc2 } = await roundTrip(doc);
// 1) The byte-stable markdown property git actually needs.
expect(md2, `${name}: markdown not byte-stable`).toBe(md1);
// 2) Semantic stability (block ids stripped, default-null normalized).
expect(
docsCanonicallyEqual(doc, doc2),
`${name}: document not canonically stable`,
).toBe(true);
});
}
});
// ---------------------------------------------------------------------------
// KNOWN CONVERTER LIMITATIONS (isolated so they do NOT make CI red).
//
// SPEC §11 explicitly flags images and diagrams as high round-trip risk. These
// fixtures are kept OUT of the green corpus above and asserted with `it.fails`
// so the documented divergence is locked in (the test FAILS if the converter
// ever starts round-tripping them — at which point promote the fixture into
// the corpus). The precise divergences for `image-diagrams.json` are:
//
// * A BLOCK-LEVEL image preceded by a paragraph is NOT byte-stable on the
// FIRST re-export. The HTML re-parser hoists the block <img> out of its
// line and leaves an empty paragraph behind, so `paragraph` + `![..](..)`
// re-imports as paragraph + empty-paragraph + image; the empty paragraph
// adds one blank line, so export #2 grows by a one-time "\n\n" (md1 !== md2).
// This is NOT non-convergence: the growth happens exactly ONCE. The doc
// CONVERGES to a fixpoint after one extra `export→import→export` pass — the
// empty paragraph is already present after the first import, so export #2
// and export #3 are byte-identical (md2 === md3, verified).
//
// * drawio / excalidraw diagrams gain `data-align="center"` on the second
// export: the schema's diagram `align` attribute has a NON-null default of
// "center", which materializes on import; the converter only emits
// data-align when set, so it appears on export #2 but not #1. Like the
// image case, this is one-time and converges after one extra pass.
//
// * A STANDALONE block image (no preceding paragraph) IS byte-stable from
// export #1 (md1 === md2) — but it is still NOT canonically stable: on
// import the bare <img> is wrapped, gaining a leading EMPTY paragraph, so
// the canonical doc differs by that spurious paragraph node even though the
// markdown bytes match.
//
// Resolution (SPEC §11, "normalize-on-write"): rather than deep-fixing the
// converter, the engine runs ONE `export→import→export` pass when writing into
// the vault; from that fixpoint onward the form is byte-stable, so git sees no
// phantom diff. The green corpus above avoids these one-time asymmetries by
// pre-authoring the materialized defaults (e.g. `align: "center"` on the
// diagrams in 06-diagrams.json) so a single pass is already at the fixpoint.
// ---------------------------------------------------------------------------
describe('round-trip KNOWN LIMITATIONS (SPEC §11 image/diagram risk)', () => {
it.fails(
'image-diagrams.json is NOT byte-stable on export #1 (block image hoist + diagram align default; converges after one extra pass — SPEC §11 normalize-on-write)',
async () => {
const doc = JSON.parse(
await readFile(join(KNOWN_LIMITATIONS_DIR, 'image-diagrams.json'), 'utf8'),
);
const { md1, md2 } = await roundTrip(doc);
// This assertion FAILS today (documented divergence). `it.fails` turns a
// failing body into a PASS; if the converter is fixed this flips and the
// test goes red, prompting promotion into the green corpus.
expect(md2).toBe(md1);
},
);
});
@@ -0,0 +1,75 @@
/**
* Pure, IO-free comparison helpers for the idempotency round-trip checks. The
* round-trip harness that drives these lives in the package's tests, not in the
* engine.
*/
/**
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
* ignored when comparing the semantic shape of two documents. Returns a NEW
* tree; the input is not mutated.
*/
export function stripBlockIds(node: any): any {
if (Array.isArray(node)) {
return node.map(stripBlockIds);
}
if (node && typeof node === "object") {
const out: any = {};
for (const key of Object.keys(node)) {
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
// Drop the `id` attr; keep every other attribute.
const { id, ...rest } = node.attrs as Record<string, unknown>;
void id;
out.attrs = stripBlockIds(rest);
} else {
out[key] = stripBlockIds(node[key]);
}
}
return out;
}
return node;
}
/**
* Find the first divergence between two values via a recursive deep compare.
* Returns a short path + the two differing values, or null if they are equal.
*/
export function firstDivergence(
a: any,
b: any,
path = "$",
): { path: string; a: any; b: any } | null {
if (a === b) return null;
const ta = typeof a;
const tb = typeof b;
if (ta !== tb || a === null || b === null) {
return { path, a, b };
}
if (ta !== "object") {
return { path, a, b };
}
const aIsArr = Array.isArray(a);
const bIsArr = Array.isArray(b);
if (aIsArr !== bIsArr) return { path, a, b };
if (aIsArr) {
if (a.length !== b.length) {
return { path: `${path}.length`, a: a.length, b: b.length };
}
for (let i = 0; i < a.length; i++) {
const d = firstDivergence(a[i], b[i], `${path}[${i}]`);
if (d) return d;
}
return null;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const d = firstDivergence(a[k], b[k], `${path}.${k}`);
if (d) return d;
}
return null;
}
@@ -0,0 +1,168 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
} from 'docmost-client';
// Import canonical-equality DIRECTLY from src so we exercise the real
// implementation alongside the converter pair above (the barrel re-exports the
// same symbol; importing from src keeps these round-trip assertions pinned to
// the package source rather than the published surface).
import { docsCanonicallyEqual } from '../src/lib/canonicalize.js';
// Resolve the fixture relative to this test file so the test is CWD-independent.
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = join(here, 'fixtures', 'sample-doc.json');
describe('round-trip idempotency (SPEC §11)', () => {
it('markdown is byte-stable across export -> import -> export', async () => {
const doc = JSON.parse(await readFile(FIXTURE, 'utf8'));
// export -> import -> export
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// The property git actually needs: a second export reproduces the first
// byte-for-byte. We intentionally do NOT deep-equal doc vs doc2 — the
// converter reconstructs schema default attrs (e.g. indent:null), a known
// SPEC §11 divergence that does not affect markdown stability.
expect(md2).toBe(md1);
});
});
// ---------------------------------------------------------------------------
// Full export -> import -> export round-trips for the schema's HTML-carried
// atoms/blocks (math, mention, details). The existing markdown-converter unit
// tests only assert the one-way emit string; here we additionally pin that the
// re-import (generateJSON via the docmost schema) rebuilds the correct node and
// that a second export reproduces the first byte-for-byte. Helpers mirror the
// converter unit tests (a single-node doc renders exactly that node, trimmed).
// ---------------------------------------------------------------------------
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string) => ({ type: 'text', text: t });
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
// Run the canonical export -> import -> export cycle for a single block node.
async function roundTrip(
node: any,
): Promise<{ md1: string; doc2: any; md2: string }> {
const md1 = convertProseMirrorToMarkdown(doc(node));
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, doc2, md2 };
}
describe('math round-trip (mathBlock + mathInline)', () => {
it('mathBlock survives export -> import -> export with LaTeX recovered', async () => {
const source = { type: 'mathBlock', attrs: { text: 'a^2+b^2' } };
const { md1, doc2, md2 } = await roundTrip(source);
// One-way emit: LaTeX rides in the `text` HTML attribute, data-katex flag set.
expect(md1).toBe(
'<div data-type="mathBlock" data-katex="true" text="a^2+b^2"></div>',
);
// Byte-stable: the second export reproduces the first exactly.
expect(md2).toBe(md1);
// The re-imported doc's only block is a mathBlock whose LaTeX was recovered
// from the text= attribute by the schema's default parser.
const block = doc2.content[0];
expect(block.type).toBe('mathBlock');
expect(block.attrs.text).toBe('a^2+b^2');
// Canonical equality: source and re-imported doc are the same node.
expect(docsCanonicallyEqual(doc(source), doc2)).toBe(true);
});
it('mathInline (inside a paragraph) survives export -> import -> export', async () => {
const source = para({ type: 'mathInline', attrs: { text: 'x_i' } });
const { md1, doc2, md2 } = await roundTrip(source);
expect(md1).toBe(
'<span data-type="mathInline" data-katex="true" text="x_i"></span>',
);
expect(md2).toBe(md1);
// The re-imported paragraph's child is a mathInline with the LaTeX recovered.
const paragraph = doc2.content[0];
expect(paragraph.type).toBe('paragraph');
const inline = paragraph.content[0];
expect(inline.type).toBe('mathInline');
expect(inline.attrs.text).toBe('x_i');
expect(docsCanonicallyEqual(doc(source), doc2)).toBe(true);
});
});
describe('mention round-trip', () => {
it('mention survives export -> import -> export with data-* re-parsed', async () => {
const source = para({
type: 'mention',
attrs: { id: 'u1', label: 'Alice', entityType: 'user' },
});
const { md1, doc2, md2 } = await roundTrip(source);
// One-way emit: schema span with data-* attrs and the visible '@Alice' text.
expect(md1).toBe(
'<span data-type="mention" data-id="u1" data-label="Alice" data-entity-type="user">@Alice</span>',
);
// Byte-stable.
expect(md2).toBe(md1);
// The visible '@Alice' is cosmetic; generateJSON rebuilds a mention node from
// the data-* attributes. The unset attrs fall back to their schema defaults.
const paragraph = doc2.content[0];
expect(paragraph.type).toBe('paragraph');
const mention = paragraph.content[0];
expect(mention.type).toBe('mention');
expect(mention.attrs.id).toBe('u1');
expect(mention.attrs.label).toBe('Alice');
expect(mention.attrs.entityType).toBe('user');
expect(mention.attrs.entityId).toBeNull();
expect(mention.attrs.slugId).toBeNull();
expect(mention.attrs.creatorId).toBeNull();
expect(mention.attrs.anchorId).toBeNull();
expect(docsCanonicallyEqual(doc(source), doc2)).toBe(true);
});
});
describe('details open-attribute round-trip', () => {
it('the markdown details fence never carries an open flag and stays byte-stable', async () => {
// Source details is OPEN (attrs.open: ''), but the top-level markdown path
// emits a plain '<details>' fence (no 'open' attribute) — see converter
// case "detailsSummary" which hardcodes '<details>\n<summary>...'.
const source = {
type: 'details',
attrs: { open: '' },
content: [
{ type: 'detailsSummary', content: [text('S')] },
{ type: 'detailsContent', content: [para(text('body'))] },
],
};
const { md1, doc2, md2 } = await roundTrip(source);
// The emitted fence drops the open flag entirely.
expect(md1).toBe('<details>\n<summary>S</summary>\n\nbody\n</details>');
expect(md1).not.toContain('open');
// Byte-stable: re-export reproduces the same fence.
expect(md2).toBe(md1);
// NOTE(review): the spec text says doc2's details attrs.open should be
// `null` (the raw return of el.getAttribute('open') on a plain <details>,
// schema src ~L438). In practice generateJSON applies the schema attribute
// default when the parseHTML result is null, so the materialised node carries
// attrs.open === false (the declared default at src ~L437), NOT null. We
// assert the ACTUAL value. The load-bearing point of the spec still holds:
// a plain <details> import does NOT recover the open flag (no truthy value),
// so renderHTML's `attrs.open ? {open:''} : {}` keeps the round-trip clean.
const details = doc2.content[0];
expect(details.type).toBe('details');
expect(details.attrs.open).toBe(false);
expect(details.attrs.open).toBeFalsy();
});
});
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { getSchema } from "@tiptap/core";
import { docmostExtensions } from "../src/lib/docmost-schema.js";
import * as editorExt from "@docmost/editor-ext";
// CROSS-PACKAGE SCHEMA CONTRACT (data-loss-sensitive).
//
// `src/lib/docmost-schema.ts` is a hand-synced VENDORED MIRROR of the canonical
// Docmost schema in `@docmost/editor-ext`. The sibling `schema-surface-snapshot`
// test pins the mirror's FULL surface (names + attrs) against an inline
// reference, but that reference is hand-curated and does not mechanically tie to
// editor-ext. This test closes that gap from the other side: it reads the ACTUAL
// Tiptap node/mark definitions exported by `@docmost/editor-ext` and asserts the
// vendored mirror is a SUPERSET of their type NAMES — so a Docmost-specific node
// or mark added upstream that the mirror forgets to vendor fails CI loudly
// (otherwise it is silently dropped on the markdown <-> ProseMirror round-trip).
//
// LIMITATION (intentional, see schema-surface-snapshot.test.ts): this is a
// NAME-LEVEL contract only, not a full attribute-level structural compare.
// editor-ext's Tiptap representation (node views, commands, suggestion plugins,
// addGlobalAttributes spread across separate extensions) differs from this
// minimal mirror, so a mechanical attribute-by-attribute equality would be
// fragile and produce false drift. Attribute parity is guarded by the inline
// surface snapshot (reviewed in every diff); this test guards that no canonical
// node/mark TYPE goes unmirrored. StarterKit-provided types (paragraph, bold,
// heading, …) are contributed by @tiptap/starter-kit in the mirror rather than
// by editor-ext, so they are naturally covered by the mirror's superset.
//
// NOT COVERED here (deferred): (1) the THIRD copy in `packages/mcp` — a separate
// package guarded by its own surface snapshot; (2) attribute *behaviour* drift,
// e.g. the details `open` attr read via getAttribute vs hasAttribute (PR #119
// review #2) — a name-level compare cannot see parseHTML/renderHTML differences.
// Mechanically guarding behavioural parity across all THREE copies needs the
// single framework-free "schema core" refactor (deferred — see AGENTS.md); until
// then each copy's header carries the manual keep-in-sync requirement.
/** Tiptap Node/Mark instances expose a `.name` and a `.type` of 'node'|'mark'. */
function isTiptapNodeOrMark(
value: unknown,
): value is { name: string; type: "node" | "mark" } {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof (value as { name: unknown }).name === "string" &&
"type" in value &&
((value as { type: unknown }).type === "node" ||
(value as { type: unknown }).type === "mark")
);
}
/** The set of node/mark type names the vendored mirror actually registers. */
function vendoredNames(): Set<string> {
const schema = getSchema(docmostExtensions as never);
return new Set([
...Object.keys(schema.nodes),
...Object.keys(schema.marks),
]);
}
/** The Docmost-specific node/mark type names exported by @docmost/editor-ext. */
function editorExtNames(): Set<string> {
const names = new Set<string>();
for (const value of Object.values(editorExt)) {
if (isTiptapNodeOrMark(value)) names.add(value.name);
}
return names;
}
describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => {
it("exposes Tiptap node/mark definitions from editor-ext (guards against the import going dark)", () => {
// If editor-ext ever stops exporting concrete node/mark objects (e.g. a
// barrel refactor), this contract would vacuously pass — assert it found a
// meaningful set so the test cannot silently become a no-op.
expect(editorExtNames().size).toBeGreaterThan(5);
});
it("vendors every Docmost-specific node/mark type defined in editor-ext (no silently-dropped types)", () => {
const vendored = vendoredNames();
const missing = [...editorExtNames()].filter((n) => !vendored.has(n)).sort();
// missing must be empty: any name here exists in editor-ext but NOT in the
// vendored mirror, so documents using it would lose that node/mark on a
// git-sync round-trip. Re-sync src/lib/docmost-schema.ts before clearing.
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { getSchema } from "@tiptap/core";
import { docmostExtensions } from "../src/lib/docmost-schema.js";
// SCHEMA-DRIFT GUARD (must-review gate).
//
// `src/lib/docmost-schema.ts` is a VENDORED MIRROR of the canonical Docmost
// document schema defined in `@docmost/editor-ext`. git-sync uses it to convert
// pages to/from ProseMirror JSON; any node, mark, or attribute that exists in
// the canonical schema but is missing here is silently dropped on a round-trip
// (data loss). The reverse — a node/mark/attr here that no longer exists in the
// canonical schema — is dead surface that can mask drift.
//
// This test derives a stable, sorted "schema surface" (every node/mark name and
// its sorted attribute keys) and pins it against an INLINE expected constant.
// It is intentionally a LOUD must-review gate rather than an automatic
// editor-ext diff: editor-ext's Tiptap representation differs from this
// vendored copy, so a cross-representation compare would be fragile. We do NOT
// use toMatchSnapshot so the reference lives in this file and is reviewed in the
// diff of every change.
//
// WHEN THIS TEST FAILS: do NOT blindly update `expectedSurface`. First confirm
// the change matches `@docmost/editor-ext` (the canonical schema) so the
// markdown <-> ProseMirror round-trip stays lossless, THEN copy the new surface
// into the expected constant below.
interface SurfaceEntry {
name: string;
kind: "node" | "mark";
attrs: string[];
}
/** Derive the deterministic schema surface from the vendored extension set. */
function deriveSurface(): SurfaceEntry[] {
const schema = getSchema(docmostExtensions as never);
const surface: SurfaceEntry[] = [];
for (const [name, type] of Object.entries(schema.nodes)) {
surface.push({
name,
kind: "node",
attrs: Object.keys((type as { spec?: { attrs?: object } }).spec?.attrs ?? {}).sort(),
});
}
for (const [name, type] of Object.entries(schema.marks)) {
surface.push({
name,
kind: "mark",
attrs: Object.keys((type as { spec?: { attrs?: object } }).spec?.attrs ?? {}).sort(),
});
}
// Sort by name, then by kind, for a representation-independent ordering.
surface.sort((a, b) =>
a.name === b.name ? a.kind.localeCompare(b.kind) : a.name.localeCompare(b.name),
);
return surface;
}
// The committed reference surface. Built from the ACTUAL current schema; review
// every change to this constant against `@docmost/editor-ext`.
const expectedSurface: SurfaceEntry[] = [
{ name: "attachment", kind: "node", attrs: ["attachmentId", "mime", "name", "placeholder", "size", "url"] },
{ name: "audio", kind: "node", attrs: ["attachmentId", "placeholder", "size", "src"] },
{ name: "blockquote", kind: "node", attrs: [] },
{ name: "bold", kind: "mark", attrs: [] },
{ name: "bulletList", kind: "node", attrs: [] },
{ name: "callout", kind: "node", attrs: ["icon", "type"] },
{ name: "code", kind: "mark", attrs: [] },
{ name: "codeBlock", kind: "node", attrs: ["language"] },
{ name: "column", kind: "node", attrs: ["width"] },
{ name: "columns", kind: "node", attrs: ["layout", "widthMode"] },
{ name: "comment", kind: "mark", attrs: ["commentId", "resolved"] },
{ name: "details", kind: "node", attrs: ["open"] },
{ name: "detailsContent", kind: "node", attrs: [] },
{ name: "detailsSummary", kind: "node", attrs: [] },
{ name: "doc", kind: "node", attrs: [] },
{ name: "drawio", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "embed", kind: "node", attrs: ["align", "height", "provider", "src", "width"] },
{ name: "excalidraw", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "footnoteDefinition", kind: "node", attrs: ["id"] },
{ name: "footnoteReference", kind: "node", attrs: ["id"] },
{ name: "footnotesList", kind: "node", attrs: [] },
{ name: "hardBreak", kind: "node", attrs: [] },
{ name: "heading", kind: "node", attrs: ["id", "indent", "level", "textAlign"] },
{ name: "highlight", kind: "mark", attrs: ["color"] },
{ name: "horizontalRule", kind: "node", attrs: [] },
{ name: "htmlEmbed", kind: "node", attrs: ["height", "source"] },
{ name: "image", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "caption", "height", "placeholder", "size", "src", "title", "width"] },
{ name: "italic", kind: "mark", attrs: [] },
{ name: "link", kind: "mark", attrs: ["class", "href", "internal", "rel", "target", "title"] },
{ name: "listItem", kind: "node", attrs: [] },
{ name: "mathBlock", kind: "node", attrs: ["text"] },
{ name: "mathInline", kind: "node", attrs: ["text"] },
{ name: "mention", kind: "node", attrs: ["anchorId", "creatorId", "entityId", "entityType", "id", "label", "slugId"] },
{ name: "orderedList", kind: "node", attrs: ["start", "type"] },
{ name: "pageBreak", kind: "node", attrs: [] },
{ name: "pageEmbed", kind: "node", attrs: ["sourcePageId"] },
{ name: "paragraph", kind: "node", attrs: ["id", "indent", "textAlign"] },
{ name: "pdf", kind: "node", attrs: ["attachmentId", "height", "name", "placeholder", "size", "src", "width"] },
{ name: "spoiler", kind: "mark", attrs: [] },
{ name: "status", kind: "node", attrs: ["color", "text"] },
{ name: "strike", kind: "mark", attrs: [] },
{ name: "subpages", kind: "node", attrs: ["recursive"] },
{ name: "subscript", kind: "mark", attrs: [] },
{ name: "superscript", kind: "mark", attrs: [] },
{ name: "table", kind: "node", attrs: [] },
{ name: "tableCell", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
{ name: "tableHeader", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
{ name: "tableRow", kind: "node", attrs: [] },
{ name: "taskItem", kind: "node", attrs: ["checked"] },
{ name: "taskList", kind: "node", attrs: [] },
{ name: "text", kind: "node", attrs: [] },
{ name: "textStyle", kind: "mark", attrs: ["color"] },
{ name: "transclusionReference", kind: "node", attrs: ["sourcePageId", "transclusionId"] },
{ name: "transclusionSource", kind: "node", attrs: ["id"] },
{ name: "underline", kind: "mark", attrs: [] },
{ name: "video", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "placeholder", "size", "src", "width"] },
{ name: "youtube", kind: "node", attrs: ["align", "height", "src", "width"] },
];
describe("docmost schema surface", () => {
it("matches the committed reference surface (re-verify against @docmost/editor-ext on change)", () => {
expect(deriveSurface()).toEqual(expectedSurface);
});
});
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/**/*"]
}
@@ -0,0 +1,15 @@
{
// Test-infra tsconfig used ONLY by vitest's `test.typecheck` pass (Finding #1).
// The build tsconfig (`tsconfig.json`) scopes the compiler to `src/**` with
// `rootDir: ./src`, so it never type-checks the `test/` tree. This config
// inherits the same strict compiler options but widens the file set to the
// type-test files so `vitest run` can run `tsc` over them. It is NOT used by
// `npm run build` (that still uses `tsconfig.json`), so it has no effect on the
// shipped output.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.test-d.ts", "src/**/*"]
}
@@ -0,0 +1,40 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
// Ported docmost-sync tests import the converter through the upstream package
// barrel specifier `docmost-client`. We vendored only the PURE half of that
// package into `src/lib`, so alias the barrel specifier to our local lib
// barrel; everything those tests use (converter, canonicalize, markdown
// envelope, markdownToProseMirror) is re-exported there.
const here = path.dirname(fileURLToPath(import.meta.url));
const libBarrel = path.resolve(here, 'src/lib/index.ts');
export default defineConfig({
resolve: {
alias: {
'docmost-client': libBarrel,
},
},
test: {
environment: 'node',
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
// below instead — so the 35 runtime suites are never typechecked.
include: ['test/**/*.test.ts'],
// Type-level contract enforcement (Finding #1). Vitest runs `tsc` over the
// `.test-d.ts` files so the `expectTypeOf`/`@ts-expect-error` guards in
// git-sync-client.contract.test-d.ts become REAL build-time assertions: a
// drift in the GitSyncClient result shapes makes `npx vitest run` FAIL with
// a type error. Scoped to `*.test-d.ts` so the runtime suites stay
// untouched, and pointed at the package tsconfig for the strict options.
typecheck: {
enabled: true,
include: ['test/**/*.test-d.ts'],
// A dedicated test-infra tsconfig (NOT the build one) that widens the file
// set to include `test/**` — the build tsconfig scopes `tsc` to `src/**`
// (rootDir ./src), so without this the type-test file is never checked.
tsconfig: './tsconfig.vitest.json',
},
},
});
+61
View File
@@ -1030,6 +1030,67 @@ importers:
specifier: ^5.0.0
version: 5.9.3
packages/prosemirror-markdown:
dependencies:
'@tiptap/core':
specifier: 3.20.4
version: 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-highlight':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-image':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-subscript':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-superscript':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-task-item':
specifier: 3.20.4
version: 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-task-list':
specifier: 3.20.4
version: 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/html':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(happy-dom@20.8.9)
'@tiptap/pm':
specifier: 3.20.4
version: 3.20.4
'@tiptap/starter-kit':
specifier: 3.20.4
version: 3.20.4
jsdom:
specifier: 25.0.0
version: 25.0.0
marked:
specifier: 17.0.5
version: 17.0.5
zod:
specifier: 4.3.6
version: 4.3.6
devDependencies:
'@docmost/editor-ext':
specifier: workspace:*
version: link:../editor-ext
'@types/jsdom':
specifier: ^21.1.7
version: 21.1.7
'@types/node':
specifier: ^20.0.0
version: 20.19.43
fast-check:
specifier: ^4.8.0
version: 4.8.0
typescript:
specifier: ^5.0.0
version: 5.9.3
vitest:
specifier: 4.1.6
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
packages:
'@aashutoshrathi/word-wrap@1.2.6':