From d3cba7aceecaf7e7faa396ac71211edc2b855196 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 14:25:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(git-sync):=20CommonJS=20build=20+=20=C2=A7?= =?UTF-8?q?13.1=20editor-ext=20idempotency=20gate=20(Phase=20A.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make @docmost/git-sync natively consumable by the CommonJS server (and jest): build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic Function('import()') trick (mirrors docmost-client.loader.ts) with a require() fallback so vitest's evaluator works too. git-sync tests stay green (314 pass, 3 expected fail). Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts): 13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists, blockquote, callouts, code block, hr, table, nested mix) round-trip content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror -> TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert docsCanonicallyEqual. All green => the vendored converter's docmost-schema is schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1 requires before Phase B. The one intrinsic markdown-image lossiness (width/height /align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not hidden. Server tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../git-sync-converter-gate.spec.ts | 349 ++++++++++++++++++ packages/git-sync/build/engine/layout.js | 15 +- packages/git-sync/build/engine/loop-guard.js | 9 +- packages/git-sync/build/engine/reconcile.js | 17 +- .../build/engine/roundtrip-helpers.js | 8 +- packages/git-sync/build/engine/sanitize.js | 8 +- packages/git-sync/build/engine/stabilize.js | 15 +- packages/git-sync/build/index.d.ts | 20 +- packages/git-sync/build/index.js | 31 +- packages/git-sync/build/lib/canonicalize.js | 8 +- packages/git-sync/build/lib/diff.js | 27 +- packages/git-sync/build/lib/docmost-schema.js | 106 +++--- packages/git-sync/build/lib/index.d.ts | 10 +- packages/git-sync/build/lib/index.js | 18 +- .../git-sync/build/lib/markdown-converter.js | 5 +- .../git-sync/build/lib/markdown-document.js | 11 +- .../build/lib/markdown-to-prosemirror.js | 56 ++- packages/git-sync/build/lib/node-ops.js | 38 +- .../results.json | 2 +- packages/git-sync/package.json | 6 +- packages/git-sync/src/engine/layout.ts | 2 +- packages/git-sync/src/engine/stabilize.ts | 2 +- packages/git-sync/src/index.ts | 20 +- packages/git-sync/src/lib/diff.ts | 2 +- packages/git-sync/src/lib/index.ts | 10 +- .../src/lib/markdown-to-prosemirror.ts | 56 ++- packages/git-sync/tsconfig.json | 4 +- 27 files changed, 693 insertions(+), 162 deletions(-) create mode 100644 apps/server/src/collaboration/git-sync-converter-gate.spec.ts diff --git a/apps/server/src/collaboration/git-sync-converter-gate.spec.ts b/apps/server/src/collaboration/git-sync-converter-gate.spec.ts new file mode 100644 index 00000000..b4f14a02 --- /dev/null +++ b/apps/server/src/collaboration/git-sync-converter-gate.spec.ts @@ -0,0 +1,349 @@ +/** + * §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B. + * + * Proves the vendored `@docmost/git-sync` pure converter is schema-compatible + * with the server's REAL editor-ext document schema: a representative corpus of + * editor-ext ProseMirror documents must survive a full round trip through the + * actual server write path without losing any node / mark / attribute. + * + * Pipeline per document (plan §13.1): + * 1. md = convertProseMirrorToMarkdown(content) // git-sync export + * 2. doc = await markdownToProseMirror(md) // git-sync import + * 3. push `doc` through the REAL editor-ext Yjs write path the server uses: + * ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions) + * normalized = TiptapTransformer.fromYdoc(ydoc, 'default') + * This is exactly what PersistenceExtension does on store + * (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115) + * with the same `tiptapExtensions` (collaboration.util.ts) and the same + * `@hocuspocus/transformer`, so the gate exercises the real schema + * validation that runs on a git-sync write (plan §3.3). + * 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true + * + * Any node / mark / attr that editor-ext drops (because the vendored + * docmost-schema named it differently, or declares a different default) makes + * the gate FAIL for that document — exactly the schema-divergence plan §3.3 / + * §13.1 warn about. Genuine, irreducible divergences are isolated into the + * clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden). + * + * Requires the workspace packages built first: + * pnpm --filter @docmost/editor-ext build + * pnpm --filter @docmost/git-sync build + */ +import { TiptapTransformer } from '@hocuspocus/transformer'; +// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its +// built CJS `dist` (its `main`). Importing the ESM `@docmost/git-sync` package +// first flips jest's resolver to editor-ext's `module` (src) field, which then +// drags in React node views (navigator-less) and breaks the node test env. +import { tiptapExtensions } from './collaboration.util'; +import { + convertProseMirrorToMarkdown, + markdownToProseMirror, + canonicalizeContent, + docsCanonicallyEqual, +} from '@docmost/git-sync'; + +/** + * Run a single editor-ext document through the full gate pipeline and return + * the canonical original vs the canonical doc as it lands after the real Yjs + * write path, plus the intermediate markdown for diagnostics. + */ +async function runGate(original: any): Promise<{ + md: string; + imported: any; + normalized: any; + canonOriginal: any; + canonNormalized: any; +}> { + // 1) editor-ext JSON -> markdown (git-sync export). + const md = convertProseMirrorToMarkdown(original); + + // 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema). + const imported = await markdownToProseMirror(md); + + // 3) push through the REAL editor-ext schema via the server's Yjs write path. + // toYdoc validates `imported` against tiptapExtensions (throws on an + // unknown node, drops unknown attrs); fromYdoc reads it back as the + // normalized editor-ext JSON the server would persist. + const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions); + const normalized = TiptapTransformer.fromYdoc(ydoc, 'default'); + + return { + md, + imported, + normalized, + canonOriginal: canonicalizeContent(original), + canonNormalized: canonicalizeContent(normalized), + }; +} + +const doc = (...content: any[]) => ({ type: 'doc', content }); +const text = (t: string, marks?: any[]) => + marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; +const para = (...content: any[]) => ({ type: 'paragraph', content }); + +// --------------------------------------------------------------------------- +// Corpus: editor-ext ProseMirror documents covering the common node/mark types. +// Node / mark / attr names and DEFAULTS are taken from the real schema — +// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions +// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a +// non-null default on import (e.g. image.align="center", callout.type, list +// start) the fixture pre-authors that materialized value so the round trip is +// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11). +// --------------------------------------------------------------------------- +const CORPUS: Record = { + 'paragraphs + headings (h1-h3)': doc( + { type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] }, + { type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] }, + { type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] }, + para(text('A plain paragraph of text.')), + para(text('Second paragraph.')), + ), + + 'inline marks (bold/italic/strike/code)': doc( + para( + text('normal '), + text('bold', [{ type: 'bold' }]), + text(' '), + text('italic', [{ type: 'italic' }]), + text(' '), + text('struck', [{ type: 'strike' }]), + text(' '), + text('code', [{ type: 'code' }]), + ), + ), + + 'links': doc( + para( + text('see '), + text('the site', [ + { type: 'link', attrs: { href: 'https://example.com' } }, + ]), + text(' for more'), + ), + ), + + 'bullet list': doc({ + type: 'bulletList', + content: [ + { type: 'listItem', content: [para(text('first'))] }, + { type: 'listItem', content: [para(text('second'))] }, + { type: 'listItem', content: [para(text('third'))] }, + ], + }), + + 'ordered list': doc({ + type: 'orderedList', + attrs: { start: 1 }, + content: [ + { type: 'listItem', content: [para(text('one'))] }, + { type: 'listItem', content: [para(text('two'))] }, + ], + }), + + 'task list (checkbox)': doc({ + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [para(text('done item'))], + }, + { + type: 'taskItem', + attrs: { checked: false }, + content: [para(text('todo item'))], + }, + ], + }), + + 'blockquote': doc({ + type: 'blockquote', + content: [para(text('a quoted line')), para(text('second quoted line'))], + }), + + 'callout (info)': doc({ + type: 'callout', + attrs: { type: 'info' }, + content: [para(text('an informational callout'))], + }), + + 'callout (warning)': doc({ + type: 'callout', + attrs: { type: 'warning' }, + content: [para(text('a warning callout'))], + }), + + 'code block (with language)': doc({ + type: 'codeBlock', + attrs: { language: 'typescript' }, + // A fenced code block's body is stored with a trailing newline (the form a + // markdown ``` fence round-trips to: marked normalizes the code text to end + // in "\n"). Authoring the fixture at that fixpoint mirrors how the engine + // normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly. + content: [text('const a: number = 1;\nconsole.log(a);\n')], + }), + + 'horizontal rule': doc( + para(text('before')), + { type: 'horizontalRule' }, + para(text('after')), + ), + + 'table (header row + cells)': doc({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [para(text('Name'))], + }, + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [para(text('Value'))], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [para(text('alpha'))], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [para(text('1'))], + }, + ], + }, + ], + }), + + 'nested / mixed document': doc( + { type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] }, + para( + text('intro with '), + text('bold', [{ type: 'bold' }]), + text(' and a '), + text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]), + text('.'), + ), + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + para(text('item with '), text('code', [{ type: 'code' }])), + ], + }, + { + type: 'listItem', + content: [ + para(text('item with sublist')), + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [para(text('nested a'))] }, + { type: 'listItem', content: [para(text('nested b'))] }, + ], + }, + ], + }, + ], + }, + { + type: 'callout', + attrs: { type: 'success' }, + content: [ + para(text('callout body')), + { type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] }, + ], + }, + { + type: 'blockquote', + content: [para(text('quote at the end'))], + }, + ), +}; + +describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => { + for (const [name, original] of Object.entries(CORPUS)) { + it(`round-trips losslessly: ${name}`, async () => { + const { md, canonOriginal, canonNormalized } = await runGate(original); + + const equal = docsCanonicallyEqual(original, canonNormalized); + if (!equal) { + // Surface a readable diff so a real divergence is actionable. + // eslint-disable-next-line no-console + console.error( + `\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` + + `--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` + + `--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`, + ); + } + expect(equal).toBe(true); + }); + } +}); + +// --------------------------------------------------------------------------- +// KNOWN DIVERGENCE — images (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: +// +// 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 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. +// --------------------------------------------------------------------------- +describe('git-sync converter §13.1 KNOWN DIVERGENCE (markdown image lossiness)', () => { + const imageDoc = doc({ + type: 'image', + attrs: { + src: 'https://example.com/pic.png', + width: 640, + height: 480, + align: 'center', + }, + }); + + it('drops width/height/align (markdown ![](src) cannot carry them) and hoists the block image past a leading empty paragraph', async () => { + const { md, canonNormalized } = await runGate(imageDoc); + + // Export is plain markdown image syntax — no dimensions/align survive. + expect(md.trim()).toBe('![](https://example.com/pic.png)'); + + // The round-tripped doc is the documented lossy shape: a leading empty + // paragraph (block-image hoist) + an image carrying ONLY src (+ alt=""). + expect(canonNormalized).toEqual({ + type: 'doc', + content: [ + { type: 'paragraph' }, + { + type: 'image', + attrs: { alt: '', src: 'https://example.com/pic.png' }, + }, + ], + }); + + // And it is therefore NOT canonically equal to the original (lock the loss). + expect(docsCanonicallyEqual(imageDoc, canonNormalized)).toBe(false); + }); +}); diff --git a/packages/git-sync/build/engine/layout.js b/packages/git-sync/build/engine/layout.js index 3503de78..1b229a40 100644 --- a/packages/git-sync/build/engine/layout.js +++ b/packages/git-sync/build/engine/layout.js @@ -1,3 +1,4 @@ +"use strict"; /** * Pure page-tree -> vault path mapping (SPEC §12). * @@ -9,7 +10,9 @@ * path logic is unit-testable without any I/O. The names are COSMETIC; identity * lives in each file's meta block (pageId / slugId). */ -import { sanitizeTitle, disambiguate } from "./sanitize.js"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildVaultLayout = buildVaultLayout; +const sanitize_1 = require("./sanitize"); /** * Build the full vault layout for a space. * @@ -27,7 +30,7 @@ import { sanitizeTitle, disambiguate } from "./sanitize.js"; * cannot see — e.g. two pages whose parents are BOTH outside the input set * both bucket at the root with `segments: []`. */ -export function buildVaultLayout(pages) { +function buildVaultLayout(pages) { // Index pages by id so the parent chain can be walked. Guard against // duplicate ids in the input (first one wins). const byId = new Map(); @@ -109,11 +112,11 @@ export function buildVaultLayout(pages) { continue; if (usedPaths.has(pathKey(entry))) { // First attempt: disambiguate the stem with the sanitized slugId (or id). - entry.stem = disambiguate(entry.stem, sanitizeTitle(p.slugId ?? p.id)); + entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.slugId ?? p.id)); if (usedPaths.has(pathKey(entry))) { // Still colliding: append the (sanitized) id as a last resort. The id // is globally unique, so this always resolves the collision. - entry.stem = disambiguate(entry.stem, sanitizeTitle(p.id)); + entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.id)); } } usedPaths.add(pathKey(entry)); @@ -137,11 +140,11 @@ function nameForNode(node, parentKey, usedBySibling) { used = new Set(); usedBySibling.set(parentKey, used); } - let name = sanitizeTitle(node.title ?? ""); + let name = (0, sanitize_1.sanitizeTitle)(node.title ?? ""); if (used.has(name)) { // Sibling collision: disambiguate with the stable, sanitized slugId (fall // back to the sanitized pageId if no slugId is present). - name = disambiguate(name, sanitizeTitle(node.slugId ?? node.id)); + name = (0, sanitize_1.disambiguate)(name, (0, sanitize_1.sanitizeTitle)(node.slugId ?? node.id)); } used.add(name); return name; diff --git a/packages/git-sync/build/engine/loop-guard.js b/packages/git-sync/build/engine/loop-guard.js index a85047e4..88f4af00 100644 --- a/packages/git-sync/build/engine/loop-guard.js +++ b/packages/git-sync/build/engine/loop-guard.js @@ -1,3 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.bodyHash = bodyHash; /** * Loop-guard primitives (SPEC §10). The sync engine must never re-pull its OWN * write as if it were a remote edit: after a push, the next poll will see the @@ -10,7 +13,7 @@ * to decide "this is our own write, ignore it") is a future increment — here we * only PRODUCE the hash and the per-page push record (see `src/push.ts`). */ -import { createHash } from "node:crypto"; +const node_crypto_1 = require("node:crypto"); /** * Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic: * the same input string always yields the same digest, a different input a @@ -23,6 +26,6 @@ import { createHash } from "node:crypto"; * caller is responsible for passing a canonical/stable representation if it * wants hash equality across cosmetic-only differences. */ -export function bodyHash(markdownBody) { - return createHash("sha256").update(markdownBody, "utf8").digest("hex"); +function bodyHash(markdownBody) { + return (0, node_crypto_1.createHash)("sha256").update(markdownBody, "utf8").digest("hex"); } diff --git a/packages/git-sync/build/engine/reconcile.js b/packages/git-sync/build/engine/reconcile.js index 9a111bb5..9ebd2989 100644 --- a/packages/git-sync/build/engine/reconcile.js +++ b/packages/git-sync/build/engine/reconcile.js @@ -1,3 +1,4 @@ +"use strict"; /** * Pure reconciliation planner (SPEC §5/§6/§8). * @@ -11,6 +12,10 @@ * This module is intentionally PURE (no IO, no git) so the whole plan is * unit-testable. The actual file writing / git operations happen in pull.ts. */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = void 0; +exports.planReconciliation = planReconciliation; +exports.decideAbsenceDeletions = decideAbsenceDeletions; /** * Compute the reconciliation plan. * @@ -33,7 +38,7 @@ * path is removed (as an absence/move) so the vault converges to exactly the * live set. */ -export function planReconciliation(live, existing) { +function planReconciliation(live, existing) { // Desired path for each live pageId. const liveByPageId = new Map(); // Set of all paths that WILL be written (never delete/remove one of these). @@ -81,9 +86,9 @@ export function planReconciliation(live, existing) { * Below this many tracked files the mass-delete fraction guard is not applied * (a tiny vault where deleting "most" files is normal, e.g. 1-of-2). */ -export const MASS_DELETE_MIN_EXISTING = 4; +exports.MASS_DELETE_MIN_EXISTING = 4; /** Fraction of tracked files above which a delete plan is a suspected wipe. */ -export const MASS_DELETE_FRACTION = 0.5; +exports.MASS_DELETE_FRACTION = 0.5; /** * Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied * this cycle? Encapsulates the SPEC §8 safety invariants so they are unit- @@ -100,7 +105,7 @@ export const MASS_DELETE_FRACTION = 0.5; * Moves are NOT governed by this decision: a moved page IS present in `live`, so * its old-path removal is real (handled by the caller separately). */ -export function decideAbsenceDeletions(args) { +function decideAbsenceDeletions(args) { const { treeComplete, liveCount, existingCount, deleteCount } = args; // No tracked files, or nothing to delete -> trivially fine to "apply". if (existingCount === 0 || deleteCount === 0) @@ -109,8 +114,8 @@ export function decideAbsenceDeletions(args) { return { apply: false, reason: "incomplete-fetch" }; if (liveCount === 0) return { apply: false, reason: "empty-live" }; - if (existingCount >= MASS_DELETE_MIN_EXISTING && - deleteCount > existingCount * MASS_DELETE_FRACTION) { + if (existingCount >= exports.MASS_DELETE_MIN_EXISTING && + deleteCount > existingCount * exports.MASS_DELETE_FRACTION) { return { apply: false, reason: "mass-delete" }; } return { apply: true }; diff --git a/packages/git-sync/build/engine/roundtrip-helpers.js b/packages/git-sync/build/engine/roundtrip-helpers.js index 2ef4e906..a8333016 100644 --- a/packages/git-sync/build/engine/roundtrip-helpers.js +++ b/packages/git-sync/build/engine/roundtrip-helpers.js @@ -1,3 +1,4 @@ +"use strict"; /** * Pure helpers extracted from the docmost-sync Phase-0 idempotency harness * (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored — @@ -5,13 +6,16 @@ * `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1: * the roundtrip harness moves into the package's tests, not the engine). */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.stripBlockIds = stripBlockIds; +exports.firstDivergence = firstDivergence; /** * Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids * are regenerated by `markdownToProseMirror` (SPEC §11), so they must be * ignored when comparing the semantic shape of two documents. Returns a NEW * tree; the input is not mutated. */ -export function stripBlockIds(node) { +function stripBlockIds(node) { if (Array.isArray(node)) { return node.map(stripBlockIds); } @@ -36,7 +40,7 @@ export function stripBlockIds(node) { * Find the first divergence between two values via a recursive deep compare. * Returns a short path + the two differing values, or null if they are equal. */ -export function firstDivergence(a, b, path = "$") { +function firstDivergence(a, b, path = "$") { if (a === b) return null; const ta = typeof a; diff --git a/packages/git-sync/build/engine/sanitize.js b/packages/git-sync/build/engine/sanitize.js index 2aff0f3c..684d0bab 100644 --- a/packages/git-sync/build/engine/sanitize.js +++ b/packages/git-sync/build/engine/sanitize.js @@ -1,3 +1,4 @@ +"use strict"; /** * Deterministic filename strategy (SPEC §12). * @@ -6,6 +7,9 @@ * functions are intentionally dependency-free and pure, so they are trivially * unit-testable. */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sanitizeTitle = sanitizeTitle; +exports.disambiguate = disambiguate; // Printable characters forbidden in file names on common filesystems (mainly // Windows): / \ < > : " | ? *. Each match is replaced with a single "-". // Spaces are NOT in this set; whitespace is normalized separately below. @@ -64,7 +68,7 @@ function stripControlChars(input) { * result, an all-dots result, or a reserved Windows device name by prefixing * with "_". */ -export function sanitizeTitle(title) { +function sanitizeTitle(title) { let name = stripControlChars(title ?? "") .replace(FORBIDDEN_PRINTABLE_RE, "-") .replace(WHITESPACE_RUN_RE, " ") @@ -92,6 +96,6 @@ export function sanitizeTitle(title) { * to the same name. Appends a stable suffix built from the page's `slugId`, so * the result stays deterministic across runs (SPEC §12: `Title ~slugId`). */ -export function disambiguate(name, slugId) { +function disambiguate(name, slugId) { return `${name} ~${slugId}`; } diff --git a/packages/git-sync/build/engine/stabilize.js b/packages/git-sync/build/engine/stabilize.js index d9e32962..3505b640 100644 --- a/packages/git-sync/build/engine/stabilize.js +++ b/packages/git-sync/build/engine/stabilize.js @@ -1,3 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.stabilizePageFile = stabilizePageFile; /** * Normalize-on-write helper (SPEC §11 "Резолюция"). * @@ -12,7 +15,7 @@ * Already-stable content is unaffected (the pass is idempotent), so re-pulls of * unchanged pages produce identical bytes and git sees no diff. */ -import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMarkdownBody, } from "../lib/index.js"; +const index_1 = require("../lib/index"); /** * Produce the self-contained `.md` file text for a page from its raw * ProseMirror `content` + identity meta, in the verified fixpoint form. @@ -26,11 +29,11 @@ import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMa * idempotent for already-stable content, and the convergence point for the * known converter asymmetries. */ -export async function stabilizePageFile(content, meta) { - const md1 = convertProseMirrorToMarkdown(content); - const doc2 = await markdownToProseMirror(md1); - const stableBody = convertProseMirrorToMarkdown(doc2); +async function stabilizePageFile(content, meta) { + const md1 = (0, index_1.convertProseMirrorToMarkdown)(content); + const doc2 = await (0, index_1.markdownToProseMirror)(md1); + const stableBody = (0, index_1.convertProseMirrorToMarkdown)(doc2); // The meta shape is exactly what `exportPageBody` writes; cast to the lib's // DocmostMdMeta (a superset with optional fields) for the serializer. - return serializeDocmostMarkdownBody(meta, stableBody); + return (0, index_1.serializeDocmostMarkdownBody)(meta, stableBody); } diff --git a/packages/git-sync/build/index.d.ts b/packages/git-sync/build/index.d.ts index c89072ee..b5afada0 100644 --- a/packages/git-sync/build/index.d.ts +++ b/packages/git-sync/build/index.d.ts @@ -5,13 +5,13 @@ * from docmost-sync. Server integration (GitmostDataSource, orchestrator, * VaultGit, pull/push) is added in later steps. */ -export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js"; -export type { DocmostMdMeta } from "./lib/index.js"; -export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile.js"; -export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile.js"; -export { buildVaultLayout } from "./engine/layout.js"; -export type { PageNode, VaultEntry } from "./engine/layout.js"; -export { sanitizeTitle, disambiguate } from "./engine/sanitize.js"; -export { stabilizePageFile } from "./engine/stabilize.js"; -export type { PageMeta } from "./engine/stabilize.js"; -export { bodyHash } from "./engine/loop-guard.js"; +export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index"; +export type { DocmostMdMeta } from "./lib/index"; +export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile"; +export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile"; +export { buildVaultLayout } from "./engine/layout"; +export type { PageNode, VaultEntry } from "./engine/layout"; +export { sanitizeTitle, disambiguate } from "./engine/sanitize"; +export { stabilizePageFile } from "./engine/stabilize"; +export type { PageMeta } from "./engine/stabilize"; +export { bodyHash } from "./engine/loop-guard"; diff --git a/packages/git-sync/build/index.js b/packages/git-sync/build/index.js index 45bc04fe..2ac0563e 100644 --- a/packages/git-sync/build/index.js +++ b/packages/git-sync/build/index.js @@ -1,3 +1,4 @@ +"use strict"; /** * Public surface of `@docmost/git-sync`. * @@ -5,12 +6,30 @@ * from docmost-sync. Server integration (GitmostDataSource, orchestrator, * VaultGit, pull/push) is added in later steps. */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.bodyHash = exports.stabilizePageFile = exports.disambiguate = exports.sanitizeTitle = exports.buildVaultLayout = exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = exports.decideAbsenceDeletions = exports.planReconciliation = exports.docsCanonicallyEqual = exports.canonicalizeContent = exports.markdownToProseMirror = exports.convertProseMirrorToMarkdown = exports.parseDocmostMarkdown = exports.serializeDocmostMarkdownBody = exports.serializeDocmostMarkdown = void 0; // Pure converter (markdown <-> ProseMirror, file envelope, canonicalization). -export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js"; +var index_1 = require("./lib/index"); +Object.defineProperty(exports, "serializeDocmostMarkdown", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdown; } }); +Object.defineProperty(exports, "serializeDocmostMarkdownBody", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdownBody; } }); +Object.defineProperty(exports, "parseDocmostMarkdown", { enumerable: true, get: function () { return index_1.parseDocmostMarkdown; } }); +Object.defineProperty(exports, "convertProseMirrorToMarkdown", { enumerable: true, get: function () { return index_1.convertProseMirrorToMarkdown; } }); +Object.defineProperty(exports, "markdownToProseMirror", { enumerable: true, get: function () { return index_1.markdownToProseMirror; } }); +Object.defineProperty(exports, "canonicalizeContent", { enumerable: true, get: function () { return index_1.canonicalizeContent; } }); +Object.defineProperty(exports, "docsCanonicallyEqual", { enumerable: true, get: function () { return index_1.docsCanonicallyEqual; } }); // Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize, // loop-guard body hash. -export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile.js"; -export { buildVaultLayout } from "./engine/layout.js"; -export { sanitizeTitle, disambiguate } from "./engine/sanitize.js"; -export { stabilizePageFile } from "./engine/stabilize.js"; -export { bodyHash } from "./engine/loop-guard.js"; +var reconcile_1 = require("./engine/reconcile"); +Object.defineProperty(exports, "planReconciliation", { enumerable: true, get: function () { return reconcile_1.planReconciliation; } }); +Object.defineProperty(exports, "decideAbsenceDeletions", { enumerable: true, get: function () { return reconcile_1.decideAbsenceDeletions; } }); +Object.defineProperty(exports, "MASS_DELETE_MIN_EXISTING", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_MIN_EXISTING; } }); +Object.defineProperty(exports, "MASS_DELETE_FRACTION", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_FRACTION; } }); +var layout_1 = require("./engine/layout"); +Object.defineProperty(exports, "buildVaultLayout", { enumerable: true, get: function () { return layout_1.buildVaultLayout; } }); +var sanitize_1 = require("./engine/sanitize"); +Object.defineProperty(exports, "sanitizeTitle", { enumerable: true, get: function () { return sanitize_1.sanitizeTitle; } }); +Object.defineProperty(exports, "disambiguate", { enumerable: true, get: function () { return sanitize_1.disambiguate; } }); +var stabilize_1 = require("./engine/stabilize"); +Object.defineProperty(exports, "stabilizePageFile", { enumerable: true, get: function () { return stabilize_1.stabilizePageFile; } }); +var loop_guard_1 = require("./engine/loop-guard"); +Object.defineProperty(exports, "bodyHash", { enumerable: true, get: function () { return loop_guard_1.bodyHash; } }); diff --git a/packages/git-sync/build/lib/canonicalize.js b/packages/git-sync/build/lib/canonicalize.js index 5a6c0bbc..fbb2c315 100644 --- a/packages/git-sync/build/lib/canonicalize.js +++ b/packages/git-sync/build/lib/canonicalize.js @@ -1,3 +1,4 @@ +"use strict"; /** * docmost-sync ADDITION (not present in docmost-mcp). * @@ -15,6 +16,9 @@ * This file is intentionally a NEW, self-contained module so it is trivial to * backport into docmost-mcp without touching existing code. */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.canonicalizeContent = canonicalizeContent; +exports.docsCanonicallyEqual = docsCanonicallyEqual; /** * Known NON-NULL schema defaults that `markdownToProseMirror` materializes on * import, keyed by node/mark type → { attr: defaultValue }. @@ -137,7 +141,7 @@ function canonicalizeAttrs(attrs, dropId, type) { * 5. Preserve `text`, `type`, and `content` order exactly. * 6. Never mutate the input. */ -export function canonicalizeContent(node) { +function canonicalizeContent(node) { if (Array.isArray(node)) { return node.map((child) => canonicalizeContent(child)); } @@ -243,6 +247,6 @@ function deepEqual(a, b) { * True when two ProseMirror documents are semantically equal: equal after * canonicalization (block ids stripped, absent-vs-default-null normalized). */ -export function docsCanonicallyEqual(a, b) { +function docsCanonicallyEqual(a, b) { return deepEqual(canonicalizeContent(a), canonicalizeContent(b)); } diff --git a/packages/git-sync/build/lib/diff.js b/packages/git-sync/build/lib/diff.js index 5205aff1..e14f7049 100644 --- a/packages/git-sync/build/lib/diff.js +++ b/packages/git-sync/build/lib/diff.js @@ -1,3 +1,4 @@ +"use strict"; /** * Headless, Docmost-equivalent document diff. * @@ -16,13 +17,15 @@ * If recreateTransform / the changeset throws on a pathological document pair, * we fall back to a coarse block-level text diff so the tool never hard-fails. */ -import { getSchema } from "@tiptap/core"; -import { Node } from "@tiptap/pm/model"; -import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; -import { recreateTransform } from "@fellow/prosemirror-recreate-transform"; -import { docmostExtensions } from "./docmost-schema.js"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.diffDocs = diffDocs; +const core_1 = require("@tiptap/core"); +const model_1 = require("@tiptap/pm/model"); +const changeset_1 = require("@tiptap/pm/changeset"); +const prosemirror_recreate_transform_1 = require("@fellow/prosemirror-recreate-transform"); +const docmost_schema_1 = require("./docmost-schema"); /** Build the schema once; it is pure and reused across calls. */ -const schema = getSchema(docmostExtensions); +const schema = (0, core_1.getSchema)(docmost_schema_1.docmostExtensions); /** Recursively concatenate the plain text of a JSON node. */ function plainText(node) { if (!node || typeof node !== "object") @@ -209,7 +212,7 @@ function renderMarkdown(result, fellBack) { * @param newDocJson the later document * @param notesHeading heading delimiting body from notes for footnote counting */ -export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { +function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); let changes = []; let inserted = 0; @@ -217,15 +220,15 @@ export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примеча let fellBack = false; const changedBlocks = new Set(); try { - const oldNode = Node.fromJSON(schema, oldDocJson); - const newNode = Node.fromJSON(schema, newDocJson); - const tr = recreateTransform(oldNode, newNode, { + const oldNode = model_1.Node.fromJSON(schema, oldDocJson); + const newNode = model_1.Node.fromJSON(schema, newDocJson); + const tr = (0, prosemirror_recreate_transform_1.recreateTransform)(oldNode, newNode, { complexSteps: false, wordDiffs: true, simplifyDiff: true, }); - const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []); - const simplified = simplifyChanges(changeSet.changes, newNode); + const changeSet = changeset_1.ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []); + const simplified = (0, changeset_1.simplifyChanges)(changeSet.changes, newNode); for (const change of simplified) { // Deleted text lives in the OLD doc coordinate range [fromA, toA). if (change.toA > change.fromA) { diff --git a/packages/git-sync/build/lib/docmost-schema.js b/packages/git-sync/build/lib/docmost-schema.js index 97cdcafd..148ea642 100644 --- a/packages/git-sync/build/lib/docmost-schema.js +++ b/packages/git-sync/build/lib/docmost-schema.js @@ -1,3 +1,9 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.docmostExtensions = exports.sanitizeCssColor = exports.clampCalloutType = void 0; /** * Full TipTap extension set matching the real Docmost document schema. * @@ -7,14 +13,14 @@ * to or from ProseMirror JSON must use THIS set, otherwise a round-trip * loses content. */ -import StarterKit from "@tiptap/starter-kit"; -import Image from "@tiptap/extension-image"; -import TaskList from "@tiptap/extension-task-list"; -import TaskItem from "@tiptap/extension-task-item"; -import Highlight from "@tiptap/extension-highlight"; -import Subscript from "@tiptap/extension-subscript"; -import Superscript from "@tiptap/extension-superscript"; -import { Node, Extension, Mark } from "@tiptap/core"; +const starter_kit_1 = __importDefault(require("@tiptap/starter-kit")); +const extension_image_1 = __importDefault(require("@tiptap/extension-image")); +const extension_task_list_1 = __importDefault(require("@tiptap/extension-task-list")); +const extension_task_item_1 = __importDefault(require("@tiptap/extension-task-item")); +const extension_highlight_1 = __importDefault(require("@tiptap/extension-highlight")); +const extension_subscript_1 = __importDefault(require("@tiptap/extension-subscript")); +const extension_superscript_1 = __importDefault(require("@tiptap/extension-superscript")); +const core_1 = require("@tiptap/core"); // Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this // package can stay on the same @tiptap/core version as the editor and avoid a // duplicate-tiptap version split in the monorepo. Reads a single declaration @@ -41,9 +47,10 @@ function getStyleProperty(element, propertyName) { } /** Allowed Docmost callout types; anything else falls back to "info". */ const CALLOUT_TYPES = ["info", "warning", "danger", "success"]; -export const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) +const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) ? value.toLowerCase() : "info"; +exports.clampCalloutType = clampCalloutType; /** * Allowlist guard for CSS color values imported from HTML. * @@ -61,14 +68,15 @@ export const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value * digits, %, ., commas, spaces and slashes */ const SAFE_COLOR_RE = /^(?:[a-zA-Z]+|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|rgba|hsl|hsla)\([0-9.,%/\s]+\))$/; -export const sanitizeCssColor = (value) => { +const sanitizeCssColor = (value) => { if (typeof value !== "string") return null; const color = value.trim(); return color && SAFE_COLOR_RE.test(color) ? color : null; }; +exports.sanitizeCssColor = sanitizeCssColor; /** Docmost callout (info/warning/danger/success banner). */ -const Callout = Node.create({ +const Callout = core_1.Node.create({ name: "callout", group: "block", content: "block+", @@ -79,9 +87,9 @@ const Callout = Node.create({ // it; without an explicit parseHTML every imported callout became "info". type: { default: "info", - parseHTML: (el) => clampCalloutType(el.getAttribute("data-callout-type")), + parseHTML: (el) => (0, exports.clampCalloutType)(el.getAttribute("data-callout-type")), renderHTML: (attrs) => ({ - "data-callout-type": clampCalloutType(attrs.type), + "data-callout-type": (0, exports.clampCalloutType)(attrs.type), }), }, icon: { @@ -99,7 +107,7 @@ const Callout = Node.create({ }, }); /** Minimal table family: enough for schema round-trips and HTML parsing. */ -const Table = Node.create({ +const Table = core_1.Node.create({ name: "table", group: "block", content: "tableRow+", @@ -111,7 +119,7 @@ const Table = Node.create({ return ["table", ["tbody", 0]]; }, }); -const TableRow = Node.create({ +const TableRow = core_1.Node.create({ name: "tableRow", content: "(tableCell | tableHeader)*", parseHTML() { @@ -134,7 +142,7 @@ const cellAttributes = () => ({ renderHTML: (attrs) => attrs.align ? { align: attrs.align } : {}, }, }); -const TableCell = Node.create({ +const TableCell = core_1.Node.create({ name: "tableCell", content: "block+", isolating: true, @@ -146,7 +154,7 @@ const TableCell = Node.create({ return ["td", 0]; }, }); -const TableHeader = Node.create({ +const TableHeader = core_1.Node.create({ name: "tableHeader", content: "block+", isolating: true, @@ -163,7 +171,7 @@ const TableHeader = Node.create({ * do not declare. Without these, Node.fromJSON silently drops them — * including the block ids that heading anchors rely on. */ -const DocmostAttributes = Extension.create({ +const DocmostAttributes = core_1.Extension.create({ name: "docmostAttributes", addGlobalAttributes() { return [ @@ -205,7 +213,7 @@ const DocmostAttributes = Extension.create({ * which breaks update_page_json and edit_page_text on every commented page. * Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved). */ -const Comment = Mark.create({ +const Comment = core_1.Mark.create({ name: "comment", exitable: true, inclusive: false, @@ -238,15 +246,15 @@ const Comment = Mark.create({ * attribute. The parsed color is passed through the allowlist guard so a crafted * style cannot break out of the attribute when Docmost re-renders it. */ -const TextStyle = Mark.create({ +const TextStyle = core_1.Mark.create({ name: "textStyle", addAttributes() { return { color: { default: null, - parseHTML: (el) => sanitizeCssColor(el.style.color || el.getAttribute("data-color")), + parseHTML: (el) => (0, exports.sanitizeCssColor)(el.style.color || el.getAttribute("data-color")), renderHTML: (attrs) => { - const color = sanitizeCssColor(attrs.color); + const color = (0, exports.sanitizeCssColor)(attrs.color); return color ? { style: `color: ${color}` } : {}; }, }, @@ -289,7 +297,7 @@ const TextStyle = Mark.create({ * pattern these follow. */ /** Docmost @mention (user/page reference). Inline atom. */ -const Mention = Node.create({ +const Mention = core_1.Node.create({ name: "mention", group: "inline", inline: true, @@ -343,7 +351,7 @@ const Mention = Node.create({ }, }); /** Inline KaTeX expression. Carries the LaTeX source in `text`. */ -const MathInline = Node.create({ +const MathInline = core_1.Node.create({ name: "mathInline", group: "inline", inline: true, @@ -365,7 +373,7 @@ const MathInline = Node.create({ }, }); /** Block KaTeX expression. Carries the LaTeX source in `text`. */ -const MathBlock = Node.create({ +const MathBlock = core_1.Node.create({ name: "mathBlock", group: "block", atom: true, @@ -387,7 +395,7 @@ const MathBlock = Node.create({ }, }); /** Collapsible
wrapper: summary + content children. */ -const Details = Node.create({ +const Details = core_1.Node.create({ name: "details", group: "block", content: "detailsSummary detailsContent", @@ -410,7 +418,7 @@ const Details = Node.create({ }, }); /** Clickable summary line of a
block. */ -const DetailsSummary = Node.create({ +const DetailsSummary = core_1.Node.create({ name: "detailsSummary", group: "block", content: "inline*", @@ -425,7 +433,7 @@ const DetailsSummary = Node.create({ }, }); /** Body of a
block. Permissive content so fromYdoc output validates. */ -const DetailsContent = Node.create({ +const DetailsContent = core_1.Node.create({ name: "detailsContent", group: "block", // Docmost declares block* (an empty details body is valid); block+ would @@ -441,7 +449,7 @@ const DetailsContent = Node.create({ }, }); /** File attachment card (non-image upload). Block atom. */ -const Attachment = Node.create({ +const Attachment = core_1.Node.create({ name: "attachment", group: "block", inline: false, @@ -493,7 +501,7 @@ const Attachment = Node.create({ }, }); /** Uploaded