fix(schema): снять excludes:"_" у марки code — она комбинируется со всеми марками (#515)

По CommonMark `**` рядом с инлайн-кодом дают `<strong><code>` — узел с
составом марок [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--<hash>` и распаковывается
обратно в `code` штатным декодером — правим mcp-тест-хелпер fragmentToJson,
чтобы он снимал хеш ровно как yattr2markname (иначе overlapping-code утёк бы
в сравнение как `code--<hash>`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:22:01 +03:00
parent 3a344626db
commit 9004de60e3
10 changed files with 106 additions and 4 deletions
@@ -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..."),
}),
@@ -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
+1
View File
@@ -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:*",
@@ -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'],
+1
View File
@@ -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";
+20
View File
@@ -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 (`<strong><code>`),
// 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: "",
});
@@ -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--<hash>`).
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;
@@ -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
@@ -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,
@@ -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: {