Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 27d51303ba fix(page-tree/dnd): drag авто-раскрытие — задержка 2с, не раскрывать при drop внутрь + guard от потери детей и сигнал «уехало сюда»
Пять точечных клиентских правок (дизайн из адверсариального разбора #523):

1. Задержка hover-раскрытия при drag: `AUTO_EXPAND_MS` 500 → 2000 мс, чтобы
   проведение курсора через дерево не раскрывало ряды — только осознанное
   удержание.
2. Не раскрывать цель при drop внутрь (make-child): удалена строка
   `onToggle(target, true)` в `onDrop` (drop оставляет узел свёрнутым; раскрытие
   только по hover-hold). Восстановление раскрытости самого source не тронуто.
3. Guard от потери детей в `handleMove` (обязателен вместе с п.2): раньше эта же
   `onToggle` была ЕДИНСТВЕННЫМ триггером корректирующего lazy-load. `treeModel.move`
   материализует `target.children = [source]`; для НЕзагруженной цели (предикат
   gate `treeModel.isUnloadedBranch`) это скрыло бы остальных серверных детей папки
   (класс #159 #1). Теперь для незагруженной make-child-цели оптимистичное дерево
   строится БЕЗ материализации source: source удаляется из старого родителя, цели
   ставится `hasChildren:true`; gate остаётся взведён и раскрытие догружает полный
   набор. Для загруженной цели поведение прежнее (append сохраняет детей).
4. Инвалидация крошек: `updateCacheOnMovePage` инвалидирует
   `["breadcrumbs", pageId]` — guard делает `remove(source)`, и для текущей
   открытой страницы `findBreadcrumbPath` промахивается → крошки показывали бы
   старого родителя до refocus.
5. Сигнал «уехало сюда»: транзиентный teal-пульс на строке при make-child-drop в
   свёрнутую цель (узел НЕ раскрывается), отдельный `data-landed-child` +
   `DROP_LANDED_HIGHLIGHT_MS`, таймер чистится при анмаунте; уважает
   prefers-reduced-motion.

Тесты: `handleMove` — незагруженная make-child-цель не материализует `[source]`
(мутация guard'а краснит), загруженная цель append'ит и сохраняет детей,
genuinely-empty лист материализует; инвалидация `["breadcrumbs", pageId]`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:15:02 +03:00
agent_coder c765483947 fix(page-tree/realtime): insertByPosition теряет непоказанных детей — предикат «незагружено» приведён к gate
`insertByPosition` считал узел незагруженным только при `children === undefined`,
но канонический незагруженный вид в этом коде — `children: []` с `hasChildren: true`
(его ставит `pageToTreeNode`, к нему сбрасывает свёрнутые ветки `pruneCollapsedChildren`).
Для реального незагруженного узла guard `=== undefined` не срабатывал → в пустой
список вставлялся `[node]` → lazy-load gate видел `length !== 0` и не догружал
остальных серверных детей папки (скрыты до полной перезагрузки) — тот же класс
потери данных #159 #1.

- Новый общий предикат `treeModel.isUnloadedBranch(node)`:
  `hasChildren === true && (children == null || children.length === 0)` — единый
  источник истины для «отложить ли фетч/материализацию».
- `insertByPosition` использует его вместо `=== undefined`; для действительно
  пустой папки (`hasChildren: false`) вставка первого ребёнка сохраняется.
- gate в `handleToggle` (`space-tree.tsx`) переведён на тот же хелпер, чтобы
  предикаты больше не разъезжались (R3).

Тесты: юнит на `isUnloadedBranch`; `insertByPosition` не материализует
`[node]` в `children:[]`+`hasChildren:true` (и оставляет ветку незагруженной),
но вставляет в genuinely-empty. Мутация: старый `=== undefined` предикат
краснит эти тесты (в т.ч. на уровне `applyMoveTreeNode`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:06:42 +03:00
59 changed files with 952 additions and 2818 deletions
+1 -30
View File
@@ -129,13 +129,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **A drifted comment suggestion can be re-synced instead of failing forever
with a 409.** A suggestion whose stored anchor no longer matched the live
document used to reject every apply attempt with an unrecoverable conflict; a
new resync path re-reads the live anchor so the suggestion applies against the
current text, and orphaned anchors (whose marked run was deleted) are
reconciled rather than left blocking. (#496)
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is
@@ -359,29 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
body-timeout so a legitimate >1-min idle between the model's tool calls no
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
10 min; see `.env.example`). (#489)
- **Decisions on comment suggestions now leave a durable audit record.**
Applying or dismissing a comment suggestion hard-deletes the (childless)
subject comment, so the only surviving trace of who decided what is the audit
event — but the audit trail was wired to a Noop service that silently
swallowed every event. The trail is now DB-backed, so
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
comment-decision events) persist to the `audit` table and can be reviewed
after the comment is gone. A persistence failure is still swallowed with a
warning so it never breaks the originating request. (#496)
- **Applying a comment suggestion no longer strips the replaced run's inline
formatting.** The suggested text was re-inserted carrying only the comment
anchor mark, silently dropping bold/italic/code/link on the affected run; the
prevailing formatting of the replaced run is now carried onto the applied
text. (#496)
- **Markdown round-trips no longer silently drop a line that opens with a block
trigger.** When a document is exported to Markdown and re-imported (git-sync
stabilize, agent writes), a paragraph or continuation line (after a hard break)
that begins with a block marker — an ATX heading `#`, a blockquote/callout `>`,
a list marker (`-`/`*`/`+`/`N.`/`N)`), a code fence, a table `|`, a thematic
break (`---`), or a setext underline (`--`, `----`, or a lone `=`) — is now
backslash-escaped so it round-trips as text instead of being re-parsed into a
heading/list/quote/rule and losing its content. Front-matter stripping is
scoped to the import path only. (#493)
- **The server no longer runs out of heap during long autonomous agent runs.** A
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
snapshot of the ENTIRE turn text on every streamed text-delta when no output
@@ -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, Code } from "@docmost/editor-ext";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
@@ -44,12 +44,7 @@ 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,5 +1,6 @@
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";
@@ -66,7 +67,6 @@ import {
FootnoteReference,
FootnotesList,
FootnoteDefinition,
Code,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -153,10 +153,6 @@ 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
@@ -9,7 +9,7 @@ import { Italic } from "@tiptap/extension-italic";
import { Link } from "@tiptap/extension-link";
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
const ZWSP = "​"; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
const ZWSP = "​"; // U+200B, the helper's block-trigger neutralizer
/**
* #377 — the web-side bridge must append the native host's transcript below the
@@ -18,9 +18,8 @@ const ZWSP = "​"; // U+200B — asserted ABSENT (the block-escape lives in the
* regression would be caught), asserting the resulting document rather than
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
* serializer block-escapes them, so no client-side ZWSP is needed);
* absent/empty/non-string -> no-op.
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
* paragraphs; absent/empty/non-string -> no-op.
*/
describe("gitmostInsertTranscriptIntoEditor", () => {
const makeEditor = () =>
@@ -92,22 +91,19 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
editor.destroy();
});
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
const editor = makeEditor();
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
// git-sync serializer now block-escapes a leading trigger itself, so the
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
// trimmed) — no invisible ZWSP is prepended anymore.
// Trigger lines (some with a leaked indent) + a normal prefixed line.
const inserted = gitmostInsertTranscriptIntoEditor(
editor,
[
"- dash",
" > quote", // leading indent is trimmed, text otherwise verbatim
" > quote", // leading indent must be trimmed then neutralized
"# hash",
"1. one",
"> [!info] note",
"```js",
"---",
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
"***",
"___",
"You: normal line",
@@ -120,23 +116,20 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
.map((n: any) => n.content?.[0]?.text)
.filter((t: any) => typeof t === "string") as string[];
// Each trigger line is stored as its own byte-exact text (indent trimmed);
// the git-sync round-trip keeps it a paragraph via the serializer's
// block-escape, so no ZWSP is needed here.
// Every block-trigger line is prefixed with the invisible ZWSP (indent
// trimmed first); the normal `You:` line is left byte-exact.
expect(texts).toEqual([
"- dash",
"> quote",
"# hash",
"1. one",
"> [!info] note",
"```js",
"---",
"***",
"___",
ZWSP + "- dash",
ZWSP + "> quote",
ZWSP + "# hash",
ZWSP + "1. one",
ZWSP + "> [!info] note",
ZWSP + "```js",
ZWSP + "---",
ZWSP + "***",
ZWSP + "___",
"You: normal line",
]);
// Guard: no invisible ZWSP leaked into any inserted line.
for (const t of texts) expect(t).not.toContain(ZWSP);
editor.destroy();
});
@@ -240,22 +240,45 @@ export async function gitmostUploadFileToEditor(
}
}
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
const GITMOST_ZWSP = "​";
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
// line, the git-sync markdown serializer (packages/prosemirror-markdown
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
// verbatim with NO block-escape (the pre-existing root cause), so a leading
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
// quote / code block / table / callout. The final alternative matches a WHOLE-
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
// that node carries NO text, an un-neutralized separator line would LOSE its
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
// and never matches, so prefixed lines are left byte-exact.
const GITMOST_MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
// Append a transcript block BELOW the recording's audio node in a live editor:
// a "Transcript" heading followed by one paragraph per non-empty transcript
// line. The transcript is plain text, `\n`-separated, each line already
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
// leak into the display). A line that begins with a col-0 markdown block
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
// gone. This is best-effort and meant to run AFTER the audio has already been
// inserted; the caller must guard against a throw so a transcript failure never
// fails the (already successful) recording. Returns true when a block was
// inserted, false when there was nothing to insert (transcript
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
// both leak into the display and, at col 0, form a markdown block trigger) and,
// if it still begins with a col-0 markdown block trigger, gets an invisible
// zero-width space prepended so the git-sync round-trip cannot turn it into a
// list/quote/heading/callout/code/table (defensive boundary against the
// serializer's missing block-escape). This is best-effort and meant to run
// AFTER the audio has already been inserted; the caller must guard against a
// throw so a transcript failure never fails the (already successful) recording.
// Returns true when a block was inserted, false when there was nothing to
// insert (transcript undefined/empty/not-a-string). A non-string value is a
// no-op, not an error.
export function gitmostInsertTranscriptIntoEditor(
editor: Editor,
transcript: unknown,
@@ -265,7 +288,13 @@ export function gitmostInsertTranscriptIntoEditor(
.split("\n")
// Trim each line and drop blank (whitespace-only) ones.
.map((line) => line.trim())
.filter((line) => line.length > 0);
.filter((line) => line.length > 0)
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
// `Speaker N:`) never match and stay byte-exact.
.map((line) =>
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
);
if (lines.length === 0) return false;
const content = [
@@ -665,6 +665,13 @@ export function updateCacheOnMovePage(
pageData: Partial<IPage>,
) {
invalidatePageTree();
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
// guard removes the moved node from the local tree when its new parent is an
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
// falls back to the server `["breadcrumbs", pageId]` query — which this move
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
// refocus/navigation.
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IPage } from "@/features/page/types/page.types";
// A fresh QueryClient stands in for the app singleton (importing the real
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
import { queryClient } from "@/main.tsx";
import { updateCacheOnMovePage } from "./page-query";
// #523: the tree-side child-loss guard removes the moved node from the local
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
// query. That query MUST be invalidated by a move, or the crumbs keep showing
// the OLD parent until a refocus/navigation.
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
beforeEach(() => {
queryClient.clear();
vi.restoreAllMocks();
});
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
const spy = vi.spyOn(queryClient, "invalidateQueries");
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
id: "moved-page",
} as Partial<IPage>);
const invalidatedBreadcrumbs = spy.mock.calls.some(
([arg]) =>
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
);
expect(invalidatedBreadcrumbs).toBe(true);
});
});
@@ -55,7 +55,14 @@ type Props<T extends object> = {
};
const DRAG_TYPE = 'doc-tree-item';
const AUTO_EXPAND_MS = 500;
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
// so merely dragging the cursor THROUGH the tree never expands rows — only a
// deliberate hold does (#523).
const AUTO_EXPAND_MS = 2000;
// How long the "a page just moved in here" cue stays on a collapsed target after
// a make-child drop. Long enough to notice at a glance during frequent
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
const DROP_LANDED_HIGHLIGHT_MS = 1800;
function DocTreeRowInner<T extends object>(props: Props<T>) {
const {
@@ -93,7 +100,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
const rowRef = useRef<HTMLElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<Instruction | null>(null);
// Transient "just received a child" cue: a make-child drop no longer expands
// the (collapsed) target, so flash the row instead so the move isn't invisible.
const [landedChild, setLandedChild] = useState(false);
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelAutoExpand = useCallback(() => {
if (autoExpandTimerRef.current) {
@@ -249,11 +260,24 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
? getDragLabel(sourceNode)
: 'item';
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
// After a make-child drop, expand this row so the user sees the
// just-dropped child — especially important when the row had no
// children before (chevron just appeared) so the drop would
// otherwise be invisible.
if (op.kind === 'make-child') onToggle(node.id, true);
// Do NOT auto-expand the target on drop: a drop must leave the node
// collapsed. Intentional expansion is handled solely by the
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
// given by the post-move flash + landed-cue highlight + live-region
// announce above. When the make-child target is collapsed, flash a
// distinct "child moved in here" cue on the row (it stays collapsed).
if (op.kind === 'make-child' && !isOpen) {
if (landedChildTimerRef.current) {
clearTimeout(landedChildTimerRef.current);
}
setLandedChild(true);
landedChildTimerRef.current = setTimeout(() => {
setLandedChild(false);
landedChildTimerRef.current = null;
}, DROP_LANDED_HIGHLIGHT_MS);
}
// Restore the openness of the MOVED page itself (source) — untouched
// by the above; the target is never expanded here.
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
},
}),
@@ -281,6 +305,17 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
useEffect(
() => () => {
if (landedChildTimerRef.current) {
clearTimeout(landedChildTimerRef.current);
landedChildTimerRef.current = null;
}
},
[],
);
const effectiveInst =
instruction?.type === 'instruction-blocked'
? instruction.desired
@@ -317,6 +352,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
className={styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-landed-child={landedChild || undefined}
data-receiving-drop={
receivingDrop === 'make-child'
? blocked
@@ -281,10 +281,10 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
if (isOpen) {
const node = treeModel.find(data, id) as SpaceTreeNode | null;
if (
node?.hasChildren &&
(!node.children || node.children.length === 0)
) {
// Same "unloaded branch" predicate the insert paths use (`isUnloadedBranch`)
// so the lazy-load gate and the realtime/DnD inserts can never disagree
// about what counts as unloaded (#525).
if (treeModel.isUnloadedBranch(node)) {
const fetched = await fetchAllAncestorChildren({
pageId: id,
spaceId: node.spaceId,
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import type { ReactNode } from "react";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import type { SpaceTreeNode } from "@/features/page/tree/types";
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
// child-loss guard is exercised end-to-end.
const moveMutate = vi.fn().mockResolvedValue({});
const updateCacheOnMovePageMock = vi.fn();
vi.mock("@/features/page/queries/page-query.ts", () => ({
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
updateCacheOnMovePage: (...args: unknown[]) =>
updateCacheOnMovePageMock(...args),
}));
vi.mock("react-router-dom", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (s: string) => s }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
// Import AFTER mocks so the hook binds to them.
import { useTreeMutation } from "./use-tree-mutation";
function node(
id: string,
over: Partial<SpaceTreeNode> = {},
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...over,
};
}
function setup(before: SpaceTreeNode[]) {
const store = createStore();
store.set(treeDataAtom, before);
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
return { store, result };
}
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
beforeEach(() => {
vi.clearAllMocks();
moveMutate.mockResolvedValue({});
});
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
// F has children on the server but none are loaded here (canonical unloaded
// form: hasChildren + children:[]). X sits at root.
const before = [
node("F", { position: "a0", hasChildren: true, children: [] }),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "F",
});
});
const tree = store.get(treeDataAtom);
const f = treeModel.find(tree, "F");
// The guard leaves F unloaded (children stay []), so a later expand fetches
// the FULL server set (incl. X) instead of showing a misleading partial [X].
// MUTATION: dropping the guard (using the `move` result) would put children
// === [X] here and redden this.
expect(f?.children).toEqual([]);
expect(f?.hasChildren).toBe(true);
// X is removed from its old (root) slot; it reappears on expand/load of F.
expect(treeModel.find(tree, "X")).toBeNull();
// The server move is still persisted.
expect(moveMutate).toHaveBeenCalledTimes(1);
});
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
// append X — this path does NOT hit the guard.
const before = [
node("F", {
position: "a0",
hasChildren: true,
children: [node("c1", { position: "a1", parentPageId: "F" })],
}),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "F",
});
});
const tree = store.get(treeDataAtom);
const f = treeModel.find(tree, "F");
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
expect(f?.hasChildren).toBe(true);
// X now lives under F.
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
});
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
// Leaf L has no server children (hasChildren:false). Dropping X in should
// show X immediately — the guard must NOT fire here.
const before = [
node("L", { position: "a0", hasChildren: false, children: [] }),
node("X", { position: "a5" }),
];
const { store, result } = setup(before);
await act(async () => {
await result.current.handleMove("X", {
kind: "make-child",
targetId: "L",
});
});
const tree = store.get(treeDataAtom);
const l = treeModel.find(tree, "L");
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
expect(l?.hasChildren).toBe(true);
});
});
@@ -61,11 +61,48 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
if (!source) return;
const oldParentId = source.parentPageId ?? null;
// optimistic apply with the new position from the payload
let optimistic = treeModel.update(after, sourceId, {
position: payload.position,
parentPageId: payload.parentPageId,
} as Partial<SpaceTreeNode>);
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
// We no longer auto-expand the make-child target on drop, so the old
// `onToggle(target, true)` — which was ALSO the only trigger of the
// corrective lazy-load — is gone. `treeModel.move` materialized
// `target.children = [source]` (only the moved node); if the target is an
// UNLOADED branch (server has children but none are loaded here), keeping
// that partial `[source]` list would defeat the lazy-load gate and hide the
// target's OTHER server children (the #159 #1 data-loss class). So for an
// unloaded make-child target, build the optimistic tree WITHOUT
// materializing source under it: just remove source from its old parent and
// flag the target `hasChildren`. The gate stays armed and a later manual
// expand fetches the FULL set (incl. the moved page, which the awaited
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
const target =
op.kind === "make-child"
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
: null;
const unloadedMakeChild =
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
let optimistic: SpaceTreeNode[];
if (unloadedMakeChild) {
// Do NOT materialize [source] into the unloaded target.
optimistic = treeModel.remove(before, sourceId);
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
} else {
// optimistic apply with the new position from the payload
optimistic = treeModel.update(after, sourceId, {
position: payload.position,
parentPageId: payload.parentPageId,
} as Partial<SpaceTreeNode>);
// For make-child onto a previously-childless (loaded) target: flip
// hasChildren on so the new parent shows its chevron.
if (op.kind === "make-child") {
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
}
// If the old parent has no children left, mark hasChildren: false so the
// chevron disappears. Without this, the empty parent keeps rendering an
@@ -79,14 +116,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
}
}
// For make-child onto a previously-childless target: flip hasChildren on
// so the new parent shows its chevron.
if (op.kind === "make-child") {
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
setData(optimistic);
try {
@@ -74,6 +74,48 @@ describe("treeModel.isDescendant", () => {
});
});
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
// gate and the insert paths. Unloaded == server says hasChildren but none are
// present locally (canonical form `children: []`, also `undefined`). A parent
// without hasChildren is genuinely empty, not unloaded.
describe("treeModel.isUnloadedBranch", () => {
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
it("true for hasChildren + empty array (canonical unloaded form)", () => {
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
expect(treeModel.isUnloadedBranch(n)).toBe(true);
});
it("true for hasChildren + undefined children", () => {
const n: PH = { id: "p", name: "P", hasChildren: true };
expect(treeModel.isUnloadedBranch(n)).toBe(true);
});
it("false for hasChildren + already-loaded children", () => {
const n: PH = {
id: "p",
name: "P",
hasChildren: true,
children: [{ id: "c", name: "C" }],
};
expect(treeModel.isUnloadedBranch(n)).toBe(false);
});
it("false for a genuinely-empty parent (no hasChildren)", () => {
expect(
treeModel.isUnloadedBranch({
id: "p",
name: "P",
hasChildren: false,
children: [],
} as PH),
).toBe(false);
expect(
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
).toBe(false);
});
it("false for null/undefined", () => {
expect(treeModel.isUnloadedBranch(null)).toBe(false);
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
});
});
describe("treeModel.visible", () => {
it("returns only root nodes when no openIds", () => {
const v = treeModel.visible(fixture, new Set());
@@ -197,43 +239,64 @@ describe("treeModel.insertByPosition", () => {
]);
});
// #159 #1: inserting/moving a node under a parent whose children are NOT
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
// parent's other real children. The node is left to be lazy-loaded; only
// `hasChildren` is flagged so the chevron appears.
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
// hide the parent's other real children. The canonical unloaded form here is
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
// The node is left to be lazy-loaded; the chevron stays enabled.
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
// full set, including this node, on expand).
expect(parent?.children).toBeUndefined();
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
// here and reredden this expectation.
expect(parent?.children).toEqual([]);
expect(treeModel.find(t, "x")).toBeNull();
// ...but the chevron is enabled so the user can expand to load it.
// ...and the chevron stays enabled so the user can expand to load it.
expect((parent as PH).hasChildren).toBe(true);
});
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
expect(parent?.children).toBeUndefined();
expect(treeModel.find(t, "x")).toBeNull();
expect((parent as PH).hasChildren).toBe(true);
});
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
// A loaded (empty) child list is complete, so the node IS inserted.
// No server children (`hasChildren: false`), so materializing the first child
// is correct — nothing is hidden.
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
});
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
});
@@ -43,6 +43,24 @@ export const treeModel = {
};
},
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
// but none are present locally. The canonical unloaded form in this codebase
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
// only checks `=== undefined` misses the real case and materializes a misleading
// partial list (#525). This is the SINGLE source of truth for "should a
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`),
// the realtime insert path (`insertByPosition`) and the DnD move guard, so they
// can never drift apart again. A parent WITHOUT `hasChildren` is genuinely empty
// (no server children) — inserting its first child is correct, not deferred.
isUnloadedBranch<T extends object>(
node: TreeNode<T> | null | undefined,
): boolean {
if (!node) return false;
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
return hasChildren && (node.children == null || node.children.length === 0);
},
isDescendant<T extends object>(
tree: TreeNode<T>[],
ancestorId: string,
@@ -127,14 +145,15 @@ export const treeModel = {
}
const parent = treeModel.find(tree, parentId);
// The parent is in the tree but its children have NOT been lazy-loaded yet
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
// here would MATERIALIZE a misleading partial child list (`[node]`) that
// defeats the lazy-load gate — which fetches only when children are
// absent/empty — so the parent's OTHER real children would never load and the
// moved/added node would be the only one shown (a silent data loss, #159 #1).
// Instead, leave the children unloaded and just flag `hasChildren` so the
// chevron appears; expanding fetches the FULL set (including this node).
if (parent && parent.children === undefined) {
if (parent && treeModel.isUnloadedBranch(parent)) {
return treeModel.update(
tree,
parentId,
@@ -150,6 +150,45 @@
);
}
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
the collapsed target, so the moved page is momentarily invisible; pulse the
target row in a distinct teal (NOT the blue make-child highlight, NOT the
neutral post-move flash) so the landing is noticeable while the node stays
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
which the row clears the attribute and the animation stops. */
@keyframes landedChildPulse {
0% {
background-color: light-dark(
var(--mantine-color-teal-2),
rgba(45, 212, 191, 0.30)
);
outline-color: light-dark(
var(--mantine-color-teal-6),
var(--mantine-color-teal-5)
);
}
100% {
background-color: transparent;
outline-color: transparent;
}
}
.node[data-landed-child="true"] {
outline: 2px solid transparent;
outline-offset: -1px;
animation: landedChildPulse 0.9s ease-out 2;
}
@media (prefers-reduced-motion: reduce) {
.node[data-landed-child="true"] {
animation: none;
background-color: light-dark(
var(--mantine-color-teal-1),
rgba(45, 212, 191, 0.18)
);
}
}
.dropLine {
position: absolute;
left: var(--drop-line-indent, 0);
@@ -82,17 +82,19 @@ describe("applyMoveTreeNode", () => {
]);
});
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded
// (children === undefined). The OLD behavior inserted `src` as the ONLY
// child ([src]), which defeated the lazy-load gate and HID the parent's
// other real children. Now the move leaves children unloaded (so expanding
// fetches the FULL set, including src) and just flags hasChildren.
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
// The pre-#525 predicate (`children === undefined`) missed this form and
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
// HIDING the parent's other real children. Now the move leaves children
// unloaded (so expanding fetches the FULL set, including src).
const tree: SpaceTreeNode[] = [
node("dstCollapsed", {
position: "a0",
hasChildren: false,
children: undefined as unknown as SpaceTreeNode[],
hasChildren: true,
children: [],
}),
node("src", { position: "a9" }),
];
@@ -105,9 +107,10 @@ describe("applyMoveTreeNode", () => {
pageData: {},
});
const dst = treeModel.find(next, "dstCollapsed");
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
// src) on expand, rather than showing a misleading partial [src] list.
expect(dst?.children).toBeUndefined();
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
// `=== undefined` predicate would insert [src] here and redden this.
expect(dst?.children).toEqual([]);
expect(dst?.hasChildren).toBe(true);
// src moved away from its old root slot (it lives under dstCollapsed
// server-side and reappears when the parent is expanded/loaded).
-1
View File
@@ -41,7 +41,6 @@
"@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:*",
+2 -2
View File
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { AuditModule } from './integrations/audit/audit.module';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { McpModule } from './integrations/mcp/mcp.module';
import { SandboxModule } from './integrations/sandbox/sandbox.module';
@@ -55,7 +55,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
AuditModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -49,7 +49,6 @@ import {
FootnotesList,
FootnoteDefinition,
PageEmbed,
Code,
} from '@docmost/editor-ext';
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
@@ -68,12 +67,7 @@ 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'],
@@ -529,107 +529,4 @@ describe('replaceYjsMarkedText', () => {
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
// #496: apply must NOT silently strip the replaced run's inline formatting.
// Build a paragraph and format the marked range with extra marks, then assert
// the replacement carries them.
function buildFormatted(
runs: Array<{ text: string; attrs?: Record<string, any> }>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, runs.map((r) => r.text).join(''));
let offset = 0;
for (const run of runs) {
if (run.attrs) text.format(offset, run.text.length, run.attrs);
offset += run.text.length;
}
return { fragment, text };
}
it('preserves the original run formatting (bold + link) on the replacement', () => {
const { fragment, text } = buildFormatted([
{ text: 'see ' },
{
text: 'old',
attrs: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ text: ' end' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
expect(result).toEqual({ applied: true, currentText: 'new' });
// The comment anchor AND the bold/link marks survive the delete+insert.
expect(text.toDelta()).toEqual([
{ insert: 'see ' },
{
insert: 'new',
attributes: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ insert: ' end' },
]);
});
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
// bold on the result only holds if the code genuinely selects the LONGEST run.
const { fragment, text } = buildFormatted([
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
{
text: 'bolded',
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
});
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
// Two equal-length runs (2 chars each) with different formatting, same
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
// (italic) prevails over the later bold one.
const { fragment, text } = buildFormatted([
{
text: 'AA',
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
},
{
text: 'BB',
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
},
]);
});
});
+5 -20
View File
@@ -145,10 +145,6 @@ type MarkedSegment = {
length: number;
text: string;
markAttrs: Record<string, any>;
// The FULL attribute set of this delta run — the `comment` mark plus any
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
// original run's formatting onto the replacement instead of dropping it.
attributes: Record<string, any>;
};
/**
@@ -206,7 +202,6 @@ export function replaceYjsMarkedText(
length,
text: insert,
markAttrs: markAttr,
attributes,
});
}
offset += length;
@@ -256,25 +251,15 @@ export function replaceYjsMarkedText(
return { applied: false, currentText: joinedText };
}
// 3. All guards passed: delete the marked run and re-insert newText at the
// same offset. Atomic within the caller's transaction.
// 3. All guards passed: delete the marked run and re-insert newText with the
// same comment attributes at the same offset. Atomic within the caller's
// transaction.
const start = segments[0].offset;
const len = segments.reduce((sum, s) => sum + s.length, 0);
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
// with only the `comment` mark silently dropped bold/italic/code/link of the
// replaced text. Yjs applies one flat attribute set to the whole insert, so
// when the marked run mixes formatting we pick the DOMINANT segment (the one
// covering the most characters) and apply its attributes — a v1 that preserves
// the common single-format case exactly and, for a mixed run, keeps the
// prevailing style rather than losing all of it. `attributes` already carries
// the `comment` mark (every collected segment is filtered on it above), so the
// anchor is preserved by copying the run's attribute set verbatim.
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
const insertAttrs = { ...dominant.attributes };
const markAttrs = segments[0].markAttrs;
node.delete(start, len);
node.insert(start, newText, insertAttrs);
node.insert(start, newText, { comment: markAttrs });
return { applied: true, currentText: newText };
}
@@ -16,7 +16,6 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
import { ResyncSuggestionAnchorDto } from './dto/resync-suggestion-anchor.dto';
import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -236,39 +235,6 @@ export class CommentController {
return this.commentService.applySuggestion(comment, user, provenance);
}
@HttpCode(HttpStatus.OK)
@Post('resync-suggestion-anchor')
async resyncSuggestionAnchor(
@Body() dto: ResyncSuggestionAnchorDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
// Authorize BEFORE revealing structural detail (mirrors apply/dismiss).
// Re-anchoring does NOT change the page text — it only corrects the stored
// selection metadata — so the page-level gate is comment access. The service
// further restricts it to the suggestion's own author.
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.resyncSuggestionAnchor(
comment,
dto.selection,
user,
);
}
@HttpCode(HttpStatus.OK)
@Post('dismiss-suggestion')
async dismissSuggestion(
@@ -146,19 +146,11 @@ describe('CommentService — applySuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: hard-deleted row → the audit payload is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -197,25 +189,17 @@ describe('CommentService — applySuggestion', () => {
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(resolvePatch.resolvedById).toBe('user-1');
// NOT deleted.
// NOT deleted; broadcast an update, not a deletion.
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'deleteCommentMark',
expect.anything(),
expect.anything(),
);
// #496 dedup: resolveComment broadcasts `commentResolved` with the enriched
// row; finalize must NOT ALSO emit a redundant `commentUpdated`. So the
// thread receives exactly ONE resolve broadcast and no update broadcast.
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentResolved', comment: UPDATED }),
);
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated' }),
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
);
expect(auditService.log).toHaveBeenCalledWith(
@@ -227,36 +211,6 @@ describe('CommentService — applySuggestion', () => {
expect(result.outcome).toBe('resolved');
});
it('re-entry: already applied+resolved WITH replies → emits commentUpdated (dedup does not over-suppress)', async () => {
// suggestionAppliedAt set → idempotent finalize; resolvedAt set → resolveComment
// is skipped, so there is NO commentResolved broadcast. The applied-stamp state
// must still reach clients via a single commentUpdated.
const { service, wsService } = makeService(
{ applied: false, currentText: 'new text' },
true,
);
await service.applySuggestion(
suggestionComment({
suggestionAppliedAt: new Date(),
resolvedAt: new Date(),
}),
user(),
);
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
);
// Nothing resolved this time (already resolved) → no resolve broadcast.
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentResolved' }),
);
});
// --- error / rejection branches -----------------------------------------
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
@@ -107,21 +107,11 @@ describe('CommentService — dismissSuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: the row is hard-deleted, so the audit payload must carry the
// decision's substance (what was suggested, the anchored text, who authored
// it, who decided) — it is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -1,126 +0,0 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { CommentService } from './comment.service';
/**
* Coverage for CommentService.resyncSuggestionAnchor (#496): re-anchoring a
* suggestion's stored selection (== apply-time expectedText) to the live-doc
* substring. The service is built directly with jest-mocked deps (the
* @InjectQueue tokens can't be resolved by Test.createTestingModule see the
* sibling specs).
*/
describe('CommentService — resyncSuggestionAnchor', () => {
const UPDATED = { id: 'c-1', selection: 'new anchor', __updated: true } as any;
function makeService() {
const commentRepo: any = {
updateComment: jest.fn(async () => undefined),
findById: jest.fn(async () => UPDATED),
};
const service = new CommentService(
commentRepo,
{} as any,
{ emitCommentEvent: jest.fn() } as any,
{} as any,
{ add: jest.fn() } as any,
{ add: jest.fn() } as any,
{ log: jest.fn() } as any,
);
return { service, commentRepo };
}
const suggestion = (over?: Partial<any>): any => ({
id: 'c-1',
creatorId: 'user-1',
parentCommentId: null,
selection: 'old anchor',
suggestedText: 'new text',
suggestionAppliedAt: null,
resolvedAt: null,
...over,
});
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
it('persists the new selection and returns the enriched comment', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion(),
'new anchor',
user(),
);
expect(commentRepo.updateComment).toHaveBeenCalledWith(
{ selection: 'new anchor' },
'c-1',
);
expect(out).toBe(UPDATED);
});
it('is idempotent: no write when the anchor already matches', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion({ selection: 'same' }),
'same',
user(),
);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
expect(out).toEqual(suggestion({ selection: 'same' }));
});
it('rejects a non-author (only the suggestion owner may re-anchor)', async () => {
const { service, commentRepo } = makeService();
await expect(
service.resyncSuggestionAnchor(suggestion(), 'new anchor', user({ id: 'other' })),
).rejects.toBeInstanceOf(ForbiddenException);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
});
it('rejects a reply / a comment with no suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ parentCommentId: 'p-1' }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: null }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects re-anchoring an already applied or resolved suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestionAppliedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ resolvedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a no-op selection equal to the suggested text', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: 'new text' }),
'new text',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+17 -122
View File
@@ -370,76 +370,6 @@ export class CommentService {
return updatedComment;
}
/**
* Re-sync a suggestion's stored `selection` (== apply-time expectedText) to the
* RAW substring the inline mark actually covers in the LIVE document (#496).
*
* The MCP client creates the comment from a DEBOUNCED REST snapshot, then
* anchors the mark in the live collab doc. When the two disagree (the doc moved
* on in the debounce window) the stored selection no longer equals the marked
* text, so EVERY apply 409s ("the commented text changed"). After anchoring the
* client re-reads the exact marked substring and calls this to store it, making
* apply's strict equality hold.
*
* Only meaningful for an un-settled top-level suggestion authored by the
* caller: applying/resolving freezes the anchor, and a reply-carrying thread is
* preserved rather than mutated. The new text must still differ from the
* suggestion (else "apply" would be a no-op), preserving create()'s invariant.
*/
async resyncSuggestionAnchor(
comment: Comment,
selection: string,
user: User,
): Promise<Comment> {
if (comment.creatorId !== user.id) {
throw new ForbiddenException(
'You can only re-anchor your own suggestion',
);
}
if (comment.parentCommentId) {
throw new BadRequestException(
'Only a top-level comment can carry a suggested edit',
);
}
if (!comment.suggestedText) {
throw new BadRequestException('This comment has no suggested edit');
}
// A settled suggestion's anchor is frozen: re-anchoring an applied/resolved
// thread is meaningless and could resurrect a stale expectedText.
if (comment.suggestionAppliedAt || comment.resolvedAt) {
throw new BadRequestException(
'Cannot re-anchor a suggestion that was already applied or resolved',
);
}
const trimmed = selection.trim();
if (trimmed.length === 0) {
throw new BadRequestException('The re-anchored selection cannot be empty');
}
// Same no-op guard as create(): the suggestion must differ from the text it
// replaces, or apply becomes indistinguishable from already-applied.
if (trimmed === comment.suggestedText.trim()) {
throw new BadRequestException(
'A suggested edit must differ from the selected text',
);
}
// Idempotent: nothing to persist when the anchor already matches.
if (comment.selection === selection) {
return comment;
}
await this.commentRepo.updateComment({ selection }, comment.id);
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
// Re-anchoring only corrects stored metadata; it does not change the page
// text or the comment body, so no ws broadcast / notification is warranted.
return updatedComment;
}
/**
* Apply the suggested edit carried by a top-level inline comment: atomically
* replace the text under the comment mark in the collaborative document with
@@ -594,7 +524,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: this.suggestionAuditMetadata(comment, user),
metadata: { pageId: comment.pageId },
});
return { ...updatedComment, outcome: 'resolved' };
}
@@ -608,7 +538,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: this.suggestionAuditMetadata(comment, user),
metadata: { pageId: comment.pageId },
});
return settled;
}
@@ -647,10 +577,8 @@ export class CommentService {
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
// broadcast and the resolve notification. Stay defensive on re-entry.
let didResolveBroadcast = false;
if (!comment.resolvedAt) {
await this.resolveComment(comment, true, user, provenance);
didResolveBroadcast = true;
}
const updatedComment = await this.commentRepo.findById(comment.id, {
@@ -658,27 +586,18 @@ export class CommentService {
includeResolvedBy: true,
});
// #496 dedup: resolveComment already broadcast `commentResolved` carrying
// the fully-enriched row (the applied stamps were persisted above, before
// that call, so its re-read reflects them). Emitting `commentUpdated` here
// too made the client receive TWO events for one apply. Broadcast the
// update ONLY when we did NOT resolve — i.e. the rare re-entry on an
// already-resolved thread, where the applied-stamp change still needs a
// broadcast and resolveComment did not run.
if (!didResolveBroadcast) {
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
}
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
this.auditService.log({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: this.suggestionAuditMetadata(comment, user),
metadata: { pageId: comment.pageId },
});
return { ...updatedComment, outcome: 'resolved' };
@@ -697,7 +616,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: this.suggestionAuditMetadata(comment, user),
metadata: { pageId: comment.pageId },
});
return settled;
@@ -708,17 +627,14 @@ export class CommentService {
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
*
* ORDER MATTERS (updated #399 #496): what runs FIRST and FATALLY here is the
* mark-removal ENQUEUE (a fast, durable Redis add), NOT the mark op itself.
* deleteCommentMark awaits only the enqueue, so a failed add throws BEFORE the
* irreversible row delete the row + mark stay consistent and the operation is
* repeatable. The actual anchor strip then runs off the HTTP path in the worker
* (idempotent, 3 retries). Only an EXHAUSTED-retries job could leave the doc
* with an orphan anchor pointing at a hard-deleted comment (the data-integrity
* bug #329 targets); that residual divergence is now self-healed by the
* resolve/unresolve mark worker, which strips an orphan mark whenever its
* comment row is gone (#496), and it is meanwhile VISIBLE via BullMQ failed-job
* metrics rather than a silently-swallowed warn.
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
* applySuggestion, which mutates the doc before writing the DB). The row
* delete is irreversible, so if the mark removal fails including the
* COLLAB_DISABLE_REDIS "no live instance" hard-error we must NOT delete the
* row and report success, or the document is left with a permanent orphan
* anchor pointing at a comment that no longer exists (the exact data-integrity
* bug #329 targets). Let the exception propagate ( 5xx); the operation is
* then repeatable with row + mark still consistent.
*
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
* removal, so a reply can land in that window. `comments.parent_comment_id` is
@@ -816,27 +732,6 @@ export class CommentService {
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
}
/**
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
* The subject comment is HARD-DELETED on the childless path, so the audit row
* is the only surviving record capture the decision's substance (what was
* suggested, the anchored text it replaced, who authored it, who decided)
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
* is the suggestion's creator.
*/
private suggestionAuditMetadata(
comment: Comment,
user: User,
): Record<string, any> {
return {
pageId: comment.pageId,
suggestedText: comment.suggestedText ?? null,
selection: comment.selection ?? null,
commentAuthor: comment.creatorId ?? null,
decidedBy: user.id,
};
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
@@ -1,20 +0,0 @@
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
/**
* #496: after the MCP client anchors a suggestion in the LIVE collab doc, it
* re-reads the exact substring under the new mark and syncs it here as the
* comment's stored `selection` (== apply-time expectedText). Fixes the perpetual
* 409 where expectedText came from a debounced REST snapshot while the mark sat
* in the live doc.
*/
export class ResyncSuggestionAnchorDto {
@IsUUID()
commentId: string;
// The raw substring the mark now covers in the live document. Bounded like the
// create-time selection (2000) so a legitimate anchored span is never cut.
@IsString()
@MinLength(1)
@MaxLength(2000)
selection: string;
}
@@ -53,10 +53,8 @@ import {
extractPageSlugId,
} from '../../../integrations/export/utils';
import { canonicalizeFootnotes } from '@docmost/editor-ext';
import {
markdownToProseMirror,
normalizeForeignMarkdown,
} from '@docmost/prosemirror-markdown';
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
import { normalizeForeignMarkdown } from '../../../integrations/import/utils/foreign-markdown';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
@@ -1,19 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE } from './audit.service';
import { DatabaseAuditService } from './database-audit.service';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
// which silently dropped every event). Kysely (@Global DatabaseModule) and
// ClsService (@Global ClsModule) are both globally available, so this module
// needs no extra imports.
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: DatabaseAuditService,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class AuditModule {}
export class NoopAuditModule {}
@@ -1,198 +0,0 @@
import { Logger } from '@nestjs/common';
import { DatabaseAuditService } from './database-audit.service';
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Observable-property coverage for the DB-backed audit trail (#496): every
* assertion pins what actually reaches the `audit` table (or that nothing does),
* driven through a chainable Kysely mock that captures the inserted rows.
*/
describe('DatabaseAuditService', () => {
function makeService(clsContext: any) {
const inserted: any[] = [];
const updated: any[] = [];
let failNextInsert = false;
const db: any = {
insertInto: jest.fn(() => ({
values: jest.fn((rows: any) => ({
execute: jest.fn(async () => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('boom');
}
inserted.push(rows);
}),
})),
})),
updateTable: jest.fn(() => ({
set: jest.fn((patch: any) => ({
where: jest.fn(() => ({
execute: jest.fn(async () => {
updated.push(patch);
}),
})),
})),
})),
};
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
const cls: any = {
get: jest.fn((key: string) => store[key]),
set: jest.fn((key: string, val: any) => {
store[key] = val;
}),
};
const service = new DatabaseAuditService(db, cls);
return {
service,
inserted,
updated,
cls,
store,
failInsert: () => {
failNextInsert = true;
},
};
}
const applyPayload = () => ({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
const ctx = (over?: any) => ({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
ipAddress: '10.0.0.1',
userAgent: 'jest',
...over,
});
it('log() persists a row with the CLS context merged onto the payload', async () => {
const { service, inserted } = makeService(ctx());
service.log(applyPayload());
// log() is fire-and-forget; flush the microtask queue.
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0]).toMatchObject({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
ipAddress: '10.0.0.1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
});
it('log() is a no-op when there is no workspace in scope', async () => {
const { service, inserted } = makeService(ctx({ workspaceId: null }));
service.log(applyPayload());
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
const { service, inserted } = makeService(ctx());
service.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
});
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('a failed insert is swallowed with a warn and floats no rejection (audit is a side-record)', async () => {
// This pins the load-bearing swallow inside persist(). Because log() is
// fire-and-forget (`void this.persist(...)`), it always returns synchronously
// without throwing — so `not.toThrow()` alone would stay green even if the
// try/catch were removed. We instead observe the two effects the catch is
// responsible for: a warn IS emitted, and NO unhandled rejection floats.
// Removing persist()'s try/catch reddens both assertions (warn count 0 + a
// captured rejection).
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
const rejections: unknown[] = [];
const onRejection = (err: unknown) => rejections.push(err);
process.on('unhandledRejection', onRejection);
try {
const { service, failInsert } = makeService(ctx());
failInsert();
expect(() => service.log(applyPayload())).not.toThrow();
// Flush microtasks so the rejected insert settles, then give any floated
// rejection a macrotask tick to be reported by the runtime.
await Promise.resolve();
await Promise.resolve();
await new Promise((r) => setImmediate(r));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain(
'Failed to persist audit event',
);
expect(rejections).toHaveLength(0);
} finally {
process.off('unhandledRejection', onRejection);
warnSpy.mockRestore();
}
});
it('logWithContext() persists with an explicit (non-request) context', async () => {
const { service, inserted } = makeService(undefined);
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0].actorType).toBe('system');
expect(inserted[0].workspaceId).toBe('ws-1');
});
it('logBatchWithContext() inserts only non-excluded events', async () => {
const { service, inserted } = makeService(undefined);
service.logBatchWithContext(
[
applyPayload(),
{
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-2',
},
],
ctx() as any,
);
await Promise.resolve();
await Promise.resolve();
// Batch is a single insert call carrying only the applied event.
expect(inserted).toHaveLength(1);
expect(inserted[0]).toHaveLength(1);
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
});
it('setActorId / setActorType mutate the ambient CLS context', () => {
const { service, store } = makeService(ctx({ actorId: null }));
service.setActorId('u-9');
service.setActorType('api_key');
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
});
it('updateRetention() writes the workspace retention window', async () => {
const { service, updated } = makeService(ctx());
await service.updateRetention('ws-1', 30);
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
});
});
@@ -1,160 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ClsService } from 'nestjs-cls';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
AuditContext,
AUDIT_CONTEXT_KEY,
} from '../../common/middlewares/audit-context.middleware';
import {
AuditLogPayload,
ActorType,
EXCLUDED_AUDIT_EVENTS,
} from '../../common/events/audit-events';
import { AuditLogContext, IAuditService } from './audit.service';
/**
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
* decision-bearing events notably comment.suggestion_applied /
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
* childless path leave a durable record of who decided what. Without this the
* events were emitted (comment.service / *.controller) but swallowed, so an
* applied/dismissed suggestion was unrecoverable once the row was gone.
*
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
* per-request actor/workspace/ip come from the CLS AuditContext populated by
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
* request (queue workers, imports) pass an explicit context via
* logWithContext / logBatchWithContext.
*
* Audit is a side-record: a write failure MUST NOT break the originating
* request, so every persistence path swallows its error with a warn. Events in
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
*/
@Injectable()
export class DatabaseAuditService implements IAuditService {
private readonly logger = new Logger(DatabaseAuditService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly cls: ClsService,
) {}
/**
* Persist a single event using the ambient request-scoped AuditContext. A
* no-op when there is no workspace in scope (the table's workspace_id is NOT
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
* awaited by hot callers, and its rejection is swallowed here.
*/
log(payload: AuditLogPayload): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (!context?.workspaceId) {
// No workspace in scope — nothing we can attribute the row to. This is
// expected for events emitted outside an HTTP request; those callers must
// use logWithContext instead.
return;
}
void this.persist(payload, {
workspaceId: context.workspaceId,
actorId: context.actorId ?? undefined,
actorType: context.actorType,
ipAddress: context.ipAddress ?? undefined,
userAgent: context.userAgent ?? undefined,
});
}
/** Persist a single event with an explicit (non-request) context. */
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
if (!context?.workspaceId) return;
void this.persist(payload, context);
}
/** Persist a batch of events sharing one explicit context (imports). */
logBatchWithContext(
payloads: AuditLogPayload[],
context: AuditLogContext,
): void {
if (!context?.workspaceId || payloads.length === 0) return;
const rows = payloads
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
.map((p) => this.toRow(p, context));
if (rows.length === 0) return;
this.db
.insertInto('audit')
.values(rows)
.execute()
.catch((err: any) =>
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
);
}
/** Update the ambient request actor (e.g. after login resolves the user). */
setActorId(actorId: string): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorId = actorId;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Update the ambient request actor type (user | system | api_key). */
setActorType(actorType: ActorType): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorType = actorType;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Persist a workspace's audit-log retention window (days). */
async updateRetention(
workspaceId: string,
retentionDays: number,
): Promise<void> {
try {
await this.db
.updateTable('workspaces')
.set({ auditRetentionDays: retentionDays })
.where('id', '=', workspaceId)
.execute();
} catch (err: any) {
this.logger.warn(
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
);
}
}
private async persist(
payload: AuditLogPayload,
context: AuditLogContext,
): Promise<void> {
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
try {
await this.db
.insertInto('audit')
.values(this.toRow(payload, context))
.execute();
} catch (err: any) {
// Audit is a side-record; never let a failed write surface to the caller.
this.logger.warn(
`Failed to persist audit event ${payload.event}: ${err?.message}`,
);
}
}
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
return {
workspaceId: context.workspaceId,
actorId: context.actorId ?? null,
actorType: context.actorType ?? 'user',
event: payload.event,
resourceType: payload.resourceType,
resourceId: payload.resourceId ?? null,
spaceId: payload.spaceId ?? null,
// jsonb columns: node-postgres serializes plain objects to JSON.
changes: payload.changes ? (payload.changes as any) : null,
metadata: payload.metadata ? (payload.metadata as any) : null,
ipAddress: context.ipAddress ?? null,
};
}
}
@@ -22,12 +22,10 @@ import { v7 } from 'uuid';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
import { canonicalizeFootnotes } from '@docmost/editor-ext';
import {
markdownToProseMirror,
normalizeForeignMarkdown,
} from '@docmost/prosemirror-markdown';
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
import { formatImportHtml } from '../utils/import-formatter';
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
@@ -18,10 +18,8 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import { canonicalizeFootnotes } from '@docmost/editor-ext';
import {
markdownToProseMirror,
normalizeForeignMarkdown,
} from '@docmost/prosemirror-markdown';
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
import {
FileTaskStatus,
FileTaskType,
@@ -1,15 +1,12 @@
import { describe, it, expect } from 'vitest';
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import {
normalizeForeignMarkdown,
normalizeAgentMarkdown,
} from '../src/lib/foreign-markdown.js';
convertProseMirrorToMarkdown,
markdownToProseMirror,
} from '@docmost/prosemirror-markdown';
import { normalizeForeignMarkdown } from './foreign-markdown';
/**
* STEP 2 goldens for issue #345 (moved into the package with the normalizer in
* #493): the foreign-markdown normalizer that runs at the import boundary BEFORE
* the strict canonical parser (`markdownToProseMirror`).
* STEP 2 goldens for issue #345: the foreign-markdown normalizer that runs at the
* import boundary BEFORE the strict canonical parser (`markdownToProseMirror`).
*
* Two layers:
* 1. PURE stringstring cases pinning the normalizer's own behavior (GFM
@@ -219,53 +216,3 @@ describe('foreign markdown import acceptance (normalizer + canonical parser)', (
).toHaveLength(1);
});
});
describe('normalizeAgentMarkdown vs normalizeForeignMarkdown — front-matter strip is IMPORT-only (#493 review)', () => {
// A page that OPENS with a horizontalRule and contains a later `---` serializes
// to a `---…---`-shaped body. On a full-body AGENT rewrite this must NOT be
// mistaken for YAML front-matter and stripped — that silently dropped the
// page's leading content.
const rulePage = '---\n\nIntro\n\nMore\n\n---\n\nRest';
it('normalizeAgentMarkdown does NOT strip a leading ---…--- (no content loss)', () => {
expect(normalizeAgentMarkdown(rulePage)).toBe(rulePage);
});
it('normalizeForeignMarkdown (file import) STILL strips a real leading YAML front-matter block', () => {
const withYaml = '---\ntitle: My Page\ntags: [a, b]\n---\n\nBody here.';
const out = normalizeForeignMarkdown(withYaml);
expect(out).toBe('Body here.');
// And the horizontalRule-shaped body IS stripped on the import path (its
// documented file-import behavior) — the two variants differ ONLY here.
expect(normalizeForeignMarkdown(rulePage)).not.toContain('Intro');
});
it('agent-write round-trip keeps a horizontalRule-led doc with a second rule intact', async () => {
// Simulate the serializer output for [horizontalRule, para, para, horizontalRule, para].
const doc = {
type: 'doc',
content: [
{ type: 'horizontalRule' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Intro' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'More' }] },
{ type: 'horizontalRule' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Rest' }] },
],
};
const body = convertProseMirrorToMarkdown(doc);
// The agent-write normalization must NOT eat the head; re-import keeps every
// paragraph's text.
const back = await markdownToProseMirror(normalizeAgentMarkdown(body));
const texts = JSON.stringify(back);
for (const t of ['Intro', 'More', 'Rest']) expect(texts).toContain(t);
// Both horizontal rules survive.
expect(back.content.filter((n: any) => n.type === 'horizontalRule')).toHaveLength(2);
});
it('agent-write STILL rewrites GFM reference footnotes (the shared drift-fix)', () => {
const gfm = 'See[^1].\n\n[^1]: the note.';
const out = normalizeAgentMarkdown(gfm);
expect(out).toContain('^[the note.]');
expect(out).not.toMatch(/\[\^1\]:/);
});
});
@@ -1,14 +1,7 @@
/**
* Foreign-markdown normalizer an input-liberal / output-canonical adapter that
* runs at the IMPORT boundary, BEFORE the canonical parser
* (`markdownToProseMirror`, this package).
*
* OWNED BY THIS PACKAGE (#493): the normalizer used to live only in
* apps/server's import path, so the MCP page-write path (`updatePageMarkdown` ->
* `markdownToProseMirrorCanonical`) handled the SAME foreign input differently
* (no front-matter strip, no `[^id]` reference-footnote rewrite) than the server
* importer. Moving it here and calling it from `markdownToProseMirrorCanonical`
* makes every canonical import boundary treat foreign markdown identically.
* (`markdownToProseMirror` from `@docmost/prosemirror-markdown`).
*
* The canonical parser is deliberately STRICT: it only understands Docmost's
* canonical markdown surface (Obsidian-style `> [!type]` callouts, Pandoc/Obsidian
@@ -254,18 +247,11 @@ function convertReferenceFootnotes(markdown: string): string {
const YAML_FRONT_MATTER_RE = /^\uFEFF?---\n[\s\S]*?\n---\n?/;
/**
* Normalize a foreign markdown string from a FILE IMPORT into Docmost's canonical
* markdown surface so the strict canonical parser accepts it losslessly: normalize
* line endings, strip a leading YAML front-matter block, then rewrite GFM reference
* footnotes into inline footnotes. Add further fixture-driven foreign-surface cases
* here as they are found.
*
* FRONT-MATTER STRIP IS IMPORT-ONLY (#493 review): use this ONLY at the server
* file-import boundary, where a `.md` file really can open with an Obsidian/Hugo
* YAML header. Do NOT use it on the canonical AGENT-WRITE path see
* {@link normalizeAgentMarkdown} for why a full-body agent rewrite must NOT strip
* a leading `---…---` (it is normally a horizontalRule the serializer emitted, and
* stripping it would silently drop the page's leading content).
* Normalize a foreign markdown string into Docmost's canonical markdown surface
* so the strict canonical parser accepts it losslessly: normalize line endings,
* strip a leading YAML front-matter block, then rewrite GFM reference footnotes
* into inline footnotes. Add further fixture-driven foreign-surface cases here as
* they are found.
*/
export function normalizeForeignMarkdown(markdown: string): string {
if (!markdown) return markdown;
@@ -278,26 +264,3 @@ export function normalizeForeignMarkdown(markdown: string): string {
const withoutFrontMatter = src.replace(YAML_FRONT_MATTER_RE, '').trimStart();
return convertReferenceFootnotes(withoutFrontMatter);
}
/**
* Canonical AGENT-WRITE normalization: normalize line endings and rewrite GFM
* `[^id]` reference footnotes to inline `^[body]` but DELIBERATELY NOT strip a
* leading YAML front-matter block.
*
* WHY the split (#493 review): the reference-footnote rewrite is the drift the
* MCP page-write path (`updatePageMarkdown` -> `markdownToProseMirrorCanonical`)
* needed unified with the server import (an agent may paste GFM footnotes). The
* front-matter strip, however, is a FILE-import concern: on a full-body agent
* rewrite a leading `---…---` is (almost) always a `horizontalRule` the
* serializer emitted plus a later rule/heading NOT a foreign YAML header so
* `YAML_FRONT_MATTER_RE` would match it and SILENTLY DELETE the page's leading
* content (a page that starts with a horizontal rule and contains a second `---`
* lost everything up to it). Agent writes must never lose already-stored content,
* so this variant skips the strip. It IS a no-op on canonical serialized content
* (which never emits `[^id]:` reference-definition lines).
*/
export function normalizeAgentMarkdown(markdown: string): string {
if (!markdown) return markdown;
const src = markdown.replace(/\r\n/g, '\n');
return convertReferenceFootnotes(src);
}
@@ -139,19 +139,13 @@ describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
);
});
it('reconcile (#496): comment row vanished → strips the orphan anchor mark', async () => {
it('skips (no throw) when the comment row has vanished', async () => {
const { proc, collaborationGateway, commentRepo } = makeProc();
commentRepo.findById.mockResolvedValue(undefined);
await expect(
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
).resolves.toBeUndefined();
// A resolve/unresolve mark job whose comment row is gone leaves a silent
// orphan; the worker self-heals by stripping the anchor instead of returning.
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'deleteCommentMark',
'page.page-1',
{ commentId: 'c-1', user: { id: 'user-1' } },
);
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
});
});
@@ -95,8 +95,7 @@ export class GeneralQueueProcessor
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
* critical path. Runs the SAME gateway path the synchronous comment.service
* code used (byte-identical mark op):
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute),
* OR strip an orphan anchor when the comment row has vanished (#496);
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute);
* - delete deleteCommentMark (strip the ephemeral-suggestion anchor #329).
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
* WorkerHost the job is retried and, on exhaustion, surfaces in failed-job
@@ -134,18 +133,7 @@ export class GeneralQueueProcessor
// of this resolve), skip it rather than flip the mark to a stale state.
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
// #496 reconcile: the comment row is GONE (e.g. an ephemeral apply/dismiss
// hard-deleted it while this resolve/unresolve mark job sat in the queue),
// but its inline anchor may still live in the doc — a silent orphan mark
// pointing at a comment that no longer exists. Self-heal by stripping it
// instead of just returning: this closes the divergence the fire-and-forget
// resolve/unresolve enqueue (comment.service resolveComment) could leave.
// Idempotent — deleteCommentMark on an already-absent mark is a no-op.
await this.getCollaborationGateway().handleYjsEvent(
'deleteCommentMark',
documentName,
{ commentId, user },
);
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
return;
}
const wantResolved = action === 'resolve';
-1
View File
@@ -1,5 +1,4 @@
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
@@ -1,20 +0,0 @@
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: "",
});
+2 -8
View File
@@ -72,13 +72,7 @@ export async function stabilizePageFile(
* keeps re-pulls of an unchanged page byte-identical (no churn, loop-guard).
*/
export async function stabilizePageBody(content: unknown): Promise<string> {
// git-sync is the LOSSLESS mirror path, so run the serializer in `strict`
// mode: a node/mark type the converter has no case for (e.g. one added to the
// schema without a matching serializer arm) throws a ConverterLossError here
// rather than silently degrading — surfacing the loss loudly at write time
// instead of committing a lossy file. Valid content (every current schema type
// has a case) is unaffected.
const md1 = convertProseMirrorToMarkdown(content, { strict: true });
const md1 = convertProseMirrorToMarkdown(content);
const doc2 = await markdownToProseMirror(md1);
return convertProseMirrorToMarkdown(doc2, { strict: true });
return convertProseMirrorToMarkdown(doc2);
}
-18
View File
@@ -4,7 +4,6 @@ import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
import { parseDocmostMarkdown } from '@docmost/prosemirror-markdown';
import { ConverterLossError } from '@docmost/prosemirror-markdown';
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
@@ -67,23 +66,6 @@ describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () =>
expect(body1).toContain('data-src="/d.drawio"');
});
it('runs the serializer in STRICT mode — an unmappable node throws, not a lossy write (#493)', async () => {
// git-sync is the lossless mirror path: a node type the converter has no
// case for (here a fabricated one, standing in for a schema type added
// without a matching serializer arm) must surface loudly at write time
// rather than being silently flattened into a lossy .md file.
const content = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'ok' }] },
{ type: 'quantumWidget', content: [{ type: 'text', text: 'lost?' }] },
],
};
await expect(stabilizePageFile(content, meta)).rejects.toBeInstanceOf(
ConverterLossError,
);
});
it('already-stable content is unchanged by the pass (idempotent)', async () => {
// Plain prose is already a fixpoint; stabilizing it once and twice agree.
const content = {
-42
View File
@@ -450,12 +450,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
// can surface the closest-block / spans-multiple-blocks hint built from the
// LIVE document (the pre-check page is not in scope there).
let liveNotFoundError: Error | null = null;
// #496: the RAW substring the mark actually covers in the LIVE doc. The
// stored selection (payload.selection) came from a DEBOUNCED REST snapshot,
// which can differ from the live doc — and apply compares the marked live
// text to the stored selection strictly, so a stale snapshot 409s on EVERY
// apply. Captured here (same doc version the mark is set in) and synced below.
let liveAnchoredSelection: string | null = null;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
@@ -495,12 +489,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
}
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
anchored = true;
// For a suggestion, re-read the exact substring now under the mark
// (the mark is an attribute, so it does not change the raw text) to
// sync as the stored expectedText after the mutation resolves.
if (hasSuggestion) {
liveAnchoredSelection = getAnchoredText(doc, selection as string);
}
return doc;
}
// Selection text not found in the LIVE document: abort the write. The
@@ -539,36 +527,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
);
}
// #496: sync the stored selection (== apply-time expectedText) to the RAW
// substring the mark actually covers in the LIVE doc when it diverged from
// the debounced REST snapshot we stored at create time. Without this, apply
// strictly compares the marked live text to a stale stored selection and
// 409s every time. Best-effort: the comment is already correctly anchored, so
// a resync failure must NOT roll it back — it only risks a later apply 409,
// which we surface as a soft warning.
if (
hasSuggestion &&
liveAnchoredSelection != null &&
liveAnchoredSelection !== payload.selection
) {
try {
await this.client.post("/comments/resync-suggestion-anchor", {
commentId: newCommentId,
selection: liveAnchoredSelection,
});
// Reflect the corrected anchor in the returned comment.
if (result.data) result.data.selection = liveAnchoredSelection;
} catch (e) {
if (process.env.DEBUG) {
console.error("Failed to resync suggestion anchor:", e);
}
result.warning =
"The suggestion was anchored, but its stored selection could not be " +
"synced to the live document; applying it may report a conflict if the " +
"text changed. Re-create the suggestion if Apply fails.";
}
}
// Soft warning (like editPageText): the selection only matched after
// stripping markdown, so the caller likely quoted a styled fragment.
if (anchorNormalized) {
+3 -24
View File
@@ -10,10 +10,7 @@ import { JSDOM } from "jsdom";
// handled there). MCP consumes it directly instead of maintaining its own
// drifted marked pipeline; only the collab/yjs write glue and the footnote
// canonicalization wrapper stay mcp-side.
import {
markdownToProseMirror,
normalizeAgentMarkdown,
} from "@docmost/prosemirror-markdown";
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import {
@@ -23,7 +20,6 @@ import {
} from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import { regraftResolvedComments } from "./comment-anchor.js";
import { VerifyReport } from "./diff.js";
import { acquireCollabSession } from "./collab-session.js";
@@ -101,15 +97,6 @@ global.WebSocket = WebSocket;
* plain `markdownToProseMirror` (no canonicalization) safe now because inline
* `^[body]` footnotes carry their body at the reference point, so a comment can
* no longer produce a reference-less footnote definition to be dropped.
*
* #493: `normalizeAgentMarkdown` runs FIRST, so an agent's `updatePageMarkdown`
* body gets the SAME GFM `[^id]` reference-footnote -> inline `^[body]` rewrite as
* the server import path (instead of the reference leaking as literal text / a
* bogus link). It DELIBERATELY does NOT strip a leading YAML front-matter block:
* a full-body agent rewrite that opens with a `---…---` is (almost) always a
* horizontalRule the serializer emitted, and stripping it would silently drop the
* page's leading content (#493 review). The front-matter strip stays on the
* server FILE-import boundary only (`normalizeForeignMarkdown`).
*/
export async function markdownToProseMirrorCanonical(
markdownContent: string,
@@ -118,9 +105,7 @@ export async function markdownToProseMirrorCanonical(
// canonicalizing, so the canonicalizer re-hangs references and drops the
// now-orphaned duplicate definitions.
return canonicalizeFootnotes(
normalizeAndMergeFootnotes(
await markdownToProseMirror(normalizeAgentMarkdown(markdownContent)),
),
normalizeAndMergeFootnotes(await markdownToProseMirror(markdownContent)),
);
}
@@ -359,12 +344,6 @@ export async function updatePageContentRealtime(
pageId,
collabToken,
baseUrl,
// #493: an agent read HIDES resolved-comment anchors (#337), so the markdown
// it sends here no longer carries them — a naive full rewrite would erase
// every resolved comment mark. Re-graft the resolved marks from the LIVE doc
// onto the matching text in the freshly-imported body. Active comments are
// untouched (they ride through the markdown themselves); a resolved span whose
// text the agent changed simply does not re-anchor and is dropped.
(liveDoc) => regraftResolvedComments(liveDoc, tiptapJson),
() => tiptapJson,
);
}
+3 -112
View File
@@ -312,9 +312,10 @@ export function canAnchorInDoc(doc: any, selection: string): boolean {
function spliceCommentMark(
blockContent: any[],
match: AnchorMatch,
commentMark: any,
commentId: string,
): void {
const { startChild, startOffset, endChild, endOffset } = match;
const commentMark = makeCommentMark(commentId);
const fragments: any[] = [];
for (let k = startChild; k <= endChild; k++) {
@@ -450,22 +451,6 @@ export function applyAnchorInDoc(
doc: any,
selection: string,
commentId: string,
): boolean {
return applyCommentMarkInDoc(doc, selection, makeCommentMark(commentId));
}
/**
* Core of {@link applyAnchorInDoc}, but splices an ARBITRARY comment mark object
* (not just a fresh `{ commentId, resolved:false }`) across the first matching
* range. This lets a caller re-apply a mark that carries `resolved:true` and any
* other stored attrs. Depth-first (same order as canAnchorInDoc); mutates in
* place on the first matching block and returns true, else returns false without
* mutating.
*/
export function applyCommentMarkInDoc(
doc: any,
selection: string,
commentMark: any,
): boolean {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return false;
@@ -474,7 +459,7 @@ export function applyCommentMarkInDoc(
if (!Array.isArray(node.content)) return false;
const match = findAnchorInBlock(node.content, effective);
if (match) {
spliceCommentMark(node.content, match, commentMark);
spliceCommentMark(node.content, match, commentId);
return true;
}
for (const child of node.content) {
@@ -486,97 +471,3 @@ export function applyCommentMarkInDoc(
};
return visit(doc, 0);
}
/** A resolved inline-comment span lifted from a doc: its mark + anchored text. */
export interface ResolvedCommentSpan {
commentId: string;
/** The full comment mark (carrying `resolved:true` + any stored attrs). */
mark: any;
/** The concatenated raw text the mark spans — used as the re-anchor selection. */
text: string;
}
/** True when a text node carries a RESOLVED comment mark; returns that mark. */
function resolvedCommentMarkOf(node: any): any | null {
if (!node || node.type !== "text" || !Array.isArray(node.marks)) return null;
return (
node.marks.find(
(m: any) =>
m && m.type === "comment" && m.attrs?.resolved === true && m.attrs?.commentId,
) || null
);
}
/**
* Collect every RESOLVED inline-comment span in `doc`, in document order. Within
* each block's direct content, a maximal run of consecutive text nodes sharing
* the same resolved `commentId` is ONE span; its concatenated raw text is the
* selection used to re-anchor it elsewhere. Active (unresolved) comment marks are
* ignored they survive a markdown round-trip on their own (a page read emits
* their `<span data-comment-id>` wrapper), whereas resolved anchors are hidden
* from agent reads (#337) and would be erased by a full-body markdown rewrite.
*/
export function collectResolvedCommentSpans(doc: any): ResolvedCommentSpan[] {
const spans: ResolvedCommentSpan[] = [];
const visit = (node: any, depth: number): void => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return;
if (!Array.isArray(node.content)) return;
const content = node.content;
let i = 0;
while (i < content.length) {
const mark = resolvedCommentMarkOf(content[i]);
if (mark) {
const commentId = mark.attrs.commentId;
let text = "";
let j = i;
while (j < content.length) {
const mj = resolvedCommentMarkOf(content[j]);
if (!mj || mj.attrs.commentId !== commentId) break;
text += typeof content[j].text === "string" ? content[j].text : "";
j++;
}
if (text.length > 0) spans.push({ commentId, mark, text });
i = j > i ? j : i + 1;
} else {
i++;
}
}
for (const child of content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
visit(child, depth + 1);
}
}
};
visit(doc, 0);
return spans;
}
/**
* Re-graft RESOLVED comment marks from `oldDoc` onto matching text ranges in
* `newDoc`, returning a NEW doc (never mutates the inputs).
*
* WHY (#493): an agent read hides resolved-comment anchors (#337), so the
* markdown it sends to a FULL-body rewrite (`updatePageMarkdown`) no longer
* carries them a naive full write would erase every resolved comment mark.
* This restores them: each resolved span from the previous document is re-anchored
* onto the SAME text in the newly-imported body (first occurrence, using the
* shared anchoring / markdown-strip fallback), preserving `resolved:true` and the
* stored attrs. A span whose text the agent changed or deleted simply does not
* re-anchor and is dropped (its anchor is gone; it was already resolved). Active
* comments are untouched they ride through the markdown themselves.
*/
export function regraftResolvedComments<T = any>(oldDoc: any, newDoc: T): T {
if (!newDoc || typeof newDoc !== "object") return newDoc;
const spans = collectResolvedCommentSpans(oldDoc);
if (spans.length === 0) return newDoc;
const out =
typeof structuredClone === "function"
? structuredClone(newDoc)
: (JSON.parse(JSON.stringify(newDoc)) as T);
for (const span of spans) {
// Clone the mark so the new document never shares a mark object with oldDoc.
const markClone = { type: "comment", attrs: { ...span.mark.attrs } };
applyCommentMarkInDoc(out, span.text, markClone);
}
return out;
}
+93 -21
View File
@@ -1,30 +1,64 @@
/**
* Locator normalization helpers for mcp. The two PRIMITIVES
* `stripInlineMarkdown` (lenient locator normalizer) and `stripWrappersAndLinks`
* (strict balanced-wrapper/link collapse) live in the canonical package
* `@docmost/prosemirror-markdown` (#493 dedup: they used to be forked verbatim
* here). This module now only re-exports `stripInlineMarkdown` and adds the two
* mcp-only helpers built on top: `stripBalancedWrappers` and `closestBlockHint`.
* Locator normalization: strip inline markdown wrappers and trailing
* decoration from a LOCATOR string so a find/anchor that the model wrote with
* markdown (or a stray emoji) can still match the document's plain text.
*
* They are used ONLY as a fallback for LOCATING (after an exact match fails) and
* for formatting-vs-plain intent detection; never applied to replacement text or
* inserted node content, so no formatting is ever lost.
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
* it is never applied to replacement text or inserted node content, so no
* formatting is ever lost.
*/
import {
stripInlineMarkdown,
stripWrappersAndLinks,
} from "@docmost/prosemirror-markdown";
// Re-export the canonical locator normalizer so mcp call sites keep importing it
// from `./text-normalize.js` unchanged.
export { stripInlineMarkdown };
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
const MAX_PASSES = 8;
/**
* STRICT formatting detector distinct from the lenient locator normalization.
* It strips ONLY what unambiguously is markdown markup (links/images to visible
* text, and balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers) and
* DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone marker
* chars (the lenient extras `stripInlineMarkdown` does).
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
* the string stops changing (nested wrappers like `**_x_**`).
*/
const WRAPPER_PATTERNS: RegExp[] = [
/\*\*([^*]+?)\*\*/g, // **x**
/__([^_]+?)__/g, // __x__
/~~([^~]+?)~~/g, // ~~x~~
/\*([^*]+?)\*/g, // *x*
/_([^_]+?)_/g, // _x_
/``([^`]+?)``/g, // ``x``
/`([^`]+?)`/g, // `x`
];
/** Links/images -> their visible text. `!?` covers both `[t](u)` and `![a](s)`. */
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
/**
* Apply ONLY the two balanced/link passes shared by both normalizers: first
* collapse links/images to their visible text, then collapse balanced inline
* wrappers repeatedly until stable. Does NOT trim decoration, does NOT guard
* against an empty result it returns exactly the transformed string.
*/
function stripWrappersAndLinks(s: string): string {
// 1. Links/images -> their visible text.
let out = s.replace(LINK_IMAGE_RE, "$1");
// 2. Strip balanced wrappers, repeating until the string is stable so nested
// wrappers (`**_x_**`) and adjacent runs both collapse.
for (let pass = 0; pass < MAX_PASSES; pass++) {
const before = out;
for (const re of WRAPPER_PATTERNS) {
out = out.replace(re, "$1");
}
if (out === before) break;
}
return out;
}
/**
* STRICT formatting detector distinct from the lenient locator
* normalization below. It strips ONLY what unambiguously is markdown markup:
* 1. links/images `[text](url)` -> `text`, `![alt](src)` -> `alt`, and
* 2. balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers (repeat-until-stable),
* and DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone
* marker chars (the lenient extras `stripInlineMarkdown` does in its step 3).
*
* It exists ONLY to recognize formatting-vs-plain INTENT in `applyTextEdits`
* (deciding whether find/replace differ purely by markdown markers). Because it
@@ -43,6 +77,44 @@ export function stripBalancedWrappers(s: string): string {
return stripWrappersAndLinks(s);
}
/**
* Conservatively strip inline markdown from a locator string.
*
* Deterministic, order-fixed steps:
* 1. Links/images: `[text](url)` -> `text`, `![alt](src)` -> `alt`.
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
* applied repeatedly until stable for nested cases.
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
* etc.) are NEVER trimmed.
*
* If the result is empty (e.g. the input was only markers like `***`), the
* ORIGINAL string is returned so a locator can never normalize down to "" and
* match everything.
*/
export function stripInlineMarkdown(s: string): string {
if (typeof s !== "string" || s.length === 0) return s;
// 1 + 2. Shared link/image and balanced-wrapper passes.
let out = stripWrappersAndLinks(s);
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
// Anchored runs only — interior text and sentence punctuation are untouched.
const DECORATION =
"[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
out = out
.replace(new RegExp("^" + DECORATION, "u"), "")
.replace(new RegExp(DECORATION + "$", "u"), "");
// 4. Never normalize a locator down to nothing.
if (out.length === 0) return s;
return out;
}
/**
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
* editPageText (json-edit) and createComment (client) so both surface the
@@ -553,189 +553,6 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
assert.equal(createPayload.suggestedText, "goodbye");
});
// -----------------------------------------------------------------------------
// 8b) #496: the DEBOUNCED REST snapshot (pages/info) DIFFERS from the LIVE collab
// doc (mutatePage) — the doc moved on in the debounce window. The stored
// selection is captured from the snapshot at create time, but the mark is set
// in the live doc, so apply would 409 forever. After anchoring, the client
// re-reads the RAW substring under the mark from the LIVE doc and POSTs it to
// /comments/resync-suggestion-anchor so the stored expectedText matches.
// -----------------------------------------------------------------------------
test("suggestion: re-syncs the stored selection to the LIVE doc substring when the REST snapshot lagged", async () => {
let createPayload = null;
let resyncPayload = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
// DEBOUNCED snapshot: ASCII quotes (stale — the live doc has since been
// typographically corrected).
sendJson(res, 200, {
data: {
id: "33333333-3333-3333-3333-333333333333",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: 'he said "hello" loudly' }],
},
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createPayload = JSON.parse(raw);
sendJson(res, 200, {
data: {
id: "cmt-resync-1",
content: createPayload.content,
selection: createPayload.selection,
suggestedText: createPayload.suggestedText,
type: createPayload.type,
},
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncPayload = JSON.parse(raw);
sendJson(res, 200, { data: { id: "cmt-resync-1" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "33333333-3333-3333-3333-333333333333";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
// LIVE doc: SMART quotes (what the mark is actually set over).
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "he said “hello” loudly" }],
},
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"33333333-3333-3333-3333-333333333333",
"please change",
"inline",
'"hello"', // ASCII quotes
undefined,
"goodbye",
);
assert.equal(result.success, true);
assert.equal(result.anchored, true);
// Create stored the STALE snapshot substring (ASCII quotes).
assert.equal(createPayload.selection, '"hello"');
// …then the client re-synced to the LIVE marked substring (smart quotes).
assert.ok(resyncPayload, "/comments/resync-suggestion-anchor must be called");
assert.equal(resyncPayload.commentId, "cmt-resync-1");
assert.equal(resyncPayload.selection, "“hello”");
// The returned comment reflects the corrected anchor.
assert.equal(result.data.selection, "“hello”");
});
// -----------------------------------------------------------------------------
// 8c) #496: when the REST snapshot and the LIVE doc AGREE, no resync round-trip
// is made (the common case must stay a single write).
// -----------------------------------------------------------------------------
test("suggestion: no resync call when the snapshot already matches the live doc", async () => {
let resyncCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "44444444-4444-4444-4444-444444444444",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
const p = JSON.parse(raw);
sendJson(res, 200, {
data: { id: "cmt-nosync-1", content: p.content, selection: p.selection, suggestedText: p.suggestedText, type: p.type },
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncCalls++;
sendJson(res, 200, { data: {} });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "44444444-4444-4444-4444-444444444444";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"44444444-4444-4444-4444-444444444444",
"rename",
"inline",
"brave",
undefined,
"bold",
);
assert.equal(result.anchored, true);
assert.equal(resyncCalls, 0, "matching snapshot must NOT trigger a resync round-trip");
});
// -----------------------------------------------------------------------------
// 8) #408: a not-found selection error QUOTES the closest block text so the
// model can self-correct instead of blind-retrying.
@@ -108,17 +108,6 @@ 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.
@@ -132,8 +121,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: yattr2markname(type), attrs }
: { type: yattr2markname(type) },
? { type, attrs }
: { type },
);
}
return node;
@@ -1,107 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectResolvedCommentSpans,
regraftResolvedComments,
applyCommentMarkInDoc,
} from "../../build/lib/comment-anchor.js";
/**
* #493 commit 6 resolved-comment anchors must survive a full markdown rewrite
* (updatePageMarkdown). An agent read HIDES resolved anchors (#337), so its
* markdown drops them; a naive full write would erase the resolved comment marks.
* `regraftResolvedComments(oldDoc, newDoc)` re-anchors them onto the matching
* text. These exercise the real anchoring (no mock).
*/
const doc = (...content) => ({ type: "doc", content });
const para = (...content) => ({ type: "paragraph", content });
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
const resolvedComment = (commentId) => ({ type: "comment", attrs: { commentId, resolved: true } });
const activeComment = (commentId) => ({ type: "comment", attrs: { commentId, resolved: false } });
/** The comment mark on a text node, or null. */
function commentMarkOf(node) {
const marks = Array.isArray(node?.marks) ? node.marks : [];
return marks.find((m) => m && m.type === "comment") || null;
}
/** Flatten every text node in a doc (deep). */
function textNodes(node, out = []) {
if (!node || typeof node !== "object") return out;
if (node.type === "text") out.push(node);
if (Array.isArray(node.content)) for (const c of node.content) textNodes(c, out);
return out;
}
test("collectResolvedCommentSpans: only resolved marks, concatenated across a run", () => {
const old = doc(
para(
text("keep "),
text("resolved bit", [resolvedComment("r1")]),
text(" and "),
text("active bit", [activeComment("a1")]),
),
);
const spans = collectResolvedCommentSpans(old);
assert.equal(spans.length, 1);
assert.equal(spans[0].commentId, "r1");
assert.equal(spans[0].text, "resolved bit");
assert.equal(spans[0].mark.attrs.resolved, true);
});
test("regraft restores a resolved mark the agent's markdown dropped", () => {
// OLD doc has a resolved comment on "important note".
const old = doc(para(text("An "), text("important note", [resolvedComment("r1")]), text(" here.")));
// NEW doc (re-imported from the agent's markdown) has the SAME text but NO
// comment mark — the resolved anchor was hidden on read.
const fresh = doc(para(text("An important note here.")));
const out = regraftResolvedComments(old, fresh);
// Inputs are not mutated.
assert.equal(commentMarkOf(textNodes(fresh)[0]), null);
// The resolved mark is back on exactly "important note".
const marked = textNodes(out).filter((n) => commentMarkOf(n));
assert.equal(marked.length, 1);
assert.equal(marked[0].text, "important note");
assert.equal(commentMarkOf(marked[0]).attrs.commentId, "r1");
assert.equal(commentMarkOf(marked[0]).attrs.resolved, true);
});
test("a resolved span whose text the agent changed is dropped (no re-anchor)", () => {
const old = doc(para(text("stale text", [resolvedComment("r1")])));
const fresh = doc(para(text("completely rewritten body")));
const out = regraftResolvedComments(old, fresh);
assert.equal(textNodes(out).filter((n) => commentMarkOf(n)).length, 0);
});
test("regraft is a no-op when the old doc has no resolved comments", () => {
const old = doc(para(text("plain "), text("active", [activeComment("a1")])));
const fresh = doc(para(text("plain active")));
const out = regraftResolvedComments(old, fresh);
assert.equal(textNodes(out).filter((n) => commentMarkOf(n)).length, 0);
});
test("multiple distinct resolved comments are all restored", () => {
const old = doc(
para(text("first", [resolvedComment("r1")]), text(" middle "), text("second", [resolvedComment("r2")])),
);
const fresh = doc(para(text("first middle second")));
const out = regraftResolvedComments(old, fresh);
const byId = Object.fromEntries(
textNodes(out)
.filter((n) => commentMarkOf(n))
.map((n) => [commentMarkOf(n).attrs.commentId, n.text]),
);
assert.equal(byId["r1"], "first");
assert.equal(byId["r2"], "second");
});
test("applyCommentMarkInDoc preserves an arbitrary mark's attrs (resolved:true)", () => {
const d = doc(para(text("anchor me somewhere")));
const ok = applyCommentMarkInDoc(d, "anchor me", { type: "comment", attrs: { commentId: "x9", resolved: true } });
assert.equal(ok, true);
const marked = textNodes(d).filter((n) => commentMarkOf(n));
assert.equal(marked[0].text, "anchor me");
assert.equal(commentMarkOf(marked[0]).attrs.resolved, true);
});
@@ -16,7 +16,6 @@
* `@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";
@@ -1482,20 +1481,7 @@ 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
+1 -28
View File
@@ -15,10 +15,7 @@ export {
} from "./markdown-document.js";
export type { DocmostMdMeta } from "./markdown-document.js";
export {
convertProseMirrorToMarkdown,
ConverterLossError,
} from "./markdown-converter.js";
export { convertProseMirrorToMarkdown } from "./markdown-converter.js";
export type { ConvertProseMirrorToMarkdownOptions } from "./markdown-converter.js";
export {
@@ -26,19 +23,6 @@ export {
markdownToProseMirrorSync,
} from "./markdown-to-prosemirror.js";
// Foreign-markdown normalizer (#493): the input-liberal pre-pass that rewrites
// GFM `[^id]` reference footnotes to canonical inline `^[body]`. Two variants:
// `normalizeForeignMarkdown` (server FILE-import boundary) ALSO strips a leading
// YAML front-matter block; `normalizeAgentMarkdown` (canonical AGENT-WRITE path,
// mcp `markdownToProseMirrorCanonical`) does NOT — a full-body agent rewrite must
// not lose a leading `---…---` horizontalRule to the front-matter strip (#493
// review). The reference-footnote rewrite is shared so agent + import stay unified
// where it matters, without the content-losing strip on the write path.
export {
normalizeForeignMarkdown,
normalizeAgentMarkdown,
} from "./foreign-markdown.js";
// The Docmost tiptap schema mirror. Exposed so consumers (and the sync
// engine's schema-validity regression tests) can build the exact ProseMirror
// schema the converter targets.
@@ -92,17 +76,6 @@ export type { OutlineEntry } from "./node-ops.js";
// string (#414: single copy shared by mcp and the CommonJS server app).
export { parseNodeArg } from "./parse-node-arg.js";
// Locator markdown-stripping (#493 dedup): the single canonical copy of the
// markdown-tolerant anchor-normalization primitives, imported by mcp's
// text-normalize.ts instead of a forked duplicate. `stripInlineMarkdown` is the
// lenient locator normalizer (trims stray decoration); `stripWrappersAndLinks`
// is the strict balanced-wrapper/link primitive mcp builds `stripBalancedWrappers`
// on top of.
export {
stripInlineMarkdown,
stripWrappersAndLinks,
} from "./text-normalize.js";
// Inline-footnote authoring convention (#414: single copy, formerly the mcp
// `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`.
export {
@@ -33,26 +33,6 @@ import {
*/
const MAX_NODE_DEPTH = 400;
/**
* Thrown by {@link convertProseMirrorToMarkdown} in `strict` mode when it hits a
* node or mark type it has no lossless markdown form for (the serializer would
* otherwise silently degrade it drop an unknown mark, flatten an unknown node
* to its children). Carries the offending kind/name so a caller (git-sync) can
* surface exactly what would have been lost.
*/
export class ConverterLossError extends Error {
readonly kind: "node" | "mark";
readonly typeName: string;
constructor(kind: "node" | "mark", typeName: string) {
super(
`convertProseMirrorToMarkdown: unknown ${kind} type "${typeName}" has no lossless markdown representation (strict mode)`,
);
this.name = "ConverterLossError";
this.kind = kind;
this.typeName = typeName;
}
}
/**
* Options for {@link convertProseMirrorToMarkdown}.
*/
@@ -66,23 +46,6 @@ export interface ConvertProseMirrorToMarkdownOptions {
* path where resolved anchors MUST be preserved for round-tripping.
*/
dropResolvedCommentAnchors?: boolean;
/**
* Optional sink for LOSS warnings. When the serializer reaches a node or mark
* type it has no dedicated case for, it degrades gracefully (flattens an
* unknown node to its children, drops an unknown mark) historically a SILENT
* data loss. When this array is provided, one human-readable message per such
* event is pushed here so the caller can observe (and log) what was degraded.
* Not provided by default -> behavior is byte-identical to before for existing
* callers.
*/
warnings?: string[];
/**
* When true, THROW a {@link ConverterLossError} on the FIRST unknown node/mark
* instead of degrading silently a warning becomes a hard error. Used by the
* lossless git-sync export path and the converter tests, where an unmapped
* type is a bug to surface, not data to quietly drop.
*/
strict?: boolean;
}
/**
@@ -100,70 +63,6 @@ export interface ConvertProseMirrorToMarkdownOptions {
* separator is emitted for any other join, so non-list output is unchanged.
*/
const LIST_MARKER_SEPARATOR = "<!-- -->";
/**
* Backslash-escape a leading markdown BLOCK trigger so a serialized paragraph
* line re-parses as a PARAGRAPH, not another block. Without this, a paragraph
* whose text begins at column 0 with an ATX heading `#`, a blockquote/callout
* `>`, a bullet marker `-`/`*`/`+`, an ordered marker `N.`/`N)`, a code fence
* (```` ``` ````/`~~~`), a table `|`, or a thematic break (`---`/`***`/`___`,
* solid or spaced) silently becomes a heading/list/quote/code block/table/rule
* on the next markdown -> ProseMirror import a known data-loss class (the
* thematic-break case drops the text entirely, since a horizontalRule carries
* none). CommonMark's escape tokenizer decodes the inserted `\` back to the
* literal character on import AND stops the block interpretation, so the line
* round-trips byte-exact as paragraph text. Only the FIRST offending character
* is escaped (the minimum needed to break block recognition); a line that does
* NOT open a block emphasis `**x**`, an inline code span, ordinary prose is
* returned verbatim, so there is no backslash churn for the common case.
*
* Applied ONLY to paragraph text, once per `\n`-separated LINE (the paragraph
* case splits on `\n` each hardBreak emits ` \n` so a trigger on a
* continuation line is escaped too): headings/lists/blockquotes legitimately
* open with these markers and render them from their own cases. This is the
* single, canonical fix for the class the client bridge worked around with a
* ZWSP (`gitmost-recording.ts`) and the generative suite self-censored around
* (`text-arbitraries.ts`) both now removed.
*/
function escapeLeadingBlockTrigger(line: string): string {
// ATX heading: 1..6 `#` then whitespace/EOL.
if (/^#{1,6}(?:\s|$)/.test(line)) return "\\" + line;
// Blockquote / Docmost callout opener (`>` or `> [!info]`).
if (line.startsWith(">")) return "\\" + line;
// Bullet list marker then whitespace/EOL. Emphasis (`*x*`, `**x**`) has no
// space after the leading marker and is intentionally left verbatim.
if (/^[-*+](?:\s|$)/.test(line)) return "\\" + line;
// Ordered list marker `N.` / `N)`: escape the DELIMITER so the digits stay
// literal (`1. x` -> `1\. x`, which imports back as the text `1. x`).
const ordered = line.match(/^(\d+)[.)](?:\s|$)/);
if (ordered) {
const digits = ordered[1].length;
return line.slice(0, digits) + "\\" + line.slice(digits);
}
// Fenced code block: 3+ backticks or tildes. A single/double backtick is an
// inline code span and is left verbatim.
if (/^(?:`{3,}|~{3,})/.test(line)) return "\\" + line;
// Thematic break: a WHOLE line of 3+ identical `-`/`*`/`_`, optionally spaced.
if (/^([-*_])(?:\s*\1){2,}\s*$/.test(line)) return "\\" + line;
// Setext underline: a continuation line (after a hardBreak) that is ONLY `-`
// or ONLY `=` (any count, trailing spaces allowed). Under a paragraph line
// such a line re-parses as a SETEXT HEADING and SILENTLY DROPS its own text
// (`a\n--` -> heading "a", the `--` is LOST; `a\n=` -> heading "a", `=` LOST).
// The bullet arm above catches a lone `-` (via its `$`) and the thematic arm
// catches 3+ dashes, but exactly TWO dashes (`--`) fall through both; and no
// arm covers a lone `=` at all (a `==` pair is neutralized earlier by the
// inline `==`->`\=\=` escape, so only a single `=` line reaches here). Escaping
// the leading char (`\--`, `\=`) breaks the setext interpretation so the line
// round-trips as paragraph text. The WHOLE line must be the marker (anchored
// `^-+`/`^=+` to EOL), so a mid-content `-`/`=` is never spuriously escaped;
// and a `---`/`----` already handled by the thematic arm never reaches here,
// so there is no double-escape.
if (/^-+[ \t]*$/.test(line) || /^=+[ \t]*$/.test(line)) return "\\" + line;
// GFM table row opener.
if (line.startsWith("|")) return "\\" + line;
return line;
}
function listMarkerFamily(type: string | undefined): "ul" | "ol" | null {
if (type === "bulletList" || type === "taskList") return "ul";
if (type === "orderedList") return "ol";
@@ -210,26 +109,6 @@ export function convertProseMirrorToMarkdown(
// callers (mcp getPage / in-app AI chat) pass it true.
const dropResolvedCommentAnchors = options.dropResolvedCommentAnchors === true;
// Loss reporting for node/mark types with no dedicated serializer case. In
// `strict` mode the FIRST such type throws (git-sync, tests); otherwise the
// serializer degrades gracefully (as it always has) but records one warning
// per unmapped type into the optional sink so the loss is observable, not
// silent. Deduped per type so a document with many unknown nodes of one type
// produces one message.
const strict = options.strict === true;
const warningsSink = options.warnings;
const seenLossTypes = new Set<string>();
const warnLoss = (kind: "node" | "mark", typeName: string): void => {
if (strict) throw new ConverterLossError(kind, typeName);
if (!warningsSink) return;
const key = `${kind}:${typeName}`;
if (seenLossTypes.has(key)) return;
seenLossTypes.add(key);
warningsSink.push(
`Unknown ${kind} type "${typeName}" has no lossless markdown form; it was degraded on export.`,
);
};
// Escape a value interpolated into an HTML double-quoted attribute value
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
// ATTRIBUTE context only the quote that delimits the value and the ampersand
@@ -483,99 +362,6 @@ 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
@@ -626,17 +412,7 @@ export function convertProseMirrorToMarkdown(
}
case "paragraph": {
// Escape a leading block trigger on EVERY line of the paragraph, not
// just the first: a hardBreak serializes as ` \n`, so a `#`/`-`/`>`/
// `1.`/`|`/fence/`---` at the start of a CONTINUATION line would also
// re-parse into another block on the next import (a heading/list/table/
// setext-`---`), and for the text-less thematic/setext case would LOSE
// that line's text entirely. Escaping each `\n`-separated line closes
// the class for multi-line paragraphs too.
const text = renderInlineChildren(nodeContent)
.split("\n")
.map(escapeLeadingBlockTrigger)
.join("\n");
const text = renderInlineChildren(nodeContent);
const align = node.attrs?.textAlign;
// Non-default alignment round-trips as an ATTACHED HTML comment at the
// END of the block line (#293 canon #9):
@@ -675,38 +451,154 @@ export function convertProseMirrorToMarkdown(
return headingLine;
}
case "text": {
case "text":
let textContent = node.text || "";
// #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);
// #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, "^\\[");
}
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
// #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.
// 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");
if (hasCode) {
textContent = `\`${textContent}\``;
return textContent;
}
for (const mark of node.marks) {
if (mark.type === "code") continue; // wrapped innermost above
textContent = applyInlineMark(textContent, mark);
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;
}
}
}
}
return textContent;
}
case "codeBlock":
const language = node.attrs?.language || "";
@@ -1281,11 +1173,7 @@ export function convertProseMirrorToMarkdown(
}
default:
// Unknown node type: no dedicated case, so the node's identity + attrs
// have no lossless markdown form. Report the loss (throws in strict
// mode) then degrade by flattening to its children — the historical
// graceful fallback.
warnLoss("node", String(type));
// Fallback: process children
return nodeContent.map(processNode).join("");
}
};
@@ -1298,165 +1186,18 @@ 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 => {
// 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++;
}
}
// 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++) {
const parts = nodes.map(processNode);
for (let i = 0; i < nodes.length - 1; i++) {
if (
segs[k].firstNode?.type === "mathInline" &&
(segs[k].text || "").startsWith("$") &&
/^[0-9]/.test(segs[k + 1].text || "")
nodes[i]?.type === "mathInline" &&
parts[i].startsWith("$") &&
/^[0-9]/.test(parts[i + 1] || "")
) {
segs[k].text = mathInlineHtml(segs[k].firstNode.attrs?.text || "");
parts[i] = mathInlineHtml(nodes[i].attrs?.text || "");
}
}
return segs.map((s) => s.text).join("");
return parts.join("");
};
// Render inline content (text runs + their marks) to HTML. Used by the raw
@@ -1491,22 +1232,7 @@ 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>`;
@@ -1571,12 +1297,6 @@ export function convertProseMirrorToMarkdown(
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
}
break;
default:
// Unknown mark on the raw-HTML path: dropped (no HTML form). Report
// the loss (throws in strict mode) — same policy as the markdown
// path's marks loop above.
warnLoss("mark", String(mark.type));
break;
}
}
return t;
@@ -7,12 +7,13 @@
* it is never applied to replacement text or inserted node content, so no
* formatting is ever lost.
*
* CANONICAL HOME (#414/#493): this is the single source of truth for locator
* markdown-stripping. `node-ops.ts` (which lives here) uses it directly, and the
* mcp-side `text-normalize.ts` now IMPORTS `stripInlineMarkdown` and the shared
* `stripWrappersAndLinks` primitive from here (via `@docmost/prosemirror-markdown`)
* instead of keeping a drifting copy mcp only adds its own thin
* `stripBalancedWrappers`/`closestBlockHint` on top.
* Scope note (#414): this package-local copy exists so `node-ops.ts` which
* lives here now (the single canonical copy) can resolve its markdown-tolerant
* anchor fallback without a circular dependency back on `@docmost/mcp`. It
* intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops`
* needs); the mcp-side `text-normalize.ts` (which additionally serves
* `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate
* dedup task and is left untouched here.
*/
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
@@ -43,7 +44,7 @@ const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
* Does NOT trim decoration, does NOT guard against an empty result it returns
* exactly the transformed string.
*/
export function stripWrappersAndLinks(s: string): string {
function stripWrappersAndLinks(s: string): string {
// 1. Links/images -> their visible text.
let out = s.replace(LINK_IMAGE_RE, "$1");
@@ -1,112 +0,0 @@
import { describe, expect, it } from "vitest";
import {
convertProseMirrorToMarkdown,
ConverterLossError,
} from "../src/lib/markdown-converter.js";
/**
* #493 commit 3 a node/mark type the serializer has no dedicated case for used
* to be degraded SILENTLY (an unknown node flattened to its children, an unknown
* mark dropped from the run). The serializer now REPORTS the loss:
* - default (non-strict): unchanged graceful degradation, but one warning per
* unmapped type is pushed into an optional `warnings` sink so callers can
* observe it;
* - strict: the FIRST unmapped type throws a ConverterLossError (git-sync +
* tests), turning a silent loss into a hard, surfaced error.
*
* Exercised through the REAL converter (no mock): the observable properties are
* the emitted markdown, the warnings collected, and the thrown error.
*/
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
describe("converter loss reporting — unknown node types", () => {
const unknownNode = doc({
type: "quantumWidget",
content: [{ type: "text", text: "inner text" }],
});
it("degrades to children AND records a warning (non-strict, sink provided)", () => {
const warnings: string[] = [];
const md = convertProseMirrorToMarkdown(unknownNode, { warnings });
// Graceful degrade: the child text still survives (historical behavior).
expect(md).toContain("inner text");
// The loss is now observable.
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("quantumWidget");
expect(warnings[0]).toContain("node");
});
it("stays byte-identical for callers that pass no sink (zero behavior change)", () => {
const withSink: string[] = [];
const a = convertProseMirrorToMarkdown(unknownNode, { warnings: withSink });
const b = convertProseMirrorToMarkdown(unknownNode);
expect(b).toBe(a); // the sink does not alter the produced markdown
});
it("throws ConverterLossError in strict mode", () => {
try {
convertProseMirrorToMarkdown(unknownNode, { strict: true });
expect.unreachable("strict mode must throw on an unknown node");
} catch (e) {
expect(e).toBeInstanceOf(ConverterLossError);
expect((e as ConverterLossError).kind).toBe("node");
expect((e as ConverterLossError).typeName).toBe("quantumWidget");
}
});
it("dedupes the warning per type (many unknown nodes -> one message)", () => {
const warnings: string[] = [];
convertProseMirrorToMarkdown(
doc(
{ type: "quantumWidget", content: [{ type: "text", text: "a" }] },
{ type: "quantumWidget", content: [{ type: "text", text: "b" }] },
),
{ warnings },
);
expect(warnings).toHaveLength(1);
});
});
describe("converter loss reporting — unknown mark types", () => {
const unknownMark = doc({
type: "paragraph",
content: [{ type: "text", text: "glowing", marks: [{ type: "glow" }] }],
});
it("drops the mark but keeps the text AND records a warning (non-strict)", () => {
const warnings: string[] = [];
const md = convertProseMirrorToMarkdown(unknownMark, { warnings });
expect(md).toBe("glowing"); // text survives, mark silently had no form
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("glow");
expect(warnings[0]).toContain("mark");
});
it("throws ConverterLossError in strict mode", () => {
expect(() =>
convertProseMirrorToMarkdown(unknownMark, { strict: true }),
).toThrow(ConverterLossError);
});
});
describe("converter loss reporting — known content is never flagged", () => {
it("a fully-mapped document produces no warnings and does not throw in strict mode", () => {
const d = doc(
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Title" }] },
{
type: "paragraph",
content: [
{ type: "text", text: "bold", marks: [{ type: "bold" }] },
{ type: "text", text: " and " },
{ type: "text", text: "link", marks: [{ type: "link", attrs: { href: "https://x.y" } }] },
],
},
{ type: "bulletList", content: [{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item" }] }] }] },
);
const warnings: string[] = [];
const md = convertProseMirrorToMarkdown(d, { warnings, strict: true });
expect(warnings).toEqual([]);
expect(md).toContain("## Title");
});
});
@@ -11,11 +11,9 @@
*
* The corpus deliberately spans the CommonMark / canon hostile alphabet
* (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal
* 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* ``).
* 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).
*/
import fc from 'fast-check';
@@ -108,16 +106,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
* `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.
* 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.
*
* 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).
* 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.
*/
export const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
// Plain text.
@@ -140,25 +138,6 @@ 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
@@ -233,93 +212,25 @@ export function normalizeInline(nodes: any[]): any[] {
return out;
}
/**
* #493 commit 1: a plain-text run whose text DELIBERATELY OPENS with a markdown
* BLOCK trigger ATX heading `#`, bullet `-`/`*`/`+`, blockquote `>`, ordered
* `N.`/`N)`, or a table `|` followed by safe text. Pre-#493 the corpus
* self-censored these away (safeTextArb's leading-word guarantee); the paragraph
* serializer now BLOCK-ESCAPES a leading trigger, so the generative round-trip
* itself proves the data-loss class is closed rather than avoiding it.
*
* DELIBERATELY excludes the code-fence (backtick) trigger the backtick is a
* code-span delimiter that re-pairs globally (see specialCharArb's note), an
* instability UNRELATED to block-escape and the whole-line thematic break
* (`---`), which only triggers when the line is ONLY dashes; both are covered by
* the deterministic pin (gitmost-transcript-neutralization.test.ts). Each still
* ENDS in a word (safeTextArb) so adjacent-run concatenation stays safe.
*/
export const blockTriggerLeadRunArb: fc.Arbitrary<any> = fc
.tuple(
fc.constantFrom('# ', '## ', '- ', '* ', '+ ', '> ', '1. ', '1) ', '| '),
safeTextArb,
)
.map(([trigger, rest]) => ({ type: 'text', text: trigger + rest }));
/**
* A hardBreak IMMEDIATELY followed by a block-trigger-leading run a two-node
* segment. Because a hardBreak serializes as ` \n`, the trigger then sits at
* the START of a CONTINUATION line, exercising the serializer's PER-LINE block
* escape (not just the first line). #493 review: without this the fuzzer never
* placed a trigger after a hardBreak, so a single-line-only escape passed P1P3.
*/
export const hardBreakThenTriggerArb: fc.Arbitrary<any[]> = fc
.tuple(hardBreakArb, blockTriggerLeadRunArb)
.map(([hb, trigger]) => [hb, trigger]);
/**
* #493 (setext data-loss): a WHOLE-LINE setext underline landing on a
* continuation line. A setext underline is a line of ONLY `-` (any count) or
* ONLY `=` (any count) that FOLLOWS a paragraph line; on re-parse it turns the
* preceding line into a heading and DROPS its own text. The block-escape must
* neutralize it. Unlike blockTriggerLeadRunArb, the underline must occupy the
* whole line, so we sandwich it between two hardBreaks (underline on its own
* line, preceded by earlier paragraph content, followed by a trailing word so
* the closing hardBreak is not dropped by normalizeInline). Covers underlines
* of every length: `--` (the two-dash case the bullet/thematic arms miss), a
* lone `=`, `==`/`====` (neutralized by the inline `==` escape), and `---`/
* `----` (regression for the existing thematic case).
*/
export const hardBreakThenSetextArb: fc.Arbitrary<any[]> = fc
.tuple(
fc.constantFrom('--', '=', '==', '====', '---', '----'),
safeTextArb,
)
.map(([underline, rest]) => [
{ type: 'hardBreak' },
{ type: 'text', text: underline },
{ type: 'hardBreak' },
{ type: 'text', text: rest },
]);
/**
* Inline content for a paragraph: at least one marked text run, optionally with
* inline atoms (math/mention) and hard breaks interspersed. The FIRST run is
* usually an ordinary marked run, but sometimes a block-trigger-leading run
* (blockTriggerLeadRunArb) so the paragraph OPENS with a markdown block trigger;
* and a `hardBreak + trigger` segment can appear anywhere in the rest, so a
* trigger also lands at the start of a CONTINUATION line both exercising the
* serializer's per-line block-escape end-to-end. (Ported, with the #493
* leading-trigger + post-hardBreak dimensions added.)
* inline atoms (math/mention) and hard breaks interspersed. Always starts with a
* text run so the paragraph never opens with a block trigger. (Ported.)
*/
export const inlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: blockTriggerLeadRunArb },
),
markedTextRunArb,
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb.map((n) => [n]) },
{ weight: 1, arbitrary: mathInlineArb.map((n) => [n]) },
{ weight: 1, arbitrary: mentionArb.map((n) => [n]) },
{ weight: 1, arbitrary: hardBreakArb.map((n) => [n]) },
{ weight: 2, arbitrary: hardBreakThenTriggerArb },
{ weight: 2, arbitrary: hardBreakThenSetextArb },
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: mathInlineArb },
{ weight: 1, arbitrary: mentionArb },
{ weight: 1, arbitrary: hardBreakArb },
),
{ minLength: 0, maxLength: 4 },
),
)
.map(([first, rest]) => normalizeInline([first, ...rest.flat()]));
.map(([first, rest]) => normalizeInline([first, ...rest]));
/**
* Inline content for a HEADING identical to a paragraph's, but WITHOUT hard
@@ -5,21 +5,32 @@ import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
/**
* #493 commit 1 the paragraph serializer's leading-block-escape closes the
* data-loss class where a paragraph whose text opens at column 0 with a markdown
* block trigger (`#`/`-`/`*`/`+`/`>`, an ordered `N.`/`N)`, a code fence, a
* table `|`, a callout opener, or a thematic break) silently re-parsed into a
* heading / list / quote / code block / table / horizontalRule on the git-sync
* doc -> markdown -> doc cycle. The thematic-break case was the worst: a
* horizontalRule carries NO text, so the line's text was lost entirely.
* gitmost #377 (round-1 review, finding #1) proof, against the REAL
* converter, that the transcript-insert boundary defense survives git-sync.
*
* This is the deterministic PIN, one assertion per trigger, exercised through
* the REAL converter round-trip (not a mock): each bare trigger line now
* round-trips as a SINGLE paragraph with its text byte-preserved proving the
* class is closed WITHOUT the former client-side ZWSP workaround (removed) or
* the generative suite's leading-word self-censorship (removed).
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
* that text VERBATIM with no block-escape, so a line whose text begins with a
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
* cycle, silently re-parse into a heading / list / quote / callout / code block.
* That missing block-escape is the pre-existing root cause; the bridge's
* boundary defense prepends an invisible zero-width space (U+200B) to a line
* that begins with such a trigger, shifting it off column 0.
*
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
* different package and can't be imported here) and asserts:
* 1. bare trigger lines DO corrupt (documents the root cause), and
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
* text byte-preserved.
*/
const ZWSP = "​"; // U+200B
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
const MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
const para = (t: string) => ({
type: "paragraph",
@@ -32,117 +43,78 @@ const roundtrip = async (text: string) => {
return back.content as any[];
};
describe("paragraph block-escape (git-sync round-trip)", () => {
// Every line here, at column 0, WOULD (pre-fix) re-parse into a non-paragraph
// block. Each is now block-escaped by the serializer and round-trips clean.
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
// Lines that, at column 0, the serializer's missing block-escape would let
// git-sync re-parse into a non-paragraph block.
const triggerLines = [
"- dash",
"* star",
"+ plus",
"> quote",
"# hash",
"## two hash",
"###### six hash",
"1. one",
"1) one",
"> [!info] note",
"```js",
"~~~",
"| a | b |",
// Solid + spaced thematic breaks — the text-LOSING case pre-fix.
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
// which carries NO text, so a bare separator line LOSES its text entirely
// (round-2 finding). `_` also only forms a block via this construct.
"---",
"***",
"___",
"- - -",
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
"_ _ _",
];
it("every bare trigger line round-trips as a single paragraph, text byte-preserved", async () => {
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
for (const line of triggerLines) {
const blocks = await roundtrip(line);
expect(blocks, `"${line}" should be one block`).toHaveLength(1);
expect(blocks[0].type, `"${line}" should stay a paragraph`).toBe(
"paragraph",
);
// At least one produced block is NOT a paragraph — i.e. corruption.
const allParagraphs = blocks.every((b) => b.type === "paragraph");
expect(
blocks[0].content?.[0]?.text,
`"${line}" text should survive byte-exact`,
).toBe(line);
allParagraphs,
`expected "${line}" to corrupt when inserted bare`,
).toBe(false);
}
});
it("emphasis / inline-code paragraphs are NOT escaped (no backslash churn)", async () => {
// These open with `*`/`` ` `` but are NOT block triggers; the serialized
// markdown must not gain a stray leading backslash, and they round-trip.
for (const [text, mark] of [
["bold", "bold"],
["italic", "italic"],
["code", "code"],
] as const) {
const node = doc({
type: "paragraph",
content: [{ type: "text", text, marks: [{ type: mark }] }],
});
const md = convertProseMirrorToMarkdown(node);
expect(md.startsWith("\\"), `${mark} must not be block-escaped`).toBe(
false,
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
// The severe case: no text node survives. Documents why neutralization
// matters more here than for list/quote (where the text survived).
for (const line of ["---", "***", "___"]) {
const blocks = await roundtrip(line);
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
// No block carries the original text anywhere.
const flat = JSON.stringify(blocks);
expect(flat).not.toContain(line);
}
});
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
for (const line of triggerLines) {
// The regex must actually classify each as a trigger.
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
true,
);
const back = await markdownToProseMirror(md);
expect(back.content[0].type).toBe("paragraph");
expect(back.content[0].content[0].text).toBe(text);
expect(back.content[0].content[0].marks?.[0]?.type).toBe(mark);
const neutralized = ZWSP + line;
const blocks = await roundtrip(neutralized);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
// Text is byte-preserved (ZWSP + original line), so the display is the
// original line with only an invisible leading character.
expect(blocks[0].content[0].text).toBe(neutralized);
}
});
it("a block trigger on a CONTINUATION line (after a hardBreak) is escaped too", async () => {
// A hardBreak serializes as ` \n`, so a trigger on the second line would,
// without a per-line escape, re-parse into another block. The worst case is
// `---`: a setext underline would turn the first line into a heading and LOSE
// the `---` text entirely. Each pair round-trips as ONE paragraph with the
// hardBreak and both texts preserved.
for (const [first, second] of [
["a", "# b"],
["a", "- b"],
["a", "> b"],
["a", "1. b"],
["a", "| b |"],
["a", "---"], // setext / thematic (3 dashes) — the text-losing case
["a", "--"], // setext underline, EXACTLY two dashes (bullet/thematic miss it)
["a", "----"], // setext / thematic (4 dashes)
["a", "="], // setext H1 underline, a lone `=` (no other arm covers it)
["a", "===="], // setext H1 underline, run of `=`
]) {
const d = doc({
type: "paragraph",
content: [
{ type: "text", text: first },
{ type: "hardBreak" },
{ type: "text", text: second },
],
});
const back = await markdownToProseMirror(convertProseMirrorToMarkdown(d));
expect(back.content, `"${first}${second}" should be one block`).toHaveLength(1);
expect(back.content[0].type).toBe("paragraph");
const texts = (back.content[0].content as any[])
.filter((n) => n.type === "text")
.map((n) => n.text);
const hasBreak = (back.content[0].content as any[]).some(
(n) => n.type === "hardBreak",
);
expect(hasBreak, `"${first}${second}" should keep the hardBreak`).toBe(true);
expect(texts, `"${first}${second}" should preserve both line texts`).toEqual([
first,
second,
]);
}
});
it("normal host-prefixed lines round-trip byte-exact (unaffected)", async () => {
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
for (const line of [
"You: hello there",
"Speaker 1: - and then a dash mid-line",
"Speaker 2: 1. not a list",
]) {
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
const blocks = await roundtrip(line);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
@@ -294,11 +294,10 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
);
});
// 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)', () => {
// 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)', () => {
const out = convertProseMirrorToMarkdown(
doc(
para({
@@ -311,7 +310,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
}),
),
);
expect(out).toBe('[`x`](http://a?b&c"d)');
expect(out).toBe('`x`');
});
// 6. hardBreak inside a heading: prefix applied once, " \n" between a and b.
@@ -431,7 +430,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
});
});
describe('converter gap coverage — formerly-lossy round-trips, now closed (specs 12–14)', () => {
describe('converter gap coverage — documented round-trip data loss (specs 12–14)', () => {
// 12. A 3-backtick fence inside a codeBlock body is now lengthened: the outer
// fence widens to (longest inner run + 1) backticks per CommonMark, so the
// inner ``` is treated as content and the block survives as ONE node.
@@ -461,24 +460,25 @@ describe('converter gap coverage — formerly-lossy round-trips, now closed (spe
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
// 13. #493 commit 1: a leading ordered-list marker in paragraph text is now
// BLOCK-ESCAPED, so the paragraph round-trips as a paragraph instead of
// silently becoming an orderedList (was documented data loss, now closed).
it('a paragraph starting with "1. " is block-escaped and stays a paragraph', async () => {
// 13. A leading ordered-list marker in paragraph text is NOT escaped, so a
// plain paragraph silently becomes an orderedList on re-import.
it('a paragraph starting with "1. " is promoted to an orderedList on re-import', async () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'text', text: '1. not a list' }],
});
const md1 = convertProseMirrorToMarkdown(d);
expect(md1).toBe('1\\. not a list'); // the ordered-list delimiter is escaped
expect(md1).toBe('1. not a list'); // no backslash escape
const doc2 = await markdownToProseMirror(md1);
expect(doc2.content?.[0]?.type).toBe('paragraph');
expect(doc2.content[0].content?.[0]).toMatchObject({
expect(doc2.content?.[0]?.type).toBe('orderedList');
const li = doc2.content[0].content?.[0];
expect(li?.type).toBe('listItem');
expect(li.content?.[0]?.content?.[0]).toMatchObject({
type: 'text',
text: '1. not a list', // the escape decodes back to the literal text
text: 'not a list', // the "1. " was consumed as a list marker
});
expect(docsCanonicallyEqual(d, doc2)).toBe(true);
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
});
// 14. #293 canon #4: the image title now round-trips via the attached
@@ -59,21 +59,22 @@ describe('convertProseMirrorToMarkdown', () => {
).toBe('`x`');
});
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.
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.
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
);
expect(out).toBe('**`x`**');
expect(out).toBe('`x`');
});
it('code + strike nests the backtick span inside the emphasis (#515)', () => {
it('code + strike combo emits the backtick code form (code wins)', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
);
expect(out).toBe('~~`x`~~');
expect(out).toBe('`x`');
});
});
@@ -80,7 +80,13 @@ 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. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
// 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
// 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
@@ -619,7 +625,7 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
});
// -------------------------------------------------------------------------
// KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
// KNOWN, DOCUMENTED non-roundtrip bug #2 (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
@@ -649,18 +655,23 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
});
// -------------------------------------------------------------------------
// #515 ROUND-TRIP PIN: `code` combined with another mark.
// KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
//
// 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].
// 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.
// -------------------------------------------------------------------------
it(
'code combined with another mark round-trips and keeps both marks (#515)',
'code mark combined with another mark is byte-stable',
async () => {
const codeComboArb = fc
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
@@ -677,90 +688,11 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
}));
await fc.assert(
fc.asyncProperty(codeComboArb, async (doc) => {
const { md1, md2, doc2 } = await roundTrip(doc);
const { md1, md2 } = 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);
}
});
});
@@ -16,16 +16,14 @@ import * as editorExt from "@docmost/editor-ext";
// or mark added upstream that the mirror forgets to vendor fails CI loudly
// (otherwise it is silently dropped on the markdown <-> ProseMirror round-trip).
//
// This file now holds TWO contracts (see the two describe blocks): the original
// NAME-LEVEL type contract (no canonical node/mark TYPE goes unmirrored) AND, as
// of #493, an ATTRIBUTE-LEVEL contract that compares each editor-ext node/mark's
// OWN declared attributes (names + defaults) against the mirror's built schema.
// A full mechanical attribute-by-attribute EQUALITY would be fragile (the mirror
// is a deliberate superset: it injects the global id/textAlign/indent attrs and
// normalizes some editor-ext defaults to null), so the attribute contract is
// asymmetric — editor-ext -> mirror — with a small, reasoned, stale-guarded
// allowlist for the two blessed divergence kinds (non-round-trippable omissions
// and null-normalized defaults). StarterKit-provided types (paragraph, bold,
// LIMITATION (intentional, see schema-surface-snapshot.test.ts): this is a
// NAME-LEVEL contract only, not a full attribute-level structural compare.
// editor-ext's Tiptap representation (node views, commands, suggestion plugins,
// addGlobalAttributes spread across separate extensions) differs from this
// minimal mirror, so a mechanical attribute-by-attribute equality would be
// fragile and produce false drift. Attribute parity is guarded by the inline
// surface snapshot (reviewed in every diff); this test guards that no canonical
// node/mark TYPE goes unmirrored. StarterKit-provided types (paragraph, bold,
// heading, …) are contributed by @tiptap/starter-kit in the mirror rather than
// by editor-ext, so they are naturally covered by the mirror's superset.
//
@@ -87,224 +85,3 @@ describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => {
expect(missing).toEqual([]);
});
});
// ── #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,
// but not ATTRIBUTE drift within a vendored type — the exact class that silently
// dropped `subpages.recursive`: editor-ext grew an attribute the hand-synced
// mirror forgot, so documents using it lost that attribute on a git-sync
// round-trip while CI stayed green. This closes that gap by comparing each
// editor-ext node/mark's OWN declared attributes (names + defaults) against the
// mirror's built ProseMirror schema `spec.attrs`.
//
// DIRECTION: editor-ext -> mirror. The mirror is deliberately a SUPERSET (it
// injects the global `id`/`textAlign`/`indent` attributes and normalizes some
// editor-ext "required" attrs to a `null` default), so a reverse compare would
// be pure false drift; the meaningful failure is an editor-ext attribute the
// mirror DROPS (name) or whose DEFAULT it silently changes. Both directions of
// staleness are guarded so the allowlists cannot rot.
/**
* The attributes an editor-ext Tiptap Node/Mark DECLARES itself, read from its
* `config.addAttributes()`. Global attributes injected by separate extensions
* (unique-id, indent, textAlign) are NOT included here they are the mirror's
* superset and are not part of a per-type declaration so this isolates each
* type's own contribution. A declared attribute with no explicit `default` is a
* required attr (Tiptap default `undefined`); we surface that as-is so the
* default compare can skip it (the mirror makes such attrs optional/`null`).
*/
function editorExtOwnAttrs(): Map<
string,
{ kind: "node" | "mark"; attrs: Record<string, unknown> }
> {
const out = new Map<
string,
{ kind: "node" | "mark"; attrs: Record<string, unknown> }
>();
for (const value of Object.values(editorExt)) {
if (!isTiptapNodeOrMark(value)) continue;
const ext = value as unknown as {
name: string;
type: "node" | "mark";
options?: unknown;
storage?: unknown;
config?: { addAttributes?: () => Record<string, { default?: unknown }> };
};
const fn = ext.config?.addAttributes;
// addAttributes reads `this.options`/`this.name`; bind a minimal context
// (verified sufficient for every editor-ext extension — none reach for
// `this.editor` here). A type with no addAttributes contributes no attrs.
const declared =
typeof fn === "function"
? fn.call({
options: ext.options ?? {},
name: ext.name,
parent: undefined,
storage: ext.storage ?? {},
} as never)
: {};
const attrs: Record<string, unknown> = {};
for (const [attr, spec] of Object.entries(declared || {})) {
// `undefined` marks a required (no-default) attr; keep it so the default
// compare can distinguish "no default declared" from "default is null".
attrs[attr] = (spec as { default?: unknown })?.default;
}
out.set(ext.name, { kind: ext.type, attrs });
}
return out;
}
/** The mirror's built-schema `spec.attrs` for a type: attr name -> default. */
function mirrorAttrs(
name: string,
kind: "node" | "mark",
): Record<string, unknown> | null {
const schema = getSchema(docmostExtensions as never);
const spec = kind === "node" ? schema.nodes[name]?.spec : schema.marks[name]?.spec;
if (!spec) return null;
const out: Record<string, unknown> = {};
for (const [attr, def] of Object.entries(spec.attrs || {})) {
out[attr] = (def as { default?: unknown }).default;
}
return out;
}
// An editor-ext attribute the mirror deliberately does NOT vendor because it has
// NO markdown round-trip representation — dropping it loses nothing on the
// git-sync cycle (the same rationale the flat-roundtrip property suite uses to
// allowlist e.g. `tableCell.backgroundColorName`). Blessed by the hand-curated
// surface snapshot (schema-surface-snapshot.test.ts), reviewed in every diff.
const ACCEPTED_ATTR_OMISSIONS = new Set<string>([
"highlight.colorName", // only `highlight.color` round-trips (==text==); the
// secondary palette-name is presentational and has no markdown form.
]);
// An editor-ext attribute the mirror vendors but with a DIFFERENT default: the
// mirror normalizes an "absent" value to `null` (its uniform optional-attr
// convention) rather than editor-ext's UI-oriented default. None of these attrs
// is emitted on the markdown surface (the converter round-trips only the
// serializable ones), so the default never round-trips and the divergence is
// inert — but pinned here so a NEW default change on either side forces review.
const ACCEPTED_DEFAULT_DIVERGENCE = new Set<string>([
"image.src", // mirror null vs editor "" (an image is never emitted src-less)
"link.internal", // mirror null vs editor false (routing attr, not in md link)
"pdf.width", // mirror null vs editor 800 (presentational sizing, not in md)
"pdf.height", // mirror null vs editor 600 (presentational sizing, not in md)
]);
describe("docmost schema vs @docmost/editor-ext (attribute-level contract)", () => {
it("vendors every editor-ext attribute (name) of every shared type — no silently-dropped attrs", () => {
const dropped: string[] = [];
for (const [name, { kind, attrs }] of editorExtOwnAttrs()) {
const mirror = mirrorAttrs(name, kind);
if (!mirror) continue; // whole-type omission is the name-level test's job
for (const attr of Object.keys(attrs)) {
const key = `${name}.${attr}`;
if (!(attr in mirror) && !ACCEPTED_ATTR_OMISSIONS.has(key)) {
dropped.push(key);
}
}
}
// Any entry here exists on the editor-ext node/mark but NOT in the mirror
// (and is not a blessed non-round-trippable omission): documents using it
// lose that attribute on a git-sync round-trip — the subpages.recursive
// class. Re-sync src/lib/docmost-schema.ts (and the surface snapshot) or add
// a reasoned ACCEPTED_ATTR_OMISSIONS entry before clearing.
expect(dropped.sort()).toEqual([]);
});
it("keeps every editor-ext attribute DEFAULT in sync — no silent default drift", () => {
const drift: string[] = [];
for (const [name, { kind, attrs }] of editorExtOwnAttrs()) {
const mirror = mirrorAttrs(name, kind);
if (!mirror) continue;
for (const [attr, extDefault] of Object.entries(attrs)) {
const key = `${name}.${attr}`;
// Skip attrs editor-ext declares WITHOUT a default (required attrs):
// the mirror deliberately makes them optional (`null`), a safe superset.
if (extDefault === undefined) continue;
if (!(attr in mirror)) continue; // a drop, reported by the name test
if (
JSON.stringify(mirror[attr]) !== JSON.stringify(extDefault) &&
!ACCEPTED_DEFAULT_DIVERGENCE.has(key)
) {
drift.push(
`${key}: mirror=${JSON.stringify(mirror[attr])} editor-ext=${JSON.stringify(extDefault)}`,
);
}
}
}
expect(drift.sort()).toEqual([]);
});
it("the attribute allowlists have no stale rows (each is really omitted / divergent)", () => {
const ext = editorExtOwnAttrs();
const staleOmission: string[] = [];
for (const key of ACCEPTED_ATTR_OMISSIONS) {
const [name, attr] = key.split(".");
const entry = ext.get(name);
const mirror = entry ? mirrorAttrs(name, entry.kind) : null;
// Stale if editor-ext no longer declares it, or the mirror now DOES vendor
// it (so it should be removed from the omission allowlist).
if (!entry || !(attr in entry.attrs) || (mirror && attr in mirror)) {
staleOmission.push(key);
}
}
expect(staleOmission, "stale ACCEPTED_ATTR_OMISSIONS rows").toEqual([]);
const staleDivergence: string[] = [];
for (const key of ACCEPTED_DEFAULT_DIVERGENCE) {
const [name, attr] = key.split(".");
const entry = ext.get(name);
const mirror = entry ? mirrorAttrs(name, entry.kind) : null;
const extDefault = entry?.attrs[attr];
// Stale if the divergence no longer exists (attr gone, or defaults now
// agree) — the row should be dropped so the allowlist stays honest.
if (
!entry ||
!mirror ||
!(attr in entry.attrs) ||
!(attr in mirror) ||
extDefault === undefined ||
JSON.stringify(mirror[attr]) === JSON.stringify(extDefault)
) {
staleDivergence.push(key);
}
}
expect(staleDivergence, "stale ACCEPTED_DEFAULT_DIVERGENCE rows").toEqual([]);
});
});
@@ -9,18 +9,11 @@ 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: {