From d6d7dd82f6458df2c0c647f9e378d98601cc93c3 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 4 Jul 2026 07:10:04 +0300 Subject: [PATCH 01/17] feat(prosemirror-markdown): new headless converter package seeded from git-sync (#293 stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 + packages/prosemirror-markdown/package.json | 45 + packages/prosemirror-markdown/src/index.ts | 9 + .../src/lib/canonicalize.ts | 247 +++ .../src/lib/docmost-schema.ts | 1544 +++++++++++++++++ .../prosemirror-markdown/src/lib/index.ts | 26 + .../src/lib/markdown-converter.ts | 1130 ++++++++++++ .../src/lib/markdown-document.ts | 154 ++ .../src/lib/markdown-to-prosemirror.ts | 365 ++++ .../prosemirror-markdown/src/lib/node-ops.ts | 897 ++++++++++ .../prosemirror-markdown/src/lib/page-file.ts | 81 + .../test/canonicalize-extra.test.ts | 205 +++ .../test/canonicalize.test.ts | 302 ++++ .../test/diagram-roundtrip.test.ts | 109 ++ .../test/docmost-schema-attrs.test.ts | 124 ++ .../corpus/01-headings-paragraphs.json | 36 + .../test/fixtures/corpus/02-inline-marks.json | 62 + .../test/fixtures/corpus/03-lists.json | 113 ++ .../test/fixtures/corpus/04-blocks.json | 38 + .../test/fixtures/corpus/05-table.json | 85 + .../test/fixtures/corpus/06-diagrams.json | 17 + .../fixtures/corpus/07-textstyle-mention.json | 35 + .../test/fixtures/corpus/08-details.json | 15 + .../test/fixtures/corpus/09-columns.json | 17 + .../corpus/10-mention-in-heading.json | 13 + .../known-limitations/image-diagrams.json | 21 + .../test/fixtures/sample-doc.json | 151 ++ .../test/markdown-converter-gaps.test.ts | 845 +++++++++ .../test/markdown-converter-golden.test.ts | 402 +++++ .../markdown-converter-html-marks.test.ts | 223 +++ .../test/markdown-converter.test.ts | 645 +++++++ .../test/markdown-document-envelope.test.ts | 218 +++ .../test/markdown-document.test.ts | 66 + ...markdown-roundtrip-spoiler-caption.test.ts | 129 ++ .../test/markdown-roundtrip.property.test.ts | 698 ++++++++ .../test/markdown-to-prosemirror-gaps.test.ts | 535 ++++++ .../test/media-roundtrip.test.ts | 275 +++ .../test/node-ops-extra.test.ts | 268 +++ .../test/node-ops.test.ts | 908 ++++++++++ .../test/page-file.test.ts | 33 + .../test/redteam-converter.test.ts | 89 + .../test/roundtrip-all-nodes.test.ts | 297 ++++ .../test/roundtrip-corpus.test.ts | 104 ++ .../test/roundtrip-helpers.ts | 75 + .../test/roundtrip.test.ts | 168 ++ .../test/schema-editor-ext-contract.test.ts | 87 + .../test/schema-surface-snapshot.test.ts | 125 ++ packages/prosemirror-markdown/tsconfig.json | 15 + .../prosemirror-markdown/tsconfig.vitest.json | 15 + .../prosemirror-markdown/vitest.config.ts | 40 + pnpm-lock.yaml | 61 + 51 files changed, 12166 insertions(+) create mode 100644 packages/prosemirror-markdown/package.json create mode 100644 packages/prosemirror-markdown/src/index.ts create mode 100644 packages/prosemirror-markdown/src/lib/canonicalize.ts create mode 100644 packages/prosemirror-markdown/src/lib/docmost-schema.ts create mode 100644 packages/prosemirror-markdown/src/lib/index.ts create mode 100644 packages/prosemirror-markdown/src/lib/markdown-converter.ts create mode 100644 packages/prosemirror-markdown/src/lib/markdown-document.ts create mode 100644 packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts create mode 100644 packages/prosemirror-markdown/src/lib/node-ops.ts create mode 100644 packages/prosemirror-markdown/src/lib/page-file.ts create mode 100644 packages/prosemirror-markdown/test/canonicalize-extra.test.ts create mode 100644 packages/prosemirror-markdown/test/canonicalize.test.ts create mode 100644 packages/prosemirror-markdown/test/diagram-roundtrip.test.ts create mode 100644 packages/prosemirror-markdown/test/docmost-schema-attrs.test.ts create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/01-headings-paragraphs.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/02-inline-marks.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/03-lists.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/04-blocks.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/05-table.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/06-diagrams.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/07-textstyle-mention.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/08-details.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/09-columns.json create mode 100644 packages/prosemirror-markdown/test/fixtures/corpus/10-mention-in-heading.json create mode 100644 packages/prosemirror-markdown/test/fixtures/known-limitations/image-diagrams.json create mode 100644 packages/prosemirror-markdown/test/fixtures/sample-doc.json create mode 100644 packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-converter-golden.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-converter-html-marks.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-converter.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-document-envelope.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-document.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-roundtrip-spoiler-caption.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts create mode 100644 packages/prosemirror-markdown/test/markdown-to-prosemirror-gaps.test.ts create mode 100644 packages/prosemirror-markdown/test/media-roundtrip.test.ts create mode 100644 packages/prosemirror-markdown/test/node-ops-extra.test.ts create mode 100644 packages/prosemirror-markdown/test/node-ops.test.ts create mode 100644 packages/prosemirror-markdown/test/page-file.test.ts create mode 100644 packages/prosemirror-markdown/test/redteam-converter.test.ts create mode 100644 packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts create mode 100644 packages/prosemirror-markdown/test/roundtrip-corpus.test.ts create mode 100644 packages/prosemirror-markdown/test/roundtrip-helpers.ts create mode 100644 packages/prosemirror-markdown/test/roundtrip.test.ts create mode 100644 packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts create mode 100644 packages/prosemirror-markdown/test/schema-surface-snapshot.test.ts create mode 100644 packages/prosemirror-markdown/tsconfig.json create mode 100644 packages/prosemirror-markdown/tsconfig.vitest.json create mode 100644 packages/prosemirror-markdown/vitest.config.ts diff --git a/.gitignore b/.gitignore index bbc6abc1..2619e48e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/prosemirror-markdown/package.json b/packages/prosemirror-markdown/package.json new file mode 100644 index 00000000..9f0bd2e3 --- /dev/null +++ b/packages/prosemirror-markdown/package.json @@ -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" + } +} diff --git a/packages/prosemirror-markdown/src/index.ts b/packages/prosemirror-markdown/src/index.ts new file mode 100644 index 00000000..fe94a762 --- /dev/null +++ b/packages/prosemirror-markdown/src/index.ts @@ -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"; diff --git a/packages/prosemirror-markdown/src/lib/canonicalize.ts b/packages/prosemirror-markdown/src/lib/canonicalize.ts new file mode 100644 index 00000000..99ff5bc6 --- /dev/null +++ b/packages/prosemirror-markdown/src/lib/canonicalize.ts @@ -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> = { + // 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, + dropId: boolean, + type: string | undefined, +): Record | undefined { + const defaults = type ? KNOWN_DEFAULTS[type] : undefined; + const out: Record = {}; + // 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 = {}; + 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, + 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 = {}; + for (const key of Object.keys(mark)) { + if (key === "attrs" && mark.attrs && typeof mark.attrs === "object") { + const canon = canonicalizeAttrs( + mark.attrs as Record, + 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)); +} diff --git a/packages/prosemirror-markdown/src/lib/docmost-schema.ts b/packages/prosemirror-markdown/src/lib/docmost-schema.ts new file mode 100644 index 00000000..276efe90 --- /dev/null +++ b/packages/prosemirror-markdown/src/lib/docmost-schema.ts @@ -0,0 +1,1544 @@ +/** + * Full TipTap extension set matching the real Docmost document schema. + * + * The default StarterKit-only schema silently destroys Docmost-specific + * nodes (callout, table) and drops attributes it does not know about + * (node ids, image sizing, link targets). Every code path that converts + * to or from ProseMirror JSON must use THIS set, otherwise a round-trip + * loses content. + * + * PROVENANCE / KEEP IN SYNC: this file is a VENDORED MIRROR of the canonical + * Docmost document schema in `@docmost/editor-ext`. The node/mark/attribute + * surface MUST be kept in sync with editor-ext — anything present there but + * missing here is silently dropped on a round-trip (data loss). The exported + * `docmostExtensions` surface is guarded by `test/schema-surface-snapshot.test.ts`, + * which fails loudly on any drift; when it does, re-verify parity against + * `@docmost/editor-ext` before updating the snapshot. + */ +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import Highlight from "@tiptap/extension-highlight"; +import Subscript from "@tiptap/extension-subscript"; +import Superscript from "@tiptap/extension-superscript"; +import { Node, Extension, Mark } from "@tiptap/core"; + +// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this +// package can stay on the same @tiptap/core version as the editor and avoid a +// duplicate-tiptap version split in the monorepo. Reads a single declaration +// from an element's inline `style` attribute, last-wins, case-insensitive. +function getStyleProperty(element: HTMLElement, propertyName: string): string | null { + const styleAttr = element.getAttribute("style"); + if (!styleAttr) { + return null; + } + const decls = styleAttr.split(";").map((decl) => decl.trim()).filter(Boolean); + const target = propertyName.toLowerCase(); + for (let i = decls.length - 1; i >= 0; i -= 1) { + const decl = decls[i]; + const colonIndex = decl.indexOf(":"); + if (colonIndex === -1) { + continue; + } + const prop = decl.slice(0, colonIndex).trim().toLowerCase(); + if (prop === target) { + return decl.slice(colonIndex + 1).trim(); + } + } + return null; +} + +/** + * Allowed Docmost callout types; anything else falls back to "info". + * + * This MUST stay in lockstep with the editor's canonical set + * (`getValidCalloutType` in `@docmost/editor-ext` callout/utils.ts: + * default | info | note | success | warning | danger). A type missing here is + * silently flattened to "info" on the markdown -> ProseMirror round-trip, so a + * `[!note]` / `[!default]` callout authored in the editor would come back as + * `[!info]` after a git sync (the QA "callout type -> [!info]" fidelity loss). + * `note` and `default` were previously absent and so were being flattened. + * + * The editor SCHEMA genuinely only supports these six banner types — there is no + * `tip`/`caution`/`important`/`question` callout node. So those are NOT first- + * class types we can round-trip literally; they are INPUT ALIASES (GitHub/Obsidian + * alert syntax). The editor's own paste/import path maps them onto the supported + * set (see `GITHUB_ALERT_TYPE_MAP` in + * `@docmost/editor-ext` markdown/utils/github-callout.marked.ts: + * tip -> success, caution -> danger, important -> info). We mirror that aliasing + * here so an ingested `> [!tip]` / `> [!caution]` lands on the closest real banner + * (success / danger) instead of flatly collapsing to `info` — matching exactly how + * the editor itself would interpret the same alias. A schema type always maps to + * itself first (idempotent round-trip); the alias map only rewrites NON-schema + * names; anything still unknown falls back to `info`. + */ +const CALLOUT_TYPES = ["default", "info", "note", "success", "warning", "danger"]; +/** + * NON-schema callout aliases -> their closest supported banner. Mirrors the + * editor's `GITHUB_ALERT_TYPE_MAP` for the names that are NOT already schema + * types (a schema type is preserved as-is and never consulted here). Keeping + * these in lockstep means git-sync ingest and an editor paste interpret the same + * `> [!alias]` identically. + */ +const CALLOUT_TYPE_ALIASES: Record = { + tip: "success", + caution: "danger", + important: "info", +}; +export const clampCalloutType = (value: string | null | undefined): string => { + if (!value) return "info"; + const lower = value.toLowerCase(); + // A real schema type round-trips to itself (idempotent). + if (CALLOUT_TYPES.includes(lower)) return lower; + // A known GitHub/Obsidian alias maps to the editor's closest banner. + if (CALLOUT_TYPE_ALIASES[lower]) return CALLOUT_TYPE_ALIASES[lower]; + // Anything else is collapsed to the safe default (matches the editor). + return "info"; +}; + +/** + * Allowlist guard for CSS color values imported from HTML. + * + * Docmost interpolates stored mark colors straight into an inline style + * attribute (e.g. style="background-color: ${color}" / "color: ${color}"). + * An unsanitized value such as `red; --x: url(...)` or `red">