From 9004de60e3548672d4fb92547f7187da59d034b3 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 23:22:01 +0300 Subject: [PATCH] =?UTF-8?q?fix(schema):=20=D1=81=D0=BD=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20excludes:"=5F"=20=D1=83=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B8=20c?= =?UTF-8?q?ode=20=E2=80=94=20=D0=BE=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B1?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=81=D0=B5=D0=BC=D0=B8=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=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: {