From 56ab17fbc23a6cf74e33912ad813924e3c25e38a Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 13:55:23 +0300 Subject: [PATCH 01/76] feat(git-sync): vendor pure converter + engine into @docmost/git-sync (Phase A.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of docs/git-sync-plan.md. New workspace package @docmost/git-sync vendoring the PURE parts from docmost-sync (HEAD b03eb35): - lib: markdown-converter, markdown-document, canonicalize, docmost-schema, node-ops, diff, and an extracted markdown-to-prosemirror (only the pure marked->HTML->generateJSON path from upstream collaboration.ts; no websocket). - engine (pure, no IO): reconcile, layout, sanitize, stabilize, loop-guard. Ported the upstream pure-module + round-trip corpus tests (vitest): 314 pass, 3 expected upstream known-limitation fails. tsc clean. No server wiring yet. docmost-schema inlines getStyleProperty (as packages/mcp does — @tiptap/core 3.20.4 doesn't export it). IO engine (pull/push/git/settings) deferred to later Phase A/B steps; the editor-ext idempotency gate (plan §13.1) is the next step. Co-Authored-By: Claude Opus 4.8 --- packages/git-sync/build/engine/layout.js | 170 --- .../build/engine/roundtrip-helpers.d.ts | 21 - .../build/engine/roundtrip-helpers.js | 70 -- packages/git-sync/build/engine/stabilize.d.ts | 41 - packages/git-sync/build/index.d.ts | 31 - packages/git-sync/build/index.js | 24 - packages/git-sync/build/lib/canonicalize.d.ts | 38 - packages/git-sync/build/lib/index.d.ts | 16 - packages/git-sync/build/lib/index.js | 15 - .../git-sync/build/lib/markdown-converter.js | 801 ------------ .../git-sync/build/lib/markdown-document.d.ts | 68 - .../build/lib/markdown-to-prosemirror.js | 306 ----- .../results.json | 1 - packages/git-sync/package.json | 41 + packages/git-sync/src/engine/layout.ts | 177 +++ packages/git-sync/src/engine/loop-guard.ts | 29 + packages/git-sync/src/engine/reconcile.ts | 200 +++ .../git-sync/src/engine/roundtrip-helpers.ts | 77 ++ packages/git-sync/src/engine/sanitize.ts | 109 ++ .../stabilize.js => src/engine/stabilize.ts} | 54 +- packages/git-sync/src/index.ts | 46 + .../lib/canonicalize.ts} | 277 +++-- packages/git-sync/src/lib/diff.ts | 319 +++++ packages/git-sync/src/lib/docmost-schema.ts | 1090 +++++++++++++++++ packages/git-sync/src/lib/index.ts | 27 + .../git-sync/src/lib/markdown-converter.ts | 861 +++++++++++++ .../lib/markdown-document.ts} | 146 ++- .../src/lib/markdown-to-prosemirror.ts | 297 +++++ packages/git-sync/src/lib/node-ops.ts | 897 ++++++++++++++ .../git-sync/test/canonicalize-extra.test.ts | 205 ++++ packages/git-sync/test/canonicalize.test.ts | 302 +++++ packages/git-sync/test/diff.test.ts | 377 ++++++ .../corpus/01-headings-paragraphs.json | 36 + .../test/fixtures/corpus/02-inline-marks.json | 62 + .../test/fixtures/corpus/03-lists.json | 113 ++ .../test/fixtures/corpus/04-blocks.json | 38 + .../test/fixtures/corpus/05-table.json | 85 ++ .../test/fixtures/corpus/06-diagrams.json | 17 + .../fixtures/corpus/07-textstyle-mention.json | 35 + .../test/fixtures/corpus/08-details.json | 15 + .../test/fixtures/corpus/09-columns.json | 17 + .../corpus/10-mention-in-heading.json | 13 + .../known-limitations/image-diagrams.json | 21 + .../git-sync/test/fixtures/sample-doc.json | 151 +++ packages/git-sync/test/layout.test.ts | 144 +++ packages/git-sync/test/loop-guard.test.ts | 41 + .../test/markdown-converter-golden.test.ts | 227 ++++ .../git-sync/test/markdown-converter.test.ts | 507 ++++++++ .../test/markdown-document-envelope.test.ts | 218 ++++ .../git-sync/test/markdown-document.test.ts | 66 + .../test/markdown-roundtrip.property.test.ts | 698 +++++++++++ packages/git-sync/test/node-ops-extra.test.ts | 268 ++++ packages/git-sync/test/node-ops.test.ts | 908 ++++++++++++++ packages/git-sync/test/reconcile.test.ts | 238 ++++ .../git-sync/test/roundtrip-corpus.test.ts | 104 ++ packages/git-sync/test/roundtrip.test.ts | 29 + packages/git-sync/test/sanitize.test.ts | 96 ++ packages/git-sync/test/stabilize.test.ts | 90 ++ packages/git-sync/tsconfig.json | 15 + packages/git-sync/vitest.config.ts | 23 + pnpm-lock.yaml | 138 ++- 61 files changed, 9729 insertions(+), 1817 deletions(-) delete mode 100644 packages/git-sync/build/engine/layout.js delete mode 100644 packages/git-sync/build/engine/roundtrip-helpers.d.ts delete mode 100644 packages/git-sync/build/engine/roundtrip-helpers.js delete mode 100644 packages/git-sync/build/engine/stabilize.d.ts delete mode 100644 packages/git-sync/build/index.d.ts delete mode 100644 packages/git-sync/build/index.js delete mode 100644 packages/git-sync/build/lib/canonicalize.d.ts delete mode 100644 packages/git-sync/build/lib/index.d.ts delete mode 100644 packages/git-sync/build/lib/index.js delete mode 100644 packages/git-sync/build/lib/markdown-converter.js delete mode 100644 packages/git-sync/build/lib/markdown-document.d.ts delete mode 100644 packages/git-sync/build/lib/markdown-to-prosemirror.js delete mode 100644 packages/git-sync/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json create mode 100644 packages/git-sync/package.json create mode 100644 packages/git-sync/src/engine/layout.ts create mode 100644 packages/git-sync/src/engine/loop-guard.ts create mode 100644 packages/git-sync/src/engine/reconcile.ts create mode 100644 packages/git-sync/src/engine/roundtrip-helpers.ts create mode 100644 packages/git-sync/src/engine/sanitize.ts rename packages/git-sync/{build/engine/stabilize.js => src/engine/stabilize.ts} (50%) create mode 100644 packages/git-sync/src/index.ts rename packages/git-sync/{build/lib/canonicalize.js => src/lib/canonicalize.ts} (53%) create mode 100644 packages/git-sync/src/lib/diff.ts create mode 100644 packages/git-sync/src/lib/docmost-schema.ts create mode 100644 packages/git-sync/src/lib/index.ts create mode 100644 packages/git-sync/src/lib/markdown-converter.ts rename packages/git-sync/{build/lib/markdown-document.js => src/lib/markdown-document.ts} (52%) create mode 100644 packages/git-sync/src/lib/markdown-to-prosemirror.ts create mode 100644 packages/git-sync/src/lib/node-ops.ts create mode 100644 packages/git-sync/test/canonicalize-extra.test.ts create mode 100644 packages/git-sync/test/canonicalize.test.ts create mode 100644 packages/git-sync/test/diff.test.ts create mode 100644 packages/git-sync/test/fixtures/corpus/01-headings-paragraphs.json create mode 100644 packages/git-sync/test/fixtures/corpus/02-inline-marks.json create mode 100644 packages/git-sync/test/fixtures/corpus/03-lists.json create mode 100644 packages/git-sync/test/fixtures/corpus/04-blocks.json create mode 100644 packages/git-sync/test/fixtures/corpus/05-table.json create mode 100644 packages/git-sync/test/fixtures/corpus/06-diagrams.json create mode 100644 packages/git-sync/test/fixtures/corpus/07-textstyle-mention.json create mode 100644 packages/git-sync/test/fixtures/corpus/08-details.json create mode 100644 packages/git-sync/test/fixtures/corpus/09-columns.json create mode 100644 packages/git-sync/test/fixtures/corpus/10-mention-in-heading.json create mode 100644 packages/git-sync/test/fixtures/known-limitations/image-diagrams.json create mode 100644 packages/git-sync/test/fixtures/sample-doc.json create mode 100644 packages/git-sync/test/layout.test.ts create mode 100644 packages/git-sync/test/loop-guard.test.ts create mode 100644 packages/git-sync/test/markdown-converter-golden.test.ts create mode 100644 packages/git-sync/test/markdown-converter.test.ts create mode 100644 packages/git-sync/test/markdown-document-envelope.test.ts create mode 100644 packages/git-sync/test/markdown-document.test.ts create mode 100644 packages/git-sync/test/markdown-roundtrip.property.test.ts create mode 100644 packages/git-sync/test/node-ops-extra.test.ts create mode 100644 packages/git-sync/test/node-ops.test.ts create mode 100644 packages/git-sync/test/reconcile.test.ts create mode 100644 packages/git-sync/test/roundtrip-corpus.test.ts create mode 100644 packages/git-sync/test/roundtrip.test.ts create mode 100644 packages/git-sync/test/sanitize.test.ts create mode 100644 packages/git-sync/test/stabilize.test.ts create mode 100644 packages/git-sync/tsconfig.json create mode 100644 packages/git-sync/vitest.config.ts diff --git a/packages/git-sync/build/engine/layout.js b/packages/git-sync/build/engine/layout.js deleted file mode 100644 index 7142c29d..00000000 --- a/packages/git-sync/build/engine/layout.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Pure page-tree -> vault path mapping (SPEC §12). - * - * Given the flat list of page nodes for a space (as returned by - * `listAllSpacePages`), compute for every page a deterministic, collision-free - * destination: a folder path (root -> leaf ancestors) plus a file stem (the - * page's own name, no extension). This module is intentionally PURE and - * dependency-free apart from the sanitization helpers, so the whole tree -> - * 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"; -/** - * Build the full vault layout for a space. - * - * Returns a Map keyed by pageId -> `{ segments, stem }`. The result is - * deterministic for a given input and guarantees every full destination path - * (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite - * another. - * - * Disambiguation is layered: - * 1. Sibling collisions (same sanitized title under the same parent) are - * resolved with a stable ` ~` suffix (the suffix is itself - * sanitized, since slugId/id is untrusted data that must never inject a - * path separator). - * 2. A final full-path pass catches residual collisions that sibling-scoping - * 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) { - // 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(); - for (const p of pages) { - if (p && p.id && !byId.has(p.id)) - byId.set(p.id, p); - } - // Resolve each node's display name once, deterministically, tracking sibling - // collisions per parent. `usedBySibling` maps a parent key -> set of names - // already taken under that parent. The bucket key is the node's parent ONLY - // when that parent is actually present in `byId`; otherwise (null parent, or - // an orphan whose parent is outside the input set) the node buckets at - // `"__root__"`. This is critical: orphans land at the vault root (see - // `folderSegmentsFor`), so they MUST share the root bucket with real root - // pages to be disambiguated against each other here — making `nameById` final - // before any `segments` are computed, so no ancestor name can drift later. - const usedBySibling = new Map(); - const nameById = new Map(); - for (const p of pages) { - if (p && p.id && !nameById.has(p.id)) { - const parentKey = p.parentPageId && byId.has(p.parentPageId) ? p.parentPageId : "__root__"; - nameById.set(p.id, nameForNode(p, parentKey, usedBySibling)); - } - } - // Every id we index above MUST get a resolved name; this helper returns it - // and THROWS if it is somehow absent, rather than silently recomputing a - // DIFFERENT, non-disambiguated name (which would desync a folder segment from - // its target file). - const nameOf = (id) => { - const name = nameById.get(id); - if (name === undefined) { - throw new Error(`buildVaultLayout: no resolved name for page id ${id}`); - } - return name; - }; - // Build the folder path for a page by walking parentPageId to the root. The - // page's OWN name is the file stem; its ancestors become folders. A `visited` - // guard prevents an infinite loop on a malformed parent cycle. - const folderSegmentsFor = (node) => { - const ancestors = []; - const visited = new Set(); - let current = node.parentPageId - ? byId.get(node.parentPageId) - : undefined; - while (current && current.id && !visited.has(current.id)) { - visited.add(current.id); - ancestors.unshift(nameOf(current.id)); - current = current.parentPageId - ? byId.get(current.parentPageId) - : undefined; - } - return ancestors; - }; - // First pass: compute the provisional { segments, stem } for every node. - const layout = new Map(); - for (const p of pages) { - if (!p || !p.id || layout.has(p.id)) - continue; - layout.set(p.id, { - segments: folderSegmentsFor(p), - stem: nameOf(p.id), - }); - } - // FOLDER-NOTE transform (native-Obsidian layout): a page WITH CHILDREN lives at - // `<…>//.md` — its body is the folder-note INSIDE its own folder - // (LostPaul Folder Notes convention), and its children sit alongside it in that - // folder. A leaf stays `<…>/.md`. Children's segments already point into - // the parent's folder (folderSegmentsFor walks ancestor NAMES), so only the - // parent's own file relocates here; the sibling name pass above already made - // the parent name unique, so folder == file name stays consistent. - for (const p of pages) { - if (!p || !p.id) - continue; - const entry = layout.get(p.id); - if (entry && p.hasChildren) { - entry.segments = [...entry.segments, entry.stem]; - } - } - // Final full-path uniqueness pass — a belt-and-suspenders safety net. Note - // that cross-bucket (orphan/root) collisions are now resolved in the name pass - // above (orphans share the "__root__" bucket), so ancestor names are final - // before `segments` are built and this pass should rarely/never re-stem an - // ancestor. It only re-stems the colliding LATER leaf via the sanitized - // slugId/id, then (if still colliding) appends the id. - // - // Process FOLDER-NOTES (pages with children) FIRST so a parent claims its - // canonical `/.md` before a same-named CHILD — the child (a leaf) - // is the one that disambiguates, never the folder-note. - const usedPaths = new Set(); - const seenIds = new Set(); - const pathKey = (e) => [...e.segments, e.stem].join("/"); - const ordered = pages - .filter((p) => Boolean(p && p.id)) - .sort((a, b) => Number(Boolean(b.hasChildren)) - Number(Boolean(a.hasChildren))); - for (const p of ordered) { - if (seenIds.has(p.id)) - continue; - seenIds.add(p.id); - const entry = layout.get(p.id); - if (!entry) - 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)); - 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)); - } - } - usedPaths.add(pathKey(entry)); - } - return layout; -} -/** - * Compute a deterministic, collision-free name for a node among its SIBLINGS. - * `usedBySibling` maps a parent key -> set of names already taken, so two - * siblings that sanitize to the same name get a stable ` ~slugId` suffix - * (SPEC §12). The suffix is itself passed through `sanitizeTitle`, because the - * slugId/id is a second untrusted-data channel that must never leak a path - * separator into the name. `parentKey` is supplied by the caller (it resolves - * to `"__root__"` for root pages AND for orphans whose parent is outside the - * input set, so they share one bucket). The name is COSMETIC; identity lives in - * the meta block. - */ -function nameForNode(node, parentKey, usedBySibling) { - let used = usedBySibling.get(parentKey); - if (!used) { - used = new Set(); - usedBySibling.set(parentKey, used); - } - let name = 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)); - } - used.add(name); - return name; -} diff --git a/packages/git-sync/build/engine/roundtrip-helpers.d.ts b/packages/git-sync/build/engine/roundtrip-helpers.d.ts deleted file mode 100644 index 30bcfa8f..00000000 --- a/packages/git-sync/build/engine/roundtrip-helpers.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Pure, IO-free comparison helpers for the idempotency round-trip checks. The - * round-trip harness that drives these lives in the package's tests, not in the - * engine. - */ -/** - * 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 declare function stripBlockIds(node: any): any; -/** - * 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 declare function firstDivergence(a: any, b: any, path?: string): { - path: string; - a: any; - b: any; -} | null; diff --git a/packages/git-sync/build/engine/roundtrip-helpers.js b/packages/git-sync/build/engine/roundtrip-helpers.js deleted file mode 100644 index 9fe4c495..00000000 --- a/packages/git-sync/build/engine/roundtrip-helpers.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Pure, IO-free comparison helpers for the idempotency round-trip checks. The - * round-trip harness that drives these lives in the package's tests, not in the - * engine. - */ -/** - * 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) { - if (Array.isArray(node)) { - return node.map(stripBlockIds); - } - if (node && typeof node === "object") { - const out = {}; - for (const key of Object.keys(node)) { - if (key === "attrs" && node.attrs && typeof node.attrs === "object") { - // Drop the `id` attr; keep every other attribute. - const { id, ...rest } = node.attrs; - void id; - out.attrs = stripBlockIds(rest); - } - else { - out[key] = stripBlockIds(node[key]); - } - } - return out; - } - return 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 = "$") { - if (a === b) - return null; - const ta = typeof a; - const tb = typeof b; - if (ta !== tb || a === null || b === null) { - return { path, a, b }; - } - if (ta !== "object") { - return { path, a, b }; - } - const aIsArr = Array.isArray(a); - const bIsArr = Array.isArray(b); - if (aIsArr !== bIsArr) - return { path, a, b }; - if (aIsArr) { - if (a.length !== b.length) { - return { path: `${path}.length`, a: a.length, b: b.length }; - } - for (let i = 0; i < a.length; i++) { - const d = firstDivergence(a[i], b[i], `${path}[${i}]`); - if (d) - return d; - } - return null; - } - const keys = new Set([...Object.keys(a), ...Object.keys(b)]); - for (const k of keys) { - const d = firstDivergence(a[k], b[k], `${path}.${k}`); - if (d) - return d; - } - return null; -} diff --git a/packages/git-sync/build/engine/stabilize.d.ts b/packages/git-sync/build/engine/stabilize.d.ts deleted file mode 100644 index 0c1f4921..00000000 --- a/packages/git-sync/build/engine/stabilize.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte - * compatible so files produced here match `exportPageBody`'s output exactly. - */ -export interface PageMeta { - version: 1; - pageId: string; - slugId: string; - title: string; - spaceId: string; - parentPageId: string | null; -} -/** - * Produce the self-contained `.md` file text for a page from its raw - * ProseMirror `content` + identity meta, in the verified fixpoint form. - * - * md1 = convertProseMirrorToMarkdown(content) - * doc2 = markdownToProseMirror(md1) // one import... - * stableBody = convertProseMirrorToMarkdown(doc2) // ...and re-export - * file = serializeDocmostMarkdownBody(meta, stableBody) - * - * The single export->import->export pass is the verified fixpoint (SPEC §11): - * idempotent for already-stable content, and the convergence point for the - * known converter asymmetries. - */ -export declare function stabilizePageFile(content: unknown, meta: PageMeta): Promise; -/** - * The fixpoint markdown BODY for a page's ProseMirror `content`, WITHOUT any meta - * envelope: - * - * md1 = convertProseMirrorToMarkdown(content) // export... - * doc2 = markdownToProseMirror(md1) // ...import... - * stableBody = convertProseMirrorToMarkdown(doc2) // ...re-export - * - * The single export->import->export pass is the verified fixpoint (SPEC §11): - * idempotent for already-stable content, and the convergence point for the known - * converter asymmetries. The native-Obsidian writer (`serializePageFile`) wraps - * this body with a minimal `gitmost_id` frontmatter; determinism here is what - * keeps re-pulls of an unchanged page byte-identical (no churn, loop-guard). - */ -export declare function stabilizePageBody(content: unknown): Promise; diff --git a/packages/git-sync/build/index.d.ts b/packages/git-sync/build/index.d.ts deleted file mode 100644 index 47ec1fdf..00000000 --- a/packages/git-sync/build/index.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Public surface of `@docmost/git-sync`. - * - * Exposes the pure converter (markdown <-> ProseMirror, file envelope, - * canonicalization) and the sync engine (reconcile planner, vault layout, - * pull/push, the git wrapper, and the settings parser) that the gitmost server - * drives in-process. - */ -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 type { GitSyncClient, GitSyncPageNodeLite } from "./engine/client.types.js"; -export { VaultGit, vaultGitEnv, buildCommitMessage, BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./engine/git.js"; -export type { DiffEntry, MergeResult, CommitOptions } from "./engine/git.js"; -export { readExisting, computePullActions, applyPullActions, } from "./engine/pull.js"; -export type { ReadExistingDeps, PullActionsInput, PullActions, ApplyPullActionsDeps, ApplyResult, } from "./engine/pull.js"; -export { classifyRenameMoves, computePushActions, applyPushActions, runPush, parentFolderFile, parseArgs, LAST_PUSHED_REF, DOCMOST_BRANCH, LOCAL_AUTHOR_NAME, LOCAL_AUTHOR_EMAIL, LOCAL_SOURCE_TRAILER, } from "./engine/push.js"; -export type { CreateAction, UpdateAction, DeleteAction, RenameMoveAction, RenameMoveActionClassified, ClassifyRenameMovesDeps, PushActions, PushActionsInput, MetaSide, ApplyPushDeps, WrittenBackPage, PushedPageRecord, PushFailure, PushNoop, ApplyPushResult, PushDeps, PushRunResult, PushParsedArgs, } from "./engine/push.js"; -export { parseSettings, envSchema } from "./engine/settings.js"; -export type { Settings } from "./engine/settings.js"; -export { loadSettingsOrExit } from "./engine/config-errors.js"; -export { runCycle } from "./engine/cycle.js"; -export type { RunCycleDeps, RunCycleResult, CycleFs, } from "./engine/cycle.js"; -export { parsePageFile, serializePageFile } from "./lib/page-file.js"; diff --git a/packages/git-sync/build/index.js b/packages/git-sync/build/index.js deleted file mode 100644 index 4dffdfc0..00000000 --- a/packages/git-sync/build/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Public surface of `@docmost/git-sync`. - * - * Exposes the pure converter (markdown <-> ProseMirror, file envelope, - * canonicalization) and the sync engine (reconcile planner, vault layout, - * pull/push, the git wrapper, and the settings parser) that the gitmost server - * drives in-process. - */ -// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization). -export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js"; -// 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"; -export { VaultGit, vaultGitEnv, buildCommitMessage, BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./engine/git.js"; -export { readExisting, computePullActions, applyPullActions, } from "./engine/pull.js"; -export { classifyRenameMoves, computePushActions, applyPushActions, runPush, parentFolderFile, parseArgs, LAST_PUSHED_REF, DOCMOST_BRANCH, LOCAL_AUTHOR_NAME, LOCAL_AUTHOR_EMAIL, LOCAL_SOURCE_TRAILER, } from "./engine/push.js"; -export { parseSettings, envSchema } from "./engine/settings.js"; -export { loadSettingsOrExit } from "./engine/config-errors.js"; -export { runCycle } from "./engine/cycle.js"; -export { parsePageFile, serializePageFile } from "./lib/page-file.js"; diff --git a/packages/git-sync/build/lib/canonicalize.d.ts b/packages/git-sync/build/lib/canonicalize.d.ts deleted file mode 100644 index 7f7017c0..00000000 --- a/packages/git-sync/build/lib/canonicalize.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Semantic canonicalization of ProseMirror/TipTap documents for the round-trip - * idempotency check (SPEC §11, "Задача №0", option (б): compare a CANONICALIZED - * form rather than raw bytes). - * - * `markdownToProseMirror` reconstructs schema DEFAULT attributes (e.g. - * `indent: null` where the source omitted it) and regenerates per-block ids on - * every import. A raw deep-equal of the source doc against the re-imported doc - * therefore diverges even when the two are semantically identical. This module - * normalizes a document so that two semantically-equal docs compare deep-equal - * regardless of block ids and absent-vs-explicit-default-null attributes. - * - * It is a self-contained module with no external dependencies. - */ -/** - * Return a DEEP COPY of a ProseMirror node tree, canonicalized so that two - * semantically-equal documents compare deep-equal. Rules (applied recursively - * to the node, its `content`, and its `marks`): - * - * 1. Remove node-level `attrs.id` (regenerated on import). Mark attrs are NOT - * touched for `id` (marks carry no block id; only their meaningful attrs). - * 2. In any `attrs` object (node OR mark) drop keys whose value is `null`/ - * `undefined` (absent ≡ explicit default null) OR equals that node/mark - * type's known non-null schema default (absent ≡ explicit default). - * Keep every non-default value. The type is passed into the attrs - * normalizer so it can look up `KNOWN_DEFAULTS`. - * 3. If an `attrs` object becomes empty after pruning, drop the `attrs` key. - * 4. Preserve `marks` (including the `comment` mark and its `commentId` — a - * meaningful anchor per SPEC §3; never strip it). - * 5. Preserve `text`, `type`, and `content` order exactly. - * 6. Never mutate the input. - */ -export declare function canonicalizeContent(node: any): any; -/** - * True when two ProseMirror documents are semantically equal: equal after - * canonicalization (block ids stripped, absent-vs-default-null normalized). - */ -export declare function docsCanonicallyEqual(a: any, b: any): boolean; diff --git a/packages/git-sync/build/lib/index.d.ts b/packages/git-sync/build/lib/index.d.ts deleted file mode 100644 index 88a8884e..00000000 --- a/packages/git-sync/build/lib/index.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Public surface of the pure converter (`lib/`). This barrel re-exports the - * PURE, IO-free pieces the sync engine needs: the self-contained markdown - * (de)serializers, the lossless ProseMirror <-> Markdown converter, the - * markdown -> ProseMirror import path, and semantic canonicalization for the - * round-trip idempotency check (SPEC §11). - * - * There is no REST client, websocket/collab write-path, auth-utils or page-lock - * here — the gitmost server writes natively. - */ -export { serializeDocmostMarkdown, parseDocmostMarkdown, serializeDocmostMarkdownBody, } from "./markdown-document.js"; -export type { DocmostMdMeta } from "./markdown-document.js"; -export { convertProseMirrorToMarkdown } from "./markdown-converter.js"; -export { markdownToProseMirror } from "./markdown-to-prosemirror.js"; -export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize.js"; -export { parsePageFile, serializePageFile } from "./page-file.js"; diff --git a/packages/git-sync/build/lib/index.js b/packages/git-sync/build/lib/index.js deleted file mode 100644 index d7ab985d..00000000 --- a/packages/git-sync/build/lib/index.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Public surface of the pure converter (`lib/`). This barrel re-exports the - * PURE, IO-free pieces the sync engine needs: the self-contained markdown - * (de)serializers, the lossless ProseMirror <-> Markdown converter, the - * markdown -> ProseMirror import path, and semantic canonicalization for the - * round-trip idempotency check (SPEC §11). - * - * There is no REST client, websocket/collab write-path, auth-utils or page-lock - * here — the gitmost server writes natively. - */ -export { serializeDocmostMarkdown, parseDocmostMarkdown, serializeDocmostMarkdownBody, } from "./markdown-document.js"; -export { convertProseMirrorToMarkdown } from "./markdown-converter.js"; -export { markdownToProseMirror } from "./markdown-to-prosemirror.js"; -export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize.js"; -export { parsePageFile, serializePageFile } from "./page-file.js"; diff --git a/packages/git-sync/build/lib/markdown-converter.js b/packages/git-sync/build/lib/markdown-converter.js deleted file mode 100644 index 285035f4..00000000 --- a/packages/git-sync/build/lib/markdown-converter.js +++ /dev/null @@ -1,801 +0,0 @@ -/** - * Convert ProseMirror/TipTap JSON content to Markdown - * Supports all Docmost-specific node types and extensions - */ -export function convertProseMirrorToMarkdown(content) { - if (!content || !content.content) - return ""; - // 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 - // that starts an entity are special, so we escape ONLY & " (and ' for safety - // when single-quoted delimiters are used). We deliberately do NOT escape < or - // >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode - // </> back inside attribute values, so escaping them would corrupt the - // stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on - // every round-trip (`a < b` -> `a < b` -> `a &lt; b`). Escaping & " - // keeps the value inert against attribute-injection while staying idempotent. - // NOTE: escape ONLY & and " here. The value is always wrapped in double - // quotes, so " is the only delimiter; ' is NOT special in a double-quoted - // value, and parse5 does not decode ' back inside attribute values, so - // escaping ' would (like < >) corrupt the value and accumulate & on every - // round-trip. Escaping & and " is idempotent (parse5 decodes them back). - const escapeAttr = (value) => String(value) - .replace(/&/g, "&") - .replace(/"/g, """); - // Escape a value placed as HTML element TEXT content (between tags), where - // <, >, and & are all significant. Used for text rendered inside raw-HTML - // blocks (table cells / columns) so stored characters cannot inject markup. - const escapeHtmlText = (value) => String(value) - .replace(/&/g, "&") - .replace(//g, ">"); - // Percent-encode characters that would break out of a markdown URL target - // (...) — whitespace/newlines and parentheses — so a stored src stays a - // single inert token (used for image/video/youtube srcs). - const encodeMdUrl = (value) => String(value || "") - .replace(/\s/g, (c) => (c === " " ? "%20" : encodeURIComponent(c))) - .replace(/\(/g, "%28") - .replace(/\)/g, "%29"); - const processNode = (node) => { - const type = node.type; - const nodeContent = node.content || []; - switch (type) { - case "doc": - return nodeContent.map(processNode).join("\n\n"); - case "paragraph": - const text = nodeContent.map(processNode).join(""); - const align = node.attrs?.textAlign; - if (align && align !== "left") { - return `
${text}
`; - } - return text || ""; - case "heading": - const level = node.attrs?.level || 1; - const headingText = nodeContent.map(processNode).join(""); - return "#".repeat(level) + " " + headingText; - case "text": - let textContent = node.text || ""; - // Apply marks (bold, italic, code, etc.) - if (node.marks) { - // The schema's `code` mark declares `excludes: "_"` — it excludes every - // other inline mark — so the editor can NEVER produce a text run that - // carries `code` together with another mark, and on import any - // co-occurring mark is always dropped (the run comes back as code-only). - // The lossless, byte-stable behavior is therefore: when a run has the - // `code` mark, emit ONLY the backtick code span and ignore every other - // mark, so md1 is already code-only and md2 === md1. Runs WITHOUT a code - // mark are rendered exactly as before. - const markTypes = node.marks.map((m) => m.type); - const hasCode = markTypes.includes("code"); - if (hasCode) { - textContent = `\`${textContent}\``; - return textContent; - } - const codeCombined = false; - for (const mark of node.marks) { - switch (mark.type) { - case "bold": - textContent = codeCombined - ? `${textContent}` - : `**${textContent}**`; - break; - case "italic": - textContent = codeCombined - ? `${textContent}` - : `*${textContent}*`; - break; - case "code": - // When combined with another mark, wrap as so the - // surrounding HTML marks can nest around it; otherwise use the - // plain backtick span. - textContent = codeCombined - ? `${textContent}` - : `\`${textContent}\``; - break; - case "link": { - const href = mark.attrs?.href || ""; - const title = mark.attrs?.title; - if (codeCombined) { - // Emit an HTML anchor so it can wrap the nested . - const safeHref = escapeAttr(href); - if (title) { - textContent = `${textContent}`; - } - else { - textContent = `${textContent}`; - } - } - else 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 = codeCombined - ? `${textContent}` - : `~~${textContent}~~`; - break; - case "underline": - textContent = `${textContent}`; - break; - case "subscript": - textContent = `${textContent}`; - break; - case "superscript": - textContent = `${textContent}`; - break; - case "highlight": { - // Preserve a null/empty color as a plain highlight (a bare - // with no background-color); only emit the style when a - // color is actually set, so a plain highlight is not forced to - // yellow on export. - const color = mark.attrs?.color; - textContent = color - ? `${textContent}` - : `${textContent}`; - break; - } - case "textStyle": - if (mark.attrs?.color) { - textContent = `${textContent}`; - } - 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) { - const resolvedAttr = mark.attrs?.resolved - ? ` data-resolved="true"` - : ""; - textContent = `${textContent}`; - } - break; - } - } - } - } - return textContent; - case "codeBlock": - const language = node.attrs?.language || ""; - // Strip ALL trailing newlines so the export is idempotent: marked - // re-adds exactly one trailing "\n" on import, so trimming only one - // here would let the text grow by "\n" on each round-trip. Removing - // every trailing newline makes repeated cycles stable. - const code = nodeContent - .map(processNode) - .join("") - .replace(/\n+$/, ""); - return "```" + language + "\n" + code + "\n```"; - case "bulletList": - return nodeContent - .map((item) => processListItem(item, "-")) - .join("\n"); - case "orderedList": - return nodeContent - .map((item, index) => processListItem(item, `${index + 1}.`)) - .join("\n"); - case "taskList": - return nodeContent.map((item) => processTaskItem(item)).join("\n"); - case "taskItem": - // Delegate to the same helper used by taskList so multi-block and - // nested task items render and indent consistently. - return processTaskItem(node); - case "listItem": - return nodeContent.map(processNode).join("\n"); - case "blockquote": - // Prefix EVERY line of EVERY child with "> " and separate block-level - // children with a blank ">" line so code blocks / multi-paragraph - // quotes round-trip correctly. - return nodeContent - .map((n) => processNode(n) - .split("\n") - .map((line) => (line.length ? `> ${line}` : ">")) - .join("\n")) - .join("\n>\n"); - case "horizontalRule": - return "---"; - case "hardBreak": - // Two trailing spaces before the newline encode a markdown hard break; - // a bare "\n" would be reimported as a soft break and lost. - return " \n"; - case "image": - const imgAlt = node.attrs?.alt || ""; - // Neutralize characters that could break out of the markdown image - // URL: spaces/newlines and parentheses would terminate the (...) target - // and let a stored src inject following markdown/HTML. Percent-encode - // them so the URL stays a single inert token. - const imgSrc = encodeMdUrl(node.attrs?.src); - // No "caption" attribute exists in the Docmost image schema, so we do - // not emit one (the previous caption branch was dead). - return `![${imgAlt}](${imgSrc})`; - case "video": { - // Emit the schema-matching