feat(git-sync): CommonJS build + §13.1 editor-ext idempotency gate (Phase A.2)

Make @docmost/git-sync natively consumable by the CommonJS server (and jest):
build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from
relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic
Function('import()') trick (mirrors docmost-client.loader.ts) with a require()
fallback so vitest's evaluator works too. git-sync tests stay green (314 pass,
3 expected fail).

Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts):
13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists,
blockquote, callouts, code block, hr, table, nested mix) round-trip
content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror ->
TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert
docsCanonicallyEqual. All green => the vendored converter's docmost-schema is
schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1
requires before Phase B. The one intrinsic markdown-image lossiness (width/height
/align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not
hidden. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 14:25:43 +03:00
parent 9a807e972d
commit 2e670934b8
27 changed files with 693 additions and 162 deletions

View File

@@ -0,0 +1,349 @@
/**
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
*
* Proves the vendored `@docmost/git-sync` pure converter is schema-compatible
* with the server's REAL editor-ext document schema: a representative corpus of
* editor-ext ProseMirror documents must survive a full round trip through the
* actual server write path without losing any node / mark / attribute.
*
* Pipeline per document (plan §13.1):
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
* 2. doc = await markdownToProseMirror(md) // git-sync import
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
* This is exactly what PersistenceExtension does on store
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
* `@hocuspocus/transformer`, so the gate exercises the real schema
* validation that runs on a git-sync write (plan §3.3).
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
*
* Any node / mark / attr that editor-ext drops (because the vendored
* docmost-schema named it differently, or declares a different default) makes
* the gate FAIL for that document — exactly the schema-divergence plan §3.3 /
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
*
* Requires the workspace packages built first:
* pnpm --filter @docmost/editor-ext build
* pnpm --filter @docmost/git-sync build
*/
import { TiptapTransformer } from '@hocuspocus/transformer';
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
// built CJS `dist` (its `main`). Importing the ESM `@docmost/git-sync` package
// first flips jest's resolver to editor-ext's `module` (src) field, which then
// drags in React node views (navigator-less) and breaks the node test env.
import { tiptapExtensions } from './collaboration.util';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
canonicalizeContent,
docsCanonicallyEqual,
} from '@docmost/git-sync';
/**
* Run a single editor-ext document through the full gate pipeline and return
* the canonical original vs the canonical doc as it lands after the real Yjs
* write path, plus the intermediate markdown for diagnostics.
*/
async function runGate(original: any): Promise<{
md: string;
imported: any;
normalized: any;
canonOriginal: any;
canonNormalized: any;
}> {
// 1) editor-ext JSON -> markdown (git-sync export).
const md = convertProseMirrorToMarkdown(original);
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
const imported = await markdownToProseMirror(md);
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
// toYdoc validates `imported` against tiptapExtensions (throws on an
// unknown node, drops unknown attrs); fromYdoc reads it back as the
// normalized editor-ext JSON the server would persist.
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
return {
md,
imported,
normalized,
canonOriginal: canonicalizeContent(original),
canonNormalized: canonicalizeContent(normalized),
};
}
const doc = (...content: any[]) => ({ type: 'doc', content });
const text = (t: string, marks?: any[]) =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...content: any[]) => ({ type: 'paragraph', content });
// ---------------------------------------------------------------------------
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
// Node / mark / attr names and DEFAULTS are taken from the real schema —
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
// non-null default on import (e.g. image.align="center", callout.type, list
// start) the fixture pre-authors that materialized value so the round trip is
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
// ---------------------------------------------------------------------------
const CORPUS: Record<string, any> = {
'paragraphs + headings (h1-h3)': doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
para(text('A plain paragraph of text.')),
para(text('Second paragraph.')),
),
'inline marks (bold/italic/strike/code)': doc(
para(
text('normal '),
text('bold', [{ type: 'bold' }]),
text(' '),
text('italic', [{ type: 'italic' }]),
text(' '),
text('struck', [{ type: 'strike' }]),
text(' '),
text('code', [{ type: 'code' }]),
),
),
'links': doc(
para(
text('see '),
text('the site', [
{ type: 'link', attrs: { href: 'https://example.com' } },
]),
text(' for more'),
),
),
'bullet list': doc({
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('first'))] },
{ type: 'listItem', content: [para(text('second'))] },
{ type: 'listItem', content: [para(text('third'))] },
],
}),
'ordered list': doc({
type: 'orderedList',
attrs: { start: 1 },
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
}),
'task list (checkbox)': doc({
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: true },
content: [para(text('done item'))],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [para(text('todo item'))],
},
],
}),
'blockquote': doc({
type: 'blockquote',
content: [para(text('a quoted line')), para(text('second quoted line'))],
}),
'callout (info)': doc({
type: 'callout',
attrs: { type: 'info' },
content: [para(text('an informational callout'))],
}),
'callout (warning)': doc({
type: 'callout',
attrs: { type: 'warning' },
content: [para(text('a warning callout'))],
}),
'code block (with language)': doc({
type: 'codeBlock',
attrs: { language: 'typescript' },
// A fenced code block's body is stored with a trailing newline (the form a
// markdown ``` fence round-trips to: marked normalizes the code text to end
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
content: [text('const a: number = 1;\nconsole.log(a);\n')],
}),
'horizontal rule': doc(
para(text('before')),
{ type: 'horizontalRule' },
para(text('after')),
),
'table (header row + cells)': doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('Name'))],
},
{
type: 'tableHeader',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('Value'))],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('alpha'))],
},
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('1'))],
},
],
},
],
}),
'nested / mixed document': doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
para(
text('intro with '),
text('bold', [{ type: 'bold' }]),
text(' and a '),
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
text('.'),
),
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
para(text('item with '), text('code', [{ type: 'code' }])),
],
},
{
type: 'listItem',
content: [
para(text('item with sublist')),
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('nested a'))] },
{ type: 'listItem', content: [para(text('nested b'))] },
],
},
],
},
],
},
{
type: 'callout',
attrs: { type: 'success' },
content: [
para(text('callout body')),
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
],
},
{
type: 'blockquote',
content: [para(text('quote at the end'))],
},
),
};
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
for (const [name, original] of Object.entries(CORPUS)) {
it(`round-trips losslessly: ${name}`, async () => {
const { md, canonOriginal, canonNormalized } = await runGate(original);
const equal = docsCanonicallyEqual(original, canonNormalized);
if (!equal) {
// Surface a readable diff so a real divergence is actionable.
// eslint-disable-next-line no-console
console.error(
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
);
}
expect(equal).toBe(true);
});
}
});
// ---------------------------------------------------------------------------
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
//
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
//
// 1. `convertProseMirrorToMarkdown` emits a standard `![alt](src)` image
// (markdown-converter.ts case "image"). Standard markdown image syntax has
// no way to express `width` / `height` / `align`, so those attrs are
// DROPPED on export and cannot be recovered on import.
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
// documented in packages/git-sync/test/fixtures/known-limitations).
//
// The gate documents the EXACT lossy shape below. If the converter is ever
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
// data-* attrs, as it already does for video/diagrams), these assertions flip
// and the image fixture should be promoted into the green CORPUS above.
// ---------------------------------------------------------------------------
describe('git-sync converter §13.1 KNOWN DIVERGENCE (markdown image lossiness)', () => {
const imageDoc = doc({
type: 'image',
attrs: {
src: 'https://example.com/pic.png',
width: 640,
height: 480,
align: 'center',
},
});
it('drops width/height/align (markdown ![](src) cannot carry them) and hoists the block image past a leading empty paragraph', async () => {
const { md, canonNormalized } = await runGate(imageDoc);
// Export is plain markdown image syntax — no dimensions/align survive.
expect(md.trim()).toBe('![](https://example.com/pic.png)');
// The round-tripped doc is the documented lossy shape: a leading empty
// paragraph (block-image hoist) + an image carrying ONLY src (+ alt="").
expect(canonNormalized).toEqual({
type: 'doc',
content: [
{ type: 'paragraph' },
{
type: 'image',
attrs: { alt: '', src: 'https://example.com/pic.png' },
},
],
});
// And it is therefore NOT canonically equal to the original (lock the loss).
expect(docsCanonicallyEqual(imageDoc, canonNormalized)).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Pure page-tree -> vault path mapping (SPEC §12). * Pure page-tree -> vault path mapping (SPEC §12).
* *
@@ -9,7 +10,9 @@
* path logic is unit-testable without any I/O. The names are COSMETIC; identity * path logic is unit-testable without any I/O. The names are COSMETIC; identity
* lives in each file's meta block (pageId / slugId). * lives in each file's meta block (pageId / slugId).
*/ */
import { sanitizeTitle, disambiguate } from "./sanitize.js"; Object.defineProperty(exports, "__esModule", { value: true });
exports.buildVaultLayout = buildVaultLayout;
const sanitize_1 = require("./sanitize");
/** /**
* Build the full vault layout for a space. * Build the full vault layout for a space.
* *
@@ -27,7 +30,7 @@ import { sanitizeTitle, disambiguate } from "./sanitize.js";
* cannot see — e.g. two pages whose parents are BOTH outside the input set * cannot see — e.g. two pages whose parents are BOTH outside the input set
* both bucket at the root with `segments: []`. * both bucket at the root with `segments: []`.
*/ */
export function buildVaultLayout(pages) { function buildVaultLayout(pages) {
// Index pages by id so the parent chain can be walked. Guard against // Index pages by id so the parent chain can be walked. Guard against
// duplicate ids in the input (first one wins). // duplicate ids in the input (first one wins).
const byId = new Map(); const byId = new Map();
@@ -109,11 +112,11 @@ export function buildVaultLayout(pages) {
continue; continue;
if (usedPaths.has(pathKey(entry))) { if (usedPaths.has(pathKey(entry))) {
// First attempt: disambiguate the stem with the sanitized slugId (or id). // First attempt: disambiguate the stem with the sanitized slugId (or id).
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.slugId ?? p.id)); entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.slugId ?? p.id));
if (usedPaths.has(pathKey(entry))) { if (usedPaths.has(pathKey(entry))) {
// Still colliding: append the (sanitized) id as a last resort. The id // Still colliding: append the (sanitized) id as a last resort. The id
// is globally unique, so this always resolves the collision. // is globally unique, so this always resolves the collision.
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.id)); entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.id));
} }
} }
usedPaths.add(pathKey(entry)); usedPaths.add(pathKey(entry));
@@ -137,11 +140,11 @@ function nameForNode(node, parentKey, usedBySibling) {
used = new Set(); used = new Set();
usedBySibling.set(parentKey, used); usedBySibling.set(parentKey, used);
} }
let name = sanitizeTitle(node.title ?? ""); let name = (0, sanitize_1.sanitizeTitle)(node.title ?? "");
if (used.has(name)) { if (used.has(name)) {
// Sibling collision: disambiguate with the stable, sanitized slugId (fall // Sibling collision: disambiguate with the stable, sanitized slugId (fall
// back to the sanitized pageId if no slugId is present). // back to the sanitized pageId if no slugId is present).
name = disambiguate(name, sanitizeTitle(node.slugId ?? node.id)); name = (0, sanitize_1.disambiguate)(name, (0, sanitize_1.sanitizeTitle)(node.slugId ?? node.id));
} }
used.add(name); used.add(name);
return name; return name;

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.bodyHash = bodyHash;
/** /**
* Loop-guard primitives (SPEC §10). The sync engine must never re-pull its OWN * 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 * write as if it were a remote edit: after a push, the next poll will see the
@@ -10,7 +13,7 @@
* to decide "this is our own write, ignore it") is a future increment — here we * 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`). * only PRODUCE the hash and the per-page push record (see `src/push.ts`).
*/ */
import { createHash } from "node:crypto"; const node_crypto_1 = require("node:crypto");
/** /**
* Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic: * Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic:
* the same input string always yields the same digest, a different input a * the same input string always yields the same digest, a different input a
@@ -23,6 +26,6 @@ import { createHash } from "node:crypto";
* caller is responsible for passing a canonical/stable representation if it * caller is responsible for passing a canonical/stable representation if it
* wants hash equality across cosmetic-only differences. * wants hash equality across cosmetic-only differences.
*/ */
export function bodyHash(markdownBody) { function bodyHash(markdownBody) {
return createHash("sha256").update(markdownBody, "utf8").digest("hex"); return (0, node_crypto_1.createHash)("sha256").update(markdownBody, "utf8").digest("hex");
} }

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Pure reconciliation planner (SPEC §5/§6/§8). * Pure reconciliation planner (SPEC §5/§6/§8).
* *
@@ -11,6 +12,10 @@
* This module is intentionally PURE (no IO, no git) so the whole plan is * 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. * 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. * Compute the reconciliation plan.
* *
@@ -33,7 +38,7 @@
* path is removed (as an absence/move) so the vault converges to exactly the * path is removed (as an absence/move) so the vault converges to exactly the
* live set. * live set.
*/ */
export function planReconciliation(live, existing) { function planReconciliation(live, existing) {
// Desired path for each live pageId. // Desired path for each live pageId.
const liveByPageId = new Map(); const liveByPageId = new Map();
// Set of all paths that WILL be written (never delete/remove one of these). // Set of all paths that WILL be written (never delete/remove one of these).
@@ -81,9 +86,9 @@ export function planReconciliation(live, existing) {
* Below this many tracked files the mass-delete fraction guard is not applied * 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). * (a tiny vault where deleting "most" files is normal, e.g. 1-of-2).
*/ */
export const MASS_DELETE_MIN_EXISTING = 4; exports.MASS_DELETE_MIN_EXISTING = 4;
/** Fraction of tracked files above which a delete plan is a suspected wipe. */ /** Fraction of tracked files above which a delete plan is a suspected wipe. */
export const MASS_DELETE_FRACTION = 0.5; exports.MASS_DELETE_FRACTION = 0.5;
/** /**
* Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied * Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied
* this cycle? Encapsulates the SPEC §8 safety invariants so they are unit- * this cycle? Encapsulates the SPEC §8 safety invariants so they are unit-
@@ -100,7 +105,7 @@ export const MASS_DELETE_FRACTION = 0.5;
* Moves are NOT governed by this decision: a moved page IS present in `live`, so * 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). * its old-path removal is real (handled by the caller separately).
*/ */
export function decideAbsenceDeletions(args) { function decideAbsenceDeletions(args) {
const { treeComplete, liveCount, existingCount, deleteCount } = args; const { treeComplete, liveCount, existingCount, deleteCount } = args;
// No tracked files, or nothing to delete -> trivially fine to "apply". // No tracked files, or nothing to delete -> trivially fine to "apply".
if (existingCount === 0 || deleteCount === 0) if (existingCount === 0 || deleteCount === 0)
@@ -109,8 +114,8 @@ export function decideAbsenceDeletions(args) {
return { apply: false, reason: "incomplete-fetch" }; return { apply: false, reason: "incomplete-fetch" };
if (liveCount === 0) if (liveCount === 0)
return { apply: false, reason: "empty-live" }; return { apply: false, reason: "empty-live" };
if (existingCount >= MASS_DELETE_MIN_EXISTING && if (existingCount >= exports.MASS_DELETE_MIN_EXISTING &&
deleteCount > existingCount * MASS_DELETE_FRACTION) { deleteCount > existingCount * exports.MASS_DELETE_FRACTION) {
return { apply: false, reason: "mass-delete" }; return { apply: false, reason: "mass-delete" };
} }
return { apply: true }; return { apply: true };

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Pure helpers extracted from the docmost-sync Phase-0 idempotency harness * Pure helpers extracted from the docmost-sync Phase-0 idempotency harness
* (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored — * (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored —
@@ -5,13 +6,16 @@
* `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1: * `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1:
* the roundtrip harness moves into the package's tests, not the engine). * the roundtrip harness moves into the package's tests, not the engine).
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.stripBlockIds = stripBlockIds;
exports.firstDivergence = firstDivergence;
/** /**
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids * Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be * are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
* ignored when comparing the semantic shape of two documents. Returns a NEW * ignored when comparing the semantic shape of two documents. Returns a NEW
* tree; the input is not mutated. * tree; the input is not mutated.
*/ */
export function stripBlockIds(node) { function stripBlockIds(node) {
if (Array.isArray(node)) { if (Array.isArray(node)) {
return node.map(stripBlockIds); return node.map(stripBlockIds);
} }
@@ -36,7 +40,7 @@ export function stripBlockIds(node) {
* Find the first divergence between two values via a recursive deep compare. * 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. * Returns a short path + the two differing values, or null if they are equal.
*/ */
export function firstDivergence(a, b, path = "$") { function firstDivergence(a, b, path = "$") {
if (a === b) if (a === b)
return null; return null;
const ta = typeof a; const ta = typeof a;

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Deterministic filename strategy (SPEC §12). * Deterministic filename strategy (SPEC §12).
* *
@@ -6,6 +7,9 @@
* functions are intentionally dependency-free and pure, so they are trivially * functions are intentionally dependency-free and pure, so they are trivially
* unit-testable. * unit-testable.
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.sanitizeTitle = sanitizeTitle;
exports.disambiguate = disambiguate;
// Printable characters forbidden in file names on common filesystems (mainly // Printable characters forbidden in file names on common filesystems (mainly
// Windows): / \ < > : " | ? *. Each match is replaced with a single "-". // Windows): / \ < > : " | ? *. Each match is replaced with a single "-".
// Spaces are NOT in this set; whitespace is normalized separately below. // Spaces are NOT in this set; whitespace is normalized separately below.
@@ -64,7 +68,7 @@ function stripControlChars(input) {
* result, an all-dots result, or a reserved Windows device name by prefixing * result, an all-dots result, or a reserved Windows device name by prefixing
* with "_". * with "_".
*/ */
export function sanitizeTitle(title) { function sanitizeTitle(title) {
let name = stripControlChars(title ?? "") let name = stripControlChars(title ?? "")
.replace(FORBIDDEN_PRINTABLE_RE, "-") .replace(FORBIDDEN_PRINTABLE_RE, "-")
.replace(WHITESPACE_RUN_RE, " ") .replace(WHITESPACE_RUN_RE, " ")
@@ -92,6 +96,6 @@ export function sanitizeTitle(title) {
* to the same name. Appends a stable suffix built from the page's `slugId`, so * 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`). * the result stays deterministic across runs (SPEC §12: `Title ~slugId`).
*/ */
export function disambiguate(name, slugId) { function disambiguate(name, slugId) {
return `${name} ~${slugId}`; return `${name} ~${slugId}`;
} }

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.stabilizePageFile = stabilizePageFile;
/** /**
* Normalize-on-write helper (SPEC §11 "Резолюция"). * Normalize-on-write helper (SPEC §11 "Резолюция").
* *
@@ -12,7 +15,7 @@
* Already-stable content is unaffected (the pass is idempotent), so re-pulls of * Already-stable content is unaffected (the pass is idempotent), so re-pulls of
* unchanged pages produce identical bytes and git sees no diff. * unchanged pages produce identical bytes and git sees no diff.
*/ */
import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMarkdownBody, } from "../lib/index.js"; const index_1 = require("../lib/index");
/** /**
* Produce the self-contained `.md` file text for a page from its raw * Produce the self-contained `.md` file text for a page from its raw
* ProseMirror `content` + identity meta, in the verified fixpoint form. * ProseMirror `content` + identity meta, in the verified fixpoint form.
@@ -26,11 +29,11 @@ import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMa
* idempotent for already-stable content, and the convergence point for the * idempotent for already-stable content, and the convergence point for the
* known converter asymmetries. * known converter asymmetries.
*/ */
export async function stabilizePageFile(content, meta) { async function stabilizePageFile(content, meta) {
const md1 = convertProseMirrorToMarkdown(content); const md1 = (0, index_1.convertProseMirrorToMarkdown)(content);
const doc2 = await markdownToProseMirror(md1); const doc2 = await (0, index_1.markdownToProseMirror)(md1);
const stableBody = convertProseMirrorToMarkdown(doc2); const stableBody = (0, index_1.convertProseMirrorToMarkdown)(doc2);
// The meta shape is exactly what `exportPageBody` writes; cast to the lib's // The meta shape is exactly what `exportPageBody` writes; cast to the lib's
// DocmostMdMeta (a superset with optional fields) for the serializer. // DocmostMdMeta (a superset with optional fields) for the serializer.
return serializeDocmostMarkdownBody(meta, stableBody); return (0, index_1.serializeDocmostMarkdownBody)(meta, stableBody);
} }

View File

@@ -5,13 +5,13 @@
* from docmost-sync. Server integration (GitmostDataSource, orchestrator, * from docmost-sync. Server integration (GitmostDataSource, orchestrator,
* VaultGit, pull/push) is added in later steps. * VaultGit, pull/push) is added in later steps.
*/ */
export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js"; export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index";
export type { DocmostMdMeta } from "./lib/index.js"; export type { DocmostMdMeta } from "./lib/index";
export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile.js"; export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile";
export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile.js"; export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile";
export { buildVaultLayout } from "./engine/layout.js"; export { buildVaultLayout } from "./engine/layout";
export type { PageNode, VaultEntry } from "./engine/layout.js"; export type { PageNode, VaultEntry } from "./engine/layout";
export { sanitizeTitle, disambiguate } from "./engine/sanitize.js"; export { sanitizeTitle, disambiguate } from "./engine/sanitize";
export { stabilizePageFile } from "./engine/stabilize.js"; export { stabilizePageFile } from "./engine/stabilize";
export type { PageMeta } from "./engine/stabilize.js"; export type { PageMeta } from "./engine/stabilize";
export { bodyHash } from "./engine/loop-guard.js"; export { bodyHash } from "./engine/loop-guard";

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Public surface of `@docmost/git-sync`. * Public surface of `@docmost/git-sync`.
* *
@@ -5,12 +6,30 @@
* from docmost-sync. Server integration (GitmostDataSource, orchestrator, * from docmost-sync. Server integration (GitmostDataSource, orchestrator,
* VaultGit, pull/push) is added in later steps. * VaultGit, pull/push) is added in later steps.
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.bodyHash = exports.stabilizePageFile = exports.disambiguate = exports.sanitizeTitle = exports.buildVaultLayout = exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = exports.decideAbsenceDeletions = exports.planReconciliation = exports.docsCanonicallyEqual = exports.canonicalizeContent = exports.markdownToProseMirror = exports.convertProseMirrorToMarkdown = exports.parseDocmostMarkdown = exports.serializeDocmostMarkdownBody = exports.serializeDocmostMarkdown = void 0;
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization). // Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js"; var index_1 = require("./lib/index");
Object.defineProperty(exports, "serializeDocmostMarkdown", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdown; } });
Object.defineProperty(exports, "serializeDocmostMarkdownBody", { enumerable: true, get: function () { return index_1.serializeDocmostMarkdownBody; } });
Object.defineProperty(exports, "parseDocmostMarkdown", { enumerable: true, get: function () { return index_1.parseDocmostMarkdown; } });
Object.defineProperty(exports, "convertProseMirrorToMarkdown", { enumerable: true, get: function () { return index_1.convertProseMirrorToMarkdown; } });
Object.defineProperty(exports, "markdownToProseMirror", { enumerable: true, get: function () { return index_1.markdownToProseMirror; } });
Object.defineProperty(exports, "canonicalizeContent", { enumerable: true, get: function () { return index_1.canonicalizeContent; } });
Object.defineProperty(exports, "docsCanonicallyEqual", { enumerable: true, get: function () { return index_1.docsCanonicallyEqual; } });
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize, // Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
// loop-guard body hash. // loop-guard body hash.
export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile.js"; var reconcile_1 = require("./engine/reconcile");
export { buildVaultLayout } from "./engine/layout.js"; Object.defineProperty(exports, "planReconciliation", { enumerable: true, get: function () { return reconcile_1.planReconciliation; } });
export { sanitizeTitle, disambiguate } from "./engine/sanitize.js"; Object.defineProperty(exports, "decideAbsenceDeletions", { enumerable: true, get: function () { return reconcile_1.decideAbsenceDeletions; } });
export { stabilizePageFile } from "./engine/stabilize.js"; Object.defineProperty(exports, "MASS_DELETE_MIN_EXISTING", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_MIN_EXISTING; } });
export { bodyHash } from "./engine/loop-guard.js"; Object.defineProperty(exports, "MASS_DELETE_FRACTION", { enumerable: true, get: function () { return reconcile_1.MASS_DELETE_FRACTION; } });
var layout_1 = require("./engine/layout");
Object.defineProperty(exports, "buildVaultLayout", { enumerable: true, get: function () { return layout_1.buildVaultLayout; } });
var sanitize_1 = require("./engine/sanitize");
Object.defineProperty(exports, "sanitizeTitle", { enumerable: true, get: function () { return sanitize_1.sanitizeTitle; } });
Object.defineProperty(exports, "disambiguate", { enumerable: true, get: function () { return sanitize_1.disambiguate; } });
var stabilize_1 = require("./engine/stabilize");
Object.defineProperty(exports, "stabilizePageFile", { enumerable: true, get: function () { return stabilize_1.stabilizePageFile; } });
var loop_guard_1 = require("./engine/loop-guard");
Object.defineProperty(exports, "bodyHash", { enumerable: true, get: function () { return loop_guard_1.bodyHash; } });

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* docmost-sync ADDITION (not present in docmost-mcp). * docmost-sync ADDITION (not present in docmost-mcp).
* *
@@ -15,6 +16,9 @@
* This file is intentionally a NEW, self-contained module so it is trivial to * This file is intentionally a NEW, self-contained module so it is trivial to
* backport into docmost-mcp without touching existing code. * backport into docmost-mcp without touching existing code.
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.canonicalizeContent = canonicalizeContent;
exports.docsCanonicallyEqual = docsCanonicallyEqual;
/** /**
* Known NON-NULL schema defaults that `markdownToProseMirror` materializes on * Known NON-NULL schema defaults that `markdownToProseMirror` materializes on
* import, keyed by node/mark type → { attr: defaultValue }. * import, keyed by node/mark type → { attr: defaultValue }.
@@ -137,7 +141,7 @@ function canonicalizeAttrs(attrs, dropId, type) {
* 5. Preserve `text`, `type`, and `content` order exactly. * 5. Preserve `text`, `type`, and `content` order exactly.
* 6. Never mutate the input. * 6. Never mutate the input.
*/ */
export function canonicalizeContent(node) { function canonicalizeContent(node) {
if (Array.isArray(node)) { if (Array.isArray(node)) {
return node.map((child) => canonicalizeContent(child)); return node.map((child) => canonicalizeContent(child));
} }
@@ -243,6 +247,6 @@ function deepEqual(a, b) {
* True when two ProseMirror documents are semantically equal: equal after * True when two ProseMirror documents are semantically equal: equal after
* canonicalization (block ids stripped, absent-vs-default-null normalized). * canonicalization (block ids stripped, absent-vs-default-null normalized).
*/ */
export function docsCanonicallyEqual(a, b) { function docsCanonicallyEqual(a, b) {
return deepEqual(canonicalizeContent(a), canonicalizeContent(b)); return deepEqual(canonicalizeContent(a), canonicalizeContent(b));
} }

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Headless, Docmost-equivalent document diff. * Headless, Docmost-equivalent document diff.
* *
@@ -16,13 +17,15 @@
* If recreateTransform / the changeset throws on a pathological document pair, * 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. * we fall back to a coarse block-level text diff so the tool never hard-fails.
*/ */
import { getSchema } from "@tiptap/core"; Object.defineProperty(exports, "__esModule", { value: true });
import { Node } from "@tiptap/pm/model"; exports.diffDocs = diffDocs;
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; const core_1 = require("@tiptap/core");
import { recreateTransform } from "@fellow/prosemirror-recreate-transform"; const model_1 = require("@tiptap/pm/model");
import { docmostExtensions } from "./docmost-schema.js"; 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. */ /** Build the schema once; it is pure and reused across calls. */
const schema = getSchema(docmostExtensions); const schema = (0, core_1.getSchema)(docmost_schema_1.docmostExtensions);
/** Recursively concatenate the plain text of a JSON node. */ /** Recursively concatenate the plain text of a JSON node. */
function plainText(node) { function plainText(node) {
if (!node || typeof node !== "object") if (!node || typeof node !== "object")
@@ -209,7 +212,7 @@ function renderMarkdown(result, fellBack) {
* @param newDocJson the later document * @param newDocJson the later document
* @param notesHeading heading delimiting body from notes for footnote counting * @param notesHeading heading delimiting body from notes for footnote counting
*/ */
export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") { function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") {
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
let changes = []; let changes = [];
let inserted = 0; let inserted = 0;
@@ -217,15 +220,15 @@ export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примеча
let fellBack = false; let fellBack = false;
const changedBlocks = new Set(); const changedBlocks = new Set();
try { try {
const oldNode = Node.fromJSON(schema, oldDocJson); const oldNode = model_1.Node.fromJSON(schema, oldDocJson);
const newNode = Node.fromJSON(schema, newDocJson); const newNode = model_1.Node.fromJSON(schema, newDocJson);
const tr = recreateTransform(oldNode, newNode, { const tr = (0, prosemirror_recreate_transform_1.recreateTransform)(oldNode, newNode, {
complexSteps: false, complexSteps: false,
wordDiffs: true, wordDiffs: true,
simplifyDiff: true, simplifyDiff: true,
}); });
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []); const changeSet = changeset_1.ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
const simplified = simplifyChanges(changeSet.changes, newNode); const simplified = (0, changeset_1.simplifyChanges)(changeSet.changes, newNode);
for (const change of simplified) { for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA). // Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) { if (change.toA > change.fromA) {

View File

@@ -1,3 +1,9 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.docmostExtensions = exports.sanitizeCssColor = exports.clampCalloutType = void 0;
/** /**
* Full TipTap extension set matching the real Docmost document schema. * Full TipTap extension set matching the real Docmost document schema.
* *
@@ -7,14 +13,14 @@
* to or from ProseMirror JSON must use THIS set, otherwise a round-trip * to or from ProseMirror JSON must use THIS set, otherwise a round-trip
* loses content. * loses content.
*/ */
import StarterKit from "@tiptap/starter-kit"; const starter_kit_1 = __importDefault(require("@tiptap/starter-kit"));
import Image from "@tiptap/extension-image"; const extension_image_1 = __importDefault(require("@tiptap/extension-image"));
import TaskList from "@tiptap/extension-task-list"; const extension_task_list_1 = __importDefault(require("@tiptap/extension-task-list"));
import TaskItem from "@tiptap/extension-task-item"; const extension_task_item_1 = __importDefault(require("@tiptap/extension-task-item"));
import Highlight from "@tiptap/extension-highlight"; const extension_highlight_1 = __importDefault(require("@tiptap/extension-highlight"));
import Subscript from "@tiptap/extension-subscript"; const extension_subscript_1 = __importDefault(require("@tiptap/extension-subscript"));
import Superscript from "@tiptap/extension-superscript"; const extension_superscript_1 = __importDefault(require("@tiptap/extension-superscript"));
import { Node, Extension, Mark } from "@tiptap/core"; const core_1 = require("@tiptap/core");
// Inlined from @tiptap/core's getStyleProperty (added after 3.20.x) so this // 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 // 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 // duplicate-tiptap version split in the monorepo. Reads a single declaration
@@ -41,9 +47,10 @@ function getStyleProperty(element, propertyName) {
} }
/** Allowed Docmost callout types; anything else falls back to "info". */ /** Allowed Docmost callout types; anything else falls back to "info". */
const CALLOUT_TYPES = ["info", "warning", "danger", "success"]; const CALLOUT_TYPES = ["info", "warning", "danger", "success"];
export const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase()) const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value.toLowerCase())
? value.toLowerCase() ? value.toLowerCase()
: "info"; : "info";
exports.clampCalloutType = clampCalloutType;
/** /**
* Allowlist guard for CSS color values imported from HTML. * Allowlist guard for CSS color values imported from HTML.
* *
@@ -61,14 +68,15 @@ export const clampCalloutType = (value) => value && CALLOUT_TYPES.includes(value
* digits, %, ., commas, spaces and slashes * digits, %, ., commas, spaces and slashes
*/ */
const SAFE_COLOR_RE = /^(?:[a-zA-Z]+|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|rgba|hsl|hsla)\([0-9.,%/\s]+\))$/; const SAFE_COLOR_RE = /^(?:[a-zA-Z]+|#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|rgba|hsl|hsla)\([0-9.,%/\s]+\))$/;
export const sanitizeCssColor = (value) => { const sanitizeCssColor = (value) => {
if (typeof value !== "string") if (typeof value !== "string")
return null; return null;
const color = value.trim(); const color = value.trim();
return color && SAFE_COLOR_RE.test(color) ? color : null; return color && SAFE_COLOR_RE.test(color) ? color : null;
}; };
exports.sanitizeCssColor = sanitizeCssColor;
/** Docmost callout (info/warning/danger/success banner). */ /** Docmost callout (info/warning/danger/success banner). */
const Callout = Node.create({ const Callout = core_1.Node.create({
name: "callout", name: "callout",
group: "block", group: "block",
content: "block+", content: "block+",
@@ -79,9 +87,9 @@ const Callout = Node.create({
// it; without an explicit parseHTML every imported callout became "info". // it; without an explicit parseHTML every imported callout became "info".
type: { type: {
default: "info", default: "info",
parseHTML: (el) => clampCalloutType(el.getAttribute("data-callout-type")), parseHTML: (el) => (0, exports.clampCalloutType)(el.getAttribute("data-callout-type")),
renderHTML: (attrs) => ({ renderHTML: (attrs) => ({
"data-callout-type": clampCalloutType(attrs.type), "data-callout-type": (0, exports.clampCalloutType)(attrs.type),
}), }),
}, },
icon: { icon: {
@@ -99,7 +107,7 @@ const Callout = Node.create({
}, },
}); });
/** Minimal table family: enough for schema round-trips and HTML parsing. */ /** Minimal table family: enough for schema round-trips and HTML parsing. */
const Table = Node.create({ const Table = core_1.Node.create({
name: "table", name: "table",
group: "block", group: "block",
content: "tableRow+", content: "tableRow+",
@@ -111,7 +119,7 @@ const Table = Node.create({
return ["table", ["tbody", 0]]; return ["table", ["tbody", 0]];
}, },
}); });
const TableRow = Node.create({ const TableRow = core_1.Node.create({
name: "tableRow", name: "tableRow",
content: "(tableCell | tableHeader)*", content: "(tableCell | tableHeader)*",
parseHTML() { parseHTML() {
@@ -134,7 +142,7 @@ const cellAttributes = () => ({
renderHTML: (attrs) => attrs.align ? { align: attrs.align } : {}, renderHTML: (attrs) => attrs.align ? { align: attrs.align } : {},
}, },
}); });
const TableCell = Node.create({ const TableCell = core_1.Node.create({
name: "tableCell", name: "tableCell",
content: "block+", content: "block+",
isolating: true, isolating: true,
@@ -146,7 +154,7 @@ const TableCell = Node.create({
return ["td", 0]; return ["td", 0];
}, },
}); });
const TableHeader = Node.create({ const TableHeader = core_1.Node.create({
name: "tableHeader", name: "tableHeader",
content: "block+", content: "block+",
isolating: true, isolating: true,
@@ -163,7 +171,7 @@ const TableHeader = Node.create({
* do not declare. Without these, Node.fromJSON silently drops them — * do not declare. Without these, Node.fromJSON silently drops them —
* including the block ids that heading anchors rely on. * including the block ids that heading anchors rely on.
*/ */
const DocmostAttributes = Extension.create({ const DocmostAttributes = core_1.Extension.create({
name: "docmostAttributes", name: "docmostAttributes",
addGlobalAttributes() { addGlobalAttributes() {
return [ return [
@@ -205,7 +213,7 @@ const DocmostAttributes = Extension.create({
* which breaks update_page_json and edit_page_text on every commented page. * which breaks update_page_json and edit_page_text on every commented page.
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved). * Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
*/ */
const Comment = Mark.create({ const Comment = core_1.Mark.create({
name: "comment", name: "comment",
exitable: true, exitable: true,
inclusive: false, inclusive: false,
@@ -238,15 +246,15 @@ const Comment = Mark.create({
* attribute. The parsed color is passed through the allowlist guard so a crafted * attribute. The parsed color is passed through the allowlist guard so a crafted
* style cannot break out of the attribute when Docmost re-renders it. * style cannot break out of the attribute when Docmost re-renders it.
*/ */
const TextStyle = Mark.create({ const TextStyle = core_1.Mark.create({
name: "textStyle", name: "textStyle",
addAttributes() { addAttributes() {
return { return {
color: { color: {
default: null, default: null,
parseHTML: (el) => sanitizeCssColor(el.style.color || el.getAttribute("data-color")), parseHTML: (el) => (0, exports.sanitizeCssColor)(el.style.color || el.getAttribute("data-color")),
renderHTML: (attrs) => { renderHTML: (attrs) => {
const color = sanitizeCssColor(attrs.color); const color = (0, exports.sanitizeCssColor)(attrs.color);
return color ? { style: `color: ${color}` } : {}; return color ? { style: `color: ${color}` } : {};
}, },
}, },
@@ -289,7 +297,7 @@ const TextStyle = Mark.create({
* pattern these follow. * pattern these follow.
*/ */
/** Docmost @mention (user/page reference). Inline atom. */ /** Docmost @mention (user/page reference). Inline atom. */
const Mention = Node.create({ const Mention = core_1.Node.create({
name: "mention", name: "mention",
group: "inline", group: "inline",
inline: true, inline: true,
@@ -343,7 +351,7 @@ const Mention = Node.create({
}, },
}); });
/** Inline KaTeX expression. Carries the LaTeX source in `text`. */ /** Inline KaTeX expression. Carries the LaTeX source in `text`. */
const MathInline = Node.create({ const MathInline = core_1.Node.create({
name: "mathInline", name: "mathInline",
group: "inline", group: "inline",
inline: true, inline: true,
@@ -365,7 +373,7 @@ const MathInline = Node.create({
}, },
}); });
/** Block KaTeX expression. Carries the LaTeX source in `text`. */ /** Block KaTeX expression. Carries the LaTeX source in `text`. */
const MathBlock = Node.create({ const MathBlock = core_1.Node.create({
name: "mathBlock", name: "mathBlock",
group: "block", group: "block",
atom: true, atom: true,
@@ -387,7 +395,7 @@ const MathBlock = Node.create({
}, },
}); });
/** Collapsible <details> wrapper: summary + content children. */ /** Collapsible <details> wrapper: summary + content children. */
const Details = Node.create({ const Details = core_1.Node.create({
name: "details", name: "details",
group: "block", group: "block",
content: "detailsSummary detailsContent", content: "detailsSummary detailsContent",
@@ -410,7 +418,7 @@ const Details = Node.create({
}, },
}); });
/** Clickable summary line of a <details> block. */ /** Clickable summary line of a <details> block. */
const DetailsSummary = Node.create({ const DetailsSummary = core_1.Node.create({
name: "detailsSummary", name: "detailsSummary",
group: "block", group: "block",
content: "inline*", content: "inline*",
@@ -425,7 +433,7 @@ const DetailsSummary = Node.create({
}, },
}); });
/** Body of a <details> block. Permissive content so fromYdoc output validates. */ /** Body of a <details> block. Permissive content so fromYdoc output validates. */
const DetailsContent = Node.create({ const DetailsContent = core_1.Node.create({
name: "detailsContent", name: "detailsContent",
group: "block", group: "block",
// Docmost declares block* (an empty details body is valid); block+ would // Docmost declares block* (an empty details body is valid); block+ would
@@ -441,7 +449,7 @@ const DetailsContent = Node.create({
}, },
}); });
/** File attachment card (non-image upload). Block atom. */ /** File attachment card (non-image upload). Block atom. */
const Attachment = Node.create({ const Attachment = core_1.Node.create({
name: "attachment", name: "attachment",
group: "block", group: "block",
inline: false, inline: false,
@@ -493,7 +501,7 @@ const Attachment = Node.create({
}, },
}); });
/** Uploaded <video> player. Block atom. */ /** Uploaded <video> player. Block atom. */
const Video = Node.create({ const Video = core_1.Node.create({
name: "video", name: "video",
group: "block", group: "block",
isolating: true, isolating: true,
@@ -564,7 +572,7 @@ const Video = Node.create({
* references this type, so accept it as a generic block atom that preserves * references this type, so accept it as a generic block atom that preserves
* its src so legacy/external documents survive a round-trip. * its src so legacy/external documents survive a round-trip.
*/ */
const Youtube = Node.create({ const Youtube = core_1.Node.create({
name: "youtube", name: "youtube",
group: "block", group: "block",
inline: false, inline: false,
@@ -606,7 +614,7 @@ const Youtube = Node.create({
}, },
}); });
/** Generic embed (provider iframe). Block atom. */ /** Generic embed (provider iframe). Block atom. */
const Embed = Node.create({ const Embed = core_1.Node.create({
name: "embed", name: "embed",
group: "block", group: "block",
inline: false, inline: false,
@@ -713,7 +721,7 @@ const diagramAttributes = () => ({
}, },
}); });
/** draw.io diagram. Block atom (image-backed). */ /** draw.io diagram. Block atom (image-backed). */
const Drawio = Node.create({ const Drawio = core_1.Node.create({
name: "drawio", name: "drawio",
group: "block", group: "block",
inline: false, inline: false,
@@ -730,7 +738,7 @@ const Drawio = Node.create({
}, },
}); });
/** Excalidraw diagram. Block atom (image-backed). */ /** Excalidraw diagram. Block atom (image-backed). */
const Excalidraw = Node.create({ const Excalidraw = core_1.Node.create({
name: "excalidraw", name: "excalidraw",
group: "block", group: "block",
inline: false, inline: false,
@@ -747,7 +755,7 @@ const Excalidraw = Node.create({
}, },
}); });
/** Multi-column layout container holding one or more `column` children. */ /** Multi-column layout container holding one or more `column` children. */
const Columns = Node.create({ const Columns = core_1.Node.create({
name: "columns", name: "columns",
group: "block", group: "block",
content: "column+", content: "column+",
@@ -777,7 +785,7 @@ const Columns = Node.create({
}, },
}); });
/** Single column within a `columns` layout. */ /** Single column within a `columns` layout. */
const Column = Node.create({ const Column = core_1.Node.create({
name: "column", name: "column",
group: "block", group: "block",
content: "block+", content: "block+",
@@ -808,7 +816,7 @@ const Column = Node.create({
* declares no attributes; the markdown-converter has a `case "subpages"`, so * declares no attributes; the markdown-converter has a `case "subpages"`, so
* the read path can emit it and toYdoc must accept it. Block atom. * the read path can emit it and toYdoc must accept it. Block atom.
*/ */
const Subpages = Node.create({ const Subpages = core_1.Node.create({
name: "subpages", name: "subpages",
group: "block", group: "block",
inline: false, inline: false,
@@ -824,7 +832,7 @@ const Subpages = Node.create({
}, },
}); });
/** Uploaded <audio> player. Block atom. Mirrors Docmost audio attrs. */ /** Uploaded <audio> player. Block atom. Mirrors Docmost audio attrs. */
const Audio = Node.create({ const Audio = core_1.Node.create({
name: "audio", name: "audio",
group: "block", group: "block",
inline: false, inline: false,
@@ -864,7 +872,7 @@ const Audio = Node.create({
}, },
}); });
/** Embedded PDF viewer. Block atom. Mirrors Docmost pdf attrs. */ /** Embedded PDF viewer. Block atom. Mirrors Docmost pdf attrs. */
const Pdf = Node.create({ const Pdf = core_1.Node.create({
name: "pdf", name: "pdf",
group: "block", group: "block",
inline: false, inline: false,
@@ -919,7 +927,7 @@ const Pdf = Node.create({
}, },
}); });
/** Page break (print/export divider). Block atom; Docmost declares no attrs. */ /** Page break (print/export divider). Block atom; Docmost declares no attrs. */
const PageBreak = Node.create({ const PageBreak = core_1.Node.create({
name: "pageBreak", name: "pageBreak",
group: "block", group: "block",
inline: false, inline: false,
@@ -939,35 +947,35 @@ const PageBreak = Node.create({
* ProseMirror DOM parser hoists <img> found inside <p> automatically. * ProseMirror DOM parser hoists <img> found inside <p> automatically.
* StarterKit v3 already bundles the link extension, configured here. * StarterKit v3 already bundles the link extension, configured here.
*/ */
export const docmostExtensions = [ exports.docmostExtensions = [
StarterKit.configure({ starter_kit_1.default.configure({
codeBlock: {}, codeBlock: {},
heading: {}, heading: {},
link: { openOnClick: false }, link: { openOnClick: false },
}), }),
Image.configure({ inline: false }), extension_image_1.default.configure({ inline: false }),
TaskList, extension_task_list_1.default,
TaskItem.configure({ nested: true }), extension_task_item_1.default.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into // Highlight stores its color unescaped and Docmost interpolates it into
// style="background-color: ${color}". Wrap the color attribute's parseHTML // style="background-color: ${color}". Wrap the color attribute's parseHTML
// with the same allowlist guard used by textStyle so a crafted import color // with the same allowlist guard used by textStyle so a crafted import color
// cannot break out of the style attribute. Multicolor behavior is preserved. // cannot break out of the style attribute. Multicolor behavior is preserved.
Highlight.extend({ extension_highlight_1.default.extend({
addAttributes() { addAttributes() {
const parent = this.parent?.() ?? {}; const parent = this.parent?.() ?? {};
return { return {
...parent, ...parent,
color: { color: {
...parent.color, ...parent.color,
parseHTML: (el) => sanitizeCssColor(el.getAttribute("data-color") || parseHTML: (el) => (0, exports.sanitizeCssColor)(el.getAttribute("data-color") ||
getStyleProperty(el, "background-color") || getStyleProperty(el, "background-color") ||
el.style.backgroundColor), el.style.backgroundColor),
}, },
}; };
}, },
}).configure({ multicolor: true }), }).configure({ multicolor: true }),
Subscript, extension_subscript_1.default,
Superscript, extension_superscript_1.default,
// StarterKit does not provide a textStyle mark, so register ours; without it // StarterKit does not provide a textStyle mark, so register ours; without it
// generateJSON drops <span style="color: ...">, defeating the color import. // generateJSON drops <span style="color: ...">, defeating the color import.
TextStyle, TextStyle,

View File

@@ -10,8 +10,8 @@
* the upstream package are deliberately NOT vendored (the gitmost server writes * the upstream package are deliberately NOT vendored (the gitmost server writes
* natively — plan §2.2/§2.3). * natively — plan §2.2/§2.3).
*/ */
export { serializeDocmostMarkdown, parseDocmostMarkdown, serializeDocmostMarkdownBody, } from "./markdown-document.js"; export { serializeDocmostMarkdown, parseDocmostMarkdown, serializeDocmostMarkdownBody, } from "./markdown-document";
export type { DocmostMdMeta } from "./markdown-document.js"; export type { DocmostMdMeta } from "./markdown-document";
export { convertProseMirrorToMarkdown } from "./markdown-converter.js"; export { convertProseMirrorToMarkdown } from "./markdown-converter";
export { markdownToProseMirror } from "./markdown-to-prosemirror.js"; export { markdownToProseMirror } from "./markdown-to-prosemirror";
export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize.js"; export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize";

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.docsCanonicallyEqual = exports.canonicalizeContent = exports.markdownToProseMirror = exports.convertProseMirrorToMarkdown = exports.serializeDocmostMarkdownBody = exports.parseDocmostMarkdown = exports.serializeDocmostMarkdown = void 0;
/** /**
* Public surface of the vendored pure converter (the `lib/` half of the * Public surface of the vendored pure converter (the `lib/` half of the
* docmost-sync `docmost-client` package). This barrel re-exports only the * docmost-sync `docmost-client` package). This barrel re-exports only the
@@ -10,7 +13,14 @@
* the upstream package are deliberately NOT vendored (the gitmost server writes * the upstream package are deliberately NOT vendored (the gitmost server writes
* natively — plan §2.2/§2.3). * natively — plan §2.2/§2.3).
*/ */
export { serializeDocmostMarkdown, parseDocmostMarkdown, serializeDocmostMarkdownBody, } from "./markdown-document.js"; var markdown_document_1 = require("./markdown-document");
export { convertProseMirrorToMarkdown } from "./markdown-converter.js"; Object.defineProperty(exports, "serializeDocmostMarkdown", { enumerable: true, get: function () { return markdown_document_1.serializeDocmostMarkdown; } });
export { markdownToProseMirror } from "./markdown-to-prosemirror.js"; Object.defineProperty(exports, "parseDocmostMarkdown", { enumerable: true, get: function () { return markdown_document_1.parseDocmostMarkdown; } });
export { canonicalizeContent, docsCanonicallyEqual, } from "./canonicalize.js"; Object.defineProperty(exports, "serializeDocmostMarkdownBody", { enumerable: true, get: function () { return markdown_document_1.serializeDocmostMarkdownBody; } });
var markdown_converter_1 = require("./markdown-converter");
Object.defineProperty(exports, "convertProseMirrorToMarkdown", { enumerable: true, get: function () { return markdown_converter_1.convertProseMirrorToMarkdown; } });
var markdown_to_prosemirror_1 = require("./markdown-to-prosemirror");
Object.defineProperty(exports, "markdownToProseMirror", { enumerable: true, get: function () { return markdown_to_prosemirror_1.markdownToProseMirror; } });
var canonicalize_1 = require("./canonicalize");
Object.defineProperty(exports, "canonicalizeContent", { enumerable: true, get: function () { return canonicalize_1.canonicalizeContent; } });
Object.defineProperty(exports, "docsCanonicallyEqual", { enumerable: true, get: function () { return canonicalize_1.docsCanonicallyEqual; } });

View File

@@ -1,8 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertProseMirrorToMarkdown = convertProseMirrorToMarkdown;
/** /**
* Convert ProseMirror/TipTap JSON content to Markdown * Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions * Supports all Docmost-specific node types and extensions
*/ */
export function convertProseMirrorToMarkdown(content) { function convertProseMirrorToMarkdown(content) {
if (!content || !content.content) if (!content || !content.content)
return ""; return "";
// Escape a value interpolated into an HTML double-quoted attribute value // Escape a value interpolated into an HTML double-quoted attribute value

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Self-contained Docmost-flavoured Markdown document (custom extensions). * Self-contained Docmost-flavoured Markdown document (custom extensions).
* *
@@ -26,6 +27,10 @@
* comment marks (anchors) embedded in the body are restored. Managing comment * comment marks (anchors) embedded in the body are restored. Managing comment
* records stays with the comment tools/UI. * records stays with the comment tools/UI.
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.serializeDocmostMarkdown = serializeDocmostMarkdown;
exports.parseDocmostMarkdown = parseDocmostMarkdown;
exports.serializeDocmostMarkdownBody = serializeDocmostMarkdownBody;
// Match the leading meta block (allow leading whitespace). Capture group 1 is // Match the leading meta block (allow leading whitespace). Capture group 1 is
// the JSON text between the markers. // the JSON text between the markers.
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/; const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
@@ -39,7 +44,7 @@ const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
* emitted too (with `[]` when there are no comments) so the format stays uniform * emitted too (with `[]` when there are no comments) so the format stays uniform
* and parsing stays simple. * and parsing stays simple.
*/ */
export function serializeDocmostMarkdown(meta, body, comments) { function serializeDocmostMarkdown(meta, body, comments) {
const metaJson = JSON.stringify(meta); const metaJson = JSON.stringify(meta);
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []); const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
const trimmedBody = (body ?? "").trim(); const trimmedBody = (body ?? "").trim();
@@ -55,7 +60,7 @@ export function serializeDocmostMarkdown(meta, body, comments) {
* inside a block that IS present is surfaced as a thrown Error with a clear * inside a block that IS present is surfaced as a thrown Error with a clear
* message. Robust to `\r\n` line endings. * message. Robust to `\r\n` line endings.
*/ */
export function parseDocmostMarkdown(full) { function parseDocmostMarkdown(full) {
// Normalize line endings so the anchored regexes work regardless of CRLF. // Normalize line endings so the anchored regexes work regardless of CRLF.
const normalized = (full ?? "").replace(/\r\n/g, "\n"); const normalized = (full ?? "").replace(/\r\n/g, "\n");
// Extract the leading meta block (start-anchored — already unambiguous). // Extract the leading meta block (start-anchored — already unambiguous).
@@ -114,6 +119,6 @@ export function parseDocmostMarkdown(full) {
* `comments: null` and treats the rest as body), so a file produced here * `comments: null` and treats the rest as body), so a file produced here
* round-trips cleanly through the parser. * round-trips cleanly through the parser.
*/ */
export function serializeDocmostMarkdownBody(meta, body) { function serializeDocmostMarkdownBody(meta, body) {
return `<!-- docmost:meta\n${JSON.stringify(meta)}\n-->\n\n${(body ?? "").trim()}\n`; return `<!-- docmost:meta\n${JSON.stringify(meta)}\n-->\n\n${(body ?? "").trim()}\n`;
} }

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.markdownToProseMirror = markdownToProseMirror;
/** /**
* Pure markdown -> ProseMirror conversion (extracted from docmost-sync's * Pure markdown -> ProseMirror conversion (extracted from docmost-sync's
* `packages/docmost-client/src/lib/collaboration.ts`). * `packages/docmost-client/src/lib/collaboration.ts`).
@@ -9,12 +12,47 @@
* lives in the same upstream file is intentionally NOT vendored — the gitmost * lives in the same upstream file is intentionally NOT vendored — the gitmost
* server writes page bodies natively through the collab gateway (plan §3.3). * server writes page bodies natively through the collab gateway (plan §3.3).
*/ */
import { marked } from "marked"; const html_1 = require("@tiptap/html");
import { generateJSON } from "@tiptap/html"; const jsdom_1 = require("jsdom");
import { JSDOM } from "jsdom"; const docmost_schema_1 = require("./docmost-schema");
import { docmostExtensions } from "./docmost-schema.js"; // `marked` is ESM-only. Under this package's CommonJS build TS would otherwise
// downlevel a literal `import()` to `require()`, which cannot load an ESM-only
// module. Indirect through `Function` so the real dynamic `import()` survives
// compilation and loads ESM from CommonJS at runtime in Node (same trick as
// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts).
const esmImport = new Function("specifier", "return import(specifier)");
// Memoize the in-flight/loaded module so the dynamic import runs at most once.
let markedPromise = null;
/**
* Lazily load the ESM-only `marked` module (cached).
*
* In the built CommonJS package (Node, jest with ts-jest) the `esmImport`
* Function trick performs a real dynamic `import()` of the ESM module. Under
* vitest, however, the transformed module is evaluated without a dynamic-import
* callback, so `new Function('return import(...)')` throws "A dynamic import
* callback was not specified"; there `require('marked')` succeeds because the
* test runner's loader interops ESM. We therefore try the Function import first
* and fall back to `require` so BOTH runtimes resolve `marked` transparently.
*/
async function loadMarked() {
if (!markedPromise) {
markedPromise = esmImport("marked")
.catch(() => {
// Function-trick import is unavailable (e.g. under vitest's evaluator):
// fall back to require, which the test runner can interop for ESM.
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("marked");
})
.catch((err) => {
// Do not cache a rejected import — allow the next call to retry.
markedPromise = null;
throw err;
});
}
return (await markedPromise).marked;
}
// Setup DOM environment for Tiptap HTML parsing in Node.js // Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>"); const dom = new jsdom_1.JSDOM("<!DOCTYPE html><html><body></body></html>");
global.window = dom.window; global.window = dom.window;
global.document = dom.window.document; global.document = dom.window.document;
// @ts-ignore // @ts-ignore
@@ -56,6 +94,7 @@ async function preprocessCallouts(markdown) {
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) { if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown; return markdown;
} }
const marked = await loadMarked();
// Recursively transform a slice of lines, converting top-level callouts in // Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may // that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function. // itself contain nested callouts) through this same function.
@@ -186,7 +225,7 @@ function bridgeTaskLists(html) {
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) { if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return html; return html;
} }
const dom = new JSDOM(html); const dom = new jsdom_1.JSDOM(html);
const document = dom.window.document; const document = dom.window.document;
// Collect the checkbox(es) that belong to THIS <li> directly: either direct // Collect the checkbox(es) that belong to THIS <li> directly: either direct
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p> // child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
@@ -264,9 +303,10 @@ function bridgeTaskLists(html) {
return document.body.innerHTML; return document.body.innerHTML;
} }
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */ /** Convert markdown to a ProseMirror doc using the full Docmost schema. */
export async function markdownToProseMirror(markdownContent) { async function markdownToProseMirror(markdownContent) {
const marked = await loadMarked();
const withCallouts = await preprocessCallouts(markdownContent); const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts); const html = await marked.parse(withCallouts);
const bridged = bridgeTaskLists(html); const bridged = bridgeTaskLists(html);
return generateJSON(bridged, docmostExtensions); return (0, html_1.generateJSON)(bridged, docmost_schema_1.docmostExtensions);
} }

View File

@@ -1,3 +1,4 @@
"use strict";
/** /**
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document * Pure, network-free helpers for manipulating a ProseMirror/TipTap document
* tree by node id. * tree by node id.
@@ -13,6 +14,19 @@
* never mutated. All functions are defensively null-safe: missing/!Array * never mutated. All functions are defensively null-safe: missing/!Array
* `content`, non-object nodes, and absent `attrs` are tolerated. * `content`, non-object nodes, and absent `attrs` are tolerated.
*/ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.blockPlainText = blockPlainText;
exports.buildOutline = buildOutline;
exports.getNodeByRef = getNodeByRef;
exports.replaceNodeById = replaceNodeById;
exports.deleteNodeById = deleteNodeById;
exports.sanitizeForYjs = sanitizeForYjs;
exports.findUnstorableAttr = findUnstorableAttr;
exports.insertNodeRelative = insertNodeRelative;
exports.readTable = readTable;
exports.insertTableRow = insertTableRow;
exports.deleteTableRow = deleteTableRow;
exports.updateTableCell = updateTableCell;
/** Deep-clone a JSON-serializable value without mutating the original. */ /** Deep-clone a JSON-serializable value without mutating the original. */
function clone(value) { function clone(value) {
if (typeof structuredClone === "function") { if (typeof structuredClone === "function") {
@@ -36,7 +50,7 @@ function matchesId(node, nodeId) {
* joined `blockPlainText` of their `content` children. Returns "" for nullish * joined `blockPlainText` of their `content` children. Returns "" for nullish
* or non-object inputs. * or non-object inputs.
*/ */
export function blockPlainText(node) { function blockPlainText(node) {
if (!isObject(node)) if (!isObject(node))
return ""; return "";
let out = ""; let out = "";
@@ -66,7 +80,7 @@ function truncate(text, n) {
* `firstText` is the block's plain text truncated to 100 chars. Null-safe: * `firstText` is the block's plain text truncated to 100 chars. Null-safe:
* a missing or non-object doc/content yields `[]`. * a missing or non-object doc/content yields `[]`.
*/ */
export function buildOutline(doc) { function buildOutline(doc) {
if (!isObject(doc) || !Array.isArray(doc.content)) if (!isObject(doc) || !Array.isArray(doc.content))
return []; return [];
const out = []; const out = [];
@@ -109,7 +123,7 @@ export function buildOutline(doc) {
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE, * (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
* so callers can mutate it without touching the input doc. Null-safe. * so callers can mutate it without touching the input doc. Null-safe.
*/ */
export function getNodeByRef(doc, ref) { function getNodeByRef(doc, ref) {
if (!isObject(doc)) if (!isObject(doc))
return null; return null;
// "#<n>": index into the top-level content array. // "#<n>": index into the top-level content array.
@@ -149,7 +163,7 @@ export function getNodeByRef(doc, ref) {
* is the number of nodes substituted. A fresh clone of `newNode` is used for * is the number of nodes substituted. A fresh clone of `newNode` is used for
* each match so they do not share references. * each match so they do not share references.
*/ */
export function replaceNodeById(doc, nodeId, newNode) { function replaceNodeById(doc, nodeId, newNode) {
const out = clone(doc); const out = clone(doc);
let replaced = 0; let replaced = 0;
// Walk a content array, replacing direct matches and recursing into the // Walk a content array, replacing direct matches and recursing into the
@@ -180,7 +194,7 @@ export function replaceNodeById(doc, nodeId, newNode) {
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is * Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
* the number of nodes removed. * the number of nodes removed.
*/ */
export function deleteNodeById(doc, nodeId) { function deleteNodeById(doc, nodeId) {
const out = clone(doc); const out = clone(doc);
let deleted = 0; let deleted = 0;
// Filter a content array in place, dropping matches and recursing into the // Filter a content array in place, dropping matches and recursing into the
@@ -214,7 +228,7 @@ export function deleteNodeById(doc, nodeId) {
* returns it; the input is never mutated. Defensively null-safe like the rest * returns it; the input is never mutated. Defensively null-safe like the rest
* of the file. * of the file.
*/ */
export function sanitizeForYjs(doc) { function sanitizeForYjs(doc) {
const out = clone(doc); const out = clone(doc);
// Drop every key whose value is strictly `undefined` from an attrs object. // Drop every key whose value is strictly `undefined` from an attrs object.
const stripUndefined = (attrs) => { const stripUndefined = (attrs) => {
@@ -252,7 +266,7 @@ export function sanitizeForYjs(doc) {
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when * (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
* every attribute is storable. Null-safe. * every attribute is storable. Null-safe.
*/ */
export function findUnstorableAttr(doc) { function findUnstorableAttr(doc) {
const isUnstorable = (value) => { const isUnstorable = (value) => {
if (value === undefined) if (value === undefined)
return "undefined"; return "undefined";
@@ -384,7 +398,7 @@ function findAnchorChain(doc, opts) {
* false when the anchor could not be resolved (the doc is returned unchanged * false when the anchor could not be resolved (the doc is returned unchanged
* apart from being cloned). * apart from being cloned).
*/ */
export function insertNodeRelative(doc, node, opts) { function insertNodeRelative(doc, node, opts) {
const out = clone(doc); const out = clone(doc);
const fresh = clone(node); const fresh = clone(node);
// Defensive: stay null-safe like the other exports — a missing opts means // Defensive: stay null-safe like the other exports — a missing opts means
@@ -605,7 +619,7 @@ function makeCellParagraph(id, text) {
* so callers can `patch_node` a cell for rich-formatted edits. * so callers can `patch_node` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc. * - `path`: index path of the table within the doc.
*/ */
export function readTable(doc, tableRef) { function readTable(doc, tableRef) {
const root = clone(doc); const root = clone(doc);
const located = locateTable(root, tableRef); const located = locateTable(root, tableRef);
if (located == null) if (located == null)
@@ -645,7 +659,7 @@ export function readTable(doc, tableRef) {
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices * `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
* the row there; otherwise the row is appended at the end. * the row there; otherwise the row is appended at the end.
*/ */
export function insertTableRow(doc, tableRef, cells, index) { function insertTableRow(doc, tableRef, cells, index) {
const out = clone(doc); const out = clone(doc);
const located = locateTable(out, tableRef); const located = locateTable(out, tableRef);
if (located == null) if (located == null)
@@ -706,7 +720,7 @@ export function insertTableRow(doc, tableRef, cells, index) {
* `deleted` is false only when the table cannot be located. Throws on an * `deleted` is false only when the table cannot be located. Throws on an
* out-of-range index, and refuses to delete the table's only row. * out-of-range index, and refuses to delete the table's only row.
*/ */
export function deleteTableRow(doc, tableRef, index) { function deleteTableRow(doc, tableRef, index) {
const out = clone(doc); const out = clone(doc);
const located = locateTable(out, tableRef); const located = locateTable(out, tableRef);
if (located == null) if (located == null)
@@ -732,7 +746,7 @@ export function deleteTableRow(doc, tableRef, index) {
* that reuses the cell's existing first-paragraph id when present, else a fresh * that reuses the cell's existing first-paragraph id when present, else a fresh
* one. * one.
*/ */
export function updateTableCell(doc, tableRef, row, col, text) { function updateTableCell(doc, tableRef, row, col, text) {
const out = clone(doc); const out = clone(doc);
const located = locateTable(out, tableRef); const located = locateTable(out, tableRef);
if (located == null) if (located == null)

View File

@@ -1 +1 @@
{"version":"4.1.6","results":[[":test/node-ops.test.ts",{"duration":72.75441799999993,"failed":false}],[":test/markdown-converter.test.ts",{"duration":40.04070800000005,"failed":false}],[":test/diff.test.ts",{"duration":45.133106999999995,"failed":false}],[":test/node-ops-extra.test.ts",{"duration":71.94072099999994,"failed":false}],[":test/reconcile.test.ts",{"duration":21.84252799999996,"failed":false}],[":test/canonicalize.test.ts",{"duration":16.461569999999938,"failed":false}],[":test/markdown-roundtrip.property.test.ts",{"duration":8485.791874999999,"failed":false}],[":test/stabilize.test.ts",{"duration":149.45158199999992,"failed":false}],[":test/canonicalize-extra.test.ts",{"duration":143.13797799999998,"failed":false}],[":test/loop-guard.test.ts",{"duration":8.612892999999985,"failed":false}],[":test/markdown-document.test.ts",{"duration":9.541832999999997,"failed":false}],[":test/sanitize.test.ts",{"duration":19.523975000000007,"failed":false}],[":test/markdown-converter-golden.test.ts",{"duration":16.99206300000003,"failed":false}],[":test/roundtrip-corpus.test.ts",{"duration":284.4818779999998,"failed":false}],[":test/layout.test.ts",{"duration":19.557320000000004,"failed":false}],[":test/markdown-document-envelope.test.ts",{"duration":17.526741999999956,"failed":false}],[":test/roundtrip.test.ts",{"duration":91.81696899999997,"failed":false}]]} {"version":"4.1.6","results":[[":test/node-ops.test.ts",{"duration":55.050974999999994,"failed":false}],[":test/markdown-converter.test.ts",{"duration":34.13685399999997,"failed":false}],[":test/diff.test.ts",{"duration":49.63132299999995,"failed":false}],[":test/node-ops-extra.test.ts",{"duration":49.444255,"failed":false}],[":test/reconcile.test.ts",{"duration":14.958778999999993,"failed":false}],[":test/canonicalize.test.ts",{"duration":15.778381999999965,"failed":false}],[":test/markdown-roundtrip.property.test.ts",{"duration":8319.439738000001,"failed":false}],[":test/stabilize.test.ts",{"duration":150.99475900000016,"failed":false}],[":test/canonicalize-extra.test.ts",{"duration":155.9570060000001,"failed":false}],[":test/loop-guard.test.ts",{"duration":8.774560999999949,"failed":false}],[":test/markdown-document.test.ts",{"duration":8.800682999999935,"failed":false}],[":test/sanitize.test.ts",{"duration":13.741441000000009,"failed":false}],[":test/markdown-converter-golden.test.ts",{"duration":16.489082999999994,"failed":false}],[":test/roundtrip-corpus.test.ts",{"duration":298.84454900000014,"failed":false}],[":test/layout.test.ts",{"duration":13.172171000000048,"failed":false}],[":test/markdown-document-envelope.test.ts",{"duration":17.021231,"failed":false}],[":test/roundtrip.test.ts",{"duration":97.44139999999993,"failed":false}]]}

View File

@@ -3,11 +3,13 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Vendored pure converter + pure sync engine for the Docmost <-> git Markdown sync (Phase A). See docs/git-sync-plan.md.", "description": "Vendored pure converter + pure sync engine for the Docmost <-> git Markdown sync (Phase A). See docs/git-sync-plan.md.",
"private": true, "private": true,
"type": "module",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./build/index.d.ts", "types": "./build/index.d.ts",
"exports": { "exports": {
".": "./build/index.js" ".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -10,7 +10,7 @@
* lives in each file's meta block (pageId / slugId). * lives in each file's meta block (pageId / slugId).
*/ */
import { sanitizeTitle, disambiguate } from "./sanitize.js"; import { sanitizeTitle, disambiguate } from "./sanitize";
/** Flat page node as returned by `listAllSpacePages` (no content). */ /** Flat page node as returned by `listAllSpacePages` (no content). */
export interface PageNode { export interface PageNode {

View File

@@ -17,7 +17,7 @@ import {
markdownToProseMirror, markdownToProseMirror,
serializeDocmostMarkdownBody, serializeDocmostMarkdownBody,
type DocmostMdMeta, type DocmostMdMeta,
} from "../lib/index.js"; } from "../lib/index";
/** /**
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte * Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte

View File

@@ -15,8 +15,8 @@ export {
markdownToProseMirror, markdownToProseMirror,
canonicalizeContent, canonicalizeContent,
docsCanonicallyEqual, docsCanonicallyEqual,
} from "./lib/index.js"; } from "./lib/index";
export type { DocmostMdMeta } from "./lib/index.js"; export type { DocmostMdMeta } from "./lib/index";
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize, // Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
// loop-guard body hash. // loop-guard body hash.
@@ -25,7 +25,7 @@ export {
decideAbsenceDeletions, decideAbsenceDeletions,
MASS_DELETE_MIN_EXISTING, MASS_DELETE_MIN_EXISTING,
MASS_DELETE_FRACTION, MASS_DELETE_FRACTION,
} from "./engine/reconcile.js"; } from "./engine/reconcile";
export type { export type {
LiveEntry, LiveEntry,
ExistingEntry, ExistingEntry,
@@ -33,14 +33,14 @@ export type {
MovedEntry, MovedEntry,
ReconciliationPlan, ReconciliationPlan,
DeletionDecision, DeletionDecision,
} from "./engine/reconcile.js"; } from "./engine/reconcile";
export { buildVaultLayout } from "./engine/layout.js"; export { buildVaultLayout } from "./engine/layout";
export type { PageNode, VaultEntry } from "./engine/layout.js"; export type { PageNode, VaultEntry } from "./engine/layout";
export { sanitizeTitle, disambiguate } from "./engine/sanitize.js"; export { sanitizeTitle, disambiguate } from "./engine/sanitize";
export { stabilizePageFile } from "./engine/stabilize.js"; export { stabilizePageFile } from "./engine/stabilize";
export type { PageMeta } from "./engine/stabilize.js"; export type { PageMeta } from "./engine/stabilize";
export { bodyHash } from "./engine/loop-guard.js"; export { bodyHash } from "./engine/loop-guard";

View File

@@ -21,7 +21,7 @@ import { getSchema } from "@tiptap/core";
import { Node } from "@tiptap/pm/model"; import { Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { recreateTransform } from "@fellow/prosemirror-recreate-transform"; import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
import { docmostExtensions } from "./docmost-schema.js"; import { docmostExtensions } from "./docmost-schema";
/** A single inserted/deleted change with its containing-block context. */ /** A single inserted/deleted change with its containing-block context. */
export interface DiffChange { export interface DiffChange {

View File

@@ -14,14 +14,14 @@ export {
serializeDocmostMarkdown, serializeDocmostMarkdown,
parseDocmostMarkdown, parseDocmostMarkdown,
serializeDocmostMarkdownBody, serializeDocmostMarkdownBody,
} from "./markdown-document.js"; } from "./markdown-document";
export type { DocmostMdMeta } from "./markdown-document.js"; export type { DocmostMdMeta } from "./markdown-document";
export { convertProseMirrorToMarkdown } from "./markdown-converter.js"; export { convertProseMirrorToMarkdown } from "./markdown-converter";
export { markdownToProseMirror } from "./markdown-to-prosemirror.js"; export { markdownToProseMirror } from "./markdown-to-prosemirror";
export { export {
canonicalizeContent, canonicalizeContent,
docsCanonicallyEqual, docsCanonicallyEqual,
} from "./canonicalize.js"; } from "./canonicalize";

View File

@@ -9,10 +9,59 @@
* lives in the same upstream file is intentionally NOT vendored — the gitmost * lives in the same upstream file is intentionally NOT vendored — the gitmost
* server writes page bodies natively through the collab gateway (plan §3.3). * server writes page bodies natively through the collab gateway (plan §3.3).
*/ */
import { marked } from "marked";
import { generateJSON } from "@tiptap/html"; import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { docmostExtensions } from "./docmost-schema.js"; import { docmostExtensions } from "./docmost-schema";
/**
* Structural type for the bits of the `marked` ESM module we use: just the
* `marked` named export's `parse` method (markdown -> HTML string).
*/
interface MarkedModule {
marked: { parse(markdown: string): string | Promise<string> };
}
// `marked` is ESM-only. Under this package's CommonJS build TS would otherwise
// downlevel a literal `import()` to `require()`, which cannot load an ESM-only
// module. Indirect through `Function` so the real dynamic `import()` survives
// compilation and loads ESM from CommonJS at runtime in Node (same trick as
// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts).
const esmImport = new Function(
"specifier",
"return import(specifier)",
) as (specifier: string) => Promise<unknown>;
// Memoize the in-flight/loaded module so the dynamic import runs at most once.
let markedPromise: Promise<MarkedModule> | null = null;
/**
* Lazily load the ESM-only `marked` module (cached).
*
* In the built CommonJS package (Node, jest with ts-jest) the `esmImport`
* Function trick performs a real dynamic `import()` of the ESM module. Under
* vitest, however, the transformed module is evaluated without a dynamic-import
* callback, so `new Function('return import(...)')` throws "A dynamic import
* callback was not specified"; there `require('marked')` succeeds because the
* test runner's loader interops ESM. We therefore try the Function import first
* and fall back to `require` so BOTH runtimes resolve `marked` transparently.
*/
async function loadMarked(): Promise<MarkedModule["marked"]> {
if (!markedPromise) {
markedPromise = (esmImport("marked") as Promise<MarkedModule>)
.catch(() => {
// Function-trick import is unavailable (e.g. under vitest's evaluator):
// fall back to require, which the test runner can interop for ESM.
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("marked") as MarkedModule;
})
.catch((err) => {
// Do not cache a rejected import — allow the next call to retry.
markedPromise = null;
throw err;
});
}
return (await markedPromise).marked;
}
// Setup DOM environment for Tiptap HTML parsing in Node.js // Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>"); const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
@@ -61,6 +110,8 @@ async function preprocessCallouts(markdown: string): Promise<string> {
return markdown; return markdown;
} }
const marked = await loadMarked();
// Recursively transform a slice of lines, converting top-level callouts in // Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may // that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function. // itself contain nested callouts) through this same function.
@@ -290,6 +341,7 @@ function bridgeTaskLists(html: string): string {
export async function markdownToProseMirror( export async function markdownToProseMirror(
markdownContent: string, markdownContent: string,
): Promise<any> { ): Promise<any> {
const marked = await loadMarked();
const withCallouts = await preprocessCallouts(markdownContent); const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts); const html = await marked.parse(withCallouts);
const bridged = bridgeTaskLists(html); const bridged = bridgeTaskLists(html);

View File

@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "Node16", "module": "CommonJS",
"moduleResolution": "Node16", "moduleResolution": "Node",
"outDir": "./build", "outDir": "./build",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,