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:
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user