diff --git a/AGENTS.md b/AGENTS.md
index 064c3ccd..642b927f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -334,7 +334,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
-| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
+| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked; jsdom (Node only) | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, `apps/server` (server-side markdown import/export, #345), AND `apps/client` (markdown paste/copy + AI-chat render, via the `browser` entry — native `DOMParser`, no jsdom in the client bundle, #347); there is exactly ONE copy of the converter now |
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
@@ -460,7 +460,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
### Client structure
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
-- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
+- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b44878a..795c7502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -270,6 +270,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Client markdown paste/copy and AI-chat rendering now go through the canonical
+ converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
+ generator, and the AI-chat markdown renderer all now use
+ `@docmost/prosemirror-markdown` (via its new `browser` entry — native
+ `DOMParser`, no jsdom in the client bundle) instead of the hand-written
+ `marked`/`turndown` markdown layer in `editor-ext`, which was **deleted**. As a
+ result, pasting canonical markdown (`^[…]` footnotes, ``,
+ `> [!type]` callouts, `$…$` math, `==…==` highlight, standalone ``
+ comments) now produces the SAME nodes the server import produces for the same
+ text. Chat/reasoning markdown now renders through the editor schema (list items
+ are wrapped in `
`; CSS keeps them tight). (#347)
+
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
a page "Shared to web" now defaults to the page alone; descendant pages become
public only when you explicitly turn on the dedicated "Include sub-pages"
diff --git a/apps/client/package.json b/apps/client/package.json
index ba042fd2..a9fb48f7 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -21,6 +21,7 @@
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
+ "@docmost/prosemirror-markdown": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
diff --git a/apps/client/src/features/ai-chat/components/ai-chat.module.css b/apps/client/src/features/ai-chat/components/ai-chat.module.css
index 7b99178c..fa9e4f10 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat.module.css
+++ b/apps/client/src/features/ai-chat/components/ai-chat.module.css
@@ -55,6 +55,15 @@
padding-inline-start: 1.4em;
}
+/* The canonical converter renders list items through the editor schema, which
+ wraps each item's content in a
(listItem content is `paragraph+`). Drop
+ that paragraph's block margin so list items render TIGHT (no extra vertical
+ gap), matching the previous marked output — same rule already applied to
+ table cells above (issue #347). */
+.markdown li p {
+ margin: 0;
+}
+
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
wide LLM table must scroll horizontally instead of collapsing its columns:
`.markdown` sets `word-break: break-word`, which (with the default table
@@ -172,6 +181,14 @@
margin: 0 0 4px;
}
+/* Same as `.markdown li p` above: the canonical converter wraps every list
+ item's content in a
, so without this each reasoning-panel list item would
+ pick up `.reasoningText p`'s 4px bottom margin and render too loose. Drop it
+ so Reasoning-panel lists stay tight, mirroring the pre-#347 marked output. */
+.reasoningText li p {
+ margin: 0;
+}
+
.inputWrapper {
flex: 0 0 auto;
padding-top: var(--mantine-spacing-xs);
diff --git a/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts
index d61315dd..0ed68095 100644
--- a/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts
+++ b/apps/client/src/features/ai-chat/utils/collapse-blank-lines.test.ts
@@ -33,29 +33,44 @@ describe("collapseBlankLines", () => {
});
});
-describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
- it("renders a blank-line-separated list as a TIGHT list (no
)", () => {
+describe("collapseBlankLines + renderChatMarkdown (canonical converter)", () => {
+ // Chat markdown now renders through @docmost/prosemirror-markdown (issue #347):
+ // the SAME converter the editor/import use. Its list items are schema-shaped —
+ // each
's content is wrapped in a
(listItem content is `paragraph+`) —
+ // so the HTML always carries `
…
` regardless of blank-line
+ // looseness in the source (the converter has no tight/loose distinction). The
+ // visual tightness that `collapseBlankLines` used to buy is now provided by
+ // CSS (`.markdown li p { margin: 0 }`), not the HTML shape.
+ it("renders a blank-line-separated bullet list as a real
");
- // The list still parses as a list after the paragraph (not a paragraph+ ).
+ // Clean, un-namespaced HTML (DOMSerializer, not XMLSerializer) — no xmlns.
expect(html).toContain("
");
+ expect(html).not.toMatch(/
]*xmlns/);
+ // The item text is present (inside the schema's
wrapper).
+ expect(html).toContain("item one");
+ // The intro paragraph renders as its own paragraph before the list.
expect(html).toContain("
Intro paragraph.
");
});
- it("renders an ordered list (1. 2.) as tight after collapsing", () => {
+ it("renders an ordered list (1. 2.) as a real list", () => {
const loose = "Intro.\n\n1. first\n\n2. second";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
expect(html).toContain("");
- expect(html).toContain("
without collapsing (control)", () => {
+ it("wraps list-item content in
(schema shape; tightness is CSS)", () => {
+ // The canonical converter always wraps a list item's content in a paragraph,
+ // whether or not the source had blank lines between items.
const loose = "- a\n\n- b";
expect(renderChatMarkdown(loose, {})).toContain("
");
+ // And a "tight" source produces the identical wrapping (no distinction).
+ expect(renderChatMarkdown(collapseBlankLines(loose), {})).toContain(
+ "
",
+ );
});
});
diff --git a/apps/client/src/features/ai-chat/utils/markdown.ts b/apps/client/src/features/ai-chat/utils/markdown.ts
index c48e5002..a4f0a7ed 100644
--- a/apps/client/src/features/ai-chat/utils/markdown.ts
+++ b/apps/client/src/features/ai-chat/utils/markdown.ts
@@ -1,6 +1,37 @@
-import { markdownToHtml } from "@docmost/editor-ext";
+import {
+ markdownToProseMirrorSync,
+ docmostExtensions,
+} from "@docmost/prosemirror-markdown/browser";
+import { getSchema } from "@tiptap/core";
+import { Node as PMNode, DOMSerializer } from "@tiptap/pm/model";
import DOMPurify from "dompurify";
+// The Docmost editor schema, built once. Chat markdown is rendered through the
+// SAME schema the editor/import use (issue #347), so chat output matches how the
+// page would render the same markdown.
+const chatSchema = getSchema(docmostExtensions);
+
+/**
+ * Markdown -> HTML for chat display, via the canonical converter. We serialize
+ * the ProseMirror doc with `DOMSerializer` into a real element and read its
+ * `innerHTML` (rather than `@tiptap/html`'s `generateHTML`, whose browser path
+ * uses `XMLSerializer` and stamps a `xmlns` on every block) so the markup is
+ * clean HTML. `li > p` wrapping is inherent to the schema (listItem content is
+ * `paragraph+`); the chat CSS zeroes those paragraph margins so lists still
+ * render tight.
+ */
+function markdownToChatHtml(markdown: string): string {
+ const doc = markdownToProseMirrorSync(markdown);
+ const node = PMNode.fromJSON(chatSchema, doc);
+ const div = document.createElement("div");
+ DOMSerializer.fromSchema(chatSchema).serializeFragment(
+ node.content,
+ { document },
+ div,
+ );
+ return div.innerHTML;
+}
+
export interface RenderChatMarkdownOptions {
/**
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
@@ -63,22 +94,32 @@ function neutralizeInternalLinksHook(node: Element): void {
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
- * app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
- * chat output matches the editor's markdown flavor, then sanitize with
- * DOMPurify — LLM output is untrusted, so it must never reach the DOM unsanitized.
+ * canonical converter (issue #347): markdown -> ProseMirror JSON (the SAME
+ * `markdownToProseMirrorSync` the editor paste/import path uses, so chat output
+ * matches the editor's markdown flavor) -> HTML via `markdownToChatHtml`
+ * (DOMSerializer), then sanitize with DOMPurify — LLM output is untrusted, so it
+ * must never reach the DOM unsanitized.
*
- * `markdownToHtml` can return `string | Promise` (it has async marked
- * extensions registered). In practice plain chat markdown resolves
- * synchronously, but we guard the Promise case by returning a safe empty string
- * for that branch (the caller renders the raw text fallback instead).
+ * Stays SYNCHRONOUS: both callers render inside React (a memo and a useMemo),
+ * so the whole pipeline must resolve without awaiting. The converter's sync
+ * entry makes that possible; on any conversion error we return "" so the caller
+ * falls back to raw text (the same fallback the old Promise-guard produced).
*/
export function renderChatMarkdown(
markdown: string,
options: RenderChatMarkdownOptions = {},
): string {
if (!markdown) return "";
- const html = markdownToHtml(markdown);
- if (typeof html !== "string") return "";
+ let html: string;
+ try {
+ // markdown -> canonical PM JSON -> HTML (native DOMParser in the browser;
+ // jsdom is never bundled — see @docmost/prosemirror-markdown/browser).
+ html = markdownToChatHtml(markdown);
+ } catch {
+ // Malformed/unsupported markdown must not crash the chat render; fall back
+ // to raw text (empty return -> caller shows the plain-text branch).
+ return "";
+ }
if (!options.neutralizeInternalLinks) {
// Internal chat: unchanged behavior, no hook registered.
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.paste.test.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.paste.test.ts
new file mode 100644
index 00000000..e5794aa6
--- /dev/null
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.paste.test.ts
@@ -0,0 +1,206 @@
+import { describe, it, expect } from "vitest";
+import { Editor } from "@tiptap/core";
+import { Document } from "@tiptap/extension-document";
+import { Paragraph } from "@tiptap/extension-paragraph";
+import { Text } from "@tiptap/extension-text";
+import { Bold } from "@tiptap/extension-bold";
+import { Italic } from "@tiptap/extension-italic";
+import { MarkdownClipboard } from "./markdown-clipboard";
+
+/**
+ * Integration coverage for the async `handlePaste` seam (issue #347). The paste
+ * conversion moved to `@docmost/prosemirror-markdown`'s browser entry, whose
+ * `markdownToProseMirror` is async — so `handlePaste` captures the range, claims
+ * the event (returns true), and dispatches the insert on the next microtask.
+ * These tests drive that path end to end on a minimal schema (a plain-markdown
+ * paste whose converted nodes fit paragraph/text/bold/italic), asserting the
+ * text lands with the right marks and that the raw markdown syntax is consumed
+ * (recognized as markdown, not inserted literally).
+ */
+
+function makeEditor() {
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ return new Editor({
+ element,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ Italic,
+ MarkdownClipboard.configure({ transformPastedText: true }),
+ ],
+ content: { type: "doc", content: [{ type: "paragraph" }] },
+ });
+}
+
+// Locate the markdownClipboard plugin and invoke its handlePaste directly with a
+// synthetic clipboard event (jsdom has no real paste pipeline). The plugin's
+// handlePaste closes over the extension `this`, so calling it off the plugin
+// props preserves `this.editor`/`this.options`.
+function paste(editor: Editor, text: string): boolean {
+ const view = editor.view;
+ const plugin = view.state.plugins.find(
+ (p: any) => p.props && p.spec?.key,
+ ) as any;
+ const event = {
+ clipboardData: {
+ getData: (type: string) => (type === "text/plain" ? text : ""),
+ },
+ } as unknown as ClipboardEvent;
+ // Find the specific handlePaste that belongs to the markdown clipboard plugin.
+ const md = view.state.plugins.find(
+ (p: any) => typeof p.props?.handlePaste === "function",
+ ) as any;
+ return md.props.handlePaste(view, event, view.state.selection.content());
+}
+
+// Flush the microtask queue so the async .then() dispatch runs.
+const flush = () => new Promise((r) => setTimeout(r, 0));
+
+describe("MarkdownClipboard handlePaste (async md -> PM)", () => {
+ it("converts a plain-markdown paste with bold/italic into marked text", async () => {
+ const editor = makeEditor();
+ const claimed = paste(editor, "hello **bold** and *italic*");
+ // The paste is claimed synchronously (async insert follows).
+ expect(claimed).toBe(true);
+ await flush();
+
+ const json = editor.getJSON();
+ const text = JSON.stringify(json);
+ // The raw markdown asterisks are consumed (recognized), not inserted literally.
+ expect(editor.getText()).not.toContain("**");
+ expect(editor.getText()).toContain("bold");
+ expect(editor.getText()).toContain("italic");
+ // The bold/italic marks materialized.
+ expect(text).toContain('"bold"');
+ expect(text).toContain('"italic"');
+ editor.destroy();
+ });
+
+ it("recognizes a bullet list paste as list structure (not literal '-')", async () => {
+ // A bullet list is not representable in this minimal schema, so the converter
+ // output would fail PMNode.fromJSON and the catch inserts raw text. Use a
+ // paste whose nodes DO fit the schema to assert the happy path instead: two
+ // paragraphs separated by a blank line.
+ const editor = makeEditor();
+ paste(editor, "first para\n\nsecond para");
+ await flush();
+ const json = editor.getJSON() as any;
+ const paras = (json.content || []).filter(
+ (n: any) => n.type === "paragraph",
+ );
+ // Two paragraphs materialized from the blank-line-separated markdown.
+ expect(paras.length).toBeGreaterThanOrEqual(2);
+ expect(editor.getText()).toContain("first para");
+ expect(editor.getText()).toContain("second para");
+ editor.destroy();
+ });
+
+ it("falls back to raw text when conversion yields nodes the schema lacks", async () => {
+ // `# heading` converts to a `heading` node absent from this minimal schema,
+ // so PMNode.fromJSON throws and the catch re-inserts the raw text — the user
+ // never loses their clipboard content.
+ const editor = makeEditor();
+ paste(editor, "# a heading line");
+ await flush();
+ // Content is preserved (either as heading text or literal), never dropped.
+ expect(editor.getText()).toContain("a heading line");
+ editor.destroy();
+ });
+});
+
+// The async seam captures the target range synchronously, then replaces on the
+// next microtask. If the document changed under it between capture and resolve
+// (impossible in prod — same microtask — but pinned here), BOTH the success
+// (replaceRange) and the fail-open (insertText) branches must fall back to the
+// LIVE selection rather than a stale absolute range, so neither clobbers content
+// nor throws a RangeError. We force the mid-flight change by dispatching a
+// doc-mutating transaction AFTER the synchronous claim but BEFORE flushing the
+// microtask that runs the `.then`/`.catch`.
+describe("MarkdownClipboard handlePaste — doc-changed-mid-flight guard", () => {
+ // Replace the whole doc with one paragraph of `text` (synchronous dispatch).
+ // An empty string yields an empty paragraph (a text node may not be empty).
+ function seedContent(editor: Editor, text: string) {
+ editor.commands.setContent({
+ type: "doc",
+ content: [
+ text
+ ? { type: "paragraph", content: [{ type: "text", text }] }
+ : { type: "paragraph" },
+ ],
+ });
+ }
+
+ it("success branch: mid-flight doc change routes the paste to the LIVE selection, never the stale range (clobber-proving)", async () => {
+ // The paste captures a NON-EMPTY range {1,5} (over "AAAA"). Then, before the
+ // async resolve, the doc GROWS ("MARKER" inserted at the start) and the cursor
+ // is parked at the doc END. The captured {1,5} is now stale and points INTO
+ // "MARKER". A WORKING guard replaces at the live (end) selection → MARKER is
+ // untouched. A BROKEN guard replaces the stale {1,5} → it erases the first
+ // characters of MARKER (this is what a zero-width `from==to` range could never
+ // reveal, which is why the earlier version was vacuous).
+ const editor = makeEditor();
+ seedContent(editor, "AAAABBBB");
+ editor.commands.setTextSelection({ from: 1, to: 5 }); // captured range = {1,5}
+ const claimed = paste(editor, "hello **bold**");
+ expect(claimed).toBe(true);
+
+ // Mid-flight: grow the doc and move the cursor to a KNOWN-safe end position.
+ editor.view.dispatch(editor.view.state.tr.insertText("MARKER", 1));
+ const end = editor.state.doc.content.size;
+ editor.commands.setTextSelection({ from: end, to: end });
+ await flush();
+
+ const text = editor.getText();
+ // MARKER intact only if the guard used the live selection, not the stale range.
+ expect(text).toContain("MARKER");
+ expect(text).toContain("bold");
+ expect(text).not.toContain("**");
+ editor.destroy();
+ });
+
+ it("fail-open branch: a mid-flight doc SHRINK makes the stale `to` out of bounds — the guard must avoid a RangeError (throw-proving)", async () => {
+ // The paste captures a range {1,9} over an 8-char paragraph, then the
+ // conversion FAILS (`# heading` -> a heading node the minimal schema lacks,
+ // so PMNode.fromJSON throws -> the fail-open catch runs). Before the reject,
+ // the doc is SHRUNK to an empty paragraph, so the captured `to` (9) is now far
+ // past the doc's end. A WORKING guard inserts the raw text at the live (valid)
+ // selection → "raw heading" lands. A BROKEN guard does insertText(md, 1, 9) on
+ // a size-2 doc → RangeError, so the dispatch never runs and "raw heading" is
+ // absent (the assertion reddens). A zero-width/growing-doc setup could never
+ // push `to` out of bounds, which is why the earlier version was vacuous.
+ const editor = makeEditor();
+ seedContent(editor, "AAAABBBB");
+ editor.commands.setTextSelection({ from: 1, to: 9 }); // captured range = {1,9}
+ paste(editor, "# raw heading");
+
+ // Mid-flight: shrink the doc so the captured `to` = 9 is now out of bounds.
+ seedContent(editor, "");
+ await flush();
+
+ const text = editor.getText();
+ // Raw text lands (via the live selection) only if the guard avoided the
+ // stale, now-out-of-bounds range.
+ expect(text).toContain("raw heading");
+ editor.destroy();
+ });
+
+ it("two pastes in flight: neither payload is lost (no data loss)", async () => {
+ // Prod-unreachable (two paste events are separate macrotasks, and each
+ // conversion resolves on a microtask before the next), but pinned here: when
+ // both resolve back-to-back, the second sees the changed doc and inserts at
+ // the live selection the first left — so the two payloads may INTERLEAVE, but
+ // neither is dropped. We assert no data loss, not contiguity.
+ const editor = makeEditor();
+ paste(editor, "alphaword");
+ paste(editor, "betaword");
+ await flush();
+ const text = editor.getText();
+ // Neither payload fully dropped (interleaving may split one of them).
+ expect(text).toContain("alpha");
+ expect(text).toContain("beta");
+ editor.destroy();
+ });
+});
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts
index b1ea3733..7c0d4f47 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.test.ts
@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
-import { htmlToMarkdown } from "@docmost/editor-ext";
+// Markdown conversion now goes through the canonical package's BROWSER entry
+// (issue #347): the same converter the server import/export uses, resolved via
+// the `browser` exports condition so it runs on the native `DOMParser` (the
+// client jsdom vitest env provides one) with jsdom never bundled.
+import {
+ convertProseMirrorToMarkdown,
+ markdownToProseMirrorSync,
+} from "@docmost/prosemirror-markdown/browser";
import {
normalizeTableColumnWidths,
classifyClipboardSelection,
@@ -175,10 +182,13 @@ describe("classifyClipboardSelection", () => {
// Output-level tests for the table clipboard regression: copying a table must
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
-// These exercise the actual markdown produced by htmlToMarkdown (the same
-// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
-// shape that the classifier-flag tests above do not cover.
-describe("table clipboard markdown output (htmlToMarkdown)", () => {
+// These exercise the actual markdown produced by convertProseMirrorToMarkdown —
+// the same serializer step the clipboardTextSerializer now runs (issue #347) —
+// so they pin the OUTPUT shape that the classifier-flag tests above do not cover.
+// Input is ProseMirror JSON (what the copied slice serializes to), matching the
+// clipboardTextSerializer's new call: it wraps the slice content in a synthetic
+// `doc` (and the bare-rows case in a `table`) and calls the converter.
+describe("table clipboard markdown output (convertProseMirrorToMarkdown)", () => {
// Trim each line and drop blanks so structural assertions are whitespace-robust.
function lines(md: string): string[] {
return md
@@ -188,10 +198,10 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
}
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
- // of the padding turndown emits.
+ // of the padding the serializer emits.
function isSeparatorRow(line: string): boolean {
const compact = line.replace(/\s+/g, "");
- return /^\|(?:-{3,}\|)+$/.test(compact);
+ return /^\|(?::?-{2,}:?\|)+$/.test(compact);
}
// Split a pipe-delimited row into trimmed cell values.
@@ -203,42 +213,33 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
.map((c) => c.trim());
}
- it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
- // Mirror the serializer's `wrapBareRows` branch exactly: bare
nodes are
- // wrapped in
and htmlToMarkdown(div.innerHTML) is called.
- // See markdown-clipboard.ts clipboardTextSerializer:
- // const table = document.createElement("table");
- // const tbody = document.createElement("tbody");
- // tbody.appendChild(fragment); table.appendChild(tbody);
- // div.appendChild(table);
- // return htmlToMarkdown(div.innerHTML);
- const div = document.createElement("div");
- const table = document.createElement("table");
- const tbody = document.createElement("tbody");
- for (const [c1, c2] of [
- ["a", "b"],
- ["c", "d"],
- ]) {
- const tr = document.createElement("tr");
- const td1 = document.createElement("td");
- td1.textContent = c1;
- const td2 = document.createElement("td");
- td2.textContent = c2;
- tr.appendChild(td1);
- tr.appendChild(td2);
- tbody.appendChild(tr);
- }
- table.appendChild(tbody);
- div.appendChild(table);
+ const cell = (t: string) => ({
+ type: "tableCell",
+ content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
+ });
+ const headerCell = (t: string) => ({
+ type: "tableHeader",
+ content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
+ });
+ const row = (nodes: any[]) => ({ type: "tableRow", content: nodes });
- const md = htmlToMarkdown(div.innerHTML);
+ it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
+ // Mirror the serializer's `wrapBareRows` branch: bare tableRow nodes are
+ // wrapped in a synthetic `table` and convertProseMirrorToMarkdown is called
+ // (see markdown-clipboard.ts clipboardTextSerializer).
+ const rows = [
+ row([cell("a"), cell("b")]),
+ row([cell("c"), cell("d")]),
+ ];
+ const md = convertProseMirrorToMarkdown({
+ type: "doc",
+ content: [{ type: "table", content: rows }],
+ });
const ls = lines(md);
- // Valid GFM: a header/data separator row is present (an empty header is
- // synthesized by the GFM turndown plugin for a header-less table — fine).
+ // Valid GFM: a header/data separator row is present.
expect(ls.some(isSeparatorRow)).toBe(true);
- // NOT the old broken "one value per line" shape: every line is pipe-delimited
- // and no line is a bare cell value on its own.
+ // NOT the old broken "one value per line" shape: every line is pipe-delimited.
expect(ls.every((l) => l.includes("|"))).toBe(true);
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
// The cell values land in real pipe-delimited data rows.
@@ -248,39 +249,21 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
});
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
- // Mirror the serializer's non-wrap branch: the full
node is appended
- // directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
- const div = document.createElement("div");
- const table = document.createElement("table");
-
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const h of ["Name", "Age"]) {
- const th = document.createElement("th");
- th.textContent = h;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
- table.appendChild(thead);
-
- const tbody = document.createElement("tbody");
- for (const [name, age] of [
- ["Alice", "30"],
- ["Bob", "25"],
- ]) {
- const tr = document.createElement("tr");
- const td1 = document.createElement("td");
- td1.textContent = name;
- const td2 = document.createElement("td");
- td2.textContent = age;
- tr.appendChild(td1);
- tr.appendChild(td2);
- tbody.appendChild(tr);
- }
- table.appendChild(tbody);
- div.appendChild(table);
-
- const md = htmlToMarkdown(div.innerHTML);
+ // Mirror the serializer's non-wrap branch: the full `table` node is the
+ // slice content and convertProseMirrorToMarkdown runs on it.
+ const md = convertProseMirrorToMarkdown({
+ type: "doc",
+ content: [
+ {
+ type: "table",
+ content: [
+ row([headerCell("Name"), headerCell("Age")]),
+ row([cell("Alice"), cell("30")]),
+ row([cell("Bob"), cell("25")]),
+ ],
+ },
+ ],
+ });
const ls = lines(md);
// Proper GFM structure: separator row + all rows pipe-delimited.
@@ -296,3 +279,146 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
});
});
+
+// #347 acceptance: pasting CANONICAL markdown yields the SAME nodes the server
+// import produces for the same text. The paste path calls markdownToProseMirror
+// (the package browser entry) — the identical converter the server import uses —
+// so asserting the converter (via the browser entry, on the native DOMParser)
+// recognizes each canon form pins the paste-parity guarantee. These forms were
+// NOT recognized by the old editor-ext marked layer the paste used before.
+describe("canonical markdown paste recognition (browser entry parity)", () => {
+ // Collect every node type present in a doc (recursively).
+ const collectTypes = (n: any, set = new Set()): Set => {
+ if (!n || typeof n !== "object") return set;
+ if (n.type) set.add(n.type);
+ if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
+ return set;
+ };
+ const 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;
+ };
+ const allText = (n: any): string => {
+ if (!n || typeof n !== "object") return "";
+ if (typeof n.text === "string") return n.text;
+ if (Array.isArray(n.content)) return n.content.map(allText).join("");
+ return "";
+ };
+
+ it("^[…] inline footnote -> footnoteReference + footnotesList", () => {
+ const doc = markdownToProseMirrorSync("Body^[a note here].");
+ const types = collectTypes(doc);
+ expect(types.has("footnoteReference")).toBe(true);
+ expect(types.has("footnotesList")).toBe(true);
+ expect(types.has("footnoteDefinition")).toBe(true);
+ });
+
+ it(' attached image comment -> image with align', () => {
+ const doc = markdownToProseMirrorSync(
+ ' ',
+ );
+ const img = findNode(doc, "image");
+ expect(img).toBeTruthy();
+ expect(img.attrs?.align).toBe("left");
+ expect(img.attrs?.src).toBe("/files/x.png");
+ });
+
+ it("> [!type] Obsidian callout -> callout node with type", () => {
+ const doc = markdownToProseMirrorSync("> [!warning]\n> be careful");
+ const callout = findNode(doc, "callout");
+ expect(callout).toBeTruthy();
+ expect(callout.attrs?.type).toBe("warning");
+ expect(allText(callout)).toContain("be careful");
+ });
+
+ it("$…$ inline math -> mathInline node", () => {
+ const doc = markdownToProseMirrorSync("Euler: $e^{i\\pi}+1=0$ done");
+ const math = findNode(doc, "mathInline");
+ expect(math).toBeTruthy();
+ expect(math.attrs?.text).toContain("e^{i\\pi}");
+ });
+
+ it("==…== highlight -> highlight mark", () => {
+ const doc = markdownToProseMirrorSync("A ==marked== word");
+ const marked = findNode(doc, "text");
+ // The highlighted run carries a `highlight` mark somewhere in the doc.
+ const hasHighlight = (n: any): boolean => {
+ if (!n || typeof n !== "object") return false;
+ if (
+ n.type === "text" &&
+ (n.marks || []).some((m: any) => m.type === "highlight")
+ )
+ return true;
+ return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
+ };
+ expect(marked).toBeTruthy();
+ expect(hasHighlight(doc)).toBe(true);
+ });
+
+ it(" standalone comment -> subpages node", () => {
+ const doc = markdownToProseMirrorSync("intro\n\n\n\nafter");
+ expect(collectTypes(doc).has("subpages")).toBe(true);
+ });
+});
+
+// #347 negatives: plain text carrying markdown-LIKE punctuation must NOT be
+// silently converted/mangled (currency, bare `==`, a `[^1]` reference form).
+describe("plain-text paste negatives (no phantom conversion)", () => {
+ const 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;
+ };
+ const collectTypes = (n: any, set = new Set()): Set => {
+ if (!n || typeof n !== "object") return set;
+ if (n.type) set.add(n.type);
+ if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
+ return set;
+ };
+ const allText = (n: any): string => {
+ if (!n || typeof n !== "object") return "";
+ if (typeof n.text === "string") return n.text;
+ if (Array.isArray(n.content)) return n.content.map(allText).join("");
+ return "";
+ };
+
+ it("currency `$5 and $10` is NOT turned into math", () => {
+ const doc = markdownToProseMirrorSync("It costs $5 and $10 total");
+ expect(findNode(doc, "mathInline")).toBeFalsy();
+ expect(allText(doc)).toContain("$5 and $10");
+ });
+
+ it("a lone `==` is NOT turned into a highlight", () => {
+ const doc = markdownToProseMirrorSync("compare a == b in code");
+ const hasHighlight = (n: any): boolean => {
+ if (!n || typeof n !== "object") return false;
+ if (
+ n.type === "text" &&
+ (n.marks || []).some((m: any) => m.type === "highlight")
+ )
+ return true;
+ return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
+ };
+ expect(hasHighlight(doc)).toBe(false);
+ expect(allText(doc)).toContain("== b");
+ });
+
+ it("a `[^1]` reference form (no `^[`) is NOT turned into a footnote", () => {
+ const doc = markdownToProseMirrorSync("see note [^1] for details");
+ expect(collectTypes(doc).has("footnoteReference")).toBe(false);
+ expect(allText(doc)).toContain("[^1]");
+ });
+});
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
index fa387569..92ef71de 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
@@ -1,15 +1,23 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
-import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
+import { DOMParser, DOMSerializer, Fragment, Slice, Node as PMNode } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import {
- markdownToHtml,
- htmlToMarkdown,
canonicalizeFootnotes,
FOOTNOTES_LIST_NAME,
FOOTNOTE_REFERENCE_NAME,
} from "@docmost/editor-ext";
+// Markdown <-> ProseMirror conversion now lives ONLY in the canonical
+// `@docmost/prosemirror-markdown` package (issue #347). The BROWSER entry uses
+// the native `DOMParser` for its HTML->DOM stage (jsdom stays out of the client
+// bundle) while producing the SAME nodes the server import does — so a paste of
+// canonical markdown (`^[…]`, ``, `> [!type]`, `$…$`, `==…==`,
+// standalone comments) is recognized identically to import.
+import {
+ markdownToProseMirror,
+ convertProseMirrorToMarkdown,
+} from "@docmost/prosemirror-markdown/browser";
import type { Schema } from "@tiptap/pm/model";
export const MarkdownClipboard = Extension.create({
@@ -39,25 +47,24 @@ export const MarkdownClipboard = Extension.create({
classifyClipboardSelection(topLevelNodes);
if (!asMarkdown) return null;
- const div = document.createElement("div");
- const serializer = DOMSerializer.fromSchema(this.editor.schema);
- const fragment = serializer.serializeFragment(slice.content);
-
+ // Convert the copied selection to Markdown through the canonical
+ // package (issue #347), the SAME serializer the server export uses,
+ // so a copied table/list matches the on-disk markdown form. The
+ // converter takes a ProseMirror `doc` JSON, so wrap the slice's
+ // top-level content in a synthetic doc.
+ const content = slice.content.toJSON() as any[];
if (wrapBareRows) {
- // A partial table cell-selection serializes to bare
nodes
- // (prosemirror-tables returns the whole `table` node only when the
- // entire table is selected). Bare
would be foster-parented
- // away by the HTML parser inside htmlToMarkdown, so wrap them in
- //
first for the GFM turndown rule to detect them.
- const table = document.createElement("table");
- const tbody = document.createElement("tbody");
- tbody.appendChild(fragment);
- table.appendChild(tbody);
- div.appendChild(table);
- } else {
- div.appendChild(fragment);
+ // A partial table cell-selection serializes to bare `tableRow`
+ // nodes (prosemirror-tables yields the whole `table` node only for
+ // a full-table selection). The converter's table case expects a
+ // `table` wrapper, so wrap the bare rows in one — mirroring the old
+ //
wrap that the HTML->markdown step needed.
+ return convertProseMirrorToMarkdown({
+ type: "doc",
+ content: [{ type: "table", content }],
+ });
}
- return htmlToMarkdown(div.innerHTML);
+ return convertProseMirrorToMarkdown({ type: "doc", content });
},
handlePaste: (view, event, slice) => {
if (!event.clipboardData) {
@@ -95,37 +102,115 @@ export const MarkdownClipboard = Extension.create({
}
}
- const { tr } = view.state;
- const { from, to } = view.state.selection;
+ const schema = this.editor.schema;
+ // Capture the target range NOW. markdownToProseMirror RETURNS A
+ // PROMISE (kept async only for the Node consumers' contract; the
+ // conversion pipeline itself is synchronous), so the actual replace
+ // happens on the next microtask. No user input can interleave a
+ // microtask, so the state is unchanged when we dispatch — but we
+ // still re-read the live state before replacing and, if the doc did
+ // change under us, fall back to the live selection rather than the
+ // captured (now-stale) range.
+ const from = view.state.selection.from;
+ const to = view.state.selection.to;
+ const startDoc = view.state.doc;
+ const md = text.replace(/\n+$/, "");
- const parsed = markdownToHtml(text.replace(/\n+$/, ""));
- const body = elementFromString(parsed);
- normalizeTableColumnWidths(body);
+ void markdownToProseMirror(md)
+ .then((doc) => {
+ if (view.isDestroyed) return;
+ // Canonical PM-JSON -> HTML via the LIVE editor schema, then
+ // reuse the UNCHANGED downstream seam (normalizeTableColumnWidths
+ // + parseSlice + canonicalizePastedFootnotes). The JSON->HTML->
+ // JSON hop is lossless (same schema both directions); it lets the
+ // existing paste-insertion logic stay byte-identical — only the
+ // SOURCE of the markdown conversion changed (issue #347 guardrail:
+ // no converter logic in the client, only a call into the package).
+ const node = PMNode.fromJSON(schema, doc);
+ const div = document.createElement("div");
+ DOMSerializer.fromSchema(schema).serializeFragment(
+ node.content,
+ { document },
+ div,
+ );
- const parsedSlice = DOMParser.fromSchema(
- this.editor.schema,
- ).parseSlice(body, {
- preserveWhitespace: true,
- });
+ const body = elementFromString(div.innerHTML);
+ normalizeTableColumnWidths(body);
- // A markdown paste builds its ProseMirror fragment directly (DOM ->
- // parseSlice), bypassing the editor's footnoteSyncPlugin, which never
- // reorders an existing list. So a pasted markdown block whose footnote
- // definitions are out of order (or contains orphan defs) would be
- // stored out of order. Canonicalize the self-contained pasted block so
- // its footnotes come out reference-ordered, deduped and orphan-free
- // (issue #228). See canonicalizePastedFootnotes for why this is scoped
- // to whole-block pastes that carry their own footnotesList.
- const contentNodes = canonicalizePastedFootnotes(
- parsedSlice,
- this.editor.schema,
- );
+ const parsedSlice = DOMParser.fromSchema(schema).parseSlice(
+ body,
+ { preserveWhitespace: true },
+ );
- tr.replaceRange(from, to, contentNodes);
- const insertEnd = tr.mapping.map(from, 1);
- tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
- tr.setMeta('paste', true)
- view.dispatch(tr);
+ // A markdown paste builds its ProseMirror fragment directly (DOM
+ // -> parseSlice), bypassing the editor's footnoteSyncPlugin, which
+ // never reorders an existing list. So a pasted markdown block whose
+ // footnote definitions are out of order (or contains orphan defs)
+ // would be stored out of order. Canonicalize the self-contained
+ // pasted block so its footnotes come out reference-ordered, deduped
+ // and orphan-free (issue #228). See canonicalizePastedFootnotes for
+ // why this is scoped to whole-block pastes that carry their own
+ // footnotesList.
+ const contentNodes = canonicalizePastedFootnotes(
+ parsedSlice,
+ schema,
+ );
+
+ // Target the captured range (normally still valid — same
+ // microtask). If the doc changed under us since capture, the
+ // captured absolute from/to are stale, so fall back to the live
+ // selection rather than StepMap-mapping the old range.
+ const tr = view.state.tr;
+ let mappedFrom = from;
+ let mappedTo = to;
+ if (view.state.doc !== startDoc) {
+ // Defensive: if the doc changed under us, fall back to the
+ // current selection rather than a stale absolute range.
+ mappedFrom = view.state.selection.from;
+ mappedTo = view.state.selection.to;
+ }
+ tr.replaceRange(mappedFrom, mappedTo, contentNodes);
+ const insertEnd = tr.mapping.map(mappedFrom, 1);
+ tr.setSelection(
+ TextSelection.near(
+ tr.doc.resolve(Math.max(mappedFrom, insertEnd - 2)),
+ -1,
+ ),
+ );
+ tr.setMeta("paste", true);
+ view.dispatch(tr);
+ })
+ .catch((err) => {
+ // Fail-open: a conversion error must not swallow the paste
+ // silently in a way that loses the text. We already claimed the
+ // event (returned true), so re-insert the raw text as a plain
+ // paragraph so the user never loses their clipboard content.
+ // Log it: this catch covers BOTH the converter and the success
+ // `.then` body (e.g. PMNode.fromJSON throwing on a schema drift
+ // between the canonical package and the live editor schema), so a
+ // silent degrade to raw text would otherwise be an invisible,
+ // non-reproducible regression ("my table pasted as text").
+ console.error(
+ "markdown paste conversion failed, inserting raw text",
+ err,
+ );
+ if (view.isDestroyed) return;
+ const tr = view.state.tr;
+ // Same guard the success path uses: if the doc changed under us
+ // since the range was captured (normally never — same microtask),
+ // the captured absolute from/to are stale and would throw a
+ // RangeError here (an unhandled rejection on a hot paste path).
+ // Fall back to the live selection instead of a stale range.
+ if (view.state.doc !== startDoc) {
+ const sel = view.state.selection;
+ tr.insertText(md, sel.from, sel.to);
+ } else {
+ tr.insertText(md, from, to);
+ }
+ tr.setMeta("paste", true);
+ view.dispatch(tr);
+ });
+ // Claim the paste: we insert asynchronously above.
return true;
},
// Strip trailing whitespace-only paragraphs from pasted content.
diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx
index 880611ae..5b4189d6 100644
--- a/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx
+++ b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx
@@ -33,10 +33,11 @@ vi.mock("@/lib/local-emitter.ts", () => ({
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
}));
-// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
-// purely via the fake page editor's getHTML().
-vi.mock("@docmost/editor-ext", () => ({
- htmlToMarkdown: (html: string) => html,
+// convertProseMirrorToMarkdown echoes a marker carried on the fake editor's
+// getJSON() doc, so each test controls the markdown purely via the fake page
+// editor (issue #347: the hook now serializes editor JSON through the package).
+vi.mock("@docmost/prosemirror-markdown/browser", () => ({
+ convertProseMirrorToMarkdown: (doc: { __md?: string }) => doc?.__md ?? "",
}));
const notificationsShowMock = vi.fn();
@@ -53,10 +54,12 @@ import { useGeneratePageTitle } from "./use-generate-page-title.ts";
// --- Test helpers -------------------------------------------------------------
-function makePageEditor(pageId: string, html = "
content
"): Editor {
+function makePageEditor(pageId: string, md = "content"): Editor {
return {
isDestroyed: false,
- getHTML: () => html,
+ // The mocked convertProseMirrorToMarkdown reads `__md` back off this doc,
+ // so `md` is exactly the markdown the hook will send to the title service.
+ getJSON: () => ({ type: "doc", __md: md }),
storage: { pageId },
} as unknown as Editor;
}
diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.ts b/apps/client/src/features/editor/hooks/use-generate-page-title.ts
index e8d9e0e2..ba3a5d70 100644
--- a/apps/client/src/features/editor/hooks/use-generate-page-title.ts
+++ b/apps/client/src/features/editor/hooks/use-generate-page-title.ts
@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
-import { htmlToMarkdown } from "@docmost/editor-ext";
+import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
import {
pageEditorAtom,
titleEditorAtom,
@@ -49,7 +49,9 @@ export function useGeneratePageTitle(pageId: string) {
mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return;
- const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
+ // Serialize the live editor content to markdown through the canonical
+ // converter (issue #347), matching the on-disk/export markdown form.
+ const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON()).trim();
if (!markdown) {
notifications.show({ message: t("The note is empty"), color: "yellow" });
return;
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index dfd4059a..5b1b38ec 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -37,7 +37,7 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
-import { htmlToMarkdown } from "@docmost/editor-ext";
+import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
import {
pageEditorAtom,
yjsConnectionStatusAtom,
@@ -199,8 +199,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const handleCopyAsMarkdown = () => {
if (!pageEditor) return;
- const html = pageEditor.getHTML();
- const markdown = htmlToMarkdown(html);
+ // Copy the page as canonical markdown through the shared converter (issue
+ // #347), so "Copy as markdown" matches the server export byte-for-byte.
+ const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON());
const title = page?.title ? `# ${page.title}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
notifications.show({ message: t("Copied") });
diff --git a/apps/server/src/common/helpers/prosemirror/html-embed-import-detect.spec.ts b/apps/server/src/common/helpers/prosemirror/html-embed-import-detect.spec.ts
index f68641f9..b045bd6b 100644
--- a/apps/server/src/common/helpers/prosemirror/html-embed-import-detect.spec.ts
+++ b/apps/server/src/common/helpers/prosemirror/html-embed-import-detect.spec.ts
@@ -1,4 +1,5 @@
-import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
+import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
+import { encodeHtmlEmbedSource } from '@docmost/editor-ext';
import { htmlToJson } from '../../../collaboration/collaboration.util';
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
@@ -10,13 +11,12 @@ import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
*
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
* this exercises the REAL server import conversion path that ImportService uses
- * (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
- * link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
- * that such a node is DETECTED and STRIPPABLE — so the share read path's
+ * (`markdownToProseMirror`, the canonical converter — issue #345/#347) and
+ * asserts that such a node is DETECTED and STRIPPABLE — so the share read path's
* master-toggle strip can remove it when the workspace toggle is OFF.
*/
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
- it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
+ it('round-trips through markdownToProseMirror and is DETECTED (base64 data-source)', async () => {
const source = '';
const encoded = encodeHtmlEmbedSource(source);
const md = [
@@ -27,12 +27,9 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
'World',
].join('\n');
- const html = await markdownToHtml(md);
- // marked preserves the raw block-level div verbatim.
- expect(html).toContain('data-type="htmlEmbed"');
-
- const json = htmlToJson(html);
- // The div parses into a real htmlEmbed node carrying the decoded source.
+ // The canonical importer parses the raw block-level div into a real
+ // htmlEmbed node carrying the decoded source.
+ const json = await markdownToProseMirror(md);
expect(hasHtmlEmbedNode(json)).toBe(true);
// Because it is detected, the share master-toggle strip can remove it.
@@ -59,8 +56,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
// therefore stripping) does not depend on the source being well-formed, so
// the bypass cannot be hidden by sending a malformed data-source.
const md = ``;
- const html = await markdownToHtml(md);
- const json = htmlToJson(html);
+ const json = await markdownToProseMirror(md);
expect(hasHtmlEmbedNode(json)).toBe(true);
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
});
diff --git a/apps/server/src/integrations/import/utils/foreign-markdown.ts b/apps/server/src/integrations/import/utils/foreign-markdown.ts
index 2173bfdc..dd7b012a 100644
--- a/apps/server/src/integrations/import/utils/foreign-markdown.ts
+++ b/apps/server/src/integrations/import/utils/foreign-markdown.ts
@@ -238,8 +238,9 @@ function convertReferenceFootnotes(markdown: string): string {
*
* LINE-ANCHORED (the same shape the canonical parser uses in
* prosemirror-markdown/page-file.ts): the block opens only on `---\n` at the
- * very start and closes only on a `\n---` line. The retired `markdownToHtml`
- * strip closed on the FIRST `---` ANYWHERE (an unanchored close), so a value
+ * very start and closes only on a `\n---` line. The retired editor-ext
+ * `markdownToHtml` front-matter strip (removed in #347) closed on the FIRST
+ * `---` ANYWHERE (an unanchored close), so a value
* containing a triple-dash (e.g. `title: Q1 --- Q2`) truncated the front-matter
* and leaked the rest into the body. An optional leading BOM is tolerated.
*/
diff --git a/packages/editor-ext/package.json b/packages/editor-ext/package.json
index 1f2b5ff8..7fb61d9b 100644
--- a/packages/editor-ext/package.json
+++ b/packages/editor-ext/package.json
@@ -11,9 +11,6 @@
"main": "dist/index.js",
"module": "./src/index.ts",
"types": "dist/index.d.ts",
- "dependencies": {
- "marked": "17.0.5"
- },
"devDependencies": {
"@vitest/coverage-v8": "4.1.6",
"vitest": "4.1.6"
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index a2f1d0eb..b9e038b7 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -18,7 +18,6 @@ export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/html-embed/html-embed";
export * from "./lib/mention";
-export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts
index f7a05f94..b38fdb5d 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts
@@ -14,7 +14,8 @@ import {
* ProseMirror JSON directly (never running the editor's plugins), so the
* canonical footnote topology was never enforced on those writes. The consumers
* of this editor-ext copy are: the server markdown/HTML import
- * (`markdownToHtml -> htmlToJson` in import.service / file-import-task.service),
+ * (`markdownToProseMirror` from @docmost/prosemirror-markdown in import.service /
+ * file-import-task.service),
* `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/
* HTML REST write paths), and the client markdown PASTE path
* (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in
diff --git a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
deleted file mode 100644
index 6c87f2d6..00000000
--- a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
-import { markdownToHtml } from "../markdown/utils/marked.utils";
-import { extractFootnoteDefinitions } from "../markdown/utils/footnote.marked";
-
-// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div).
-const HTML =
- `
Water and clay.
` +
- `` +
- `
First note.
` +
- `
Second note.
` +
- ``;
-
-describe("footnote markdown round-trip", () => {
- it("HTML -> Markdown produces pandoc footnote syntax", () => {
- const md = htmlToMarkdown(HTML);
- expect(md).toContain("[^fn1]");
- expect(md).toContain("[^fn2]");
- expect(md).toContain("[^fn1]: First note.");
- expect(md).toContain("[^fn2]: Second note.");
- });
-
- it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => {
- const md = htmlToMarkdown(HTML);
- const html = await markdownToHtml(md);
- expect(html).toContain('data-footnote-ref data-id="fn1"');
- expect(html).toContain('data-footnote-ref data-id="fn2"');
- expect(html).toContain("data-footnotes");
- expect(html).toContain('data-footnote-def data-id="fn1"');
- expect(html).toContain("First note.");
- expect(html).toContain("Second note.");
- });
-
- it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => {
- // A document that DOCUMENTS footnote syntax inside a code fence. The
- // `[^demo]: ...` line is example text, not a real definition, and must
- // survive the Markdown -> HTML conversion verbatim.
- const md = [
- "Here is how footnotes look:",
- "",
- "```markdown",
- "Some text[^demo]",
- "",
- "[^demo]: this is the definition",
- "```",
- "",
- "End of doc.",
- ].join("\n");
-
- const html = await markdownToHtml(md);
- // The example definition line is kept inside the rendered code block.
- expect(html).toContain("[^demo]: this is the definition");
- // It did NOT get pulled out into a real footnotes section.
- expect(html).not.toContain("data-footnotes");
- expect(html).not.toContain("data-footnote-def");
- });
-
- it("extractFootnoteDefinitions keeps the FIRST duplicate definition and reuses markers", () => {
- // Two definitions share id `d`, and the body has two `[^d]` markers. Under
- // the import model (#166) duplicate definition ids are FIRST-WINS: only the
- // first definition is kept; markers are NEVER rewritten, so the two `[^d]`
- // references reuse the single footnote.
- const md = [
- "See here[^d] and there[^d].",
- "",
- "[^d]: first",
- "[^d]: second",
- ].join("\n");
-
- const { body, section } = extractFootnoteDefinitions(md);
-
- const defIds = Array.from(
- section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
- ).map((m) => m[1]);
- expect(defIds).toEqual(["d"]); // first-wins: one definition
- expect(section).toContain("first");
- expect(section).not.toContain("second"); // duplicate dropped
-
- // Both markers stay `[^d]` (reuse) — no `d__2` minting.
- const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
- (m) => m[1],
- );
- expect(refIds).toEqual(["d", "d"]);
- });
-
- it("extractFootnoteDefinitions is DETERMINISTIC and stable (same input -> same output)", () => {
- // The output must be a pure function of the input markdown so importing the
- // same source twice (or via the editor and the MCP mirror) is identical.
- const md = [
- "See[^d] one[^d] two[^d].",
- "",
- "[^d]: first",
- "[^d]: second",
- "[^d]: third",
- ].join("\n");
-
- const run = () => {
- const { body, section } = extractFootnoteDefinitions(md);
- const defIds = Array.from(
- section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
- ).map((m) => m[1]);
- const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
- (m) => m[1],
- );
- return { defIds, refIds };
- };
-
- const a = run();
- const b = run();
- expect(a).toEqual(b);
- // First-wins: one kept definition `d`; all three reuse markers stay `d`.
- expect(a.defIds).toEqual(["d"]);
- expect(a.refIds).toEqual(["d", "d", "d"]);
- });
-
- it("markdownToHtml with a reused id renders ONE shared footnote def", async () => {
- const md = [
- "See here[^d] and there[^d].",
- "",
- "[^d]: first",
- "[^d]: second",
- ].join("\n");
- const html = await markdownToHtml(md);
- const defIds = Array.from(
- html.matchAll(/data-footnote-def data-id="([^"]+)"/g),
- ).map((m) => m[1]);
- expect(defIds).toEqual(["d"]); // one shared definition
- expect(html).toContain("first");
- expect(html).not.toContain("second");
- });
-});
diff --git a/packages/editor-ext/src/lib/footnote/footnote-sync.ts b/packages/editor-ext/src/lib/footnote/footnote-sync.ts
index d0891e1a..891d8f9c 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-sync.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-sync.ts
@@ -103,8 +103,9 @@ interface CollisionPlan {
* `X__2`, `X__3`, collision-bumped) so it survives as a distinct footnote — which,
* having no matching reference, then falls under the normal orphan policy. It is
* only ever dropped for lacking a reference, never for colliding. The IMPORT
- * paths (footnote.marked.ts / MCP extractFootnotes) instead apply first-wins +
- * drop + warn for duplicate definitions; that divergence is intentional — import
+ * paths (@docmost/prosemirror-markdown / MCP extractFootnotes) instead apply
+ * first-wins + drop + warn for duplicate definitions; that divergence is
+ * intentional — import
* is an agent-authored artifact we sanitize, the editor is live user data we must
* not lose.
*
diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts b/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts
index 96d448ae..4cbb5605 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts
@@ -6,8 +6,9 @@ import { deriveFootnoteId } from "./footnote-util";
*
* `deriveFootnoteId` lives ONLY in editor-ext now — it is used by
* `resolveCollisions` (re-id of a duplicate definition) and `footnotePastePlugin`
- * (re-id of a pasted colliding definition). The MCP/marked import paths no longer
- * derive ids (duplicate definitions there are first-wins-dropped, #166), so there
+ * (re-id of a pasted colliding definition). The MCP / @docmost/prosemirror-markdown
+ * import paths no longer derive ids (duplicate definitions there are
+ * first-wins-dropped, #166), so there
* is no cross-package copy and no parity test to keep in sync. This table pins the
* deterministic scheme so a future change to it is a conscious one.
*/
diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.ts b/packages/editor-ext/src/lib/footnote/footnote-util.ts
index d27c9685..0136c3d0 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-util.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-util.ts
@@ -63,8 +63,9 @@ export function generateFootnoteId(): string {
* its own seen-set before requesting the next derived id.
*
* Used only inside editor-ext now (resolveCollisions for a re-id'd duplicate
- * DEFINITION, and footnotePastePlugin). The MCP/marked import paths no longer
- * derive ids — duplicate definitions there are first-wins-dropped (#166) — so
+ * DEFINITION, and footnotePastePlugin). The MCP / @docmost/prosemirror-markdown
+ * import paths no longer derive ids — duplicate definitions there are
+ * first-wins-dropped (#166) — so
* there is no cross-package copy to keep in sync. The golden table in
* footnote-util.derive-id.test.ts pins the scheme.
*/
diff --git a/packages/editor-ext/src/lib/image/image-markdown.test.ts b/packages/editor-ext/src/lib/image/image-markdown.test.ts
deleted file mode 100644
index 76803516..00000000
--- a/packages/editor-ext/src/lib/image/image-markdown.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { generateJSON } from "@tiptap/html";
-import { Document } from "@tiptap/extension-document";
-import { Paragraph } from "@tiptap/extension-paragraph";
-import { Text } from "@tiptap/extension-text";
-import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
-import { markdownToHtml } from "../markdown/utils/marked.utils";
-import { TiptapImage } from "./image";
-
-// Minimal schema for parsing markdownToHtml output back to JSON (mirrors
-// image.spec.ts), so we can assert the recovered caption EXACTLY.
-const parseExtensions = [Document, Paragraph, Text, TiptapImage];
-
-// Lossless markdown round-trip for image captions (issue #221). An image WITH a
-// caption can't be expressed as ``, so it is emitted as a raw
-// (carrying data-caption) wrapped in a block