From 9004de60e3548672d4fb92547f7187da59d034b3 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 23:22:01 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(schema):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20excludes:"=5F"=20=D1=83=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20code=20=E2=80=94=20=D0=BE=D0=BD=D0=B0=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B1=D0=B8=D0=BD=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BE=20=D0=B2=D1=81=D0=B5=D0=BC=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BA=D0=B0=D0=BC=D0=B8=20(#515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По CommonMark `**` рядом с инлайн-кодом дают `` — узел с составом марок [code, bold]. Марка `code` из tiptap несёт `excludes: "_"` (исключает все прочие инлайн-марки), и ProseMirror на HTML→PM импорте (`generateJSON`) выкидывает сосуществующую bold — так `**`--flag`**` терял жирный. Ставим `excludes: ""` (не исключает ничего) во всех четырёх местах, где марка конфигурируется независимо: - Единый источник: новая каноническая марка `Code = TiptapCode.extend({ excludes: "" })` в @docmost/editor-ext, экспортируется из barrel. - Живой редактор (extensions.ts): базовая `Code` теперь из editor-ext, поверх сохранены client-only addInputRules/addKeyboardShortcuts. - Collab-сервер + серверный HTML-парс/экспорт (collaboration.util.ts): StarterKit code:false + общая `Code`; @docmost/editor-ext объявлен в apps/server/package.json (использовался, но не был задекларирован). - Редактор комментариев (comment-editor.tsx): StarterKit code:false + `Code`. - Вендор-зеркало docmostExtensions (docmost-schema.ts) — сознательно отдельная копия, не тянущая editor-ext в node-рантайм: `excludes:""` объявлен локально (StarterKit code:false + `Code.extend({ excludes:"" })`), держится в синхроне с editor-ext паритет-тестом. Паритет-гард: тест в пакете сверяет excludes марки code вендор-зеркала с канонической editor-ext Code (обе ""); vitest резолвит @docmost/editor-ext на sibling-исходник, чтобы гард был герметичным. `excludes:""` делает code «перекрывающейся» маркой в y-prosemirror (как comment): она пишется в Yjs под хешированным ключом `code--` и распаковывается обратно в `code` штатным декодером — правим mcp-тест-хелпер fragmentToJson, чтобы он снимал хеш ровно как yattr2markname (иначе overlapping-code утёк бы в сравнение как `code--`). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../comment/components/comment-editor.tsx | 7 +++- .../features/editor/extensions/extensions.ts | 6 +++- apps/server/package.json | 1 + .../src/collaboration/collaboration.util.ts | 6 ++++ packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/code.ts | 20 +++++++++++ .../test/mock/markdown-patch-insert.test.mjs | 15 +++++++-- .../src/lib/docmost-schema.ts | 14 ++++++++ .../test/schema-editor-ext-contract.test.ts | 33 +++++++++++++++++++ .../prosemirror-markdown/vitest.config.ts | 7 ++++ 10 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/editor-ext/src/lib/code.ts diff --git a/apps/client/src/features/comment/components/comment-editor.tsx b/apps/client/src/features/comment/components/comment-editor.tsx index 525cc2a1..1d3f9f6f 100644 --- a/apps/client/src/features/comment/components/comment-editor.tsx +++ b/apps/client/src/features/comment/components/comment-editor.tsx @@ -1,7 +1,7 @@ import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; import { Placeholder } from "@tiptap/extension-placeholder"; import { StarterKit } from "@tiptap/starter-kit"; -import { Mention, LinkExtension } from "@docmost/editor-ext"; +import { Mention, LinkExtension, Code } from "@docmost/editor-ext"; import classes from "./comment.module.css"; import { useFocusWithin } from "@mantine/hooks"; import clsx from "clsx"; @@ -44,7 +44,12 @@ const CommentEditor = forwardRef( gapcursor: false, dropcursor: false, link: false, + // #515: use the shared editor-ext `Code` (excludes: "") instead of + // StarterKit's excluding one, so inline code in a comment can carry + // other marks and does not drop them when the comment is edited. + code: false, }), + Code, Placeholder.configure({ placeholder: placeholder || t("Reply..."), }), diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 829d32c9..87161270 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,6 +1,5 @@ import { markInputRule } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; -import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions"; @@ -67,6 +66,7 @@ import { FootnoteReference, FootnotesList, FootnoteDefinition, + Code, } from "@docmost/editor-ext"; import { randomElement, @@ -153,6 +153,10 @@ export const mainExtensions = [ codeBlock: false, code: false, }), + // Base `Code` comes from @docmost/editor-ext, which overrides `excludes: ""` + // (#515) so inline code can co-occur with bold/italic/… — the SINGLE shared + // source also used by the collab server and comment editor. Here we keep the + // existing client-only behavior on top of it: // Override TipTap's Code extension to fix the inline code input rule. // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // before the opening backtick as part of the match, causing markInputRule diff --git a/apps/server/package.json b/apps/server/package.json index a6791d80..fbe7df80 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,6 +41,7 @@ "@aws-sdk/s3-request-presigner": "3.1050.0", "@azure/storage-blob": "12.31.0", "@clickhouse/client": "^1.18.2", + "@docmost/editor-ext": "workspace:*", "@docmost/mcp": "workspace:*", "@docmost/pdf-inspector": "1.9.6", "@docmost/prosemirror-markdown": "workspace:*", diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 50e961ce..a2be020d 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -49,6 +49,7 @@ import { FootnotesList, FootnoteDefinition, PageEmbed, + Code, } from '@docmost/editor-ext'; import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; @@ -67,7 +68,12 @@ export const tiptapExtensions = [ link: false, trailingNode: false, heading: false, + // #515: replace StarterKit's bundled inline `code` (which inherits tiptap's + // `excludes: "_"`) with the shared editor-ext `Code` below, so the server's + // HTML->PM parse/export keeps code co-occurring with other marks. + code: false, }), + Code, Heading, UniqueID.configure({ types: ['heading', 'paragraph', 'transclusionSource'], diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index b9e038b7..aa8d0fce 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -1,4 +1,5 @@ export * from "./lib/trailing-node"; +export * from "./lib/code"; export * from "./lib/comment/comment"; export * from "./lib/utils"; export * from "./lib/math"; diff --git a/packages/editor-ext/src/lib/code.ts b/packages/editor-ext/src/lib/code.ts new file mode 100644 index 00000000..7ab1bad6 --- /dev/null +++ b/packages/editor-ext/src/lib/code.ts @@ -0,0 +1,20 @@ +import { Code as TiptapCode } from "@tiptap/extension-code"; + +// #515: canonical inline `code` mark for Docmost. +// +// Tiptap's stock Code mark (via StarterKit) declares `excludes: "_"`, which +// makes it exclude EVERY other inline mark: applying `code` drops any co- +// occurring bold/italic/… on both the HTML->PM import and editor transactions. +// That silently stripped emphasis adjacent to inline code (`` **`--flag`** `` +// lost its bold on markdown import). CommonMark nests them (``), +// so Docmost lets `code` combine with all marks by overriding `excludes` to the +// empty string (excludes nothing). +// +// This is the SINGLE shared source imported by the live editor, the collab +// server and the comment editor schemas. The markdown-import mirror in +// @docmost/prosemirror-markdown re-declares the same override locally (it must +// not pull this React-aware package into its node runtime) and a parity test +// keeps the two in lockstep. +export const Code = TiptapCode.extend({ + excludes: "", +}); diff --git a/packages/mcp/test/mock/markdown-patch-insert.test.mjs b/packages/mcp/test/mock/markdown-patch-insert.test.mjs index a6b843eb..427f3500 100644 --- a/packages/mcp/test/mock/markdown-patch-insert.test.mjs +++ b/packages/mcp/test/mock/markdown-patch-insert.test.mjs @@ -108,6 +108,17 @@ async function spawnCollabStack(seedDoc) { return { state, baseURL }; } +// y-prosemirror stores an OVERLAPPING mark (one whose type does not exclude +// itself — e.g. `comment`, and since #515 `code` with `excludes: ""`) under a +// HASHED Yjs attribute key `name--<8-char hash>` so several may coexist on a +// range. The real read path (yDocToProsemirrorJSON) strips that suffix back to +// the bare mark name via this exact regex; mirror it here so this minimal decoder +// reports the same mark names Docmost actually returns (without it an overlapping +// `code` would leak as `code--`). +const hashedMarkNameRegex = /(.*)(--[a-zA-Z0-9+/=]{8})$/; +const yattr2markname = (attrName) => + hashedMarkNameRegex.exec(attrName)?.[1] ?? attrName; + // Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost // stores. Reads element name as node type, attributes as attrs, and recurses into // children; text nodes carry their string. @@ -121,8 +132,8 @@ function fragmentToJson(frag) { if (d.attributes && Object.keys(d.attributes).length) { node.marks = Object.entries(d.attributes).map(([type, attrs]) => attrs && typeof attrs === "object" && Object.keys(attrs).length - ? { type, attrs } - : { type }, + ? { type: yattr2markname(type), attrs } + : { type: yattr2markname(type) }, ); } return node; diff --git a/packages/prosemirror-markdown/src/lib/docmost-schema.ts b/packages/prosemirror-markdown/src/lib/docmost-schema.ts index 882902ff..ecd744c3 100644 --- a/packages/prosemirror-markdown/src/lib/docmost-schema.ts +++ b/packages/prosemirror-markdown/src/lib/docmost-schema.ts @@ -16,6 +16,7 @@ * `@docmost/editor-ext` before updating the snapshot. */ import StarterKit from "@tiptap/starter-kit"; +import { Code } from "@tiptap/extension-code"; import Image from "@tiptap/extension-image"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; @@ -1481,7 +1482,20 @@ export const docmostExtensions = [ codeBlock: {}, heading: {}, link: { openOnClick: false }, + // #515: disable StarterKit's bundled inline `code` mark so it can be replaced + // by the local override below. StarterKit's `code` inherits tiptap's + // `excludes: "_"`, which strips every co-occurring mark on HTML->PM import + // (`generateJSON`) — so `` **`--flag`** `` lost its bold. This mirror is a + // DELIBERATE standalone copy (it must not pull @docmost/editor-ext into the + // node import runtime — that would drag in React/node-views; see #293), so + // the `excludes: ""` override is declared LOCALLY here and kept in lockstep + // with the canonical `Code` in @docmost/editor-ext by a parity test. + code: false, }), + // #515: inline code that COMBINES with other marks (CommonMark-consistent). + // `excludes: ""` means the mark excludes nothing, so bold/italic/strike/… may + // co-occur with `code` and survive import. + Code.extend({ excludes: "" }), // Preserve image width/height as the AUTHORED string. Without an explicit // parseHTML the stock Image node attribute falls back to tiptap core's // `fromString`, which coerces a numeric width like "320" into the number 320 diff --git a/packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts b/packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts index 7570ff11..5a217082 100644 --- a/packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts +++ b/packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts @@ -88,6 +88,39 @@ describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => { }); }); +// ── #515 CODE-MARK `excludes` PARITY (data-loss-sensitive) ────────────────── +// +// The `code` mark's `excludes` field decides whether inline code can co-occur +// with other marks. #515 sets it to "" (excludes nothing) in the canonical +// `Code` exported by @docmost/editor-ext AND, because the vendored markdown +// mirror must NOT pull that React-aware package into its node runtime, RE-DECLARES +// the same override locally in docmost-schema.ts. If the two drift, markdown +// import would silently strip bold/italic adjacent to inline code again. Guard it +// mechanically: the mirror's built `code` mark and the canonical editor-ext +// `Code` must agree on `excludes` (both ""). getSchema surfaces the resolved +// value on the mark spec. +describe("docmost schema vs @docmost/editor-ext (#515 code excludes parity)", () => { + it("keeps the vendored `code` mark's excludes in lockstep with editor-ext Code", () => { + // Mirror side: the value the mirror's BUILT schema resolves for `code`. + const mirrorExcludes = getSchema(docmostExtensions as never).marks.code.spec + .excludes; + // Canonical side: the `excludes` DECLARED on the editor-ext `Code` extension + // (read from its config — getSchema needs a full node set, so a lone mark + // can't be built into a schema here). + const canonicalCode = ( + editorExt as unknown as { Code?: { config?: { excludes?: unknown } } } + ).Code; + const canonicalExcludes = canonicalCode?.config?.excludes; + // Both must be the empty string: `code` excludes NOTHING, so bold/italic/… + // survive alongside inline code (#515). A drift here would silently strip + // marks adjacent to code on markdown import again. + expect(canonicalCode).toBeDefined(); + expect(mirrorExcludes).toBe(""); + expect(canonicalExcludes).toBe(""); + expect(mirrorExcludes).toBe(canonicalExcludes); + }); +}); + // ── ATTRIBUTE-LEVEL CONTRACT (#493 commit 2) ──────────────────────────────── // // The name-level contract above catches a WHOLE node/mark type going unmirrored, diff --git a/packages/prosemirror-markdown/vitest.config.ts b/packages/prosemirror-markdown/vitest.config.ts index 82b97d05..68c9820d 100644 --- a/packages/prosemirror-markdown/vitest.config.ts +++ b/packages/prosemirror-markdown/vitest.config.ts @@ -9,11 +9,18 @@ import { defineConfig } from 'vitest/config'; // envelope, markdownToProseMirror) is re-exported there. const here = path.dirname(fileURLToPath(import.meta.url)); const libBarrel = path.resolve(here, 'src/lib/index.ts'); +// Resolve the cross-package `@docmost/editor-ext` specifier to the SIBLING +// workspace SOURCE. In a normal checkout this is what pnpm's workspace link + +// the package's `module` field already yield; pinning it here makes the schema +// contract tests (incl. the #515 code-excludes parity) hermetic and independent +// of node_modules layout (e.g. a shared/hoisted store in a git worktree). +const editorExtBarrel = path.resolve(here, '../editor-ext/src/index.ts'); export default defineConfig({ resolve: { alias: { 'docmost-client': libBarrel, + '@docmost/editor-ext': editorExtBarrel, }, }, test: { -- 2.52.0 From 95c0d813b07cd16cda04b710ccd89466ff4c0ad9 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 23:22:28 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(converter):=20round-trip=20code+=D1=8D?= =?UTF-8?q?=D0=BC=D1=84=D0=B0=D0=B7=D0=B0=20=E2=80=94=20code=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D0=B8,=20=D1=81=D0=BA=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=B3=D0=BE=D0=B2,=20HT?= =?UTF-8?q?ML-fallback=20(#515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь узел может нести [code, bold], и сериализатор PM→Markdown должен это корректно выгружать в обе стороны (git-sync — data-loss-critical, требуется байт-стабильность md2===md1). case "text": убран ранний return для code-рана. Backtick-спан оборачивается ПЕРВЫМ (самая внутренняя марка), затем прочие марки в том же порядке массива — `**`code`**`. Для НЕ-code ранов вывод байт-идентичен прежнему (вынесены хелперы escapeInlineText и applyInlineMark, поведение сохранено). renderInlineChildren: собирает максимальный пробег подряд идущих text-узлов с голой-делимитерной эмфазис-маркой (bold/italic/strike/uncolored-highlight), содержащий хотя бы один code-узел. ОДНОРОДНЫЙ пробег (у всех идентичное множество не-code марок) и безопасные границы → общие марки выносятся наружу ОДИН раз: `**`aaa` + `bbb`**`, `**`code4` tail**`. НЕОДНОРОДНЫЙ (`[code,bold]` рядом с `[italic]`) ИЛИ граница упирается в словесный символ (делимитер `**` перед backtick не был бы flanking → эмфаза потерялась бы) → весь пробег через lossless inlineToHtml (схема-HTML). НЕ-code вывод байт-идентичен, кроме редкого случая голой-делимитерной эмфазы вплотную к code+эмфазе. Узлы, где code раньше вообще не мог нести эмфазу, — новая территория, существующие страницы не затрагиваются. inlineToHtml: `` тоже оборачивается ВНУТРЕННИМ (импорт отдаёт code последним в массиве — `[emphasis, code]`; порядок-зависимый цикл переворачивал бы `` в `` на реэкспорте и ломал байт-фикспойнт). Тесты: перевёрнуты ассерты, фиксировавшие старое (по CommonMark неверное) поведение (code+bold/strike/link → теперь `**`x`**`/`~~`x`~~`/`[`x`](…)`); генераторы расширены на code+{bold,italic,strike,highlight} в каноническом порядке импорта; добавлены явные round-trip пины 5 кейсов репорта (импорт→марки и md→pm→md идемпотентность) + усиление code-combo свойства (обе марки выживают). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/markdown-converter.ts | 434 ++++++++++++------ .../test/generative/text-arbitraries.ts | 43 +- .../test/markdown-converter-gaps.test.ts | 11 +- .../test/markdown-converter.test.ts | 15 +- .../test/markdown-roundtrip.property.test.ts | 114 ++++- 5 files changed, 416 insertions(+), 201 deletions(-) diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 3f647a75..e96cbad3 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -483,6 +483,99 @@ export function convertProseMirrorToMarkdown( return `${htmlRows}
`; }; + // Layer the intentional inline escapes onto a NON-code text run BEFORE its + // marks are applied. Extracted so both `case "text"` and the #515 code-emphasis + // run factoring (renderInlineChildren) escape the inner text identically. NEVER + // called on code content (a code span is literal — see the gating in the text + // case and the run helper). Order is load-bearing: the footnote raw-backslash + // doubling MUST precede the `==`/`$`/`^[` escapes (see inFootnoteBody). + const escapeInlineText = (text: string): string => { + let t = text; + if (inFootnoteBody) t = t.replace(/\\/g, "\\\\"); + t = t.replace(/==/g, "\\=\\="); + t = escapeProseMath(t); + t = t.replace(/\^\[/g, "^\\["); + return t; + }; + + // Wrap `text` with the markdown/HTML form of a SINGLE inline mark. Extracted + // from `case "text"` so the same per-mark emission is reused when the #515 + // run factoring layers a shared outer mark over a code-emphasis run. `code` is + // handled by the callers (wrapped innermost, before this runs), so this branch + // is defensive only. For any non-code mark the output is byte-identical to the + // pre-#515 inline switch. + const applyInlineMark = (text: string, mark: any): string => { + switch (mark.type) { + case "bold": + return `**${text}**`; + case "italic": + return `*${text}*`; + case "code": + // Callers wrap the code span innermost themselves; reached only if a + // mark list is applied through here directly. Emit the backtick span. + return `\`${text}\``; + case "link": { + const href = mark.attrs?.href || ""; + const title = mark.attrs?.title; + if (title) { + // Emit the optional markdown link title; escape an embedded double- + // quote so it cannot terminate the title string early. + const safeTitle = String(title).replace(/"/g, '\\"'); + return `[${text}](${href} "${safeTitle}")`; + } + return `[${text}](${href})`; + } + case "strike": + return `~~${text}~~`; + case "underline": + return `${text}`; + case "subscript": + return `${text}`; + case "superscript": + return `${text}`; + case "highlight": { + // #293 canon #7: a highlight WITHOUT a color serializes as the + // Obsidian/GFM `==text==` syntax; a colored highlight keeps the `` HTML form. The inner text already had any literal `==` + // backslash-escaped upstream. + const color = mark.attrs?.color; + return color + ? `${text}` + : `==${text}==`; + } + case "textStyle": + if (mark.attrs?.color) { + return `${text}`; + } + return text; + case "spoiler": + // Markdown has no native spoiler syntax, so emit the same raw inline HTML + // the editor-ext/MCP stack uses (span[data-spoiler] round-trips). + return `${text}`; + case "comment": { + // Inline comment anchor (span[data-comment-id]); resolved anchors are + // optionally dropped for agent reads, keeping only the bare text. + const cid = mark.attrs?.commentId; + if (cid) { + if (mark.attrs?.resolved && dropResolvedCommentAnchors) { + return text; + } + const resolvedAttr = mark.attrs?.resolved + ? ` data-resolved="true"` + : ""; + return `${text}`; + } + return text; + } + default: + // Unknown mark: no dedicated case, so it has no markdown form and is + // dropped from the run. Report the loss (throws in strict mode) then + // leave the text unwrapped — the historical behavior. + warnLoss("mark", String(mark.type)); + return text; + } + }; + const processNode = (node: any): string => { if (nodeDepth >= MAX_NODE_DEPTH) { // Bail out of deeper recursion without throwing. A text node still has @@ -582,160 +675,38 @@ export function convertProseMirrorToMarkdown( return headingLine; } - case "text": + case "text": { let textContent = node.text || ""; - // #293 canon #7: `==` is now a LIVE inline highlight syntax on import (a - // marked inline extension turns `==text==` into a color-less highlight - // mark). A LITERAL `==` in a text run would therefore be misparsed as a - // highlight on the next import, so backslash-escape each `=` of a `==` - // pair; marked's escape tokenizer decodes `\=` back to a literal `=`, so - // a literal `==` round-trips as text (never materializes a phantom mark). - // This runs for BOTH unmarked text and marked non-code runs, but NOT for - // an inline code span (a run carrying the `code` mark returns a backtick - // span below with `==` verbatim, matching `` `a == b` `` staying code). - // A highlight run's own `==` delimiters are appended AFTER this in the - // marks loop, so they are never escaped; only the run's inner text is. - if (!(node.marks || []).some((m: any) => m.type === "code")) { - // #293 canon #2 (F2): inside a footnote body, DOUBLE every RAW user - // backslash FIRST, so it survives `^[…]` (the import tokenizer treats - // `\` as an escape when balancing brackets, and `parseInline` - // decodes escapes). Doing it before the intentional escapes below keeps - // the serializer's own single escapes (`\=` `\$` `^\[`, and the `\[`/ - // `\]` balanceBrackets adds) single; only genuine user backslashes are - // doubled. Skipped for code runs (a code span's content is NOT decoded - // by parseInline, so its backslashes must stay verbatim). - if (inFootnoteBody) { - textContent = textContent.replace(/\\/g, "\\\\"); - } - textContent = textContent.replace(/==/g, "\\=\\="); - // #293 canon #6: escape a would-be inline-math `$…$` span so it stays - // literal text on re-import (currency `$5` is left clean — see - // escapeProseMath). Runs on the SAME non-code runs as the `==` escape - // above; an inline `code` run returns verbatim below, matching the - // codeBlock path (a `$…$` inside code must stay code, never math). - textContent = escapeProseMath(textContent); - // #293 canon #2: `^[` opens a LIVE inline-footnote span on import - // (`^[text]` -> a footnote reference). A LITERAL `^[` in prose text - // would therefore materialize a phantom footnote on the next import, so - // backslash-escape the bracket (`^[` -> `^\[`); marked's escape - // tokenizer decodes `\[` back to `[`, so a literal `^[…]` round-trips - // as text and never opens a footnote. Only the OPENING `^[` needs - // breaking (the tokenizer requires it), so this is a minimal, idempotent - // escape. A real footnoteReference node emits `^[body]` from its own - // case, never through here. - textContent = textContent.replace(/\^\[/g, "^\\["); + // #515: `code` is no longer exclusive (`excludes: ""`), so a run may + // carry `code` TOGETHER with other marks. The inner escapes below apply + // ONLY to a NON-code run (a code span's content is literal — `==`, `$…$`, + // `^[` must stay verbatim, matching `` `a == b` `` staying code). See + // #293 canon #2/#6/#7 for why each escape exists (extracted into + // escapeInlineText). A code run's `==`/`$`/`^[` are protected by the + // backticks, so they are never misparsed on re-import. + const hasCode = (node.marks || []).some((m: any) => m.type === "code"); + if (!hasCode) { + textContent = escapeInlineText(textContent); } - // Apply marks (bold, italic, code, etc.) if (node.marks) { - // The schema's `code` mark declares `excludes: "_"` — it excludes every - // other inline mark — so the editor can NEVER produce a text run that - // carries `code` together with another mark, and on import any - // co-occurring mark is always dropped (the run comes back as code-only). - // The lossless, byte-stable behavior is therefore: when a run has the - // `code` mark, emit ONLY the backtick code span and ignore every other - // mark, so md1 is already code-only and md2 === md1. Runs WITHOUT a code - // mark are rendered exactly as before. - const markTypes = node.marks.map((m: any) => m.type); - const hasCode = markTypes.includes("code"); + // #515: wrap the backtick code span FIRST (innermost mark), then layer + // the REMAINING marks in array order. For a run WITHOUT a code mark the + // loop applies every mark exactly as the pre-#515 switch did, so the + // output is byte-identical. For a code+emphasis run the code span sits + // inside the emphasis delimiters (`` **`code`** ``), matching CommonMark. + // The shared-mark grouping across ADJACENT nodes (`` **`a` + `b`** ``) + // lives in renderInlineChildren; this direct path handles a lone run + // and the table/`default` callers that invoke processNode per node. if (hasCode) { textContent = `\`${textContent}\``; - return textContent; } for (const mark of node.marks) { - switch (mark.type) { - case "bold": - textContent = `**${textContent}**`; - break; - case "italic": - textContent = `*${textContent}*`; - break; - case "code": - // A `code` run already returned above (hasCode early return), so - // this branch is only reached for a non-code run that somehow - // still lists `code`; emit the plain backtick span. - textContent = `\`${textContent}\``; - break; - case "link": { - const href = mark.attrs?.href || ""; - const title = mark.attrs?.title; - if (title) { - // Emit the optional markdown link title; escape an embedded - // double-quote so it cannot terminate the title string early. - const safeTitle = String(title).replace(/"/g, '\\"'); - textContent = `[${textContent}](${href} "${safeTitle}")`; - } else { - textContent = `[${textContent}](${href})`; - } - break; - } - case "strike": - textContent = `~~${textContent}~~`; - break; - case "underline": - textContent = `${textContent}`; - break; - case "subscript": - textContent = `${textContent}`; - break; - case "superscript": - textContent = `${textContent}`; - break; - case "highlight": { - // #293 canon #7: a highlight WITHOUT a color serializes as the - // Obsidian/GFM `==text==` syntax (the importer's marked inline - // `==` extension parses it back to a color-less highlight mark). - // A highlight WITH a color keeps the `` HTML form (the condition is deterministic on the - // `color` attr), so a colored highlight is not flattened. The - // inner textContent already had any literal `==` backslash- - // escaped above, so a highlight over text containing `==` still - // round-trips. - const color = mark.attrs?.color; - textContent = color - ? `${textContent}` - : `==${textContent}==`; - break; - } - case "textStyle": - if (mark.attrs?.color) { - textContent = `${textContent}`; - } - break; - case "spoiler": - // Markdown has no native spoiler syntax, so emit the same raw - // inline HTML the editor-ext/MCP stack uses. The schema's Spoiler - // mark parses span[data-spoiler] back on import, so the mark - // survives the PM -> MD -> PM round-trip. - textContent = `${textContent}`; - break; - case "comment": { - // Emit the inline comment anchor so highlights round-trip. The - // schema's Comment mark parses span[data-comment-id] (attrs - // commentId/resolved). - const cid = mark.attrs?.commentId; - if (cid) { - // Hide resolved anchors from agent reads: drop the wrapper and - // keep only the bare text. Active anchors keep their wrapper. - if (mark.attrs?.resolved && dropResolvedCommentAnchors) { - break; - } - const resolvedAttr = mark.attrs?.resolved - ? ` data-resolved="true"` - : ""; - textContent = `${textContent}`; - } - break; - } - default: - // Unknown mark: no dedicated case, so it has no markdown form and - // is dropped from the run. Report the loss (throws in strict - // mode) then leave the text unwrapped — the historical behavior. - warnLoss("mark", String(mark.type)); - break; - } + if (mark.type === "code") continue; // wrapped innermost above + textContent = applyInlineMark(textContent, mark); } } return textContent; + } case "codeBlock": const language = node.attrs?.language || ""; @@ -1327,18 +1298,165 @@ export function convertProseMirrorToMarkdown( // For that node ONLY we fall back to the lossless schema-HTML `` form. // Every other inline node is rendered exactly as processNode would, so output // is unchanged whenever no math sits directly before a digit. + // #515: a "bare-delimiter" emphasis mark is one that serializes as a naked + // markdown delimiter run (`**` `*` `~~` `==`) — bold / italic / strike / + // UNCOLORED highlight. These delimiters COLLIDE with the backtick-flanking + // delimiters emitted around a code+emphasis run: rendering `[code,bold]` next + // to `[italic]` node-by-node would produce `` **`a`***b* `` (a `***` run that + // re-imports wrong). Every OTHER mark (underline/sub/sup/spoiler/comment/ + // textStyle/colored-highlight/link) emits an HTML/bracket form whose boundaries + // do NOT collapse, so those neighbors never join a run. + const isBareEmphasisMark = (mark: any): boolean => { + switch (mark?.type) { + case "bold": + case "italic": + case "strike": + return true; + case "highlight": + return !mark.attrs?.color; // colored highlight emits , not `==` + default: + return false; + } + }; + + // A text node participates in a code-emphasis run iff it carries at least one + // bare-delimiter emphasis mark. A code-ONLY node (no emphasis) does NOT — so a + // plain `` `code` `` next to `**bold**` keeps its clean, byte-identical + // markdown (they share no colliding delimiter). Existing pages, where a code + // node could never carry emphasis, therefore serialize exactly as before. + const isEmphasisMember = (node: any): boolean => + node?.type === "text" && + (node.marks || []).some((m: any) => isBareEmphasisMark(m)); + + // The run's non-code marks (order preserved) — the candidate marks to factor. + const nonCodeMarks = (node: any): any[] => + (node.marks || []).filter((m: any) => m.type !== "code"); + + // Deep structural equality of two marks (type + full attrs). Two `link` marks + // are equal only when EVERY attr matches (class/href/internal/rel/target/title + // — not just href), so a homogeneous run never merges links that differ. + const marksEqual = (a: any, b: any): boolean => + a.type === b.type && + JSON.stringify(a.attrs ?? null) === JSON.stringify(b.attrs ?? null); + + // Two non-code mark lists are equal AS SETS (a run is homogeneous when every + // node shares the identical non-code mark set — order-independent). + const markSetsEqual = (a: any[], b: any[]): boolean => + a.length === b.length && + a.every((ma) => b.some((mb) => marksEqual(ma, mb))) && + b.every((mb) => a.some((ma) => marksEqual(mb, ma))); + + // Serialize one node's INNER form for a homogeneous run: the factored marks are + // applied by the caller, so here a code node emits only its literal backtick + // span and a non-code node emits only its (escaped) text. + const renderRunInner = (node: any): string => { + const text = node.text || ""; + if ((node.marks || []).some((m: any) => m.type === "code")) { + return `\`${text}\``; // code content is literal + } + return escapeInlineText(text); + }; + + // A markdown emphasis delimiter (`**`/`*`/`~~`/`==`) wrapping a code span opens + // with the delimiter immediately followed by a backtick and closes immediately + // preceded by one. A backtick is CommonMark punctuation, so such a delimiter is + // only left/right-flanking — able to open/close emphasis — when the character + // on its OUTER side is start/end, whitespace or punctuation. If a run boundary + // abuts a word character, the delimiter would NOT flank (`a**` `code` `**` + // never opens) and the emphasis silently degrades on re-import. This checks the + // outer boundary char conservatively: ASCII whitespace or ASCII punctuation (or + // the string edge) is safe; anything else (a letter/number, unicode letter or + // emoji) is treated as unsafe so the run takes the lossless HTML fallback. + const SAFE_BOUNDARY = /[\s!-/:-@[-`{-~]/; + const isSafeBoundary = (c: string): boolean => c === "" || SAFE_BOUNDARY.test(c); + + // Serialize a maximal run of adjacent emphasis-member text nodes that contains + // at least one `code` node (#515). HOMOGENEOUS (all share the identical + // non-code mark set) AND flank-safe on both boundaries: factor the common marks + // ONCE around the concatenated inner spans — `` **`aaa` + `bbb`** ``, code + // innermost. Otherwise — HETEROGENEOUS (non-code sets differ, e.g. `[code,bold]` + // next to `[italic]`) OR a boundary abuts a word char — emit the whole run as + // schema-HTML via the lossless inlineToHtml fallback, avoiding a colliding + // `***` delimiter run or a non-flanking `a**` that would drop the emphasis. + const renderCodeEmphasisRun = ( + run: any[], + prevChar: string, + nextChar: string, + ): string => { + const firstNonCode = nonCodeMarks(run[0]); + const homogeneous = run.every((n) => + markSetsEqual(nonCodeMarks(n), firstNonCode), + ); + if (!homogeneous || !isSafeBoundary(prevChar) || !isSafeBoundary(nextChar)) { + return inlineToHtml(run); + } + let out = run.map(renderRunInner).join(""); + // Apply the common non-code marks in the FIRST node's array order (code is + // already innermost inside each span). + for (const mark of firstNonCode) out = applyInlineMark(out, mark); + return out; + }; + const renderInlineChildren = (nodes: any[]): string => { - const parts = nodes.map(processNode); - for (let i = 0; i < nodes.length - 1; i++) { - if ( - nodes[i]?.type === "mathInline" && - parts[i].startsWith("$") && - /^[0-9]/.test(parts[i + 1] || "") - ) { - parts[i] = mathInlineHtml(nodes[i].attrs?.text || ""); + // Pass 1: segment the nodes. Each segment is either an already-rendered + // non-run node / pure-emphasis node (byte-identical to the pre-#515 output), + // or a DEFERRED code-emphasis run (a maximal block of consecutive + // emphasis-member text nodes containing a code node) — its markdown-vs-HTML + // choice needs the neighbor boundary chars, resolved in pass 2. + type Seg = { firstNode: any; text?: string; run?: any[] }; + const segs: Seg[] = []; + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + if (isEmphasisMember(node)) { + let j = i; + while (j < nodes.length && isEmphasisMember(nodes[j])) j++; + const run = nodes.slice(i, j); + const hasCode = run.some((n: any) => + (n.marks || []).some((m: any) => m.type === "code"), + ); + if (hasCode) { + segs.push({ firstNode: run[0], run }); + } else { + // Pure-emphasis run (no code): render each node as before. + for (const n of run) segs.push({ firstNode: n, text: processNode(n) }); + } + i = j; + } else { + segs.push({ firstNode: node, text: processNode(node) }); + i++; } } - return parts.join(""); + // A deferred run always emits either a delimiter/backtick (markdown) or `<` + // (HTML) first — both punctuation — so a following run counts as a safe + // boundary for the current one without resolving it first. + const firstCharOf = (seg: Seg): string => + seg.text !== undefined ? seg.text[0] || "" : "*"; + // Pass 2: resolve deferred runs left-to-right, tracking the previous emitted + // char (for the opening boundary) and peeking the next segment (for closing). + let prevChar = ""; + for (let k = 0; k < segs.length; k++) { + const seg = segs[k]; + if (seg.text === undefined) { + const nextChar = k + 1 < segs.length ? firstCharOf(segs[k + 1]) : ""; + seg.text = renderCodeEmphasisRun(seg.run!, prevChar, nextChar); + } + if (seg.text.length > 0) prevChar = seg.text[seg.text.length - 1]; + } + // Preserve the mathInline-before-digit guard: a `$…$` immediately followed by + // a digit-leading segment would re-tokenize as a longer math span, so emit + // that math node as HTML instead. A code-emphasis run never starts with a + // digit (it opens with a delimiter or `<`), so segment granularity is safe. + for (let k = 0; k < segs.length - 1; k++) { + if ( + segs[k].firstNode?.type === "mathInline" && + (segs[k].text || "").startsWith("$") && + /^[0-9]/.test(segs[k + 1].text || "") + ) { + segs[k].text = mathInlineHtml(segs[k].firstNode.attrs?.text || ""); + } + } + return segs.map((s) => s.text).join(""); }; // Render inline content (text runs + their marks) to HTML. Used by the raw @@ -1373,7 +1491,17 @@ export function convertProseMirrorToMarkdown( return processNode(n); } let t = escapeHtmlText(n.text || ""); + // #515: wrap `` INNERMOST first (before the array-order mark loop), + // then skip `code` in the loop. Import (`generateJSON`) always yields the + // code mark LAST in the array (canonical order `[emphasis, code]`), so an + // order-sensitive loop would flip `` to `` on + // re-export and break the byte fixpoint. Code-innermost is stable in both + // directions and matches the markdown path (case "text" / run factoring). + if ((n.marks || []).some((m: any) => m.type === "code")) { + t = `${t}`; + } for (const mark of n.marks || []) { + if (mark.type === "code") continue; // wrapped innermost above switch (mark.type) { case "bold": t = `${t}`; diff --git a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts index 63067427..ae15692a 100644 --- a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts +++ b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts @@ -11,9 +11,11 @@ * * The corpus deliberately spans the CommonMark / canon hostile alphabet * (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal - * mark combinations on runs (including the `code` mark, which the schema's - * `excludes: "_"` makes suppress every co-occurring mark — so it is never - * combined with another mark in the byte-stable space). + * mark combinations on runs. As of #515 the `code` mark no longer excludes other + * marks (`excludes: ""`), so the corpus ALSO combines `code` with bold / italic / + * strike / highlight — exercising both the HOMOGENEOUS run factoring (adjacent + * code+bold spans -> `` **`a` `b`** ``) and the HETEROGENEOUS anti-collision + * fallback (`[code,bold]` next to `[italic]` -> schema-HTML, never `` `a`***b* ``). */ import fc from 'fast-check'; @@ -106,16 +108,16 @@ export const urlArb: fc.Arbitrary = fc /** * A text run with an OPTIONAL single non-code formatting mark (bold/italic/ * strike/underline/superscript/subscript/spoiler), or a SOLE `code` mark, or a - * link, or an inline comment anchor. `code` is NEVER combined with another mark - * in the byte-stable space (that combination is a documented converter - * limitation — the schema's `code` mark declares `excludes: "_"`). Marks wrap - * `safeTextArb`, which stays stable even when it contains isolated specials. + * `code` mark COMBINED with a bare-delimiter emphasis mark (#515), or a link, or + * an inline comment anchor. Marks wrap `safeTextArb`, which stays stable even + * when it contains isolated specials. * - * The mark set here is broadened past the sibling test's {bold,italic,strike} - * to also cover underline / superscript / subscript / spoiler / textStyle / - * highlight (all single, non-code marks), so the marks-on-text generator - * exercises every mark the schema declares except the deliberately-excluded - * `code`+other combination. + * The mark set here is broadened past the sibling test's {bold,italic,strike} to + * also cover underline / superscript / subscript / spoiler / textStyle / + * highlight (all single, non-code marks). As of #515 it ALSO emits `code` + * combined with bold/italic/strike, so the assembled inline content exercises the + * converter's code-emphasis run detection (adjacent combos -> homogeneous + * factoring or heterogeneous HTML fallback, both lossless). */ export const markedTextRunArb: fc.Arbitrary = fc.oneof( // Plain text. @@ -138,6 +140,23 @@ export const markedTextRunArb: fc.Arbitrary = fc.oneof( // Sole code mark (backtick span). safeTextArb is backtick-free, so the span // content cannot contain an inner backtick. safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })), + // #515: code COMBINED with a bare-delimiter emphasis mark. The converter nests + // the backtick span inside the emphasis delimiters (`` **`x`** ``) and, when + // such runs sit adjacent, factors a shared mark or falls back to schema-HTML. + // Mark order is `[emphasis, code]` — the canonical order the HTML->PM import + // yields (code last) — so the P1 semantic round-trip is order-exact. + fc + .tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike')) + .map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }, { type: 'code' }] })), + // #515: code combined with an UNCOLORED highlight (also a bare-delimiter mark, + // `==…==`), so the highlight+code delimiter interaction is covered too. Import + // yields `[code, highlight]` here (the `==` inline extension nests code first), + // so the generator matches that order for the order-exact P1 round-trip. + safeTextArb.map((t) => ({ + type: 'text', + text: t, + marks: [{ type: 'code' }, { type: 'highlight' }], + })), // Link with safe text, a paren/space-free href, optionally a letter-bearing // title (a purely numeric title is coerced to a number and dropped). fc diff --git a/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts b/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts index 9008c34b..50212819 100644 --- a/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts +++ b/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts @@ -294,10 +294,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => { ); }); - // 5. code + link co-occur: the schema's `code` mark excludes all other marks - // (including link), so the link cannot survive import. The lossless, - // byte-stable behavior is to emit ONLY the backtick code span (code wins). - it('a code+link run emits the backtick code form (code wins, link dropped)', () => { + // 5. code + link co-occur (#515): `code` no longer excludes other marks, so a + // link can wrap inline code. The code span is emitted innermost and the link + // wraps it — CommonMark allows inline code inside link text, so it survives + // the round trip. + it('a code+link run nests the backtick span inside the link (#515)', () => { const out = convertProseMirrorToMarkdown( doc( para({ @@ -310,7 +311,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => { }), ), ); - expect(out).toBe('`x`'); + expect(out).toBe('[`x`](http://a?b&c"d)'); }); // 6. hardBreak inside a heading: prefix applied once, " \n" between a and b. diff --git a/packages/prosemirror-markdown/test/markdown-converter.test.ts b/packages/prosemirror-markdown/test/markdown-converter.test.ts index bd4e734b..87daad1c 100644 --- a/packages/prosemirror-markdown/test/markdown-converter.test.ts +++ b/packages/prosemirror-markdown/test/markdown-converter.test.ts @@ -59,22 +59,21 @@ describe('convertProseMirrorToMarkdown', () => { ).toBe('`x`'); }); - it('code + another mark emits the backtick code form (code wins)', () => { - // The schema's `code` mark excludes all other marks, so the editor can - // never produce code+bold on one run and import always drops the co-mark. - // The lossless, byte-stable behavior is to emit ONLY the backtick code - // span and ignore the co-occurring mark. + it('code + bold nests the backtick span inside the emphasis (#515)', () => { + // #515: the `code` mark no longer excludes other marks (`excludes: ""`), so + // a run can carry code+bold. CommonMark nests them (``), so + // the code span is emitted innermost and the bold delimiters wrap it. const out = convertProseMirrorToMarkdown( doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))), ); - expect(out).toBe('`x`'); + expect(out).toBe('**`x`**'); }); - it('code + strike combo emits the backtick code form (code wins)', () => { + it('code + strike nests the backtick span inside the emphasis (#515)', () => { const out = convertProseMirrorToMarkdown( doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))), ); - expect(out).toBe('`x`'); + expect(out).toBe('~~`x`~~'); }); }); diff --git a/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts b/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts index 50f83d73..ee61f59b 100644 --- a/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts +++ b/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts @@ -80,13 +80,7 @@ import { stripBlockIds } from './roundtrip-helpers.js'; // `it.fails` blocks below (so the suite stays green only because they are marked // expected-to-fail, never by hiding them): // -// 1. The `code` mark COMBINED with any other mark. The converter emits nested -// HTML (`x`), but the schema's `code` mark -// declares `excludes: "_"`, so on import every co-occurring mark is dropped -// and the run comes back as `code` only -> md2 == "`x`". Acknowledged in -// markdown-converter.ts (the long comment above the marks switch); -// impossible to round-trip both while `code` excludes them. -// 2. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node +// 1. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node // is block-level but `![](url)` is inline; marked wraps it in a

, the // schema hoists the out and leaves an empty paragraph sibling, which // injects an extra blank gap on the second export. An image IS byte-stable @@ -625,7 +619,7 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => { }); // ------------------------------------------------------------------------- - // KNOWN, DOCUMENTED non-roundtrip bug #2 (kept honest as it.fails). + // KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails). // // BUG: a block-level `image` placed BETWEEN other blocks is not byte-stable. // The Docmost image node is BLOCK-level but its markdown form `![](url)` is @@ -655,23 +649,18 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => { }); // ------------------------------------------------------------------------- - // KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails). + // #515 ROUND-TRIP PIN: `code` combined with another mark. // - // BUG: the `code` mark combined with ANY other mark does NOT round-trip. - // The converter emits nested HTML so the output is well-formed, e.g. - // marks [code, bold] -> md1 = "x" - // but the schema's `code` mark declares `excludes: "_"`, so on import the - // co-occurring mark is dropped and the run comes back as code-only: - // md2 = "`x`" (=> md2 !== md1). - // Minimal repro doc: - // { type:'doc', content:[ { type:'paragraph', content:[ - // { type:'text', text:'x', marks:[{type:'code'},{type:'bold'}] } ] } ] } - // This is acknowledged in markdown-converter.ts (the long comment above the - // marks switch): preserving both marks is impossible while `code` excludes - // them. Documented here, not "fixed", because the source must not change. + // Before #515 the `code` mark declared `excludes: "_"`, dropping every co- + // occurring mark on import so `` **`x`** `` came back as code-only. Now + // `excludes: ""` lets code combine with all marks (CommonMark nests them, + // `x`), so the run BOTH round-trips byte-stably + // AND preserves the co-occurring mark. This asserts the observable property in + // both directions: md2 === md1 (idempotent export) and the imported doc still + // carries [code, other]. // ------------------------------------------------------------------------- it( - 'code mark combined with another mark is byte-stable', + 'code combined with another mark round-trips and keeps both marks (#515)', async () => { const codeComboArb = fc .tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike')) @@ -688,11 +677,90 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => { })); await fc.assert( fc.asyncProperty(codeComboArb, async (doc) => { - const { md1, md2 } = await roundTrip(doc); + const { md1, md2, doc2 } = await roundTrip(doc); expect(md2).toBe(md1); + // The re-imported run carries BOTH code and the co-occurring mark. + const run = doc2?.content?.[0]?.content?.[0]; + const markTypes = (run?.marks || []).map((m: any) => m.type).sort(); + expect(markTypes).toContain('code'); + expect(markTypes.length).toBe(2); }), { numRuns: 20, seed: SEED }, ); }, ); + + // ------------------------------------------------------------------------- + // #515 REPRO CASES: the five markdown inputs from the issue must import to a + // code+bold node (import correctness) AND re-export byte-stably with no + // dangling `**` (export correctness). Import direction is checked against the + // real markdown->PM bridge; export direction via the md->pm->md fixpoint. + // ------------------------------------------------------------------------- + it('the five #515 repro cases import to [code,bold] and round-trip clean', async () => { + // Collect every inline text run in a doc with its mark type set. + const runs = (node: any): { text: string; marks: string[] }[] => { + if (node?.type === 'text') { + return [{ text: node.text || '', marks: (node.marks || []).map((m: any) => m.type) }]; + } + return (node?.content || []).flatMap(runs); + }; + const findRun = (doc: any, text: string) => + runs(doc).find((r) => r.text === text); + + // Case 1: **`code1`** -> code1 = [code, bold]. + { + const md = '**`code1`**'; + const pm = await markdownToProseMirror(md); + const r = findRun(pm, 'code1'); + expect(r?.marks.sort()).toEqual(['bold', 'code']); + const md2 = convertProseMirrorToMarkdown(pm); + expect(md2).toBe('**`code1`**'); + // md -> pm -> md fixpoint. + expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2); + } + + // Case 2: **`aaa` + `bbb`** -> aaa,bbb = [code,bold], "+" carries bold; no + // dangling `**` on export. + { + const md = '**`aaa` + `bbb`**'; + const pm = await markdownToProseMirror(md); + expect(findRun(pm, 'aaa')?.marks.sort()).toEqual(['bold', 'code']); + expect(findRun(pm, 'bbb')?.marks.sort()).toEqual(['bold', 'code']); + const md2 = convertProseMirrorToMarkdown(pm); + expect(md2).toBe('**`aaa` + `bbb`**'); + // NOT the old broken export with the bold delimiters split onto each span. + expect(md2).not.toBe('`aaa`** + **`bbb`'); + expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2); + } + + // Case 3 (control): **bold3** and `code3` -> bold and code stay SEPARATE. + { + const md = '**bold3** and `code3`'; + const pm = await markdownToProseMirror(md); + expect(findRun(pm, 'bold3')?.marks).toEqual(['bold']); + expect(findRun(pm, 'code3')?.marks).toEqual(['code']); + const md2 = convertProseMirrorToMarkdown(pm); + expect(md2).toBe('**bold3** and `code3`'); + } + + // Case 4: **`code4` tail** -> code4 = [code,bold], " tail" = [bold]. + { + const md = '**`code4` tail**'; + const pm = await markdownToProseMirror(md); + expect(findRun(pm, 'code4')?.marks.sort()).toEqual(['bold', 'code']); + const md2 = convertProseMirrorToMarkdown(pm); + expect(md2).toBe('**`code4` tail**'); + expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2); + } + + // Case 5: pre **`code5`** post -> code5 = [code,bold], surroundings plain. + { + const md = 'pre **`code5`** post'; + const pm = await markdownToProseMirror(md); + expect(findRun(pm, 'code5')?.marks.sort()).toEqual(['bold', 'code']); + const md2 = convertProseMirrorToMarkdown(pm); + expect(md2).toBe('pre **`code5`** post'); + expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2); + } + }); }); -- 2.52.0 From 0a53be9e810bc2211cb9a2a17abc6c8087943923 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 02:44:54 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(converter):=20correct=20#515=20mark-or?= =?UTF-8?q?der=20comment=20=E2=80=94=20imported=20order=20depends=20on=20e?= =?UTF-8?q?xtension,=20not=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inlineToHtml comment falsely claimed import ALWAYS yields the code mark last; import yields code last for bold/italic/strike but code FIRST for the ==-highlight extension. Reworded to state the real invariant (wrap innermost regardless of imported order) and de-universalized the parallel phrase in text-arbitraries.ts. Comment-only, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/markdown-converter.ts | 15 ++++++++++----- .../test/generative/text-arbitraries.ts | 6 ++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index e96cbad3..eae8c578 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -1492,11 +1492,16 @@ export function convertProseMirrorToMarkdown( } let t = escapeHtmlText(n.text || ""); // #515: wrap `` INNERMOST first (before the array-order mark loop), - // then skip `code` in the loop. Import (`generateJSON`) always yields the - // code mark LAST in the array (canonical order `[emphasis, code]`), so an - // order-sensitive loop would flip `` to `` on - // re-export and break the byte fixpoint. Code-innermost is stable in both - // directions and matches the markdown path (case "text" / run factoring). + // then skip `code` in the loop. The imported mark order is NOT fixed — it + // DEPENDS on the emphasis extension: import (`generateJSON`) yields code + // LAST for bold/italic/strike (`[emphasis, code]`) but code FIRST for the + // `==`-highlight extension (`[code, highlight]`). So we cannot rely on a + // fixed array position; the invariant is instead "wrap `` innermost + // regardless of the imported order". That keeps `` nested inside the + // emphasis tag both directions (preserving the byte fixpoint — an order- + // sensitive loop would flip ``↔`` depending on + // which order it happened to see) and matches the markdown path (case + // "text" / run factoring). if ((n.marks || []).some((m: any) => m.type === "code")) { t = `${t}`; } diff --git a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts index ae15692a..266fd963 100644 --- a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts +++ b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts @@ -143,8 +143,10 @@ export const markedTextRunArb: fc.Arbitrary = fc.oneof( // #515: code COMBINED with a bare-delimiter emphasis mark. The converter nests // the backtick span inside the emphasis delimiters (`` **`x`** ``) and, when // such runs sit adjacent, factors a shared mark or falls back to schema-HTML. - // Mark order is `[emphasis, code]` — the canonical order the HTML->PM import - // yields (code last) — so the P1 semantic round-trip is order-exact. + // Mark order here is `[emphasis, code]` — the order the HTML->PM import yields + // for bold/italic/strike specifically (code last). This is NOT universal: the + // `==`-highlight case below imports code FIRST — so match each case to its own + // imported order for the order-exact P1 round-trip (do not assume a fixed order). fc .tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike')) .map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }, { type: 'code' }] })), -- 2.52.0