Compare commits

...

4 Commits

Author SHA1 Message Date
vvzvlad 6487bdae73 Merge pull request 'fix(converter+schema): эмфаза при инлайн-коде не теряется — снять excludes у code (#515)' (#519) from fix/515-code-emphasis into feat/493-converter
Reviewed-on: #519
2026-07-12 04:06:54 +03:00
agent_coder 0a53be9e81 docs(converter): correct #515 mark-order comment — imported order depends on extension, not fixed
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 <code>
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) <noreply@anthropic.com>
2026-07-12 02:44:54 +03:00
agent_coder 95c0d813b0 fix(converter): round-trip code+эмфаза — code внутри, склейка соседних пробегов, HTML-fallback (#515)
Теперь узел может нести [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>` тоже оборачивается ВНУТРЕННИМ (импорт отдаёт code
последним в массиве — `[emphasis, code]`; порядок-зависимый цикл переворачивал
бы `<strong><code>` в `<code><strong>` на реэкспорте и ломал байт-фикспойнт).

Тесты: перевёрнуты ассерты, фиксировавшие старое (по 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) <noreply@anthropic.com>
2026-07-11 23:41:07 +03:00
agent_coder 9004de60e3 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>
2026-07-11 23:41:07 +03:00
15 changed files with 529 additions and 205 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
@@ -483,6 +483,99 @@ export function convertProseMirrorToMarkdown(
return `<table><tbody>${htmlRows}</tbody></table>`;
};
// 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 `<u>${text}</u>`;
case "subscript":
return `<sub>${text}</sub>`;
case "superscript":
return `<sup>${text}</sup>`;
case "highlight": {
// #293 canon #7: a highlight WITHOUT a color serializes as the
// Obsidian/GFM `==text==` syntax; a colored highlight keeps the `<mark
// style>` HTML form. The inner text already had any literal `==`
// backslash-escaped upstream.
const color = mark.attrs?.color;
return color
? `<mark style="background-color: ${escapeAttr(color)}">${text}</mark>`
: `==${text}==`;
}
case "textStyle":
if (mark.attrs?.color) {
return `<span style="color: ${escapeAttr(mark.attrs.color)}">${text}</span>`;
}
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 `<span data-spoiler="true">${text}</span>`;
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 `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${text}</span>`;
}
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
// `\<char>` 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 = `<u>${textContent}</u>`;
break;
case "subscript":
textContent = `<sub>${textContent}</sub>`;
break;
case "superscript":
textContent = `<sup>${textContent}</sup>`;
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 `<mark style="background-
// color: …">` 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
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
: `==${textContent}==`;
break;
}
case "textStyle":
if (mark.attrs?.color) {
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
}
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 = `<span data-spoiler="true">${textContent}</span>`;
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 = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
}
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 `<span>` 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 <mark>, 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,22 @@ export function convertProseMirrorToMarkdown(
return processNode(n);
}
let t = escapeHtmlText(n.text || "");
// #515: wrap `<code>` INNERMOST first (before the array-order mark loop),
// 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 `<code>` innermost
// regardless of the imported order". That keeps `<code>` nested inside the
// emphasis tag both directions (preserving the byte fixpoint — an order-
// sensitive loop would flip `<strong><code>`↔`<code><strong>` 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 = `<code>${t}</code>`;
}
for (const mark of n.marks || []) {
if (mark.type === "code") continue; // wrapped innermost above
switch (mark.type) {
case "bold":
t = `<strong>${t}</strong>`;
@@ -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<string> = 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<any> = fc.oneof(
// Plain text.
@@ -138,6 +140,25 @@ export const markedTextRunArb: fc.Arbitrary<any> = 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 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' }] })),
// #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
@@ -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.
@@ -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 (`<strong><code>`), 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`~~');
});
});
@@ -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 (`<strong><code>x</code></strong>`), 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 <p>, the
// schema hoists the <img> 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 = "<strong><code>x</code></strong>"
// 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,
// `<strong><code>x</code></strong>`), 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);
}
});
});
@@ -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: {