/** * 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">