From 44b902cdfc323a15a7e0f0931179d40fe525df7a Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Tue, 23 Jun 2026 11:44:32 +0300 Subject: [PATCH] chore(git-sync): stop committing build/ and node_modules; build in CI/Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding #2: packages/git-sync/build/ (the COMPILED engine) and the package's node_modules/ were committed. Prod executed the committed build/ while CI/tests ran src/ and never rebuilt it — so a fix in src/ could pass tests while stale compiled code shipped (a silent src/prod skew). The committed node_modules were pnpm symlinks with a baked machine-local store path (/home/claude/...), useless and misleading for everyone else. - git rm --cached packages/git-sync/{build,node_modules} (42 + 31 files). - .gitignore: ignore packages/*/node_modules/ and packages/git-sync/build/. - Build the package where it is actually consumed: apps/server `pretest` now builds @docmost/git-sync (its suite imports the built build/index.js), and the CI Test workflow gains an explicit "Build git-sync" step. The Dockerfile builder already runs `pnpm build` (nx builds the package) and now COPYs the fresh build/. Verified: wiped build/, rebuilt via `pnpm --filter @docmost/git-sync build`, then the server converter gate (26/26, imports the rebuilt package) and the git-sync suite (588 passed) both pass against the freshly-built, non-committed output. NOTE: packages/mcp/ has the same committed-build/node_modules pattern (pre-existing, out of this PR's scope) and should get the same treatment in a follow-up. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 7 + .gitignore | 5 + apps/server/package.json | 2 +- .../git-sync/build/engine/config-errors.d.ts | 1 - packages/git-sync/build/engine/git.d.ts | 259 ----- packages/git-sync/build/engine/layout.d.ts | 44 - .../git-sync/build/engine/loop-guard.d.ts | 13 - packages/git-sync/build/engine/loop-guard.js | 31 - packages/git-sync/build/engine/reconcile.d.ts | 126 --- packages/git-sync/build/engine/reconcile.js | 122 -- packages/git-sync/build/engine/sanitize.d.ts | 23 - packages/git-sync/build/engine/sanitize.js | 101 -- packages/git-sync/build/lib/diff.d.ts | 54 - packages/git-sync/build/lib/diff.js | 276 ----- .../git-sync/build/lib/docmost-schema.d.ts | 9 - packages/git-sync/build/lib/docmost-schema.js | 1007 ----------------- .../build/lib/markdown-converter.d.ts | 5 - .../build/lib/markdown-to-prosemirror.d.ts | 2 - packages/git-sync/build/lib/node-ops.d.ts | 194 ---- packages/git-sync/build/lib/node-ops.js | 784 ------------- packages/git-sync/node_modules/.bin/esbuild | 14 - packages/git-sync/node_modules/.bin/jiti | 17 - packages/git-sync/node_modules/.bin/lessc | 17 - packages/git-sync/node_modules/.bin/marked | 17 - packages/git-sync/node_modules/.bin/terser | 17 - packages/git-sync/node_modules/.bin/tsc | 17 - packages/git-sync/node_modules/.bin/tsserver | 17 - packages/git-sync/node_modules/.bin/tsx | 17 - packages/git-sync/node_modules/.bin/vite | 17 - packages/git-sync/node_modules/.bin/vitest | 17 - packages/git-sync/node_modules/.bin/yaml | 17 - .../@fellow/prosemirror-recreate-transform | 1 - packages/git-sync/node_modules/@tiptap/core | 1 - .../node_modules/@tiptap/extension-highlight | 1 - .../node_modules/@tiptap/extension-image | 1 - .../node_modules/@tiptap/extension-subscript | 1 - .../@tiptap/extension-superscript | 1 - .../node_modules/@tiptap/extension-task-item | 1 - .../node_modules/@tiptap/extension-task-list | 1 - packages/git-sync/node_modules/@tiptap/html | 1 - packages/git-sync/node_modules/@tiptap/pm | 1 - .../git-sync/node_modules/@tiptap/starter-kit | 1 - packages/git-sync/node_modules/@types/jsdom | 1 - packages/git-sync/node_modules/@types/node | 1 - packages/git-sync/node_modules/fast-check | 1 - packages/git-sync/node_modules/jsdom | 1 - packages/git-sync/node_modules/marked | 1 - packages/git-sync/node_modules/typescript | 1 - packages/git-sync/node_modules/vitest | 1 - packages/git-sync/node_modules/zod | 1 - 50 files changed, 13 insertions(+), 3255 deletions(-) delete mode 100644 packages/git-sync/build/engine/config-errors.d.ts delete mode 100644 packages/git-sync/build/engine/git.d.ts delete mode 100644 packages/git-sync/build/engine/layout.d.ts delete mode 100644 packages/git-sync/build/engine/loop-guard.d.ts delete mode 100644 packages/git-sync/build/engine/loop-guard.js delete mode 100644 packages/git-sync/build/engine/reconcile.d.ts delete mode 100644 packages/git-sync/build/engine/reconcile.js delete mode 100644 packages/git-sync/build/engine/sanitize.d.ts delete mode 100644 packages/git-sync/build/engine/sanitize.js delete mode 100644 packages/git-sync/build/lib/diff.d.ts delete mode 100644 packages/git-sync/build/lib/diff.js delete mode 100644 packages/git-sync/build/lib/docmost-schema.d.ts delete mode 100644 packages/git-sync/build/lib/docmost-schema.js delete mode 100644 packages/git-sync/build/lib/markdown-converter.d.ts delete mode 100644 packages/git-sync/build/lib/markdown-to-prosemirror.d.ts delete mode 100644 packages/git-sync/build/lib/node-ops.d.ts delete mode 100644 packages/git-sync/build/lib/node-ops.js delete mode 100755 packages/git-sync/node_modules/.bin/esbuild delete mode 100755 packages/git-sync/node_modules/.bin/jiti delete mode 100755 packages/git-sync/node_modules/.bin/lessc delete mode 100755 packages/git-sync/node_modules/.bin/marked delete mode 100755 packages/git-sync/node_modules/.bin/terser delete mode 100755 packages/git-sync/node_modules/.bin/tsc delete mode 100755 packages/git-sync/node_modules/.bin/tsserver delete mode 100755 packages/git-sync/node_modules/.bin/tsx delete mode 100755 packages/git-sync/node_modules/.bin/vite delete mode 100755 packages/git-sync/node_modules/.bin/vitest delete mode 100755 packages/git-sync/node_modules/.bin/yaml delete mode 120000 packages/git-sync/node_modules/@fellow/prosemirror-recreate-transform delete mode 120000 packages/git-sync/node_modules/@tiptap/core delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-highlight delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-image delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-subscript delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-superscript delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-task-item delete mode 120000 packages/git-sync/node_modules/@tiptap/extension-task-list delete mode 120000 packages/git-sync/node_modules/@tiptap/html delete mode 120000 packages/git-sync/node_modules/@tiptap/pm delete mode 120000 packages/git-sync/node_modules/@tiptap/starter-kit delete mode 120000 packages/git-sync/node_modules/@types/jsdom delete mode 120000 packages/git-sync/node_modules/@types/node delete mode 120000 packages/git-sync/node_modules/fast-check delete mode 120000 packages/git-sync/node_modules/jsdom delete mode 120000 packages/git-sync/node_modules/marked delete mode 120000 packages/git-sync/node_modules/typescript delete mode 120000 packages/git-sync/node_modules/vitest delete mode 120000 packages/git-sync/node_modules/zod diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a756656..f70aa6f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,13 @@ jobs: - name: Build editor-ext run: pnpm --filter @docmost/editor-ext build + # git-sync is no longer committed in built form (build/ is gitignored), so + # CI must compile it: the server suite imports the package via its built + # build/index.js. The server pretest also builds it, but building here keeps + # it explicit and independent of pnpm lifecycle ordering. + - name: Build git-sync + run: pnpm --filter @docmost/git-sync build + - name: Run unit tests run: pnpm -r test diff --git a/.gitignore b/.gitignore index cf440100..313c8db1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ data # compiled output /dist /node_modules +# workspace package node_modules (pnpm symlinks — never commit; they bake +# machine-local store paths) and the git-sync compiled output (built in CI/Docker +# via `pnpm build`, never committed, so src/ and prod can never silently diverge). +packages/*/node_modules/ +packages/git-sync/build/ # Logs logs diff --git a/apps/server/package.json b/apps/server/package.json index 3afed950..1e4c7afc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,7 +23,7 @@ "migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS", "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "pretest": "pnpm --filter @docmost/editor-ext build", + "pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build", "test": "jest", "test:int": "jest --config test/jest-integration.json", "test:watch": "jest --watch", diff --git a/packages/git-sync/build/engine/config-errors.d.ts b/packages/git-sync/build/engine/config-errors.d.ts deleted file mode 100644 index 3e710684..00000000 --- a/packages/git-sync/build/engine/config-errors.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function loadSettingsOrExit(factory: () => T): T; diff --git a/packages/git-sync/build/engine/git.d.ts b/packages/git-sync/build/engine/git.d.ts deleted file mode 100644 index 85cba296..00000000 --- a/packages/git-sync/build/engine/git.d.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** Bot identity used for engine-authored vault commits (SPEC §7.3). */ -export declare const BOT_AUTHOR_NAME = "Docmost Sync"; -export declare const BOT_AUTHOR_EMAIL = "docmost-sync@local"; -/** Default branch the vault repo is initialized on. */ -export declare const DEFAULT_BRANCH = "main"; -/** - * One row of `git diff --name-status` (SPEC §6 "ФС → Docmost"). `status` is the - * single-letter change code (`-M` rename detection on), `path` is the (new) file - * path; for a rename/copy (`R`/`C`) `oldPath` is the source and `path` is the - * destination, with `score` carrying git's similarity index (0–100). - */ -export interface DiffEntry { - status: "A" | "M" | "D" | "R" | "C"; - /** New (destination) path. For A/M/D it is the only path. */ - path: string; - /** Source path — present only for R/C. */ - oldPath?: string; - /** Rename/copy similarity score (0–100) — present only for R/C. */ - score?: number; -} -/** Result of a `merge`: whether it succeeded cleanly or left conflict markers. */ -export interface MergeResult { - /** True when the merge applied cleanly (fast-forward or clean 3-way). */ - ok: boolean; - /** True when the merge stopped on conflicts (markers left in the worktree). */ - conflict: boolean; - /** Raw combined stdout+stderr, for logging/diagnostics. */ - output: string; -} -/** Options for an engine-authored commit (provenance, SPEC §7.3). */ -export interface CommitOptions { - authorName: string; - authorEmail: string; - /** - * Trailer lines appended to the commit message body (e.g. - * `Docmost-Sync-Source: docmost`). These are the machine-readable provenance - * the loop-guard keys on (SPEC §12, "commit-attribution"). - */ - trailers?: string[]; -} -/** - * A git wrapper bound to a single vault path. Construct once per vault; every - * method runs git with `cwd = vaultPath`. - */ -export declare class VaultGit { - private readonly vaultPath; - constructor(vaultPath: string); - /** - * Preflight: verify a runnable `git` binary is on PATH. The daemon shells out - * to system `git` for every vault operation, so a missing binary (e.g. a slim - * container image without git) must fail fast with an actionable message - * rather than a cryptic ENOENT deep inside the first real git call. Presence - * check only — we do NOT gate on a specific version. Runs `git --version` - * with NO `cwd` (the vault dir may not exist yet at preflight time). - */ - assertGitAvailable(): Promise; - /** - * Run a git command in the vault and return trimmed stdout. THIN wrapper over - * the single `runRaw` primitive: throws a clear, unified Error (including - * stderr/stdout) on a non-zero exit. - */ - private run; - /** - * The ONE primitive every git invocation in this module flows through. Builds - * the full argv (`--no-pager -c core.quotepath=false `), env, cwd, and - * maxBuffer, runs git, and NEVER throws — it returns the exit info so callers - * can treat a non-zero exit as either an error (`run`) or a meaningful state - * (e.g. a merge conflict, a porcelain diff that "fails" deliberately). - * - * - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never - * blocks on a pager and always prints verbatim UTF-8 paths (no octal - * escaping/quoting). `quotepath=false` is the baseline for ALL path- - * printing commands (ls-files, diff --name-only, …). - * - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the - * vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`. - * - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras). - * - On a spawn/exec error we capture the error `message` too, so a failure - * before git could write to stderr (e.g. ENOENT) is NOT lost. - */ - private runRaw; - /** - * Ensure the vault directory exists and is an initialized git repo on `main` - * with an initial (empty) commit so branches exist. Idempotent: safe to call - * on every run. Sets a LOCAL bot identity for the vault repo if none is set - * (so engine commits never fall back to a global/unset identity). - */ - ensureRepo(): Promise; - /** True if `cwd` is inside a git work-tree (the vault is initialized). */ - private isRepo; - /** True if a LOCAL git config key is set in the vault repo. */ - private hasLocalConfig; - /** True if the repo has at least one commit (HEAD resolves). */ - private hasAnyCommit; - /** True if a branch with the given name exists. */ - branchExists(name: string): Promise; - /** - * Create `name` from `fromBranch` if it does not already exist. No-op (and no - * checkout) when the branch is already present. - */ - ensureBranch(name: string, fromBranch: string): Promise; - /** Name of the currently checked-out branch. */ - currentBranch(): Promise; - /** Check out an existing branch. */ - checkout(name: string): Promise; - /** Stage everything (adds, modifications, deletions). */ - stageAll(): Promise; - /** - * True if the vault is mid-merge (an unresolved merge from a previous run, - * SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged - * (conflicted) index entries (`git ls-files -u`). The pull cycle checks this - * BEFORE any checkout so a left-over merge produces a clear, actionable - * message instead of a raw "you need to resolve your current index first" - * failure deep inside `checkout`. This is what makes re-runs converge - * (resumability, SPEC §12). - */ - isMergeInProgress(): Promise; - /** - * Commit the currently STAGED changes with an explicit author/committer - * identity and the given trailers appended to the message body (SPEC §7.3 - * provenance). Returns `true` if a commit was made, `false` if there was - * nothing to commit (graceful no-op). The caller is expected to have staged - * its changes first (e.g. via `stageAll`). - */ - commit(message: string, opts: CommitOptions): Promise; - /** - * Low-level commit used by both `commit` and `ensureRepo`'s initial commit. - * Builds the full message with appended trailers and sets author + committer - * identity via env vars (so the committer matches the author, not the repo - * default). - */ - private commitRaw; - /** - * Merge `fromBranch` into the current branch (`git merge --no-edit`). - * Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict - * state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict - * markers are left in the worktree for manual resolution by a later increment, - * and — critically — nothing is pushed to Docmost (we never write to Docmost - * anyway). - */ - merge(fromBranch: string): Promise; - /** True if the index has any unmerged (conflicted) paths. */ - private hasUnmergedPaths; - /** - * List tracked files on the current branch (paths relative to the vault - * root, forward-slash separated). An optional glob (a git pathspec) narrows - * the listing, e.g. `"*.md"`. - * - * The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic - * (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files` - * returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`), - * which `src/pull.ts` `readExisting` would then parse as garbage paths, - * breaking move/duplicate detection. We defeat that two ways at once: - * - `core.quotepath=false` disables the octal-escape/quoting. It is now the - * `runRaw` argv baseline (prepended to EVERY invocation), so we no longer - * pass it inline here. - * - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline - * ambiguity), which we split on `\0`. - * We read the RAW stdout (NOT the trimming `run()` helper, which would mangle - * the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths - * are returned verbatim — git already emits forward slashes. - */ - listTrackedFiles(glob?: string): Promise; - /** - * Diff two refs with `--name-status -M -z` and parse the NUL-delimited output - * (SPEC §6: the FS→Docmost push direction diffs `main` against - * `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed - * file is reported as a single `R` row with both its old and new path instead - * of a delete+add pair — that distinction is what lets the push planner tell a - * move from a delete+create (SPEC §8 "Move vs delete"). - * - * `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has - * Cyrillic file names) with NO quoting/escaping. The record shape differs by - * status: - * - A/M/D: `status\0path\0` - * - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`) - * We read the RAW stdout (not the trimming `run()` helper, which would mangle - * the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the - * tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim. - */ - diffNameStatus(fromRef: string, toRef: string): Promise; - /** - * Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist. - * `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an - * unknown ref, so a non-zero exit maps cleanly to `null`. Used to read - * `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push. - */ - revParse(ref: string): Promise; - /** - * Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`, - * named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5: - * "что из `main` уже отражено в Docmost"). - */ - readRef(ref: string): Promise; - /** - * Point `ref` at `target` (`git update-ref `). Used to advance - * `refs/docmost/last-pushed` to the just-pushed `main` commit after a push - * (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts. - */ - updateRef(ref: string, target: string): Promise; - /** - * Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward, - * i.e. the current `branch` tip is an ancestor of `toCommit` (verified via - * `git merge-base --is-ancestor `). Used to advance the - * `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a - * push succeeds, Docmost already contains the pushed `main` content, so the - * mirror must reflect it — otherwise the NEXT pull would diff our own write - * back and re-pull it (loop-guard). - * - * SAFETY — never force, never clobber divergent history: - * - If `branch` IS an ancestor of `toCommit`, advance it with - * `git update-ref refs/heads/ `. The `docmost` branch is - * NOT checked out during a push (push works on `main`), so updating the ref - * directly is safe and avoids any working-tree touch. - * - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward), - * do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and - * let the caller log it. We must never overwrite a `docmost` history that - * has commits the push base does not contain. - * - * Returns `{ ok: true }` when the branch was advanced (or already at - * `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise. - * A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason. - */ - fastForwardBranch(branch: string, toCommit: string): Promise<{ - ok: boolean; - reason?: string; - }>; - /** - * Read a file's content at a specific ref (`git show :`), or `null` - * if the path does not exist there. Used by the push direction to read the - * PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its - * `docmost:meta` — and therefore its `pageId` — can be recovered to translate - * the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones - * that had a pageId, are deleted in Docmost). A non-zero exit (path absent at - * that ref) maps to `null` rather than throwing. - */ - showFileAtRef(ref: string, path: string): Promise; -} -/** - * Build the environment for a vault git invocation (SPEC §12 cwd-isolation). - * Used by the single `runRaw` primitive every git command flows through, so - * these pins apply uniformly (including the `git --version` preflight). - * - * cwd-isolation is this module's central safety guarantee: every git command - * MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An - * inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently - * redirect the operation away from `cwd` (e.g. to the source repo or another - * checkout), defeating that guarantee. So we always strip them, regardless of - * whatever else the caller adds (author/committer identity, etc.). - * - * Exported for unit testing. - */ -export declare function vaultGitEnv(extra?: Record): NodeJS.ProcessEnv; -/** - * Build a commit message body with trailer lines appended (SPEC §7.3). The - * trailers are separated from the subject by a blank line so `git interpret- - * trailers` / `git log --format=%(trailers)` parse them as trailers. - * Exported for unit testing. - */ -export declare function buildCommitMessage(subject: string, trailers?: string[]): string; diff --git a/packages/git-sync/build/engine/layout.d.ts b/packages/git-sync/build/engine/layout.d.ts deleted file mode 100644 index 8e6d14b4..00000000 --- a/packages/git-sync/build/engine/layout.d.ts +++ /dev/null @@ -1,44 +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). - */ -/** Flat page node as returned by `listAllSpacePages` (no content). */ -export interface PageNode { - id: string; - title?: string; - slugId?: string; - parentPageId?: string | null; - hasChildren?: boolean; -} -/** A page's resolved vault destination: folder path + file stem. */ -export interface VaultEntry { - /** Folder path, root -> leaf (the page's ancestors). Empty for a root page. */ - segments: string[]; - /** The page's own file name without extension. */ - stem: string; -} -/** - * 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 declare function buildVaultLayout(pages: PageNode[]): Map; diff --git a/packages/git-sync/build/engine/loop-guard.d.ts b/packages/git-sync/build/engine/loop-guard.d.ts deleted file mode 100644 index 95980d02..00000000 --- a/packages/git-sync/build/engine/loop-guard.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic: - * the same input string always yields the same digest, a different input a - * different one. Used to recognize our own write later (loop suppression). - * - * We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex. - * SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT - * counts as "the body" (here it is the exact string passed in — typically the - * self-contained markdown that was pushed). No normalization is applied: the - * caller is responsible for passing a canonical/stable representation if it - * wants hash equality across cosmetic-only differences. - */ -export declare function bodyHash(markdownBody: string): string; diff --git a/packages/git-sync/build/engine/loop-guard.js b/packages/git-sync/build/engine/loop-guard.js deleted file mode 100644 index 88f4af00..00000000 --- a/packages/git-sync/build/engine/loop-guard.js +++ /dev/null @@ -1,31 +0,0 @@ -"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 - * page it just wrote with a fresh `updatedAt`. To suppress that, we key on two - * signals — the body HASH of what we pushed (this module) and the `updatedAt` - * returned by the write — recorded per page at push time. - * - * This module owns the PURE, deterministic body-hash. The CONSUMPTION on the - * pull side (comparing an incoming page's body hash against the last pushed hash - * 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`). - */ -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 - * different one. Used to recognize our own write later (loop suppression). - * - * We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex. - * SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT - * counts as "the body" (here it is the exact string passed in — typically the - * self-contained markdown that was pushed). No normalization is applied: the - * caller is responsible for passing a canonical/stable representation if it - * wants hash equality across cosmetic-only differences. - */ -function bodyHash(markdownBody) { - return (0, node_crypto_1.createHash)("sha256").update(markdownBody, "utf8").digest("hex"); -} diff --git a/packages/git-sync/build/engine/reconcile.d.ts b/packages/git-sync/build/engine/reconcile.d.ts deleted file mode 100644 index 28a58e92..00000000 --- a/packages/git-sync/build/engine/reconcile.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Pure reconciliation planner (SPEC §5/§6/§8). - * - * Given the desired live set of files (computed from the current Docmost tree) - * and the set of files currently tracked in the vault, compute what to write, - * what to move (old path to remove), and what to delete. Identity is `pageId` - * (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but - * changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from - * the live tree is a DELETE. - * - * 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. - */ -/** A page that SHOULD exist in the vault at a given path. */ -export interface LiveEntry { - pageId: string; - /** Vault-relative path (forward-slash), e.g. `Space/Parent/Child.md`. */ - relPath: string; -} -/** A page currently tracked in the vault (pageId parsed from its meta). */ -export interface ExistingEntry { - pageId: string; - /** Vault-relative path (forward-slash) of the tracked file. */ - relPath: string; -} -/** A page to (re)write at its destination path. */ -export interface WriteEntry { - pageId: string; - relPath: string; -} -/** A page that moved: written at its NEW relPath, with the OLD path removed. */ -export interface MovedEntry { - pageId: string; - fromRelPath: string; - toRelPath: string; - /** - * Whether the old path (`fromRelPath`) is SAFE to remove. False when another - * live page will (re)write that exact path (path reuse): removing it would - * destroy real data, so the caller must skip the removal. The move itself is - * still recorded (the new path is written regardless). - */ - removeOldPath: boolean; -} -/** The full reconciliation plan. */ -export interface ReconciliationPlan { - /** - * Pages present in `live` -> (re)write at their relPath. This naturally - * covers add, content-update (same path) AND move (same pageId, new path), - * since every live page is (re)written regardless of whether it existed. - */ - toWrite: WriteEntry[]; - /** - * Vault-relative paths to delete because their tracked pageId is ABSENT from - * `live` (page removed/trashed). This set is ONLY absence-based deletions — - * the OLD paths of moved pages are NOT here (they live in `moved` and are - * applied separately by the caller). Keeping the two apart lets pull.ts gate - * absence deletions behind the incomplete-fetch suppression + mass-delete - * guard (SPEC §8) while still applying real moves. - */ - toDelete: string[]; - /** - * Tracked pages whose relPath changed. The caller writes the page at - * `toRelPath`, then removes `fromRelPath` — but ONLY after the new-path write - * succeeded. The old path is NOT in `toDelete`. - */ - moved: MovedEntry[]; -} -/** - * Compute the reconciliation plan. - * - * Rules: - * - Every `live` page is written at its relPath (covers add + update + move). - * - A tracked pageId present in `live` whose relPath changed is `moved`; its - * OLD relPath goes into `moved` ONLY (the caller removes it after the new - * path is written) and is NEVER added to `toDelete`. - * - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath - * is added to `toDelete`. - * - * Notes: - * - Safety filter (no data loss): no path that is a live TARGET path of any - * page is ever deleted/removed (a write owns it). This applies to BOTH the - * absence `toDelete` set AND a moved page's old-path removal — if a moved - * page's OLD path is reused by ANOTHER live page, the move records no old - * path to remove, because that path will be (re)written. - * - `existing` may legitimately contain duplicate pageIds (two stray files - * carrying the same meta pageId); each such file that is not the live target - * path is removed (as an absence/move) so the vault converges to exactly the - * live set. - */ -export declare function planReconciliation(live: LiveEntry[], existing: ExistingEntry[]): ReconciliationPlan; -/** - * 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 declare const MASS_DELETE_MIN_EXISTING = 4; -/** Fraction of tracked files above which a delete plan is a suspected wipe. */ -export declare const MASS_DELETE_FRACTION = 0.5; -/** Why absence-based deletions were (or were not) applied this cycle. */ -export type DeletionDecision = { - apply: true; -} | { - apply: false; - reason: "incomplete-fetch" | "empty-live" | "mass-delete"; -}; -/** - * Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied - * this cycle? Encapsulates the SPEC §8 safety invariants so they are unit- - * testable without live creds or git: - * - * - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page - * missing from a partial tree is NOT proof of deletion (SPEC §8); we must not - * delete merely-absent files this cycle. (Writes/updates/moves still happen.) - * - The live fetch returned 0 pages while files are tracked -> SUPPRESS - * (almost always a failed fetch, never a real "delete everything"). - * - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial - * vault -> SUPPRESS as a mass-deletion guard (defense in depth). - * - * 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 declare function decideAbsenceDeletions(args: { - treeComplete: boolean; - liveCount: number; - existingCount: number; - deleteCount: number; -}): DeletionDecision; diff --git a/packages/git-sync/build/engine/reconcile.js b/packages/git-sync/build/engine/reconcile.js deleted file mode 100644 index 9ebd2989..00000000 --- a/packages/git-sync/build/engine/reconcile.js +++ /dev/null @@ -1,122 +0,0 @@ -"use strict"; -/** - * Pure reconciliation planner (SPEC §5/§6/§8). - * - * Given the desired live set of files (computed from the current Docmost tree) - * and the set of files currently tracked in the vault, compute what to write, - * what to move (old path to remove), and what to delete. Identity is `pageId` - * (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but - * changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from - * the live tree is a DELETE. - * - * 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. - * - * Rules: - * - Every `live` page is written at its relPath (covers add + update + move). - * - A tracked pageId present in `live` whose relPath changed is `moved`; its - * OLD relPath goes into `moved` ONLY (the caller removes it after the new - * path is written) and is NEVER added to `toDelete`. - * - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath - * is added to `toDelete`. - * - * Notes: - * - Safety filter (no data loss): no path that is a live TARGET path of any - * page is ever deleted/removed (a write owns it). This applies to BOTH the - * absence `toDelete` set AND a moved page's old-path removal — if a moved - * page's OLD path is reused by ANOTHER live page, the move records no old - * path to remove, because that path will be (re)written. - * - `existing` may legitimately contain duplicate pageIds (two stray files - * carrying the same meta pageId); each such file that is not the live target - * path is removed (as an absence/move) so the vault converges to exactly the - * live set. - */ -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). - const liveTargetPaths = new Set(); - for (const e of live) { - liveByPageId.set(e.pageId, e.relPath); - liveTargetPaths.add(e.relPath); - } - const toWrite = live.map((e) => ({ - pageId: e.pageId, - relPath: e.relPath, - })); - const moved = []; - // Absence-based deletions ONLY (tracked pageId absent from `live`). Use a Set - // so the same path coming from multiple existing rows is queued only once. - const toDeleteSet = new Set(); - for (const ex of existing) { - const liveRel = liveByPageId.get(ex.pageId); - if (liveRel === undefined) { - // Tracked page is gone from the live tree -> absence delete. - // Never queue a path a live page will (re)write (path reuse -> no loss). - if (!liveTargetPaths.has(ex.relPath)) - toDeleteSet.add(ex.relPath); - continue; - } - if (liveRel !== ex.relPath) { - // Same pageId, different path -> a MOVE. Record it so the caller can write - // the new path first, then remove the old one. If the old path is itself a - // live target (reused by another page), it must NOT be removed — the write - // owns it — so flag `removeOldPath: false` (move still recorded). - moved.push({ - pageId: ex.pageId, - fromRelPath: ex.relPath, - toRelPath: liveRel, - removeOldPath: !liveTargetPaths.has(ex.relPath), - }); - } - // liveRel === ex.relPath -> content-update in place; nothing extra to do - // (the write above re-emits the file; identical bytes => git no-op). - } - const toDelete = [...toDeleteSet]; - return { toWrite, toDelete, moved }; -} -/** - * 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). - */ -exports.MASS_DELETE_MIN_EXISTING = 4; -/** Fraction of tracked files above which a delete plan is a suspected wipe. */ -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- - * testable without live creds or git: - * - * - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page - * missing from a partial tree is NOT proof of deletion (SPEC §8); we must not - * delete merely-absent files this cycle. (Writes/updates/moves still happen.) - * - The live fetch returned 0 pages while files are tracked -> SUPPRESS - * (almost always a failed fetch, never a real "delete everything"). - * - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial - * vault -> SUPPRESS as a mass-deletion guard (defense in depth). - * - * 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). - */ -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) - return { apply: true }; - if (!treeComplete) - return { apply: false, reason: "incomplete-fetch" }; - if (liveCount === 0) - return { apply: false, reason: "empty-live" }; - 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/sanitize.d.ts b/packages/git-sync/build/engine/sanitize.d.ts deleted file mode 100644 index 0889a9f6..00000000 --- a/packages/git-sync/build/engine/sanitize.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Deterministic filename strategy (SPEC §12). - * - * The file name is COSMETIC — the source of truth for the file<->page link is - * `pageId` / `slugId` inside the meta block, so renaming a file is safe. These - * functions are intentionally dependency-free and pure, so they are trivially - * unit-testable. - */ -/** - * Sanitize a page title into a safe file-name component (WITHOUT extension). - * - * Steps: replace forbidden / control characters with "-", collapse whitespace - * runs to a single space, trim, cap the length, then guard against an empty - * result, an all-dots result, or a reserved Windows device name by prefixing - * with "_". - */ -export declare function sanitizeTitle(title: string): string; -/** - * Disambiguate a sanitized name when two siblings in the same folder collapse - * 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 declare function disambiguate(name: string, slugId: string): string; diff --git a/packages/git-sync/build/engine/sanitize.js b/packages/git-sync/build/engine/sanitize.js deleted file mode 100644 index 684d0bab..00000000 --- a/packages/git-sync/build/engine/sanitize.js +++ /dev/null @@ -1,101 +0,0 @@ -"use strict"; -/** - * Deterministic filename strategy (SPEC §12). - * - * The file name is COSMETIC — the source of truth for the file<->page link is - * `pageId` / `slugId` inside the meta block, so renaming a file is safe. These - * 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. -// ASCII control characters (code points 0..31) are stripped in a separate pass -// (see stripControlChars) to keep this literal free of embedded control bytes. -const FORBIDDEN_PRINTABLE_RE = /[/\\<>:"|?*]/g; -// Runs of whitespace (including tabs/newlines) collapse to a single space. -const WHITESPACE_RUN_RE = /\s+/g; -// Reserved Windows device names (case-insensitive). A bare match (with or -// without an extension) is unusable as a file name, so it is prefixed with "_". -const RESERVED_WINDOWS_NAMES = new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9", -]); -// Cap on the sanitized length to stay well within filesystem path-component -// limits (255 bytes on most FSes) while leaving room for an extension and a -// disambiguation suffix. -const MAX_LENGTH = 120; -/** - * Replace every ASCII control character (code points 0..31) with "-". Done by - * scanning code points rather than a control-range regex literal, so the source - * file carries no embedded control bytes. - */ -function stripControlChars(input) { - let out = ""; - for (let i = 0; i < input.length; i++) { - out += input.charCodeAt(i) < 32 ? "-" : input[i]; - } - return out; -} -/** - * Sanitize a page title into a safe file-name component (WITHOUT extension). - * - * Steps: replace forbidden / control characters with "-", collapse whitespace - * runs to a single space, trim, cap the length, then guard against an empty - * result, an all-dots result, or a reserved Windows device name by prefixing - * with "_". - */ -function sanitizeTitle(title) { - let name = stripControlChars(title ?? "") - .replace(FORBIDDEN_PRINTABLE_RE, "-") - .replace(WHITESPACE_RUN_RE, " ") - .trim(); - if (name.length > MAX_LENGTH) { - name = name.slice(0, MAX_LENGTH).trim(); - } - // Compare the base name (before the first dot) against reserved names, so - // both "CON" and "con.md" are caught. - const base = name.split(".")[0]?.toLowerCase() ?? ""; - // A name that is empty, consists only of dots ("." / ".." / "..."), or is a - // reserved Windows device name is unusable as a path component. The all-dots - // case is a path-traversal hazard in particular: an unprefixed ".." would - // become a parent-directory segment and let a page escape the vault, so it - // MUST be neutralized here (becomes "_..", which is a literal file name). - if (name.length === 0 || - /^\.+$/.test(name) || - RESERVED_WINDOWS_NAMES.has(base)) { - name = "_" + name; - } - return name; -} -/** - * Disambiguate a sanitized name when two siblings in the same folder collapse - * 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`). - */ -function disambiguate(name, slugId) { - return `${name} ~${slugId}`; -} diff --git a/packages/git-sync/build/lib/diff.d.ts b/packages/git-sync/build/lib/diff.d.ts deleted file mode 100644 index 60997f4a..00000000 --- a/packages/git-sync/build/lib/diff.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Headless, Docmost-equivalent document diff. - * - * Docmost's history editor computes a change set with the exact pipeline below - * (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as - * editor decorations. This module runs the SAME computation but serializes the - * result to text + integrity counts instead of decorations, so a diff can be - * previewed without a browser. - * - * recreateTransform here comes from @fellow/prosemirror-recreate-transform, the - * maintained published fork of the MIT prosemirror-recreate-steps source that - * Docmost vendors in @docmost/editor-ext; it exposes the identical - * recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff }) - * signature. - * - * 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. - */ -/** A single inserted/deleted change with its containing-block context. */ -export interface DiffChange { - op: "insert" | "delete"; - /** Lead (plain) text of the block that contains the change, for context. */ - block: string; - /** The inserted or deleted text. */ - text: string; -} -/** Integrity counts as [old, new] tuples; footnoteMarkers as [oldList, newList]. */ -export interface DiffIntegrity { - images: [number, number]; - links: [number, number]; - tables: [number, number]; - callouts: [number, number]; - footnoteMarkers: [number[], number[]]; -} -export interface DiffResult { - summary: { - inserted: number; - deleted: number; - blocksChanged: number; - }; - integrity: DiffIntegrity; - changes: DiffChange[]; - /** Human-readable unified-ish summary. */ - markdown: string; -} -/** - * Diff two ProseMirror JSON documents the way Docmost's history editor does and - * serialize the result to text + integrity counts. - * - * @param oldDocJson the earlier document - * @param newDocJson the later document - * @param notesHeading heading delimiting body from notes for footnote counting - */ -export declare function diffDocs(oldDocJson: any, newDocJson: any, notesHeading?: string): DiffResult; diff --git a/packages/git-sync/build/lib/diff.js b/packages/git-sync/build/lib/diff.js deleted file mode 100644 index e14f7049..00000000 --- a/packages/git-sync/build/lib/diff.js +++ /dev/null @@ -1,276 +0,0 @@ -"use strict"; -/** - * Headless, Docmost-equivalent document diff. - * - * Docmost's history editor computes a change set with the exact pipeline below - * (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as - * editor decorations. This module runs the SAME computation but serializes the - * result to text + integrity counts instead of decorations, so a diff can be - * previewed without a browser. - * - * recreateTransform here comes from @fellow/prosemirror-recreate-transform, the - * maintained published fork of the MIT prosemirror-recreate-steps source that - * Docmost vendors in @docmost/editor-ext; it exposes the identical - * recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff }) - * signature. - * - * 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. - */ -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 = (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") - return ""; - let out = ""; - if (typeof node.text === "string") - out += node.text; - if (Array.isArray(node.content)) { - for (const child of node.content) - out += plainText(child); - } - return out; -} -/** Count nodes in a JSON doc that satisfy `pred` (recursive). */ -function countNodes(doc, pred) { - let n = 0; - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (pred(node)) - n++; - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return n; -} -/** - * Count UNIQUE links in a JSON doc by their `href`. A single link can be split - * across several adjacent text runs (e.g. a "link+bold" run followed by a "link" - * run); counting link-bearing runs would over-count it. Walking the tree and - * collecting hrefs into a Set keys each distinct link once. Link marks with a - * missing/empty href are bucketed under a single "" key so a malformed link is - * still counted as one. - */ -function countUniqueLinks(doc) { - const hrefs = new Set(); - const visit = (node) => { - if (!node || typeof node !== "object") - return; - if (node.type === "text" && Array.isArray(node.marks)) { - for (const m of node.marks) { - if (m && m.type === "link") { - const href = m.attrs && typeof m.attrs.href === "string" ? m.attrs.href : ""; - hrefs.add(href); - } - } - } - if (Array.isArray(node.content)) - for (const c of node.content) - visit(c); - }; - visit(doc); - return hrefs.size; -} -/** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. - */ -function footnoteMarkers(doc, notesHeading) { - const top = Array.isArray(doc?.content) ? doc.content : []; - const notesIdx = top.findIndex((n) => n && - n.type === "heading" && - plainText(n).trim() === notesHeading); - const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; - const markers = []; - const re = /\[(\d+)\]/g; - for (const block of bodyBlocks) { - const text = plainText(block); - let m; - re.lastIndex = 0; - while ((m = re.exec(text)) !== null) { - markers.push(Number(m[1])); - } - } - return markers; -} -/** Compute the [old,new] integrity tuples for two JSON docs. */ -function computeIntegrity(oldDoc, newDoc, notesHeading) { - const images = [ - countNodes(oldDoc, (n) => n.type === "image"), - countNodes(newDoc, (n) => n.type === "image"), - ]; - const links = [ - countUniqueLinks(oldDoc), - countUniqueLinks(newDoc), - ]; - const tables = [ - countNodes(oldDoc, (n) => n.type === "table"), - countNodes(newDoc, (n) => n.type === "table"), - ]; - const callouts = [ - countNodes(oldDoc, (n) => n.type === "callout"), - countNodes(newDoc, (n) => n.type === "callout"), - ]; - const fns = [ - footnoteMarkers(oldDoc, notesHeading), - footnoteMarkers(newDoc, notesHeading), - ]; - return { images, links, tables, callouts, footnoteMarkers: fns }; -} -/** - * Resolve the lead text of the top-level block in a ProseMirror Node that - * contains the given document position. Returns "" when out of range. - */ -function blockContextAt(node, pos) { - try { - const clamped = Math.max(0, Math.min(pos, node.content.size)); - const $pos = node.resolve(clamped); - // depth 1 is the top-level block in a doc node. - const block = $pos.depth >= 1 ? $pos.node(1) : $pos.node(0); - const text = block.textContent || ""; - return text.length > 80 ? text.slice(0, 77) + "..." : text; - } - catch { - return ""; - } -} -/** Truncate a string for the markdown summary. */ -function truncate(s, n = 120) { - return s.length > n ? s.slice(0, n - 3) + "..." : s; -} -/** - * Coarse fallback: a block-by-block plain-text diff. Used only when the precise - * changeset pipeline throws, so the tool degrades gracefully instead of failing. - */ -function coarseDiff(oldDoc, newDoc) { - const oldBlocks = Array.isArray(oldDoc?.content) ? oldDoc.content : []; - const newBlocks = Array.isArray(newDoc?.content) ? newDoc.content : []; - const oldTexts = oldBlocks.map(plainText); - const newTexts = newBlocks.map(plainText); - const oldSet = new Set(oldTexts); - const newSet = new Set(newTexts); - const changes = []; - for (const t of oldTexts) { - if (!newSet.has(t) && t.trim() !== "") { - changes.push({ op: "delete", block: truncate(t, 80), text: t }); - } - } - for (const t of newTexts) { - if (!oldSet.has(t) && t.trim() !== "") { - changes.push({ op: "insert", block: truncate(t, 80), text: t }); - } - } - return changes; -} -/** Build the human-readable unified-ish markdown summary. */ -function renderMarkdown(result, fellBack) { - const lines = []; - const { summary, integrity, changes } = result; - lines.push(`# Diff: ${summary.inserted} inserted / ${summary.deleted} deleted (${summary.blocksChanged} blocks changed)`); - if (fellBack) { - lines.push(""); - lines.push("> note: precise diff failed; coarse block-level diff shown."); - } - lines.push(""); - lines.push("## Integrity (old -> new)"); - lines.push(`- images: ${integrity.images[0]} -> ${integrity.images[1]}`); - lines.push(`- links: ${integrity.links[0]} -> ${integrity.links[1]}`); - lines.push(`- tables: ${integrity.tables[0]} -> ${integrity.tables[1]}`); - lines.push(`- callouts: ${integrity.callouts[0]} -> ${integrity.callouts[1]}`); - lines.push(`- footnoteMarkers: [${integrity.footnoteMarkers[0].join(", ")}] -> [${integrity.footnoteMarkers[1].join(", ")}]`); - lines.push(""); - lines.push("## Changes"); - if (changes.length === 0) { - lines.push("(no textual changes)"); - } - else { - for (const c of changes) { - const sign = c.op === "insert" ? "+" : "-"; - const ctx = c.block ? ` @ ${truncate(c.block, 60)}` : ""; - lines.push(`${sign} ${truncate(c.text)}${ctx}`); - } - } - return lines.join("\n"); -} -/** - * Diff two ProseMirror JSON documents the way Docmost's history editor does and - * serialize the result to text + integrity counts. - * - * @param oldDocJson the earlier document - * @param newDocJson the later document - * @param notesHeading heading delimiting body from notes for footnote counting - */ -function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { - const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); - let changes = []; - let inserted = 0; - let deleted = 0; - let fellBack = false; - const changedBlocks = new Set(); - try { - 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_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) { - const text = oldNode.textBetween(change.fromA, change.toA, "\n", " "); - if (text.length > 0) { - deleted += text.length; - const block = blockContextAt(oldNode, change.fromA); - changes.push({ op: "delete", block, text }); - if (block) - changedBlocks.add("d:" + block); - } - } - // Inserted text lives in the NEW doc coordinate range [fromB, toB). - if (change.toB > change.fromB) { - const text = newNode.textBetween(change.fromB, change.toB, "\n", " "); - if (text.length > 0) { - inserted += text.length; - const block = blockContextAt(newNode, change.fromB); - changes.push({ op: "insert", block, text }); - if (block) - changedBlocks.add("i:" + block); - } - } - } - } - catch { - // Pathological pair: degrade to a coarse block-level diff so we never throw. - fellBack = true; - changes = coarseDiff(oldDocJson, newDocJson); - for (const c of changes) { - if (c.op === "insert") - inserted += c.text.length; - else - deleted += c.text.length; - if (c.block) - changedBlocks.add(c.op[0] + ":" + c.block); - } - } - const partial = { - summary: { inserted, deleted, blocksChanged: changedBlocks.size }, - integrity, - changes, - }; - return { ...partial, markdown: renderMarkdown(partial, fellBack) }; -} diff --git a/packages/git-sync/build/lib/docmost-schema.d.ts b/packages/git-sync/build/lib/docmost-schema.d.ts deleted file mode 100644 index 8684e1bc..00000000 --- a/packages/git-sync/build/lib/docmost-schema.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Node, Extension, Mark } from "@tiptap/core"; -export declare const clampCalloutType: (value: string | null | undefined) => string; -export declare const sanitizeCssColor: (value: string | null | undefined) => string | null; -/** - * Full extension list. Image is block-level (matches Docmost); the - * ProseMirror DOM parser hoists found inside

