Merge develop into feat/git-sync — unify converter on the branch (#293/#326 step 6a)

Per maintainer directive (#119 comment): land the canonical converter on the
git-sync branch so sync is tested on the real format, NOT a dead legacy copy.
#119 itself stays FROZEN (не вливается) — this only merges develop in.

Resolutions (all git-sync converter conflicts → develop; engine kept as-is):
- Dropped the branch's legacy `packages/git-sync/src/lib/*` converter — the
  converter now lives solely in `@docmost/prosemirror-markdown` (#293); the
  engine (pull/push/stabilize/index) only switches its imports to the package
  (no logic change, verified by diff).
- Removed the branch's orphaned converter tests + fixtures under
  `packages/git-sync/test/` (their coverage moved to the package's own test
  suite on develop); git-sync/test now holds engine tests only.
- .gitignore / Dockerfile / test.yml / AGENTS.md: unioned — build/ ignored for
  every package; Dockerfile COPYs both prosemirror-markdown/build (mcp+git-sync
  runtime) and git-sync/build (git-sync's runtime consumer lands on this branch);
  CI builds prosemirror-markdown before git-sync/mcp.
- pnpm-lock.yaml regenerated for the merged workspace.

Branch adaptations to canon (server-side tests only — converter untouched, per
the guardrail that converter fixes go to the package on develop, fixtures-first):
- git-sync-converter-gate.spec.ts: heading textAlign and image width/height now
  round-trip via the canon trailing-comment forms (#9 `<!--attrs {...}-->`, #4
  `<!--img {...}-->`) instead of the old HTML-tag forms — expectations flipped to
  the real canon output. RESIDUAL: canon #4 does not yet carry image `align`
  (documented as a known divergence; fix belongs in the package on develop).
- schema-attribute-contract.spec.ts: the schema mirror moved from
  `@docmost/git-sync/lib/docmost-schema` to `@docmost/prosemirror-markdown`;
  import + jest source-mapper updated.

Verified: prosemirror-markdown/git-sync/mcp build clean; git-sync corpus green;
server `tsc --noEmit` 0; gate + schema-attribute-contract specs 32/32.
This commit is contained in:
agent_coder
2026-07-04 20:09:26 +03:00
150 changed files with 10315 additions and 4868 deletions
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
expect(value).toBe(42);
});
});
describe('CollaborationHandler.deleteCommentMark', () => {
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
const doc = buildDocWithComment('Hello world', 'c1');
const { hocuspocus, connection } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
// The mark is gone; the text itself stays (deleting the anchor, not the run).
const xmlText = (
doc.getXmlFragment('default').get(0) as Y.XmlElement
).get(0) as Y.XmlText;
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
expect(connection.transact).toHaveBeenCalledTimes(1);
expect(connection.disconnect).toHaveBeenCalledTimes(1);
});
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
const doc = buildDocWithComment('abc', 'c9');
const { hocuspocus } = fakeHocuspocus(doc);
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
expect(spy).toHaveBeenCalledWith(
doc.getXmlFragment('default'),
'comment',
'commentId',
'c9',
);
spy.mockRestore();
});
it('leaves a different comment\'s mark intact', async () => {
const doc = buildDocWithComment('keep me', 'other');
const { hocuspocus } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
const xmlText = (
doc.getXmlFragment('default').get(0) as Y.XmlElement
).get(0) as Y.XmlText;
expect(xmlText.toDelta()).toEqual([
{
insert: 'keep me',
attributes: { comment: { commentId: 'other', resolved: false } },
},
]);
});
});
@@ -6,6 +6,7 @@ import {
tiptapExtensions,
} from './collaboration.util';
import {
removeYjsMarkByAttribute,
replaceYjsMarkedText,
setYjsMark,
updateYjsMarkAttribute,
@@ -82,6 +83,40 @@ export class CollaborationHandler {
},
);
},
deleteCommentMark: async (
documentName: string,
payload: {
commentId: string;
user: User;
},
) => {
const { commentId, user } = payload;
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
// applied one has no replies, the comment is hard-deleted and its inline
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
// the collaborative document.
//
// Routing this through collaboration.gateway's handleYjsEvent means the
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
// no-op) and a missing live instance is a hard error — the same guarantee
// applyCommentSuggestion/resolveCommentMark rely on.
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
removeYjsMarkByAttribute(
fragment,
'comment',
'commentId',
commentId,
);
},
);
},
applyCommentSuggestion: async (
documentName: string,
payload: {
@@ -464,26 +464,23 @@ describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () =>
});
// ---------------------------------------------------------------------------
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
// Image layout attrs — width/height now PRESERVED by canon #4, align is a
// RESIDUAL divergence (isolated so it does NOT silently weaken the gate).
//
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
// The `image` NODE round-trips through editor-ext fine. Plain markdown `![](src)`
// has no way to express layout attrs, so the canonical converter (#293/#326
// canon decision #4) appends a machine comment `<!--img {...}-->` carrying the
// non-default attrs, and re-parses it on import — the same trailing-comment
// pattern used for media/textAlign. This closes the former width/height loss.
//
// 1. `convertProseMirrorToMarkdown` emits a standard `![alt](src)` image
// (markdown-converter.ts case "image"). Standard markdown image syntax has
// no way to express `width` / `height` / `align`, so those attrs are
// DROPPED on export and cannot be recovered on import.
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
// documented in packages/git-sync/test/fixtures/known-limitations).
//
// The gate documents the EXACT lossy shape below. If the converter is ever
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
// data-* attrs, as it already does for video/diagrams), these assertions flip
// and the image fixture should be promoted into the green CORPUS above.
// RESIDUAL GAP (must be fixed in the converter PACKAGE on develop, fixtures-first
// — never on this branch, which no longer owns a converter copy): canon #4
// currently serializes only `width`/`height` into `<!--img {...}-->`, NOT
// `align`. So an image `align` is still dropped across the round trip. Locked
// below as the exact current shape; when the package learns to carry `align`,
// this assertion flips to `toBe('center')`.
// ---------------------------------------------------------------------------
describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERGENCE)', () => {
describe('git-sync converter §13.1 image width/height preserved (align: residual divergence)', () => {
const imageDoc = doc({
type: 'image',
attrs: {
@@ -494,26 +491,28 @@ describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERG
},
});
it('preserves width/height/align by exporting an HTML <img> (PR #119 round-trip fix)', async () => {
it('preserves width/height via the canon `<!--img {...}-->` comment; align still drops', async () => {
const { md, canonNormalized } = await runGate(imageDoc);
// A top-level image carrying layout attrs is now exported as a schema-
// matching HTML <img> (the same path video/diagrams already use), so the
// dimensions and alignment survive the round trip instead of collapsing to
// bare `![](src)`.
// Canon #4: bare `![](src)` plus a trailing `<!--img {...}-->` comment that
// carries the non-default layout attrs (currently width/height only), so the
// dimensions survive the round trip instead of collapsing to bare `![](src)`.
expect(md.trim()).toBe(
'<img src="https://example.com/pic.png" width="640" height="480" align="center">',
'![](https://example.com/pic.png) <!--img {"width":"640","height":"480"}-->',
);
// The round-tripped image keeps src + the layout attrs. width/height are
// The round-tripped image keeps src + width/height. width/height are
// re-imported as strings (matching the video/audio/pdf string convention),
// so assert the values rather than the JS type.
const imgAttrs = (canonNormalized as any).content[0].attrs;
expect((canonNormalized as any).content[0].type).toBe('image');
expect(imgAttrs.src).toBe('https://example.com/pic.png');
expect(imgAttrs.align).toBe('center');
expect(String(imgAttrs.width)).toBe('640');
expect(String(imgAttrs.height)).toBe('480');
// RESIDUAL GAP: canon #4 does not (yet) carry `align` in `<!--img {...}-->`,
// so the original `align: 'center'` is lost. Fix belongs in the converter
// package on develop (fixtures-first); flip to toBe('center') once landed.
expect(imgAttrs.align).toBeUndefined();
});
});
@@ -534,9 +533,10 @@ describe('git-sync converter §13.1 heading text alignment round-trips', () => {
const { md, canonNormalized } = await runGate(alignedHeading);
// Export is a styled <h2> (was a lossy bare `## centered heading`).
// Canon #9: ATX heading plus a trailing `<!--attrs {...}-->` comment carrying
// the non-default textAlign (was a lossy bare `## centered heading`).
expect(md.trim()).toBe(
'<h2 style="text-align:center">centered heading</h2>',
'## centered heading <!--attrs {"textAlign":"center"}-->',
);
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true);
});
@@ -1,10 +1,11 @@
import { getSchema } from '@tiptap/core';
import { Schema } from '@tiptap/pm/model';
import { tiptapExtensions } from './collaboration.util';
// The vendored git-sync mirror's extension set. Imported via the subpath the
// server jest config maps to the package SOURCE (moduleNameMapper
// `^@docmost/git-sync/(.*)$`), so this reads the real mirror, not a build.
import { docmostExtensions as gitSyncExtensions } from '@docmost/git-sync/lib/docmost-schema';
// The canonical converter mirror's extension set. The schema mirror now lives in
// the single `@docmost/prosemirror-markdown` package (#293); the server jest
// config maps it to the package SOURCE (moduleNameMapper
// `^@docmost/prosemirror-markdown$`), so this reads the real mirror, not a build.
import { docmostExtensions as gitSyncExtensions } from '@docmost/prosemirror-markdown';
/**
* ATTRIBUTE-LEVEL SCHEMA CONTRACT (review #293, variant A).