automatically. - * StarterKit v3 already bundles the link extension, configured here. - */ -export declare const docmostExtensions: (Node | Mark | Extension | Extension | Node | Node | Node | Mark | Mark)[]; diff --git a/packages/git-sync/build/lib/docmost-schema.js b/packages/git-sync/build/lib/docmost-schema.js deleted file mode 100644 index 148ea642..00000000 --- a/packages/git-sync/build/lib/docmost-schema.js +++ /dev/null @@ -1,1007 +0,0 @@ -"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. - * - * The default StarterKit-only schema silently destroys Docmost-specific - * nodes (callout, table) and drops attributes it does not know about - * (node ids, image sizing, link targets). Every code path that converts - * to or from ProseMirror JSON must use THIS set, otherwise a round-trip - * loses content. - */ -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 -// from an element's inline `style` attribute, last-wins, case-insensitive. -function getStyleProperty(element, propertyName) { - const styleAttr = element.getAttribute("style"); - if (!styleAttr) { - return null; - } - const decls = styleAttr.split(";").map((decl) => decl.trim()).filter(Boolean); - const target = propertyName.toLowerCase(); - for (let i = decls.length - 1; i >= 0; i -= 1) { - const decl = decls[i]; - const colonIndex = decl.indexOf(":"); - if (colonIndex === -1) { - continue; - } - const prop = decl.slice(0, colonIndex).trim().toLowerCase(); - if (prop === target) { - return decl.slice(colonIndex + 1).trim(); - } - } - return null; -} -/** Allowed Docmost callout types; anything else falls back to "info". */ -const CALLOUT_TYPES = ["info", "warning", "danger", "success"]; -const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) - ? value.toLowerCase() - : "info"; -exports.clampCalloutType = clampCalloutType; -/** - * Allowlist guard for CSS color values imported from HTML. - * - * Docmost interpolates stored mark colors straight into an inline style - * attribute (e.g. style="background-color: ${color}" / "color: ${color}"). - * An unsanitized value such as `red; --x: url(...)` or `red">