Compare commits

...

20 Commits

Author SHA1 Message Date
claude code agent 227 832c3cafdf test(mcp): update test-e2e.mjs listComments calls to the {items} shape (#328 review F1)
The listComments Comment[] -> { items, resolvedThreadsHidden } shape change
reached every src/host consumer but not the live-server e2e harness (run via
`node test-e2e.mjs`, not the node --test gate — so the green suite missed it).
The 4 calls now read .items; the post-resolve check passes includeResolved:true
so it still sees the now-resolved root c1 (the default feed hides it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:22:34 +03:00
claude code agent 227 bcd194ee5d feat(mcp): hide resolved-comment anchors + feed from the agent (#328)
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.

Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.

Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.

The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.

Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).

package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).

NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:26:43 +03:00
claude code agent 227 08222345ef fix(prosemirror-markdown): escape canon inline-extension triggers = $ ^ in link/alt text (#333 review F5)
F1 (round 1) wrapped the image alt in escapeLinkText, and that helper also guards
the link-form media captions (attachment/pdf/embed). But its character class
covered only stock CommonMark — NOT the Docmost inline EXTENSIONS this same PR
registers on the marked instance: highlight `==x==` (canon #7), math `$x$`
(canon #6), footnote `^[x]` (canon #2). Their triggers `= $ ^` are not CommonMark
punctuation, so an alt or media filename like `x $A$ y`, `use ==bold==`, `^[fn]`,
or `data $A$.csv` was silently turned into a math/highlight/footnote node on
import — the same class of round-trip data loss F1 closed, reintroduced by this
PR's own canon.

Fix: add `= $ ^` to the escapeLinkText class (`/[\\`*_~[\]<&!()=$^]/g`). `\= \$ \^`
decode back to literals (all ASCII punctuation) AND, being escape tokens, stop
the extension tokenizer from matching — verified lossless byte-stable round-trip.
Updated the helper comment to name the two trigger sets (CommonMark + Docmost
inline extensions). Extended the adversarial round-trip tests: image alt gains
`x $A$ y` / `5$ and 10$` / `use ==bold==` / `^[fn]` / `cost $5 == price`; pdf name
gains `data $A$.csv` / `q3 ==final==.pdf` / `5$ and 10$.pdf` / `note ^[x].pdf` —
all byte-stable with the node intact, so the hole can't reopen.

package vitest: 658 passed; tsc clean. git-sync: 268. mcp: 454.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:46:30 +03:00
claude code agent 227 1a7b817250 fix(prosemirror-markdown): escape image alt + consolidate schema sanitizers + tidy (#333 review F1-F4)
F1 [critical, data-loss] — escape the image alt in `![alt](src)`. Canon #4 moved
the top-level image off the lossless <img> form onto markdown `![alt](src)`, but
the alt was inserted raw; the importer re-parses the `![alt]` label as CommonMark
inline, so a markdown-active char in a realistic description ("Figure [1]", "the
*new* logo", "a]b[c") broke the round-trip — the image node vanished or emphasis
collapsed. Now `escapeLinkText(imgAttrs.alt ?? "")`, exactly as the link-form
media (attachment/pdf/embed) already escape their visible text. Regression test
added: six active-punctuation alts round-trip byte-stable with the node intact.

F2 [drift] — re-export `clampCalloutType` / `sanitizeCssColor` from the package
barrel and drop the verbatim copies in the mcp schema shim. The copies had
already drifted (the mcp `clampCalloutType` lost the callout-type alias mapping
the package applies), which is exactly the schema drift #293 exists to kill. The
sanitizers now live only in the package; mcp `schema.test.mjs` exercises the
single alias-aware implementation.

F3 [docs] — AGENTS.md:296 said `packages/mcp/build/` is committed; this branch
gitignored it (git-sync/prosemirror-markdown convention). Updated the line to say
it is gitignored and rebuilt in CI/Docker via `pnpm build`.

F4 [cleanup] — removed the dead `test.typecheck` block from the package
vitest.config.ts and deleted tsconfig.vitest.json. Both were copied verbatim from
git-sync; this package has zero `*.test-d.ts` files, and the ported comments
referenced git-sync-only entities. Kept the `docmost-client` resolve alias
(22 tests use it) and the runtime include/environment.

package vitest: 658 passed (+1 F1 regression); tsc clean. git-sync: 268 passed.
mcp: node --test 454 passed; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:17:46 +03:00
claude code agent 227 124f5a45a2 refactor(mcp): consume @docmost/prosemirror-markdown, drop the drifted converter copy (#293/#326 step 5)
mcp had its OWN drifted copy of the converter (markdown-converter.ts ~900 lines,
docmost-schema.ts ~1270 lines, markdown-document.ts) — older than the shared
package, missing the git-sync fixes AND the #293 canon. This switches mcp's
converter CORE to @docmost/prosemirror-markdown, so mcp jumps straight to the
canonical format and the drift-generating second copy is gone.

- markdown-converter.ts / markdown-document.ts / docmost-schema.ts become thin
  re-export shims of the package (convertProseMirrorToMarkdown, the docmost:meta
  envelope, docmostExtensions + docmostSchema=getSchema(docmostExtensions)). The
  mcp-only helpers clampCalloutType/sanitizeCssColor are preserved verbatim in
  the schema shim (the package doesn't expose them via its barrel). ~2170 lines
  of the drifted converter/schema bodies deleted.
- collaboration.ts drops its own ~360-line marked pipeline (preprocessCallouts,
  bridgeTaskLists, extractFootnotes, the footnoteRef extension) and re-points to
  the package's markdownToProseMirror, keeping markdownToProseMirrorCanonical and
  all the yjs/collab write glue. footnote-lex/analyze doc comments updated (they
  now describe advisory legacy-syntax diagnostics, not an importer).

Schema parity verified: the package schema is a strict SUPERSET of mcp's old
schema — every node and attr mcp declared is present (the package only adds
status/pageEmbed/transclusion*/subpages.recursive/etc.), so nothing is silently
dropped on the switch. The switch actually FIXES two pre-existing mcp data-loss
bugs its own tests documented: htmlEmbed and pageBreak now round-trip (were
dropped by the old mcp converter).

Footnotes: the package assembles inline ^[body] footnotes on import (sequential
fn-N ids, identical bodies merged), so mcp's canonicalizeFootnotes is now an
idempotent no-op after it (verified). Legacy reference footnotes [^id]/[^id]:
are inert literal text (canon #2 no-backward-compat) — lossless, the text
survives verbatim.

Build hygiene: packages/mcp/build/ is now gitignored and untracked, matching the
git-sync/prosemirror-markdown convention (private package, rebuilt in CI/Docker,
so src and prod can never silently diverge). This also removes a dead untracked
build/_vendored_editor_ext/ artifact that a broad `git add` would otherwise
commit.

Dependency: packages/mcp/package.json gains @docmost/prosemirror-markdown
(workspace:*); pnpm-lock.yaml gets the matching link importer (mirrors git-sync).

mcp tests updated deliberately to the canonical forms (highlight ==, math $…$,
image ![](src)<!--img-->, drawio/media discriminators, subpages/pageBreak
comments, textAlign, inline ^[…] footnotes) with strict assertions; 4 structural
safety-net round-trip tests added.

mcp: node --test 454 passed; tsc clean. package: 657 passed. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:16:09 +03:00
claude code agent 227 b751852425 fix(prosemirror-markdown): converter inventory bugs — spoiler/link-title in raw-HTML, contract test, codeCombined dead code (#293)
The four bugs found during the #293 HTML-emission inventory, fixed in the package:

1. Spoiler mark was silently lost in the raw-HTML path: inlineToHtml (columns /
   spanned cells) had no `case "spoiler"`, so spoilered text there dropped the
   mark on round-trip. Now emits `<span data-spoiler="true">` — the same form the
   top-level serializer uses and exactly what the schema's Spoiler mark parses.

2. Link `title` was dropped in the raw-HTML path: inlineToHtml's link case
   emitted `<a href>` without the title. The schema's link mark carries a
   `title` global attr (DocmostAttributes), so a titled link inside a column now
   round-trips via `<a href … title=…>`.

3. Serializer contract test: emoji/date/toc were flagged as possibly caseless
   inline atoms. Verified they exist in NEITHER the package schema NOR
   editor-ext, so no node handling is needed today. Added
   serializer-contract.test.ts, which derives every node type from the live
   schema (getSchema(docmostExtensions)) and asserts each has an explicit
   serializer `case` — all 45 current node types are covered and present, and a
   future node added without a case will fail this test loudly.

4. codeCombined dead code: `const codeCombined = false` was hardcoded, so every
   `codeCombined ? <html> : <markdown>` ternary always took the markdown branch.
   Removed the variable and the dead HTML-alternative branches (bold/italic/code/
   link/strike). Pure cleanup — output is byte-identical (goldens + full suite
   pass unchanged). The `hasCode` early-return (code excludes other marks) stays.

Tests: spoiler-inside-column and link-title-inside-column round-trips, the
serializer contract test + inline-atom non-empty behavioral checks.

package vitest: 657 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:37:35 +03:00
claude code agent 227 65d81f745a feat(prosemirror-markdown): inline footnotes ^[text] (#293 canon #2)
Footnotes now use the single canonical Pandoc/Obsidian inline form: the note
body is written AT the reference as `^[body]`, and the separate
`<section data-footnotes>` list is NOT emitted in markdown — it is reassembled
on import. New shared module src/lib/footnote.ts.

Serialize (markdown-converter.ts): a top-of-convert pre-scan builds
Map<id, definition> from the footnotesList; a footnoteReference emits
`^[<rendered body>]` (body paragraphs joined by a literal `\n`, real
backslash-n written `\\n`, stray unbalanced `[`/`]` escaped via balanceBrackets
while a balanced `[link](url)` stays intact); footnotesList/footnoteDefinition
emit nothing; an ORPHAN definition (no ref) is appended at doc end as its own
`^[body]` line so bodies are never lost (intentional, documented). The raw-HTML
path (inlineToHtml, columns) emits `<sup data-footnote-ref data-fn-text="…">`,
carrying the text at the ref there too; blockToHtml keeps the schema
`<section>`/`<div>` form for a list nested in a column.

Parse (markdown-to-prosemirror.ts): a `^[…]` inline extension on the dedicated
marked instance BALANCES brackets with a depth counter (respecting `\`-escapes),
so `^[note [a] b]` captures the full content, unbalanced `^[` fails open to
literal text. A post-marked assembleFootnotes pass collects every
`<sup data-fn-text>`, dedups by the EXACT body string, assigns sequential ids
(fn-1, fn-2, … first-seen), builds one `<div data-footnote-def>` per unique body
in a single `<section data-footnotes>`, and strips data-fn-text. No hash is used
(F1): dedup keying on the exact text makes an id collision between DIFFERENT
bodies impossible, while identical bodies still merge; ids are never written to
markdown, so round-trips stay byte-stable, and all id assignment is local to the
one call (race-free).

Correctness hardening from internal review:
- F2: raw user backslashes in a footnote body are doubled (`\`->`\\`) at text
  emission (via a per-conversion inFootnoteBody closure flag) BEFORE the
  serializer's own escapes (`\[ \] \= \$`) are layered on, so a body ending in
  `\` (Windows path, LaTeX, regex) no longer breaks the `^[…]` envelope and
  round-trips exactly; parseInline decodes `\\`->`\`. The old `\n`->`\\n` step is
  subsumed by this and removed.
- N1: assembleFootnotes runs to a FIXED POINT — parseInline of a def body can
  spawn a nested `<sup data-fn-text>` (a legal nested footnote `^[a ^[b] c]`),
  so the section is attached before the loop (querySelectorAll only sees
  attached nodes) and the scan repeats until no pending sup remains; the dedup
  map persists across rounds. Nested and 3+-level footnotes now round-trip
  byte-stably instead of silently dropping the inner body. Bounded by
  MAX_FOOTNOTE_ROUNDS as a fail-open safety net.
- N2: the id counter is seeded past the highest existing fn-<N> so a reused
  section's ids can never collide with generated ones.
- A literal `^[` in prose text is escaped `^\[` so it does not become a phantom
  footnote on re-import (codeBlock/inline-code excluded).

No backward compat: reference form `[^id]`/`[^id]: def` is not parsed (stays
literal). No existing golden asserted the old footnote HTML output.

Tests: new footnote.test.ts (22 cases: basic byte-stable round-trip, bracket
balancing, multi-paragraph `\n`, real backslash-n, dedup both directions,
NESTED + 3-level nest, F1 hash-collision pair surviving as distinct defs, F2
backslash bodies byte-stable, N2 id-seed, column data-fn-text form, orphan def,
no-backward-compat, literal-`^[` prose, fail-open, empty `^[]`).

package vitest: 607 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:31:00 +03:00
claude code agent 227 bfbd927866 feat(prosemirror-markdown): math as $…$ / $$…$$ (#293 canon #6)
mathInline serializes as `$LaTeX$` and mathBlock as an own-line `$$\n<latex>\n$$`
fence (multi-line safe), closing hand-authoring gap A18. The LaTeX still lives in
node.attrs.text; a literal `$` inside it is escaped `\$`. On the raw-HTML path
(columns/cells) math keeps the schema-HTML `<span data-type="mathInline">` /
`<div data-type="mathBlock">` form (markdown is not re-parsed inside raw HTML) —
blockToHtml gets an explicit mathBlock case and inlineToHtml a mathInline case,
sharing the mathInlineHtml/mathBlockHtml helpers with the fallbacks so the two
forms cannot drift.

Parse: mathInlineExtension (inline) + mathBlockExtension (block) are added to the
SAME dedicated marked instance introduced for canon #7 (global singleton
untouched). The inline extension uses a currency-safe PANDOC rule: an opening `$`
must not be followed by whitespace, and the closing `$` must not be preceded by
whitespace nor followed by a digit — so `$5`, `$5 and $10`, `a $5 b $6 c`, `100$`
stay literal text while `$x^2$` is math. The block extension matches a `$$` fence
line and captures multi-line LaTeX non-greedily up to the next `$$` line.

The pandoc boundary rule lives ONCE in the new math-inline.ts
(INLINE_MATH_SOURCE) and is shared by the import tokenizer (^-anchored) and the
export prose escaper (global), so parse and serialize cannot disagree about what
is math. escapeProseMath (case "text", non-code runs only) escapes ONLY the two
delimiting `$` of a span the rule WOULD match, so a would-be-math prose span like
`the set $A$` re-imports as literal text while currency `$5 and $10` is emitted
CLEAN (zero backslash churn). marked decodes `\$`→`$` on re-parse, byte-stable.

Fallbacks to the lossless schema-HTML form (all documented + tested):
mathInline → <span> when empty / whitespace-edged / multi-line / pre-existing
`\$` / trailing `\` / immediately before a digit-text sibling (renderInlineChildren
guard, so `$…$5` can't lose the node); mathBlock → <div> when the LaTeX contains
`$$`. Each fallback round-trips losslessly and byte-stably.

Code safety (guards the canon #7 regression class): codeBlock reads raw child
text and inline `code` runs are excluded from escapeProseMath, so `$5`/`$x$` in
code stay literal with no math and no backslash corruption. ReDoS-checked on
adversarial 40k-char inputs (0–1 ms).

Tests: new math.test.ts (26 cases: serialize exactness, multi-line block, `\$`
escaping, currency ×5 asserting no `\$`, prose escape, columns schema-HTML,
inline-code/codeBlock safety, fail-open). Goldens in roundtrip / markdown-converter
flipped top-level math to `$…$`/`$$…$$`; the escapeAttr-idempotence golden wraps
math in a column (still exercises escapeAttr); columns/raw-HTML math assertions
unchanged.

package vitest: 585 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:37:37 +03:00
claude code agent 227 77f5224b55 feat(prosemirror-markdown): highlight without color as ==text== (#293 canon #7)
A `highlight` mark WITHOUT a color now serializes as the Obsidian/GFM `==text==`
syntax (closing hand-authoring gap A19); a highlight WITH a color keeps the
`<mark style="background-color: …">` HTML form (condition is deterministic on
the color attr). On the raw-HTML path (columns/spanned cells) BOTH forms stay
`<mark>` via inlineToHtml — markdown is not re-parsed inside a raw-HTML block.

Parse: `==` is not standard markdown, so the importer uses a DEDICATED marked
instance (`new Marked().use({extensions:[highlightMark]})`) rather than the
global singleton — registered once, never leaks `==` behavior to other callers.
The inline extension tokenizes `==text==` (non-empty, non-space-leading inner,
lazy so `==a== ==b==` is two marks; inner re-tokenized so nested marks survive;
`====`/`==x` fail-open to literal) into `<mark>` with no color, which the schema
parses as a color-less highlight. Inline code (`` `a == b` ``) stays code via
marked token precedence. marked 17 defaults (gfm:true, breaks:false) are
identical for the fresh instance, so tables/strike/autolinks are unaffected.

Losslessness: a LITERAL `==` in a text run would otherwise be misparsed as a
highlight on the next import, so `case "text"` backslash-escapes each `=` of a
`==` pair (marked decodes `\=` back to `=`), and this round-trips byte-stably.
The escape does NOT run for inline-code runs, and — CRITICALLY — codeBlock now
reads its child text RAW (schema `content: "text*"`) instead of routing through
`case "text"`: marked does not decode `\=` inside a fence, so escaping there
would permanently stamp backslashes into any `==` comparison (ubiquitous in
source code) and corrupt the block on the git-sync data path.

Tests: new highlight.test.ts (19 cases incl. serialize forms, colored vs plain,
column `<mark>` path, nested marks, inline-code exclusion, literal-`==` escape,
fail-open, AND a codeBlock-with-`==` regression proving no backslash corruption
+ byte-stable round-trip). Golden inline-mark matrix flipped top-level no-color
highlight to `==m==`; the kept `<mark style=…>` assertions are the colored/
raw-HTML cases.

package vitest: 559 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:12:18 +03:00
claude code agent 227 e2a3b5fc4d feat(prosemirror-markdown): media family as md-form + discriminator comment (#293 canon #8)
Ten media/embed node types move their TOP-LEVEL serialization off raw schema
HTML onto a readable markdown target plus an always-emitted discriminator
comment whose NAME selects the node type. The schema-HTML form is retained on
the raw-HTML/columns path (comments are dropped by the DOM parse stage there).

  image-form  ![](src)<!--name …-->   youtube, video, audio, drawio, excalidraw
  link-form   [text](src)<!--name …--> pdf, attachment, embed (text=filename/provider)
  standalone  <!--pageembed …--> / <!--transclusion …-->  pageEmbed, transclusionReference

The comment NAME is the node-type discriminator and is ALWAYS emitted, even when
the attr JSON is empty (`![](u)<!--youtube-->`), so a bare `![](u)` is never
mistaken for an `image` and a bare `[t](u)` stays a plain link — no URL-sniffing.
src rides in the markdown target; every other non-default attr (incl. the id
links attachmentId/sourcePageId/transclusionId) rides in the comment JSON
(stable key order, numerics stringified, align="center" omitted).

New src/lib/media-html.ts: byte-exact builders reproducing the schema HTML each
old processNode case returned. Both the serializer's raw-HTML path (blockToHtml,
now de-delegated from `return processNode(block)` to explicit per-type cases)
and the importer call these, so serialize and parse cannot drift.

Import (applyCommentDirectives): image-form binds the preceding <img> (src from
it), link-form the preceding <a> (src=href, text=filename/provider), standalone
replaces the comment (same leading-doc-level handling as #5). Each rebuilds the
schema element via the media-html builder, then swaps it in; the empty-<p> hoist
is absorbed by stripEmptyParagraphs. Fail-open: wrong element/position/name or
malformed JSON -> inert, no throw.

Link-form visible text is escaped (escapeLinkText) for the FULL set of
CommonMark inline-active punctuation (\ ` * _ ~ [ ] < & ! ( )), not just [ ] \:
the label is parsed as inline content, so a filename/provider like
`report *v2*.pdf` or `![shot](x).pdf` would otherwise lose the markup (or
fragment the parse) when the importer reads a.textContent back — a data-loss
regression vs the old data-attachment-name form. Adversarial round-trip fixtures
lock byte- and value-stability for emphasis/code/strike/autolink/entity/image
markers and nested-link names.

Tests: new media-comments.test.ts (40 cases: per-type exact md + lossless
byte-stable round-trip incl. id links, minimal-node discriminator-still-emitted,
in-column schema-HTML form, discriminator integrity, fail-open, active-punct
filenames). Goldens in media-roundtrip / markdown-converter-golden /
markdown-converter / diagram-roundtrip updated to the md+comment form (columns
stay schema-HTML). The former known-limitation image-diagrams fixture is now
byte- AND canonically-stable (canon #8 omits the diagram align="center" default)
and was promoted from an it.fails into the green corpus (11-image-diagrams.json).
git-sync stabilize.test.ts: the "diagram materializes data-align=center" fixpoint
moved into a column (where the raw-HTML asymmetry still holds), since top level
is now byte-stable.

package vitest: 540 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:52:02 +03:00
claude code agent 227 d7d8db2102 feat(prosemirror-markdown): images as ![alt](src) + attached img-comment (#293 canon #4)
Every image now serializes as `![alt](src)`; non-default layout/identity attrs
that markdown cannot express ride along in an attached `<!--img {…}-->` comment
on the same line, replacing the prior "image-with-attrs -> raw <img>" split for
the top-level path:
  ![схема](/s.png) <!--img {"width":"420","align":"left","attachmentId":"…"}-->

Keys (emitted only when non-default, stable order): width, height, align, size,
aspectRatio, attachmentId, caption, title. Numeric sizing attrs are stringified
in the payload (the import side reads DOM attributes back as strings), so a
numeric `width:420` round-trips byte-stably instead of churning `420 -> "420"`.
attachedCommentFor defuses any `--` in a value (e.g. a caption containing the
comment-closing `-->`) so the payload can never close the comment early.

Align default unified to "center" (#293 canon #4): editor-ext declares
image.align default "center" while this package's schema declared null — keeping
null would make the clean `![](src)` form dead code (every editor image is
"center"). Now the schema default is "center" (docmost-schema image align, with
explicit parseHTML/renderHTML), canonicalize KNOWN_DEFAULTS drops align=="center"
for image, and the serializer omits align when it is null OR "center". A null
align collapses to "center" on re-import (a null align is not a distinct editor
state) — stable, no ping-pong. Only left/right emit a comment.

Import: applyCommentDirectives gains an `img` handler that targets the comment's
previousElementSibling <img> and writes each decoded key to the DOM attribute
the schema reads (align, width, height, data-size, data-aspect-ratio,
data-attachment-id, data-caption, title), then removes the comment. Attached
only: a standalone `<!--img-->` with no adjacent image is inert. Fail-open on
malformed JSON / unknown keys.

Raw-HTML path unchanged in spirit: images inside columns/cells keep the
`<img …>` form (comments are dropped by the DOM parse stage); imageToHtml now
omits a redundant align="center" to match the unified default.

Tests: new image-comment.test.ts (21 cases incl. caption == `-->`, numeric-size
byte-stability, image-in-column <img> form, fail-open). Goldens updated
deliberately: markdown-roundtrip-spoiler-caption (captioned image -> comment
form), markdown-converter-gaps spec 14/15 (title now round-trips via comment;
column image drops redundant align), canonicalize-extra (center+null dropped,
left kept).

package vitest: 498 passed | 1 expected-fail; tsc clean. git-sync (rebuilt
build): 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:16:28 +03:00
claude code agent 227 e814bca243 feat(prosemirror-markdown): subpages/pageBreak as standalone comments (#293 canon #5)
Move the two "invisible machinery" atoms off the <div data-type="..."> HTML
form onto standalone HTML comments on their own line, keeping the markdown
human-readable while still round-tripping:
  subpages  -> <!--subpages-->  /  <!--subpages {"recursive":true}-->
  pageBreak -> <!--pagebreak-->

Adds standaloneCommentFor(name, attrs?) to attached-comment.ts (emits
`<!--name-->` when attrs are empty/absent, else `<!--name {compact-json}-->`).
The `--`-escaping + compact-JSON logic is factored into a shared internal
escapeCommentJson() so standaloneCommentFor and attachedCommentFor cannot drift
(verified byte-identical output for attachedCommentFor — no #9 regression).

Position determines legality (canon #5): subpages/pagebreak are honored ONLY
standalone; the same comment attached after visible text is inert. The parser
pass (applyAttachedComments renamed applyCommentDirectives) now also
materializes these standalone comments into the schema `<div data-type=...>`
element before generateJSON drops the comment node. A LEADING standalone
comment is parsed at document level (outside <body>); the pass walks the whole
document and re-inserts leading comments into <body> in document order, so
block order is preserved.

Raw-HTML path: blockToHtml gains explicit subpages/pageBreak cases emitting the
`<div data-type=...>` form. Comments are dropped by the DOM parse stage inside
columns/cells, so the div-form must stay there — this also fixes a latent
default-fallthrough (`<div></div>`) that silently dropped these atoms inside a
column.

Tests: new machinery-comments.test.ts (primitive, subpages default/recursive
exact strings + round-trip, pageBreak, subpages-inside-column div-form,
fail-open for attached-position/malformed, and multi-node document-order
regression locking the leading/mid/trailing comment ordering). Top-level
goldens in markdown-converter-golden/gaps updated deliberately to the comment
form; the columns/raw-HTML goldens keep the div-form.

package vitest: 477 passed | 1 expected-fail; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:56:40 +03:00
claude code agent 227 f1ab76e879 feat(prosemirror-markdown): serialize textAlign as attached comment (#293 canon #9)
Move paragraph/heading textAlign off the HTML-wrapper form
(<p style="text-align:…"> / <hN style=…>) onto a trailing attached HTML
comment on the block line: `text <!--attrs {"textAlign":"center"}-->`. This
keeps the readable markdown block form (plain `text` / `## Title`) while
preserving alignment losslessly. "left"/null stay bare (no churn).

Adds a reusable attached-comment primitive (attached-comment.ts) that #4
(image) and #8 (media) will reuse:
- attachedCommentFor(name, json) -> `<!--name {compact-json}-->`, escaping any
  `--` pair inside the JSON as -- so the payload can never close the
  comment early;
- parseAttachedComment(data) with grammar `^\s*([A-Za-z][\w-]*)(?:\s+({…}))?\s*$`
  whose name excludes `:`, so envelope comments (docmost:meta / docmost:comments)
  never match — fail-open on anything malformed.

On import, applyAttachedComments runs AFTER marked.parse but BEFORE generateJSON
(parse5 drops comments), re-expressing the attrs comment as an inline
text-align style on the parent block, then removing the comment node.

Guards: emit only when there is a visible element to attach to — paragraph
requires non-empty text, heading requires non-empty headingText (symmetry:
an empty aligned heading stays bare `##`, no orphan comment).

Goldens in markdown-converter-golden/gaps updated deliberately to the
attached-comment form (assertions stay strict: exact output + lossless
round-trip). New textalign.test.ts (19 tests) covers center/right/justify on
paragraph and heading, byte-stable re-export, and fail-open branches.

Raw-HTML containers (columns/cells/callout via blockToHtml) keep the inline
text-align form intentionally — comments are dropped inside raw HTML.

package vitest: 462 passed | 1 expected-fail; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:39:46 +03:00
claude code agent 227 6dcc19ce59 refactor(git-sync): consume @docmost/prosemirror-markdown, drop the duplicate lib (#293 stage 3 / no-op)
git-sync's converter-core (src/lib) was a byte-identical duplicate of the new
@docmost/prosemirror-markdown package (created in the previous commit). Switch
git-sync to consume the package and delete its copy — ending the duplication
that the whole #293 effort targets. Pure no-op: NO format/behavior change.

- git-sync depends on @docmost/prosemirror-markdown (workspace:*); engine
  (stabilize/push/pull) + src/index barrel + 12 engine tests re-point their
  converter imports to the package.
- Delete git-sync/src/lib (8 files) and the 23 duplicate converter-core test
  files + their fixtures — the converter and its ~440 tests now live once, in the
  package. git-sync keeps only its ENGINE tests, which exercise the converter
  through the package (the no-op proof). Kept roundtrip-helpers.ts (an engine
  test imports firstDivergence from it; pure helper, no double-run).
- Added docmostExtensions to the package barrel (a kept engine schema-validity
  test needs it).

Verified: editor-ext + prosemirror-markdown + git-sync all tsc EXIT 0;
git-sync vitest 28 files, 268 passed, 0 failures (engine cycle/roundtrip/push/
pull/reconcile green = no-op proof); prosemirror-markdown vitest still 443 passed
| 1 expected-fail; pnpm --frozen-lockfile EXIT 0; no ../lib refs remain in git-sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:19:29 +03:00
claude code agent 227 d6d7dd82f6 feat(prosemirror-markdown): new headless converter package seeded from git-sync (#293 stage 1)
Create @docmost/prosemirror-markdown — the single framework-free ProseMirror<->
Markdown converter + schema mirror that git-sync and mcp will both consume,
ending the three-hand-synced-copies drift (#293). This step only CREATES the
package (no consumer yet; git-sync untouched); the switch of git-sync and mcp
onto it, plus the canonical format decisions, come in later commits of this PR.

- packages/prosemirror-markdown/src/lib/: the 8 converter-core files copied
  VERBATIM from packages/git-sync/src/lib (docmost-schema, markdown-converter,
  markdown-to-prosemirror, canonicalize, markdown-document, node-ops, page-file,
  index). Confirmed byte-identical — no behavioral drift introduced.
- src/index.ts barrel; package.json (@tiptap/* + jsdom/marked/zod, editor-ext
  workspace devDep for the contract test); tsconfig/vitest configs.
- 24 converter-core test files + fixtures copied (engine-coupled layout/
  redteam-layout-title tests correctly excluded — they import ../src/engine).
- pnpm-lock importer added; build/ gitignored (CI-built).

Verified (clean checkout, no network): pnpm --frozen-lockfile EXIT 0; tsc EXIT 0;
vitest 23 files, 443 passed | 1 expected-fail (the same image-diagrams
known-limitation carried from git-sync) — faithful extraction. git-sync untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:10:04 +03:00
vvzvlad f5d19f9728 Merge pull request 'build(git-sync): пакет @docmost/git-sync в develop, code-only (#326 step 1 / PR-A)' (#327) from feat/293-A-git-sync-package into develop
Reviewed-on: #327
2026-07-04 07:02:25 +03:00
agent_vscode 351615e5bc prompt(mcp): fix inaccurate and misleading tool descriptions
Audit of all 41 tool descriptions against the actual implementation found
factually wrong or misleading texts:

- list_comments claimed '(paginated)' — it takes only pageId and returns ALL
  comments in one call (internal pagination); now also states that RESOLVED
  threads are included and how to filter them. In-app twin synced.
- search claimed the limit default is 'applied by the client' — the client
  deliberately omits it so the SERVER applies its default.
- create_page's '(automatically moves it to the correct hierarchy)' said
  nothing useful — now documents parentPageId nesting semantics; move_page
  drops the stale 'essential for organizing pages created via create_page'.
- share_page now warns the page becomes accessible to ANYONE with the URL.
- get_page (both transports) now explains inline <span data-comment-id> tags
  are comment anchors (incl. resolved) — markup, not page text.
- patch_node/delete_node/insert_node pointed only at the expensive page-JSON
  view for block ids — now route through the cheap page outline first.
- docmost_transform marks 'Примечания переводчика' as the DEFAULT
  notesHeading, overridable for non-Russian pages.

Checks: @docmost/mcp tests 450/450 (incl. the server-instructions guard);
server ai-chat-tools spec 20/20; mcp build/ artifacts rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:00:16 +03:00
agent_vscode 1fda0ec8b0 prompt(mcp): rewrite SERVER_INSTRUCTIONS to cover all tools + guard test
The intent-routing guide had rotted: 17 of 41 registered tools were absent
(get_outline, get_node, the whole table_* family, search, stash_page, sharing,
page lifecycle), and two tips were actively harmful — 'read block ids via
get_page_json' told agents to pull the whole ~100KB document when get_outline
exists precisely to grab ids cheaply, and 'table cell -> patch_node by
attrs.id' dead-ends because table nodes carry no attrs.id.

- Rewrite SERVER_INSTRUCTIONS as intent clusters (READ / EDIT / PAGES /
  COMMENTS / HISTORY) covering every tool except get_workspace; add safety
  notes (share_page = PUBLIC, delete_page = soft) and a comment-anchor
  markup warning for get_page.
- delete_page tool description: state SOFT delete / restorable explicitly.
- MAINTENANCE RULE comments at both registration sites (index.ts,
  tool-specs.ts) + an AGENTS.md convention bullet: adding/renaming/removing
  a tool REQUIRES updating the guide.
- New guard test (test/unit/server-instructions.test.mjs): extracts every
  registered tool name from source and fails when one is not mentioned in
  the shipped SERVER_INSTRUCTIONS (word-boundary match, so get_page can't
  hide behind get_page_json); EXCEPTIONS list is itself validated against
  the registry. SERVER_INSTRUCTIONS exported for the test.

Tests: @docmost/mcp 450/450 (448 + 2 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:51:01 +03:00
agent_vscode 2637640291 prompt(agents): drop the role-name label prefix from comments (#315)
Chat now shows the agent's name in the comment header, so the '[Copyedit]' /
'[Structure]' / '[Style]' / '[Facts]' prefix each role prepended just
duplicated the visible author.

- Remove the 'open the comment with the label [Role]' instruction from all four
  labelled roles (structural-editor, line-editor, fact-checker, proofreader),
  ru + en; the narrator was already label-free.
- Severity tags ([Critical]/[Major]/[Minor]) and the fact-checker's verdicts
  ([Incorrect]/[Unverified]/…) are kept — they carry meaning, not the role name.
- Versions bumped: structural-editor 3->4, line-editor 3->4, fact-checker 4->5,
  proofreader 6->7; content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 06:08:12 +03:00
agent_vscode aa0428e28b prompt(agents): make the copyeditor exhaustive in one pass (#315)
The copyeditor had to be re-run several times to surface all issues: it has no
'work the whole document' instruction (unlike the developmental editor and the
narrator), and the severity labels nudge it toward reporting only the salient
few.

- Add a HOW TO WORK section (ru + en): one pass over the whole text start to
  finish; flag EVERY violation including all repeat occurrences and [Minor]
  items; don't summarize instead of marking up; one run covers the whole text,
  not just 'the most important'.
- proofreader version 5 -> 6, content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:50:39 +03:00
136 changed files with 6722 additions and 15397 deletions
+10 -1
View File
@@ -4,12 +4,21 @@
data
# compiled output
/dist
node_modules/
node_modules
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
# so src/ and prod can never silently diverge).
packages/git-sync/build/
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
# never committed, so src/ and prod can never silently diverge).
packages/prosemirror-markdown/build/
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
# markdown convention; the package is private and rebuilt at deploy.
packages/mcp/build/
# Logs
logs
*.log
+1
View File
@@ -293,6 +293,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
## CI / release
@@ -34,7 +34,7 @@ roles:
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
HOW TO LEAVE COMMENTS
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
- [Minor] — an optional improvement to framing or flow.
@@ -87,7 +87,7 @@ roles:
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
- [Critical] — the sentence is unclear or distorts the meaning.
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
- [Minor] — a stylistic improvement to taste.
@@ -128,7 +128,7 @@ roles:
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
- [Major] — a doubtful or unconfirmed claim that needs a source.
- [Minor] — a small correction, or false precision worth rounding or confirming.
@@ -168,8 +168,11 @@ roles:
- Don't verify facts — that's the Fact-checker.
- Don't make substantive changes. Edits are minimal and mechanical.
HOW TO WORK
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important".
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
- [Critical] — a grammar/spelling error or typo visible to the reader.
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
- [Minor] — optional polish.
@@ -34,7 +34,7 @@ roles:
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
- [Незначительно] — улучшение подачи или стройности, не обязательное.
@@ -87,7 +87,7 @@ roles:
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
- [Критично] — предложение непонятно или искажает смысл.
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
- [Незначительно] — стилистическое улучшение на вкус.
@@ -128,7 +128,7 @@ roles:
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
@@ -169,8 +169,11 @@ roles:
- Не проверяешь достоверность фактов — это фактчекер.
- Не вносишь содержательных изменений. Правки — минимальные и механические.
КАК РАБОТАТЬ
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное».
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
- [Незначительно] — необязательная шлифовка.
+5 -5
View File
@@ -12,13 +12,13 @@ bundles:
- en
roles:
- slug: structural-editor
version: 3
- slug: line-editor
version: 3
- slug: fact-checker
version: 4
- slug: proofreader
- slug: line-editor
version: 4
- slug: fact-checker
version: 5
- slug: proofreader
version: 7
- slug: narrator
version: 2
- id: research
@@ -1,26 +1,26 @@
{
"fact-checker": {
"version": 4,
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
"version": 5,
"hash": "d7769872968109a1ccfb58d71bc3f3564a750b91766156f59031762848de4f24"
},
"line-editor": {
"version": 3,
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
"version": 4,
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
},
"narrator": {
"version": 2,
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
},
"proofreader": {
"version": 5,
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
"version": 7,
"hash": "fdf8e0a443fa3c4102095e024146401363629a3f9015fb938c7bac2642825e56"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 3,
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
"version": 4,
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
}
}
@@ -303,7 +303,9 @@ export class AiChatToolsService {
getPage: tool({
description:
'Fetch a single page as Markdown by its page id. Returns the page ' +
'title and its Markdown content.',
'title and its Markdown content. Inline <span data-comment-id> tags ' +
'in the markdown are comment highlight anchors (also present for ' +
'RESOLVED threads) — treat them as markup, not page text.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id (or slugId) of the page.'),
}),
@@ -647,11 +649,21 @@ export class AiChatToolsService {
listComments: tool({
description:
'List all comments on a page (content as Markdown).',
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
'threads are returned; resolved threads (a resolved top-level ' +
'comment and all its replies) are hidden and their count reported ' +
'as `resolvedThreadsHidden` so you can re-query with ' +
'`includeResolved: true` to see everything. Returns ' +
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
includeResolved: z
.boolean()
.optional()
.describe('default only active threads; true — include resolved'),
}),
execute: async ({ pageId }) => await client.listComments(pageId),
execute: async ({ pageId, includeResolved }) =>
await client.listComments(pageId, includeResolved),
}),
getComment: tool({
@@ -56,7 +56,12 @@ export interface DocmostClientLike {
getPageJson(pageId: string): Promise<Record<string, unknown>>;
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
listComments(pageId: string): Promise<unknown[]>;
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
// false) hides resolved threads wholesale; pass true for the full feed.
listComments(
pageId: string,
includeResolved?: boolean,
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
getComment(
commentId: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
+1
View File
@@ -20,6 +20,7 @@
},
"license": "MIT",
"dependencies": {
"@docmost/prosemirror-markdown": "workspace:*",
"@tiptap/core": "3.20.4",
"@tiptap/extension-highlight": "3.20.4",
"@tiptap/extension-image": "3.20.4",
+1 -1
View File
@@ -31,7 +31,7 @@
*/
import { dirname } from "node:path";
import { sep } from "node:path";
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
import { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
import type { GitSyncClient } from "./client.types.js";
import { buildVaultLayout, type PageNode } from "./layout.js";
import {
+5 -2
View File
@@ -26,8 +26,11 @@
* the gitmost server drives the engine in-process (there is no standalone CLI
* entry point).
*/
import { type DocmostMdMeta } from "../lib/index.js";
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
import {
type DocmostMdMeta,
parsePageFile,
serializePageFile,
} from "@docmost/prosemirror-markdown";
import type { GitSyncClient } from "./client.types.js";
import type { DiffEntry } from "./git.js";
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
+1 -1
View File
@@ -17,7 +17,7 @@ import {
markdownToProseMirror,
serializeDocmostMarkdownBody,
type DocmostMdMeta,
} from "../lib/index.js";
} from "@docmost/prosemirror-markdown";
/**
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
+7 -3
View File
@@ -8,6 +8,10 @@
*/
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
// Re-exported from the standalone `@docmost/prosemirror-markdown` package,
// which is the single source of truth for the converter core; git-sync keeps
// only the engine (vault/git/orchestrator) and re-surfaces the converter for
// in-process consumers of the git-sync barrel.
export {
serializeDocmostMarkdown,
serializeDocmostMarkdownBody,
@@ -16,8 +20,8 @@ export {
markdownToProseMirror,
canonicalizeContent,
docsCanonicallyEqual,
} from "./lib/index.js";
export type { DocmostMdMeta } from "./lib/index.js";
} from "@docmost/prosemirror-markdown";
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
// loop-guard body hash.
@@ -123,4 +127,4 @@ export {
} from "./engine/path-guard.js";
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
export { parsePageFile, serializePageFile } from "./lib/page-file.js";
export { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
File diff suppressed because it is too large Load Diff
@@ -1,365 +0,0 @@
/**
* Pure markdown -> ProseMirror conversion.
*
* The converter path is `markdownToProseMirror` (marked -> HTML ->
* generateJSON) plus the two pre/post processors it needs (`preprocessCallouts`,
* `bridgeTaskLists`). The gitmost server writes the resulting page bodies
* natively through the collab gateway, so no websocket/Yjs write-path lives
* here.
*/
import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom";
import { marked } from "marked";
import { docmostExtensions } from "./docmost-schema.js";
// Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
global.window = dom.window as any;
global.document = dom.window.document;
// @ts-ignore
global.Element = dom.window.Element;
/**
* Hard ceiling above which we skip callout preprocessing entirely. The linear
* scanner below has no quadratic blow-up, but we still cap input defensively so
* a pathological multi-megabyte payload cannot tie up the event loop; in that
* case the markdown is passed through verbatim (callouts are simply not
* detected) rather than risking a slow scan.
*/
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
/** Matches a bare closing callout fence: `:::`. */
const CALLOUT_CLOSE_RE = /^:::\s*$/;
/**
* Matches an Obsidian-native callout opener: `> [!type]` (type captured). An
* optional title after the type is allowed but ignored (the Docmost callout
* schema has no title). The body is the following contiguous blockquote lines.
*/
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
/** Matches any blockquote continuation line (`>` … ). */
const BLOCKQUOTE_LINE_RE = /^>/;
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
/**
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
* callout blocks (the syntax our markdown export produces) into HTML
* divs that the callout extension parses. The inner content is rendered
* through marked as regular markdown.
*
* Implemented as a single linear pass over the lines (no quadratic regex
* rescan). It:
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
* `:::` line that lives inside a code fence as a callout delimiter, so a
* callout body that itself contains a fenced code block with a `:::` line is
* no longer corrupted;
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
* nesting level, supporting NESTED callouts via a depth counter (an inner
* `:::type` opens a deeper level and consumes a matching `:::`);
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
* (inner rendered through marked) as the previous regex implementation.
*/
async function preprocessCallouts(markdown: string): Promise<string> {
// Defensive cap: skip preprocessing for pathologically large inputs.
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown;
}
// Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function.
const transform = async (lines: string[]): Promise<string> => {
const out: string[] = [];
let inCodeFence = false;
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Inside a code fence, only its matching closing fence is significant;
// everything else (including `:::` lines) is copied through verbatim.
if (inCodeFence) {
out.push(line);
const fence = line.match(CODE_FENCE_RE);
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
fence[2].length >= codeFenceMarker.length) {
inCodeFence = false;
codeFenceMarker = "";
}
i++;
continue;
}
// A code fence opening outside any callout body: enter code-fence mode.
const fenceOpen = line.match(CODE_FENCE_RE);
if (fenceOpen) {
inCodeFence = true;
codeFenceMarker = fenceOpen[2];
out.push(line);
i++;
continue;
}
// An opening callout fence: scan forward (with code-fence and nested
// callout awareness) for its matching closing `:::` at the same level.
const open = line.match(CALLOUT_OPEN_RE);
if (open) {
const type = open[1].toLowerCase();
const bodyLines: string[] = [];
let depth = 1;
let innerInCodeFence = false;
let innerCodeFenceMarker = "";
let j = i + 1;
for (; j < lines.length; j++) {
const bl = lines[j];
if (innerInCodeFence) {
const f = bl.match(CODE_FENCE_RE);
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
f[2].length >= innerCodeFenceMarker.length) {
innerInCodeFence = false;
innerCodeFenceMarker = "";
}
bodyLines.push(bl);
continue;
}
const innerFence = bl.match(CODE_FENCE_RE);
if (innerFence) {
innerInCodeFence = true;
innerCodeFenceMarker = innerFence[2];
bodyLines.push(bl);
continue;
}
if (CALLOUT_OPEN_RE.test(bl)) {
depth++;
bodyLines.push(bl);
continue;
}
if (CALLOUT_CLOSE_RE.test(bl)) {
depth--;
if (depth === 0) break; // matching close for THIS callout
bodyLines.push(bl);
continue;
}
bodyLines.push(bl);
}
if (j < lines.length) {
// Found the matching closing fence: render the body (recursively, so
// nested callouts are handled) and emit the callout div.
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j + 1; // skip past the closing `:::`
continue;
}
// No matching close (unterminated callout): treat the opener as a
// literal line and continue, preserving the original text.
out.push(line);
i++;
continue;
}
// An Obsidian-native callout: `> [!type]` opener; the body is the following
// CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
// its first line) does not match and stays a blockquote.
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
if (bqOpen) {
const type = bqOpen[1].toLowerCase();
const bodyLines: string[] = [];
let j = i + 1;
for (; j < lines.length; j++) {
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
bodyLines.push(lines[j].replace(/^>\s?/, ""));
}
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j;
continue;
}
out.push(line);
i++;
}
return out.join("\n");
};
return transform(markdown.split("\n"));
}
/**
* Bridge marked's checkbox lists to TipTap task lists.
*
* marked renders GitHub task list items (`- [x] done`) as a plain
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
* into the shape those extensions expect:
* TaskList parseHTML matches `ul[data-type="taskList"]`,
* TaskItem matches `li[data-type="taskItem"]`,
* the checked state is read from `data-checked === "true"`.
*
* A list is only converted when it has at least one `<li>` and EVERY direct
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
*/
function bridgeTaskLists(html: string): string {
// Cheap early-out: if the markup contains no checkbox input at all there is
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
// common case (most pages have no task lists).
if (!/type=["']?checkbox/i.test(html)) {
return html;
}
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
// pathologically large inputs rather than running a second expensive JSDOM
// parse on a multi-megabyte payload. The markup is passed through verbatim.
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return html;
}
const dom = new JSDOM(html);
const document = dom.window.document;
// 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 (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
// bullet <li> that merely contains a nested task sublist is not misdetected.
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
// ALL of them so none survive into the converted item.
const directCheckboxes = (li: Element): Element[] => {
const found: Element[] = [];
for (const child of Array.from(li.children)) {
if (
child.tagName === "INPUT" &&
child.getAttribute("type") === "checkbox"
) {
found.push(child);
continue;
}
if (child.tagName === "P") {
for (const inp of Array.from(
child.querySelectorAll(":scope > input[type='checkbox']"),
)) {
found.push(inp);
}
}
}
return found;
};
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
// its own checkbox is a numbered checklist that must also become a taskList.
const lists = Array.from(document.querySelectorAll("ul, ol"));
for (const list of lists) {
// Only consider DIRECT child <li> elements; nested lists are handled by
// their own iteration of the outer loop.
const items = Array.from(list.children).filter(
(child) => child.tagName === "LI",
);
if (items.length === 0) continue;
const itemCheckboxes = items.map((li) => directCheckboxes(li));
// Convert only when every direct <li> carries at least one OWN checkbox.
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
// <ol> while tagging it data-type="taskList": generateJSON would then match
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
// emitting a phantom empty orderedList beside the real taskList. So rename a
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
let target: Element = list;
if (list.tagName === "OL") {
const ul = document.createElement("ul");
// Carry over existing attributes (e.g. class) so nothing is silently lost.
for (const attr of Array.from(list.attributes)) {
ul.setAttribute(attr.name, attr.value);
}
// Move every child node (including the <li>s we collected) into the <ul>.
while (list.firstChild) {
ul.appendChild(list.firstChild);
}
list.replaceWith(ul);
target = ul;
}
target.setAttribute("data-type", "taskList");
items.forEach((li, index) => {
const boxes = itemCheckboxes[index];
// The first checkbox determines the checked state (matches the previous
// single-checkbox behaviour); any extras only need removing.
const input = boxes[0] ?? null;
li.setAttribute("data-type", "taskItem");
const checked =
input != null &&
(input.hasAttribute("checked") || (input as any).checked);
li.setAttribute("data-checked", checked ? "true" : "false");
// Remove ALL direct checkbox inputs so none survive into the content
// (a raw-inline-HTML <li> may carry more than one).
for (const box of boxes) {
box.remove();
}
});
}
return document.body.innerHTML;
}
/**
* Recursively strip content-less paragraph nodes from a generated doc.
*
* A block-level atom whose markdown form is INLINE (e.g. the block `image`'s
* `![](url)`, or a bare media element) is wrapped by marked in a <p>; the schema
* then HOISTS the block atom out of that paragraph, leaving an EMPTY paragraph
* sibling. On the next export that empty `<p>` renders to "" and the doc "\n\n"
* join injects a phantom blank gap, so the markdown is not byte-stable.
*
* Markdown blank lines are separators, never content, so generateJSON only ever
* produces an empty paragraph as such a hoist artifact removing them is safe
* and general (it also subsumes the <div>-wrapper workaround the `video` case
* uses). We remove ONLY `type === 'paragraph'` nodes whose `content` is absent
* or an empty array; every other node (including atoms without `content`) is
* preserved, and we recurse into the content of any node that has children.
*/
function stripEmptyParagraphs(node: any): any {
if (!node || !Array.isArray(node.content)) {
// Atom / leaf node (no children to recurse into): keep as-is.
return node;
}
const mapped = node.content.map((child: any) => stripEmptyParagraphs(child));
const isEmptyParagraph = (child: any): boolean =>
!!child &&
child.type === "paragraph" &&
(!Array.isArray(child.content) || child.content.length === 0);
const filtered = mapped.filter((child: any) => !isEmptyParagraph(child));
// Schema-validity guard: several nodes require NON-empty block content
// (`content: "block+"` — tableCell, tableHeader, blockquote, column, callout,
// and the doc root). For an empty one of those, generateJSON materializes a
// single empty paragraph as its OBLIGATORY content — that is not a hoist
// artifact. If stripping would empty the container, keep ONE empty paragraph
// so the result stays schema-valid (an empty cell/quote must not become `[]`).
const cleaned =
filtered.length === 0 && mapped.length > 0 ? [mapped[0]] : filtered;
return { ...node, content: cleaned };
}
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
export async function markdownToProseMirror(
markdownContent: string,
): Promise<any> {
const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts);
const bridged = bridgeTaskLists(html);
const doc = generateJSON(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
}
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
import { bodyHash } from '../src/engine/loop-guard';
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
import { parsePageFile, serializePageFile } from '../src/lib/page-file';
import { parsePageFile, serializePageFile } from '@docmost/prosemirror-markdown';
// The Docmost space this vault mirrors (native files carry no spaceId; the run
// supplies it). A CREATE targets this space.
@@ -5,7 +5,7 @@ import type {
MetaSide,
RenameMoveAction,
} from '../src/engine/push';
import type { DocmostMdMeta } from '../src/lib/index';
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { computePushActions } from '../src/engine/push';
import type { DiffEntry, MetaSide } from '../src/engine/push';
import type { DocmostMdMeta } from '../src/lib/index';
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
// half: it classifies each `git diff --name-status` row into a Docmost action by
@@ -8,7 +8,7 @@ import { runCycle } from "../src/engine/cycle";
import type { CycleFs } from "../src/engine/cycle";
import { VaultGit } from "../src/engine/git";
import type { Settings } from "../src/engine/settings";
import { serializeDocmostMarkdownBody } from "../src/lib/index";
import { serializeDocmostMarkdownBody } from "@docmost/prosemirror-markdown";
const execFileAsync = promisify(execFile);
+1 -1
View File
@@ -8,7 +8,7 @@ import { firstDivergence } from './roundtrip-helpers';
import { applyPullActions } from '../src/engine/pull';
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
import type { DeletionDecision } from '../src/engine/reconcile';
import { serializePageFile, parsePageFile } from '../src/lib/page-file';
import { serializePageFile, parsePageFile } from '@docmost/prosemirror-markdown';
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
// report, Module 2 `src/engine`). Each block targets a specific under-covered
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { readExisting } from '../src/engine/pull';
import { serializePageFile } from '../src/lib/page-file';
import { serializePageFile } from '@docmost/prosemirror-markdown';
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
@@ -6,7 +6,7 @@ import type {
MetaSide,
RenameMoveAction,
} from '../src/engine/push.js';
import type { DocmostMdMeta } from '../src/lib/index.js';
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
// RED-TEAM finding #4 (two facets):
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
@@ -8,7 +8,7 @@ import {
import type { PushDeps } from '../src/engine/push';
import type { Settings } from '../src/engine/settings';
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
import { serializePageFile } from '../src/lib/page-file';
import { serializePageFile } from '@docmost/prosemirror-markdown';
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
// behavior, so it FAILS today iff the bug is real.
@@ -1,104 +0,0 @@
import { readFile } from 'node:fs/promises';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
docsCanonicallyEqual,
} from 'docmost-client';
// Resolve fixtures relative to this test file so the test is CWD-independent.
const here = dirname(fileURLToPath(import.meta.url));
const CORPUS_DIR = join(here, 'fixtures', 'corpus');
const KNOWN_LIMITATIONS_DIR = join(here, 'fixtures', 'known-limitations');
/** Run a single document through export -> import -> export. */
async function roundTrip(doc: any) {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, md2, doc2 };
}
describe('round-trip corpus (SPEC §11)', () => {
// Discover the corpus synchronously at collection time so each fixture gets
// its own `it` with the file name in the test title.
const files = readdirSync(CORPUS_DIR)
.filter((name) => name.endsWith('.json'))
.sort();
it('has a non-empty corpus', () => {
expect(files.length).toBeGreaterThan(0);
});
for (const name of files) {
it(`${name}: markdown byte-stable AND canonically stable`, async () => {
const doc = JSON.parse(await readFile(join(CORPUS_DIR, name), 'utf8'));
const { md1, md2, doc2 } = await roundTrip(doc);
// 1) The byte-stable markdown property git actually needs.
expect(md2, `${name}: markdown not byte-stable`).toBe(md1);
// 2) Semantic stability (block ids stripped, default-null normalized).
expect(
docsCanonicallyEqual(doc, doc2),
`${name}: document not canonically stable`,
).toBe(true);
});
}
});
// ---------------------------------------------------------------------------
// KNOWN CONVERTER LIMITATIONS (isolated so they do NOT make CI red).
//
// SPEC §11 explicitly flags images and diagrams as high round-trip risk. These
// fixtures are kept OUT of the green corpus above and asserted with `it.fails`
// so the documented divergence is locked in (the test FAILS if the converter
// ever starts round-tripping them — at which point promote the fixture into
// the corpus). The precise divergences for `image-diagrams.json` are:
//
// * A BLOCK-LEVEL image preceded by a paragraph is NOT byte-stable on the
// FIRST re-export. The HTML re-parser hoists the block <img> out of its
// line and leaves an empty paragraph behind, so `paragraph` + `![..](..)`
// re-imports as paragraph + empty-paragraph + image; the empty paragraph
// adds one blank line, so export #2 grows by a one-time "\n\n" (md1 !== md2).
// This is NOT non-convergence: the growth happens exactly ONCE. The doc
// CONVERGES to a fixpoint after one extra `export→import→export` pass — the
// empty paragraph is already present after the first import, so export #2
// and export #3 are byte-identical (md2 === md3, verified).
//
// * drawio / excalidraw diagrams gain `data-align="center"` on the second
// export: the schema's diagram `align` attribute has a NON-null default of
// "center", which materializes on import; the converter only emits
// data-align when set, so it appears on export #2 but not #1. Like the
// image case, this is one-time and converges after one extra pass.
//
// * A STANDALONE block image (no preceding paragraph) IS byte-stable from
// export #1 (md1 === md2) — but it is still NOT canonically stable: on
// import the bare <img> is wrapped, gaining a leading EMPTY paragraph, so
// the canonical doc differs by that spurious paragraph node even though the
// markdown bytes match.
//
// Resolution (SPEC §11, "normalize-on-write"): rather than deep-fixing the
// converter, the engine runs ONE `export→import→export` pass when writing into
// the vault; from that fixpoint onward the form is byte-stable, so git sees no
// phantom diff. The green corpus above avoids these one-time asymmetries by
// pre-authoring the materialized defaults (e.g. `align: "center"` on the
// diagrams in 06-diagrams.json) so a single pass is already at the fixpoint.
// ---------------------------------------------------------------------------
describe('round-trip KNOWN LIMITATIONS (SPEC §11 image/diagram risk)', () => {
it.fails(
'image-diagrams.json is NOT byte-stable on export #1 (block image hoist + diagram align default; converges after one extra pass — SPEC §11 normalize-on-write)',
async () => {
const doc = JSON.parse(
await readFile(join(KNOWN_LIMITATIONS_DIR, 'image-diagrams.json'), 'utf8'),
);
const { md1, md2 } = await roundTrip(doc);
// This assertion FAILS today (documented divergence). `it.fails` turns a
// failing body into a PASS; if the converter is fixed this flips and the
// test goes red, prompting promotion into the green corpus.
expect(md2).toBe(md1);
},
);
});
@@ -8,7 +8,7 @@ import { runPush, LAST_PUSHED_REF } from '../src/engine/push';
import type { PushDeps } from '../src/engine/push';
import { VaultGit } from '../src/engine/git';
import type { Settings } from '../src/engine/settings';
import { serializeDocmostMarkdownBody } from '../src/lib/index';
import { serializeDocmostMarkdownBody } from '@docmost/prosemirror-markdown';
const execFileAsync = promisify(execFile);
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
import type { PushDeps } from '../src/engine/push';
import type { Settings } from '../src/engine/settings';
import { serializePageFile } from '../src/lib/page-file';
import { serializePageFile } from '@docmost/prosemirror-markdown';
/** A native page file: `gitmost_id` frontmatter + clean body (title = filename). */
function fileFor(pageId: string, body = 'body'): string {
+18 -7
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest';
import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import { parseDocmostMarkdown } from '../src/lib/markdown-document.js';
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
import { parseDocmostMarkdown } from '@docmost/prosemirror-markdown';
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
@@ -22,16 +22,27 @@ const meta: PageMeta = {
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
// A diagram is the canonical one-pass asymmetry: drawio's `align` default of
// "center" materializes on import, so a NAIVE export differs on the second
// export. stabilizePageFile runs the convergence pass at write time, so the
// written body must already be at the fixpoint: re-importing its body and
// A diagram inside a column is the canonical one-pass asymmetry: on the
// raw-HTML/columns path a diagram's `align` default of "center" materializes
// on import, so a NAIVE export differs on the second export. (#293 canon #8
// made the TOP-LEVEL diagram form — `![](src)<!--drawio …-->` — byte-stable by
// omitting the default, so the asymmetry now lives only on the columns path
// where the schema `<div data-type="drawio">` form is retained.)
// stabilizePageFile runs the convergence pass at write time, so the written
// body must already be at the fixpoint: re-importing its body and
// re-stabilizing yields the exact same bytes.
const content = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
{ type: 'drawio', attrs: { src: '/d.drawio' } },
{
type: 'columns',
attrs: { layout: 'two_equal' },
content: [
{ type: 'column', content: [{ type: 'drawio', attrs: { src: '/d.drawio' } }] },
{ type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'side' }] }] },
],
},
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
],
};
@@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import { getSchema } from "@tiptap/core";
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror";
import { docmostExtensions } from "../src/lib/docmost-schema";
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions } from "@docmost/prosemirror-markdown";
// REGRESSION LOCK for the stripEmptyParagraphs schema-validity guard.
//
File diff suppressed because it is too large Load Diff
-133
View File
@@ -1,133 +0,0 @@
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createDocmostMcpServer } from "./index.js";
/**
* Build a stateful Streamable-HTTP handler for the Docmost MCP server. The
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
* `handleRequest`. One McpServer + transport is created per MCP session and
* kept alive between requests, keyed by the `mcp-session-id` header.
*
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
* service account, unchanged) OR a `McpConfigResolver` run once per session at
* `initialize` to bind that session to the request's identity.
*/
export function createMcpHttpHandler(config, options = {}) {
// One transport (and one McpServer) per MCP session, keyed by session id.
const transports = {};
// Last activity timestamp per session id, used for idle eviction.
const lastSeen = {};
// Anti-session-fixation: the opaque identity key bound to each session at
// initialize. A later request for that session whose key differs is rejected.
const sessionIdentity = {};
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
// every early rejection is a well-formed JSON-RPC error, not a torn response.
const sendJsonRpcError = (res, statusCode, code, message) => {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
jsonrpc: "2.0",
error: { code, message },
id: null,
}));
};
// Idle session TTL (ms): a session with no activity for this long is evicted.
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
const idleTtlMs = (() => {
const parsed = parseInt(process.env.MCP_SESSION_IDLE_MS ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000;
})();
// Periodically close transports idle longer than the TTL. transport.close()
// triggers its onclose, which removes it from `transports`; we also drop the
// lastSeen entry. unref() so this timer never keeps the process alive.
const sweepIntervalMs = 5 * 60 * 1000;
const sweepTimer = setInterval(() => {
const now = Date.now();
for (const sid of Object.keys(transports)) {
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
void transports[sid].close();
delete lastSeen[sid];
delete sessionIdentity[sid];
}
}
}, sweepIntervalMs);
sweepTimer.unref();
async function handleRequest(req, res, parsedBody) {
const sessionId = req.headers["mcp-session-id"];
const method = (req.method || "GET").toUpperCase();
let transport = sessionId ? transports[sessionId] : undefined;
if (method === "POST" && !transport) {
// A new session may only be created by an initialize request without a
// session id.
if (sessionId || !isInitializeRequest(parsedBody)) {
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
return;
}
// Resolve the per-session config from the request (per-user identity) when
// a resolver was supplied; otherwise use the static config unchanged. The
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
// a created session.
let sessionConfig;
let identity;
try {
sessionConfig =
typeof config === "function" ? await config(req) : config;
if (options.identify)
identity = await options.identify(req);
}
catch (err) {
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
return;
}
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports[sid] = transport;
lastSeen[sid] = Date.now();
// Bind the resolved identity to the new session id for anti-fixation.
if (identity !== undefined)
sessionIdentity[sid] = identity;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid])
delete transports[sid];
if (sid)
delete sessionIdentity[sid];
};
const server = createDocmostMcpServer(sessionConfig);
await server.connect(transport);
await transport.handleRequest(req, res, parsedBody);
return;
}
if (!transport) {
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
return;
}
// Anti-session-fixation: a request reusing an existing session id must
// present credentials/token that resolve to the SAME identity bound at
// initialize, otherwise reject with 401. This prevents hijacking another
// user's established session by replaying its session id with different
// credentials.
if (options.identify && sessionId && sessionId in sessionIdentity) {
let presented;
try {
presented = await options.identify(req);
}
catch (err) {
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
return;
}
if (presented !== sessionIdentity[sessionId]) {
sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session.");
return;
}
}
// Routing to an existing transport: refresh its idle timestamp.
if (sessionId)
lastSeen[sessionId] = Date.now();
await transport.handleRequest(req, res, parsedBody);
}
return { handleRequest };
}
-782
View File
@@ -1,782 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
import { SHARED_TOOL_SPECS } from "./tool-specs.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
export { DocmostClient } from "./client.js";
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
// service can read it off the loaded module (it cannot import the ESM package's
// internals directly; it goes through loadDocmostMcp()).
export { SHARED_TOOL_SPECS } from "./tool-specs.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
const VERSION = packageJson.version;
// Configuration for an MCP server instance is the DocmostMcpConfig union
// (credentials OR getToken) defined and re-exported above. The factory below is
// fully side-effect-free on import: it reads no environment variables and opens
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
// (http.ts) supply this config and own the process/transport lifecycle.
// --- Modern McpServer Implementation ---
// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
// Helper to format JSON responses
const jsonContent = (data) => ({
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
});
/**
* Create a fully configured Docmost MCP server. Side-effect-free: it does not
* read environment variables and does not connect any transport the caller
* decides how to expose it (stdio or HTTP). The client talks to Docmost over
* REST + the collaboration WebSocket using the provided service-account
* credentials and auto-re-authenticates.
*/
export function createDocmostMcpServer(config) {
// Pass the whole config union through: the client branches internally on
// credentials vs. getToken, so both the external /mcp (creds) and the
// internal per-user (getToken) paths are wired here unchanged.
const docmostClient = new DocmostClient(config);
const server = new McpServer({
name: "docmost-mcp",
version: VERSION,
}, { instructions: SERVER_INSTRUCTIONS });
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
// the canonical name + model-facing description + (optional) schema builder;
// only the execute body is supplied per call. buildShape is invoked with THIS
// package's zod (v3); the in-app layer passes its own zod (v4).
//
// The spec's schema builder returns a plain ZodRawShape (Record<string,
// unknown> in the shared module since it must stay zod-agnostic), so the
// McpServer.registerTool overloads cannot infer the execute arg's shape from
// it. We type `execute` loosely and cast the call through `any`; runtime
// behaviour is unchanged — each execute body destructures the same fields the
// builder declares.
const registerShared = (spec, execute) => server.registerTool(spec.mcpName, spec.buildShape
? { description: spec.description, inputSchema: spec.buildShape(z) }
: { description: spec.description }, execute);
// Tool: get_workspace
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
});
// Tool: list_spaces
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
});
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool("list_pages", {
description: "List most recent pages in a space ordered by updatedAt (descending). " +
"Returns a bounded list (default 50, max 100) — use search for lookups " +
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
"space's full page hierarchy as a nested tree.",
inputSchema: {
spaceId: z.string().optional(),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Max pages to return (default 50, max 100)"),
tree: z
.boolean()
.optional()
.describe("When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit."),
},
}, async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
});
// Tool: get_page
server.registerTool("get_page", {
description: "Get page details with content converted to Markdown. The conversion is " +
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
"lossless representation use get_page_json.",
inputSchema: {
pageId: z.string().min(1),
},
}, async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
});
// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
const page = await docmostClient.getPageJson(pageId);
return jsonContent(page);
});
// Tool: get_outline
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
const result = await docmostClient.getOutline(pageId);
return jsonContent(result);
});
// Tool: get_node
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.getNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: table_get
server.registerTool("table_get", {
description: "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
"get_outline, or any block id inside the table. Use cellIds with " +
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
"width; ragged tables may vary per row, so use the per-row length of " +
"`cells` for each row.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
},
}, async ({ pageId, table }) => {
const result = await docmostClient.getTable(pageId, table);
return jsonContent(result);
});
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool("table_insert_row", {
description: "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
"a block id inside it. `cells` = text per column (padded to the table's " +
"column count; error if more cells than columns). `index` = 0-based " +
"insert position (0 inserts before the header); omit to append at the end.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
cells: z.array(z.string()),
index: z.number().int().optional(),
},
}, async ({ pageId, table, cells, index }) => {
const result = await docmostClient.tableInsertRow(pageId, table, cells, index);
return jsonContent(result);
});
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_delete_row", {
description: "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
"a block id inside it). Refuses to delete the table's only row. An " +
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
"and the next row becomes the new header.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
index: z.number().int(),
},
}, async ({ pageId, table, index }) => {
const result = await docmostClient.tableDeleteRow(pageId, table, index);
return jsonContent(result);
});
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool("table_update_cell", {
description: "Set the plain-text content of cell [row,col] (0-based) in a table " +
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
"content with a single text paragraph; for rich formatting use patch_node " +
"on the cell's paragraph id from table_get.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
row: z.number().int(),
col: z.number().int(),
text: z.string(),
},
}, async ({ pageId, table, row, col, text }) => {
const result = await docmostClient.tableUpdateCell(pageId, table, row, col, text);
return jsonContent(result);
});
// Tool: create_page
server.registerTool("create_page", {
description: "Create a new page with content (automatically moves it to the correct hierarchy).",
inputSchema: {
title: z.string().min(1).describe("Title of the page"),
content: z.string().min(1).describe("Markdown content"),
spaceId: z.string().min(1),
parentPageId: z
.string()
.optional()
.describe("Optional parent page ID to nest under"),
},
}, async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(title, content, spaceId, parentPageId);
return jsonContent(result);
});
// Tool: update_page_json
server.registerTool("update_page_json", {
description: "Replace a page's content with a raw ProseMirror JSON document " +
"(lossless write: preserves the block ids, callouts, tables and " +
"attributes you pass in). Typical flow: get_page_json -> modify the " +
"JSON -> update_page_json. Keep existing node ids intact so heading " +
"anchors and history stay stable. Minimal full-doc example: " +
'{"type":"doc","content":[{"type":"paragraph","content":' +
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
"the title (though prefer rename_page for a title-only change). " +
"Supplying neither content nor title is an error.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to update"),
content: z
.any()
.optional()
.describe('ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
"JSON string). Omit to rename only."),
title: z.string().optional().describe("Optional new title"),
},
}, async ({ pageId, content, title }) => {
// Only parse/validate the document when it was actually supplied; when it
// is omitted, pass it straight through so the client performs a title-only
// (or no-op) update.
let doc;
if (content === undefined || content === null) {
doc = undefined;
}
else {
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, "content was a string but not valid JSON");
}
const result = await docmostClient.updatePageJson(pageId, doc, title);
return jsonContent(result);
});
// Tool: export_page_markdown
server.registerTool("export_page_markdown", {
description: "Export a page to a single self-contained, lossless Docmost-flavoured " +
"Markdown file (custom extensions): YAML-free meta header, body with " +
"inline comment anchors and diagrams, and a trailing comments-thread " +
"block. Designed for a download -> edit body -> import_page_markdown " +
"round-trip that preserves everything, including comment highlights. " +
"Comment THREADS are preserved in the file but are not re-pushed to the " +
"server on import.",
inputSchema: {
pageId: z.string().min(1),
},
}, async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text", text: md }] };
});
// Tool: import_page_markdown
registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => {
const res = await docmostClient.importPageMarkdown(pageId, markdown);
return jsonContent(res);
});
// Tool: copy_page_content
registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => {
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
return jsonContent(result);
});
// Tool: rename_page
server.registerTool("rename_page", {
description: "Rename a page (change its title only) without touching or resending " +
"its content.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to rename"),
title: z.string().min(1).describe("New title"),
},
}, async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
});
// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
const result = await docmostClient.editPageText(pageId, edits);
return jsonContent(result);
});
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
// body never enters the model context. Registered directly (not via
// registerShared) because that helper only emits text content. Also returns
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
// shape alongside the resource_link, so MCP clients receive the blob's sha256
// (its ETag, for integrity) and mirror counts, not just the link.
server.registerTool(SHARED_TOOL_SPECS.stashPage.mcpName, {
description: SHARED_TOOL_SPECS.stashPage.description,
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape(z),
}, async ({ pageId }) => {
const result = await docmostClient.stashPage(pageId);
return {
content: [
{
type: "resource_link",
uri: result.uri,
name: "page.json",
mimeType: "application/json",
size: result.size,
},
],
// Mirror the full documented result shape ({ uri, size, sha256, images })
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
// integrity) and the mirror counts, not just the resource_link.
structuredContent: {
uri: result.uri,
sha256: result.sha256,
size: result.size,
images: result.images,
},
};
});
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
});
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
anchorText,
});
return jsonContent(result);
});
// Tool: delete_node
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.deleteNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: insert_image
server.registerTool("insert_image", {
description: "Download an image from a web (http/https) URL and insert it into " +
"a page in one step. By default " +
"appends the image at the end of the page. With replaceText, replaces the " +
"first top-level block whose text contains that string (handy for " +
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
"With afterText, inserts the image right after the first block containing " +
"that string. Preserves all other block ids.",
inputSchema: {
pageId: z.string().min(1),
imageUrl: z
.string()
.min(1)
.describe("http(s) URL of the image to download and upload"),
align: z.enum(["left", "center", "right"]).optional(),
alt: z.string().optional(),
replaceText: z
.string()
.optional()
.describe("Replace the first top-level block whose text contains this string with the image"),
afterText: z
.string()
.optional()
.describe("Insert the image right after the first top-level block whose text contains this string"),
},
}, async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
const result = await docmostClient.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
});
return jsonContent(result);
});
// Tool: replace_image
server.registerTool("replace_image", {
description: "Replace an existing image on a page with a new image fetched from a web " +
"(http/https) URL: uploads the new file as a NEW " +
"attachment (fresh clean URL that renders and busts browser caches), then " +
"repoints every image node referencing the old attachmentId (recursively, " +
"incl. callouts/tables) via the live document, preserving comments, " +
"alignment and alt. The old attachment is left as an unreferenced orphan " +
"(Docmost has no API to delete a single attachment; it is removed only when " +
"the page/space is deleted). In-place byte overwrite is avoided because some " +
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
inputSchema: {
pageId: z.string().min(1),
attachmentId: z
.string()
.min(1)
.describe("attachmentId of the image currently in the page to replace"),
imageUrl: z
.string()
.min(1)
.describe("http(s) URL of the new image to download"),
align: z.enum(["left", "center", "right"]).optional(),
alt: z.string().optional(),
},
}, async ({ pageId, attachmentId, imageUrl, align, alt }) => {
const result = await docmostClient.replaceImage(pageId, attachmentId, imageUrl, {
align,
alt,
});
return jsonContent(result);
});
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool("share_page", {
description: "Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to share"),
searchIndexing: z
.boolean()
.optional()
.describe("Allow search engines to index the page (default true)"),
},
}, async ({ pageId, searchIndexing }) => {
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
return jsonContent(result);
});
// Tool: unshare_page
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
const result = await docmostClient.unsharePage(pageId);
return jsonContent(result);
});
// Tool: list_shares
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
const result = await docmostClient.listShares();
return jsonContent(result);
});
// Tool: move_page
server.registerTool("move_page", {
description: "Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
inputSchema: {
pageId: z.string().min(1),
parentPageId: z
.string()
.nullable()
.optional()
.describe("Target parent page ID. Pass 'null' or empty string to move to root."),
position: z
.string()
.min(5)
.optional()
.describe("fractional-index position key; min 5 chars; omit to append at the end."),
},
}, async ({ pageId, parentPageId, position }) => {
const finalParentId = parentPageId === "" || parentPageId === "null" ? null : parentPageId;
// Cheap cycle guard: a page cannot be moved directly under itself.
// (Deeper descendant-cycle detection is intentionally out of scope.)
if (finalParentId !== null && finalParentId === pageId) {
throw new Error("cannot move a page under itself");
}
const result = await docmostClient.movePage(pageId, finalParentId || null, position);
// Require POSITIVE confirmation: the live /pages/move success shape is
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
// shape lacking success === true must NOT be reported as a successful move,
// so we surface the raw API result instead of declaring success.
if (!(result && typeof result === "object" && result.success === true)) {
throw new Error(`Failed to move page ${pageId}: ${JSON.stringify(result)}`);
}
return jsonContent({
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
result,
});
});
// Tool: delete_page
server.registerTool("delete_page", {
description: "Delete a single page by ID.",
inputSchema: {
pageId: z.string().min(1),
},
}, async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text", text: `Successfully deleted page ${pageId}` },
],
};
});
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
// Tool: list_comments
server.registerTool("list_comments", {
description: "List all comments on a page (paginated). Content is returned as Markdown.",
inputSchema: {
pageId: z.string().describe("ID of the page"),
},
}, async ({ pageId }) => {
const comments = await docmostClient.listComments(pageId);
return jsonContent(comments);
});
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool("create_comment", {
description: "Create a new comment on a page. The comment is ALWAYS inline and is " +
"anchored to (highlights) its `selection` text — there are no page-level " +
"comments. Content is provided as Markdown and automatically converted. " +
"A top-level comment REQUIRES an exact `selection`; if the selection " +
"cannot be found in the page the call fails (no orphan comment is left). " +
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
"selection. You may also attach a `suggestedText` proposing a replacement " +
"for the `selection`; a human applies (or rejects) it from the UI. When " +
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
"page — expand it with surrounding context if it is ambiguous.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
selection: z
.string()
.min(1)
// Enforce the documented 250-char cap to match the description above.
.max(250)
.optional()
.describe("EXACT contiguous text from a single paragraph/block to anchor the " +
"comment on (<=250 chars). Required for a top-level comment; omit " +
"only when replying via parentCommentId."),
parentCommentId: z
.string()
.optional()
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe("Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
"applied by a human via the UI (never auto-applied). REQUIRES a " +
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
"be UNIQUE in the page — expand it with surrounding context (still " +
"<=250 chars) if it occurs more than once, or the call is refused."),
},
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error("create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.");
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error("create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.");
}
if (!selection || !selection.trim()) {
throw new Error("create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.");
}
}
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId, suggestedText);
return jsonContent(result);
});
// Tool: update_comment
server.registerTool("update_comment", {
description: "Update an existing comment's content. Only the comment creator can " +
"update it. Content is provided as Markdown.",
inputSchema: {
commentId: z.string().min(1).describe("ID of the comment to update"),
content: z
.string()
.min(1)
.describe("New comment content in Markdown format"),
},
}, async ({ commentId, content }) => {
const result = await docmostClient.updateComment(commentId, content);
return jsonContent(result);
});
// Tool: delete_comment
server.registerTool("delete_comment", {
description: "Delete a comment. Only the comment creator or space admin can delete it.",
inputSchema: {
commentId: z.string().min(1).describe("ID of the comment to delete"),
},
}, async ({ commentId }) => {
await docmostClient.deleteComment(commentId);
return {
content: [
{
type: "text",
text: `Successfully deleted comment ${commentId}`,
},
],
};
});
// Tool: resolve_comment
server.registerTool("resolve_comment", {
description: "Resolve (close) or reopen a comment thread. Only top-level comments can " +
"be resolved — the server rejects resolving a reply. Reversible: pass " +
"resolved=false to reopen. Resolving keeps the thread and its replies " +
"(unlike delete_comment, which permanently removes them).",
inputSchema: {
commentId: z
.string()
.min(1)
.describe("ID of the top-level comment thread to resolve or reopen"),
resolved: z
.boolean()
.optional()
.default(true)
.describe("true (default) marks the thread resolved/closed; false reopens it"),
},
}, async ({ commentId, resolved }) => {
const result = await docmostClient.resolveComment(commentId, resolved);
return jsonContent(result);
});
// Tool: check_new_comments
server.registerTool("check_new_comments", {
description: "Check for new comments across pages in a space since a given timestamp. " +
"Optionally scope to a page subtree (folder). Returns only comments " +
"created after the specified time.",
inputSchema: {
spaceId: z.string().describe("Space ID to check for new comments"),
since: z
.string()
.min(1)
.describe("ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')"),
parentPageId: z
.string()
.optional()
.describe("Optional root page ID to scope the check to a subtree (folder). " +
"Only pages under this parent will be checked."),
},
}, async ({ spaceId, since, parentPageId }) => {
// Reject an unparseable timestamp up front: otherwise the comparison
// against NaN silently treats every comment as "not new" and the tool
// returns zero results without signalling the bad input.
if (Number.isNaN(Date.parse(since))) {
throw new Error(`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`);
}
const result = await docmostClient.checkNewComments(spaceId, since, parentPageId);
return jsonContent(result);
});
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
server.registerTool("search", {
description: "Search for pages and content. Results are bounded by `limit` " +
"(default applied by the client, max 100).",
inputSchema: {
query: z.string().min(1).describe("Search query"),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Max results to return (max 100)"),
},
}, async ({ query, limit }) => {
// The tool exposes no spaceId filter, so pass undefined for the client's
// optional spaceId parameter and forward limit into its correct slot.
const result = await docmostClient.search(query, undefined, limit);
return jsonContent(result);
});
// Tool: docmost_transform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool("docmost_transform", {
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
"against its LIVE ProseMirror document, with a diff preview and page " +
"history as the safety net. By default dryRun=true: returns a diff " +
"preview WITHOUT writing. Set dryRun=false to apply (atomic, won't " +
"clobber concurrent edits). `doc` is the lossless ProseMirror document " +
"({type:'doc',content:[...]}); return a new doc of the same shape. " +
"`ctx` gives you: comments (the page's comments, each {id, content " +
"(markdown), selection, type}); log (array; console.log pushes to it); " +
"consume(id) (mark a comment id as consumed — those are deleted when " +
"deleteComments=true after a successful apply); and helpers: " +
"blockText(node) (plain text), walk(node, fn) (depth-first over all " +
"nodes incl. callouts/tables/lists), getList(doc, predicate) (find a " +
"node even without attrs.id), insertMarkerAfter(doc, anchor, marker, " +
"{beforeBlock}) (insert a plain unmarked text run after anchor, " +
"mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " +
"[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " +
"fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " +
"commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " +
"comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " +
"footnote numbering + the single bottom list from reference order, drop " +
"orphans/duplicates — runs AUTOMATICALLY on the transform RESULT, so the " +
"applied (and dryRun-previewed) doc is always footnote-canonical; a dryRun " +
"diff may therefore show footnote tidy-ups your script did not make, and " +
"it is idempotent after the first run), and " +
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
"plain '[N]' text in the body; the notes are an orderedList under a " +
"heading whose text is 'Примечания переводчика'. The transform runs " +
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
"{type:'doc'} node.",
inputSchema: {
pageId: z.string().min(1),
transformJs: z
.string()
.min(1)
.describe("A JS function `(doc, ctx) => doc` (expression-arrow or " +
"parenthesized function). It receives a clone of the live doc and " +
"ctx (comments, log, consume(id), helpers: blockText/walk/getList/" +
"insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" +
"commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " +
"and must return a {type:'doc'} node."),
dryRun: z
.boolean()
.optional()
.default(true)
.describe("Preview only (no write) when true (default)."),
deleteComments: z
.boolean()
.optional()
.default(false)
.describe("After a successful apply, delete every comment id passed to " +
"ctx.consume(id)."),
},
}, async ({ pageId, transformJs, dryRun, deleteComments }) => {
const result = await docmostClient.transformPage(pageId, transformJs, {
dryRun,
deleteComments,
});
return jsonContent(result);
});
// Tool: insert_footnote
server.registerTool("insert_footnote", {
description: "Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
"and WHAT (text). The footnote marker is placed right after anchorText in " +
"the body, and the bottom footnotes list + the numbering are derived " +
"deterministically server-side. You do NOT assign a number, and you " +
"never see or edit the footnotes list — so footnotes cannot end up out " +
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
"SAME text already exists, its number is REUSED (one definition, several " +
"references). The write is atomic and won't clobber concurrent edits; if " +
"anchorText is not found, nothing is written and an error is returned.",
inputSchema: {
pageId: z.string().min(1),
anchorText: z
.string()
.min(1)
.describe("A snippet of existing body text; the footnote marker is inserted " +
"immediately after its first occurrence (mark-safe)."),
text: z
.string()
.min(1)
.describe("The footnote content as markdown (becomes the definition)."),
},
}, async ({ pageId, anchorText, text }) => {
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
return jsonContent(result);
});
// Tool: diff_page_versions
registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => {
const result = await docmostClient.diffPageVersions(pageId, from, to);
return jsonContent(result);
});
// Tool: list_page_history
registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => {
const result = await docmostClient.listPageHistory(pageId, cursor);
return jsonContent(result);
});
// Tool: restore_page_version
registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => {
const result = await docmostClient.restorePageVersion(historyId);
return jsonContent(result);
});
return server;
}
-92
View File
@@ -1,92 +0,0 @@
import axios from "axios";
export async function getCollabToken(baseUrl, apiToken) {
try {
const response = await axios.post(`${baseUrl}/auth/collab-token`, {}, {
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
});
// console.error('Collab Token Response:', response.data);
// Response is wrapped in { data: { token: ... } }
return response.data.data?.token || response.data.token;
}
catch (error) {
if (axios.isAxiosError(error)) {
// Attach the HTTP status to the plain Error so callers (e.g.
// getCollabTokenWithReauth) can still detect a 401/403 after the
// original AxiosError has been wrapped away.
// Avoid leaking the full server response body by default; include only
// status + statusText. Append the body only when DEBUG is set.
let message = `Failed to get collab token: ${error.response?.status} ${error.response?.statusText}`;
if (process.env.DEBUG) {
message += ` - ${JSON.stringify(error.response?.data)}`;
}
const err = new Error(message);
err.status = error.response?.status;
throw err;
}
throw error;
}
}
/**
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
* can be unit-tested without performing the login network request. Given the
* raw `Set-Cookie` header array from the login response, return the `authToken`
* cookie's value.
*
* Behavior (kept identical to the original inline logic):
* - throws if there is no Set-Cookie header at all;
* - matches the cookie NAME exactly (`authToken`), so a future
* `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be);
* - returns everything after the FIRST `=` up to the first `;`, so a base64
* value containing `=` padding is preserved (a naive `split("=")` would
* truncate it);
* - cookie attributes after the first `;` (Path, HttpOnly, Expires, ) are
* ignored;
* - throws if no `authToken` cookie is present.
*/
export function extractAuthTokenFromSetCookie(cookies) {
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
// Match the cookie name exactly to avoid matching a future
// authTokenRefresh cookie (startsWith would catch it).
const authCookie = cookies.find((c) => {
const kv = c.split(";")[0];
return kv.slice(0, kv.indexOf("=")) === "authToken";
});
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
// Take everything after the FIRST "=" up to the first ";".
// Splitting on "=" would truncate base64 values containing "=" padding.
const kv = authCookie.split(";")[0];
return kv.slice(kv.indexOf("=") + 1);
}
export async function performLogin(baseUrl, email, password) {
try {
const response = await axios.post(`${baseUrl}/auth/login`, {
email,
password,
});
// Extract token from Set-Cookie header
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
}
catch (error) {
// Avoid leaking the full server response body by default; log only the
// HTTP status. Log the verbose body only when DEBUG is set.
if (axios.isAxiosError(error)) {
if (process.env.DEBUG) {
console.error("Login failed:", error.response?.data);
}
else {
console.error("Login failed:", error.response?.status);
}
}
else {
console.error("Login failed:", error.message);
}
throw error;
}
}
-743
View File
@@ -1,743 +0,0 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import { marked } from "marked";
import { generateJSON } from "@tiptap/html";
import { Node as PMNode } from "@tiptap/pm/model";
import { updateYFragment } from "y-prosemirror";
import { JSDOM } from "jsdom";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
import { lexFootnoteLines } from "./footnote-lex.js";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { summarizeChange } from "./diff.js";
/**
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
* `applyDocToFragment` -> `updateYFragment`) so the message wording stays in one
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
* `findUnstorableAttr`.
*/
function unstorableYjsError(safe, label, e) {
const bad = findUnstorableAttr(safe);
return new Error(`Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
}
// Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
global.window = dom.window;
global.document = dom.window.document;
// @ts-ignore
global.Element = dom.window.Element;
// @ts-ignore
global.WebSocket = WebSocket;
// Navigator is read-only in newer Node versions and already exists
// global.navigator = dom.window.navigator;
/**
* Hard ceiling above which we skip callout preprocessing entirely. The linear
* scanner below has no quadratic blow-up, but we still cap input defensively so
* a pathological multi-megabyte payload cannot tie up the event loop; in that
* case the markdown is passed through verbatim (callouts are simply not
* detected) rather than risking a slow scan.
*/
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
/** Matches a bare closing callout fence: `:::`. */
const CALLOUT_CLOSE_RE = /^:::\s*$/;
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
/**
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
* callout blocks (the syntax our markdown export produces) into HTML
* divs that the callout extension parses. The inner content is rendered
* through marked as regular markdown.
*
* Implemented as a single linear pass over the lines (no quadratic regex
* rescan). It:
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
* `:::` line that lives inside a code fence as a callout delimiter, so a
* callout body that itself contains a fenced code block with a `:::` line is
* no longer corrupted;
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
* nesting level, supporting NESTED callouts via a depth counter (an inner
* `:::type` opens a deeper level and consumes a matching `:::`);
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
* (inner rendered through marked) as the previous regex implementation.
*/
async function preprocessCallouts(markdown) {
// Defensive cap: skip preprocessing for pathologically large inputs.
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown;
}
// Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function.
const transform = async (lines) => {
const out = [];
let inCodeFence = false;
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Inside a code fence, only its matching closing fence is significant;
// everything else (including `:::` lines) is copied through verbatim.
if (inCodeFence) {
out.push(line);
const fence = line.match(CODE_FENCE_RE);
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
fence[2].length >= codeFenceMarker.length) {
inCodeFence = false;
codeFenceMarker = "";
}
i++;
continue;
}
// A code fence opening outside any callout body: enter code-fence mode.
const fenceOpen = line.match(CODE_FENCE_RE);
if (fenceOpen) {
inCodeFence = true;
codeFenceMarker = fenceOpen[2];
out.push(line);
i++;
continue;
}
// An opening callout fence: scan forward (with code-fence and nested
// callout awareness) for its matching closing `:::` at the same level.
const open = line.match(CALLOUT_OPEN_RE);
if (open) {
const type = open[1].toLowerCase();
const bodyLines = [];
let depth = 1;
let innerInCodeFence = false;
let innerCodeFenceMarker = "";
let j = i + 1;
for (; j < lines.length; j++) {
const bl = lines[j];
if (innerInCodeFence) {
const f = bl.match(CODE_FENCE_RE);
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
f[2].length >= innerCodeFenceMarker.length) {
innerInCodeFence = false;
innerCodeFenceMarker = "";
}
bodyLines.push(bl);
continue;
}
const innerFence = bl.match(CODE_FENCE_RE);
if (innerFence) {
innerInCodeFence = true;
innerCodeFenceMarker = innerFence[2];
bodyLines.push(bl);
continue;
}
if (CALLOUT_OPEN_RE.test(bl)) {
depth++;
bodyLines.push(bl);
continue;
}
if (CALLOUT_CLOSE_RE.test(bl)) {
depth--;
if (depth === 0)
break; // matching close for THIS callout
bodyLines.push(bl);
continue;
}
bodyLines.push(bl);
}
if (j < lines.length) {
// Found the matching closing fence: render the body (recursively, so
// nested callouts are handled) and emit the callout div.
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`);
i = j + 1; // skip past the closing `:::`
continue;
}
// No matching close (unterminated callout): treat the opener as a
// literal line and continue, preserving the original text.
out.push(line);
i++;
continue;
}
out.push(line);
i++;
}
return out.join("\n");
};
return transform(markdown.split("\n"));
}
/**
* Bridge marked's checkbox lists to TipTap task lists.
*
* marked renders GitHub task list items (`- [x] done`) as a plain
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
* into the shape those extensions expect:
* TaskList parseHTML matches `ul[data-type="taskList"]`,
* TaskItem matches `li[data-type="taskItem"]`,
* the checked state is read from `data-checked === "true"`.
*
* A list is only converted when it has at least one `<li>` and EVERY direct
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
*/
function bridgeTaskLists(html) {
// Cheap early-out: if the markup contains no checkbox input at all there is
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
// common case (most pages have no task lists).
if (!/type=["']?checkbox/i.test(html)) {
return html;
}
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
// pathologically large inputs rather than running a second expensive JSDOM
// parse on a multi-megabyte payload. The markup is passed through verbatim.
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return html;
}
const dom = new JSDOM(html);
const document = dom.window.document;
// 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 (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
// bullet <li> that merely contains a nested task sublist is not misdetected.
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
// ALL of them so none survive into the converted item.
const directCheckboxes = (li) => {
const found = [];
for (const child of Array.from(li.children)) {
if (child.tagName === "INPUT" &&
child.getAttribute("type") === "checkbox") {
found.push(child);
continue;
}
if (child.tagName === "P") {
for (const inp of Array.from(child.querySelectorAll(":scope > input[type='checkbox']"))) {
found.push(inp);
}
}
}
return found;
};
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
// its own checkbox is a numbered checklist that must also become a taskList.
const lists = Array.from(document.querySelectorAll("ul, ol"));
for (const list of lists) {
// Only consider DIRECT child <li> elements; nested lists are handled by
// their own iteration of the outer loop.
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
if (items.length === 0)
continue;
const itemCheckboxes = items.map((li) => directCheckboxes(li));
// Convert only when every direct <li> carries at least one OWN checkbox.
if (!itemCheckboxes.every((boxes) => boxes.length > 0))
continue;
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
// <ol> while tagging it data-type="taskList": generateJSON would then match
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
// emitting a phantom empty orderedList beside the real taskList. So rename a
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
let target = list;
if (list.tagName === "OL") {
const ul = document.createElement("ul");
// Carry over existing attributes (e.g. class) so nothing is silently lost.
for (const attr of Array.from(list.attributes)) {
ul.setAttribute(attr.name, attr.value);
}
// Move every child node (including the <li>s we collected) into the <ul>.
while (list.firstChild) {
ul.appendChild(list.firstChild);
}
list.replaceWith(ul);
target = ul;
}
target.setAttribute("data-type", "taskList");
items.forEach((li, index) => {
const boxes = itemCheckboxes[index];
// The first checkbox determines the checked state (matches the previous
// single-checkbox behaviour); any extras only need removing.
const input = boxes[0] ?? null;
li.setAttribute("data-type", "taskItem");
const checked = input != null &&
(input.hasAttribute("checked") || input.checked);
li.setAttribute("data-checked", checked ? "true" : "false");
// Remove ALL direct checkbox inputs so none survive into the content
// (a raw-inline-HTML <li> may carry more than one).
for (const box of boxes) {
box.remove();
}
});
}
return document.body.innerHTML;
}
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
// definition lines are collected into a single <section data-footnotes>.
// Definition detection + fence handling are shared with analyzeFootnotes via
// lexFootnoteLines (footnote-lex.js). FOOTNOTE_REF_RE is the inline tokenizer's.
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
function escapeFootnoteAttr(value) {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
const footnoteRefMarkedExtension = {
name: "footnoteRef",
level: "inline",
start(src) {
return src.match(/\[\^/)?.index ?? -1;
},
tokenizer(src) {
const match = FOOTNOTE_REF_RE.exec(src);
if (match && match.index === 0) {
return { type: "footnoteRef", raw: match[0], id: match[1] };
}
return undefined;
},
renderer(token) {
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(token.id)}"></sup>`;
},
};
marked.use({ extensions: [footnoteRefMarkedExtension] });
/**
* Pull `[^id]: text` definition lines out of the body and render a single
* <section data-footnotes> for them (or "" when there are none).
*/
function extractFootnotes(markdown) {
const bodyLines = [];
const defs = [];
// Shared lexer (footnote-lex): a `[^id]: ...` line inside a ``` / ~~~ code
// block is inert and stays in the body verbatim; only real definition lines
// are pulled out. analyzeFootnotes() consumes the SAME lexer so its diagnostics
// match exactly what import keeps/strips (#166).
for (const tok of lexFootnoteLines(markdown)) {
if (!tok.inFence && tok.definition)
defs.push(tok.definition);
else
bodyLines.push(tok.line);
}
if (defs.length === 0)
return { body: markdown, section: "" };
// Duplicate definition ids: FIRST WINS, the rest are DROPPED (mirror of
// editor-ext extractFootnoteDefinitions). Reference markers are left untouched
// so repeated `[^a]` references reuse the single footnote (Pandoc semantics,
// #166). The dropped duplicate is surfaced to the caller via analyzeFootnotes
// (`duplicateDefinitions`), not silently lost. MUST stay in sync with the
// editor-ext mirror.
const firstById = new Map(); // id -> first definition text
for (const def of defs) {
if (!firstById.has(def.id))
firstById.set(def.id, def.text);
}
const inner = [...firstById.entries()]
.map(([id, text]) => `<div data-footnote-def data-id="${escapeFootnoteAttr(id)}"><p>${marked.parseInline(text || "")}</p></div>`)
.join("");
return {
body: bodyLines.join("\n"),
section: `<section data-footnotes>${inner}</section>`,
};
}
/**
* Convert markdown to a ProseMirror doc using the full Docmost schema.
*
* This conversion does NOT canonicalize footnotes it is the shared, content-
* preserving primitive used by BOTH page write paths and COMMENT bodies
* (createComment / updateComment). Canonicalization MUST NOT run on a comment
* body: a comment may legitimately contain a footnote-definition line
* (`[^1]: text`) with no matching reference, and the canonicalizer drops a
* reference-less footnotesList which would silently delete the comment's text.
*
* Page write paths that DO need the canonical footnote topology call
* `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown
* path). Keep this function reference-loss-free.
*/
export async function markdownToProseMirror(markdownContent) {
const withCallouts = await preprocessCallouts(markdownContent);
const { body, section } = extractFootnotes(withCallouts);
const html = (await marked.parse(body)) + section;
const bridged = bridgeTaskLists(html);
return generateJSON(bridged, docmostExtensions);
}
/**
* Page-write variant of `markdownToProseMirror`: converts markdown then enforces
* the canonical footnote topology. The footnote `section` markdown is emitted in
* DEFINITION order, but numbering derives from REFERENCE order, so without this
* the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and
* duplicate lists are also normalized. Idempotent a no-op once canonical, and a
* no-op for footnote-free content.
*
* Use this ONLY for full-document PAGE writes (never for comment bodies, where it
* would drop a reference-less footnote definition see `markdownToProseMirror`).
*/
export async function markdownToProseMirrorCanonical(markdownContent) {
return canonicalizeFootnotes(await markdownToProseMirror(markdownContent));
}
/**
* Build the collaboration WebSocket URL from an API base URL:
* switch http(s)->ws(s), strip a trailing /api, mount on /collab.
* Shared by the live read and the mutate path so both target the same socket.
*/
export function buildCollabWsUrl(baseUrl) {
let wsUrl = baseUrl.replace(/^http/, "ws");
try {
const urlObj = new URL(wsUrl);
if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) {
urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, "");
}
urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab";
// Drop any query/hash from the base URL so it is not carried into the
// collaboration ws URL.
urlObj.search = "";
urlObj.hash = "";
wsUrl = urlObj.toString();
}
catch (e) {
// Fallback if URL parsing fails
if (!wsUrl.endsWith("/collab")) {
wsUrl = wsUrl.replace(/\/$/, "") + "/collab";
}
}
return wsUrl;
}
/**
* Encode a ProseMirror doc to a Yjs document, sanitizing it first and turning
* the opaque yjs "Unexpected content type" failure into a descriptive error.
*
* `sanitizeForYjs` strips `undefined` node/mark attributes (the common cause of
* the failure); if `toYdoc` still throws, `findUnstorableAttr` is used to point
* at the offending attribute path.
*/
export function buildYDoc(doc) {
const safe = sanitizeForYjs(doc);
try {
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
}
catch (e) {
throw unstorableYjsError(safe, "toYdoc", e);
}
}
/**
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
* preserving the Yjs identity of unchanged nodes (issue #152).
*
* The previous approach deleted the whole fragment and re-applied a fresh Y.Doc,
* which discarded every Yjs node id. y-prosemirror anchors the editor selection
* to those ids, so an open editor's cursor lost its anchor and snapped to the
* end of the document on every agent write (most visibly on comment anchoring,
* which changes no text at all). `updateYFragment` is exactly the routine the
* editor itself uses to sync ProseMirror edits into Yjs: it diffs the new node
* against the current fragment and touches only the changed children, so
* unchanged nodes keep their ids and the live cursor stays put.
*
* Must run inside a single `transact` so the diff applies atomically (no remote
* update interleaves). Keeps `buildYDoc`'s `findUnstorableAttr` diagnostic for
* the opaque "Unexpected content type" encode failure.
*/
export function applyDocToFragment(ydoc, newDoc) {
const safe = sanitizeForYjs(newDoc);
const fragment = ydoc.getXmlFragment("default");
// Hydrate the ProseMirror node in its OWN try so a failure here (e.g. an
// unknown node type) is labelled "fromJSON" — the stage that actually threw —
// instead of being misattributed to the Yjs write stage (#154 review).
let pmNode;
try {
pmNode = PMNode.fromJSON(docmostSchema, safe);
}
catch (e) {
throw unstorableYjsError(safe, "fromJSON", e);
}
try {
ydoc.transact(() => {
updateYFragment(ydoc, fragment, pmNode, {
mapping: new Map(),
isOMark: new Map(),
});
});
}
catch (e) {
throw unstorableYjsError(safe, "updateYFragment", e);
}
}
/**
* Run an independent Yjs-encodability check (the same `sanitizeForYjs` + schema
* the apply path uses) and throw the same descriptive error when the doc cannot
* be stored. Used by the dry-run preview.
*
* Note: it does NOT run `updateYFragment` against the live fragment, so it is an
* encodability GATE, not a byte-for-byte rehearsal of apply `buildYDoc`
* (`toYdoc`) and `applyDocToFragment` (`updateYFragment`) are two different
* encoders that nonetheless reject the same unstorable attributes. To narrow the
* preview/apply gap it ALSO rehearses the apply path's `PMNode.fromJSON`
* hydration, so a doc that would only fail there (e.g. an unknown node type) is
* rejected at preview time too (#154 review). Still cheap: no live fragment, no
* `updateYFragment`.
*/
export function assertYjsEncodable(doc) {
buildYDoc(doc);
const safe = sanitizeForYjs(doc);
try {
PMNode.fromJSON(docmostSchema, safe);
}
catch (e) {
throw unstorableYjsError(safe, "fromJSON", e);
}
}
/** Time we wait for the initial handshake/sync before giving up. */
const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/**
* Safely mutate the live content of a page over the collaboration websocket.
*
* This is the single safe write path for every MCP content mutation. It:
* 1. serializes per-page writes through withPageLock (no two MCP writes on
* the same page overlap);
* 2. connects to Hocuspocus and waits for the initial sync so the local ydoc
* mirrors the authoritative server doc INCLUDING edits/comments/images
* that are not yet in the debounced REST snapshot;
* 3. inside onSynced, SYNCHRONOUSLY reads the live doc, runs `transform`, and
* writes the result back with no `await` between read and write so no
* remote update can interleave and clobber concurrent human edits;
* 4. waits for the server to acknowledge the write (unsyncedChanges -> 0)
* before resolving, so the next operation observes our change.
*
* `transform` receives the live ProseMirror doc and returns the NEW full
* ProseMirror doc to write, or `null` to abort with no write (a no-op). If
* `transform` throws, the error is propagated to the caller (not swallowed).
*
* Resolves a `MutationResult { doc, verify }`: `doc` is the doc that was
* written (or the live doc when the transform aborted), and `verify` is a
* verifiable change report (text/block/mark deltas) of what actually changed.
* The report is computed AFTER the atomic read->write, so it never widens the
* read->write window, and it never throws (it can NEVER break a write).
*/
export async function mutatePageContent(pageId, collabToken, baseUrl, transform) {
return withPageLock(pageId, () => {
if (process.env.DEBUG) {
console.error(`Starting realtime content mutate for page ${pageId}`);
// Token prefix is sensitive; only log it under DEBUG.
console.error(`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`);
}
const ydoc = new Y.Doc();
const wsUrl = buildCollabWsUrl(baseUrl);
if (process.env.DEBUG)
console.error(`Connecting to WebSocket: ${wsUrl}`);
return new Promise((resolve, reject) => {
let provider;
let applied = false; // onSynced may fire again on reconnect — apply once.
let settled = false;
// Set true on disconnect/close so a reconnect-driven unsyncedChanges->0
// cannot be mistaken for a successful persist of our write.
let connectionLost = false;
let connectTimer;
let persistTimer;
let unsyncedHandler;
const cleanup = () => {
if (connectTimer)
clearTimeout(connectTimer);
if (persistTimer)
clearTimeout(persistTimer);
if (provider) {
if (unsyncedHandler) {
try {
provider.off("unsyncedChanges", unsyncedHandler);
}
catch (err) { }
}
try {
provider.destroy();
}
catch (err) { }
}
};
const finish = (err, value) => {
if (settled)
return;
settled = true;
cleanup();
if (err)
reject(err);
else
resolve(value);
};
connectTimer = setTimeout(() => {
finish(new Error("Connection timeout to collaboration server"));
}, CONNECT_TIMEOUT_MS);
// Resolve once the server has acknowledged our update. The provider
// increments unsyncedChanges when our local update is sent and
// decrements it when the server replies with a SyncStatus(applied=true);
// reaching 0 means the authoritative in-memory ydoc on the server now
// contains our write.
const waitForPersistence = () => {
if (settled)
return;
// A missing provider is a failure, not a success: without it the write
// can never have been acknowledged. Only an actual unsyncedChanges===0
// on a live provider counts as persisted.
if (!provider) {
finish(new Error("collab provider gone before persistence"));
return;
}
if (provider.unsyncedChanges === 0) {
finish(null, mutationResult);
return;
}
persistTimer = setTimeout(() => {
finish(new Error("Timeout waiting for collaboration server to persist the update"));
}, PERSIST_TIMEOUT_MS);
unsyncedHandler = (data) => {
// Only treat unsyncedChanges->0 as success when the connection is
// still up. A transient disconnect + reconnect handshake can drive
// the counter back to 0 without our write being re-transmitted; in
// that case let the disconnect/close error win instead.
if (data.number === 0 && !connectionLost) {
finish(null, mutationResult);
}
};
provider.on("unsyncedChanges", unsyncedHandler);
};
// The verifiable result resolved on every success/abort path. Set on
// abort (no-op report) and after a real write (computed change report).
let mutationResult;
provider = new HocuspocusProvider({
url: wsUrl,
name: `page.${pageId}`,
document: ydoc,
token: collabToken,
// @ts-ignore - Required for Node.js environment
WebSocketPolyfill: WebSocket,
onConnect: () => {
if (process.env.DEBUG)
console.error("WS Connect");
},
// An unexpected disconnect/close while we are still waiting (during the
// connect-wait before onSynced, or during the persistence wait after the
// write) means the update will never be acknowledged — surface it now
// instead of hanging until the connect/persist timeout fires. `finish`
// is idempotent via the `settled` flag, so the onClose that our own
// cleanup()->provider.destroy() triggers (after settled=true is set) is
// a harmless no-op and cannot cause a double-resolve.
onDisconnect: () => {
if (process.env.DEBUG)
console.error("WS Disconnect");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(new Error("Collaboration connection closed before the update was persisted/synced"));
},
onClose: () => {
if (process.env.DEBUG)
console.error("WS Close");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(new Error("Collaboration connection closed before the update was persisted/synced"));
},
onSynced: () => {
if (applied || settled)
return;
applied = true;
if (process.env.DEBUG)
console.error("Connected and synced!");
// CRITICAL: everything between reading the live doc and writing it
// back must stay synchronous (no await). While the JS event loop is
// not yielded, no incoming remote update can interleave, so any
// already-synced concurrent edits are preserved in liveDoc.
let newDoc;
let beforeDoc;
try {
let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
if (!liveDoc ||
typeof liveDoc !== "object" ||
!Array.isArray(liveDoc.content)) {
liveDoc = { type: "doc", content: [] };
}
// Snapshot the before-doc for the change report. Docs are
// JSON-serializable, so this is a safe deep clone.
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
newDoc = transform(liveDoc);
if (newDoc == null) {
// Transform aborted — write nothing, return the live doc with a
// no-op change report.
mutationResult = {
doc: liveDoc,
verify: {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no changes (transform aborted)",
},
};
finish(null, mutationResult);
return;
}
// Structural diff into the live fragment (issue #152): preserves
// the Yjs ids of unchanged nodes, so an open editor's cursor is not
// yanked to the end of the document on every agent write.
applyDocToFragment(ydoc, newDoc);
}
catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
finish(e instanceof Error ? e : new Error(String(e)));
return;
}
// Compute the verifiable change report AFTER the transact write: it
// only needs the JSON before/after, so it cannot affect the atomic
// read->write window, and summarizeChange never throws.
mutationResult = {
doc: newDoc,
verify: summarizeChange(beforeDoc, newDoc),
};
if (process.env.DEBUG)
console.error("Content written, waiting for server to persist...");
waitForPersistence();
},
onAuthenticationFailed: () => {
finish(new Error("Authentication failed for collaboration connection"));
},
});
});
});
}
/**
* Replace the live content of a page over the collaboration websocket.
* Accepts a ready ProseMirror JSON document; the caller controls whether
* it was produced from markdown (ids regenerate) or edited in place
* (existing block ids preserved).
*
* This is an intentional full replace (used by update_page / update_page_json),
* but now runs under the per-page lock and waits for server persistence via
* mutatePageContent.
*/
export async function replacePageContent(pageId, prosemirrorDoc, collabToken, baseUrl) {
// Fail fast on a bad document instead of deferring the failure into the
// collaboration write (where TiptapTransformer.toYdoc(undefined) used to
// throw). The transform must return a valid ProseMirror doc.
if (prosemirrorDoc == null ||
typeof prosemirrorDoc !== "object" ||
prosemirrorDoc.type !== "doc") {
throw new Error("replacePageContent: invalid ProseMirror document");
}
return await mutatePageContent(pageId, collabToken, baseUrl, () => prosemirrorDoc);
}
/**
* Markdown update path (kept for backwards compatibility).
* NOTE: this re-imports the whole document block ids are regenerated.
* Tables and :::callout::: blocks survive thanks to the full schema.
*/
export async function updatePageContentRealtime(pageId, markdownContent, collabToken, baseUrl) {
// PAGE write: canonicalize footnotes (markdown import builds the bottom list in
// definition order; numbering is reference-ordered).
const tiptapJson = await markdownToProseMirrorCanonical(markdownContent);
return await mutatePageContent(pageId, collabToken, baseUrl, () => tiptapJson);
}
-371
View File
@@ -1,371 +0,0 @@
/**
* Inline-comment anchoring against a ProseMirror document.
*
* Docmost stores an inline comment's highlight as a `comment` MARK on the
* document text (`{ type: "comment", attrs: { commentId, resolved } }`); the
* `/comments/create` API only records the comment row + its `selection` text and
* does NOT insert that mark, so the anchor has to be written into the page
* content separately. This module finds where a selection lives in the document
* and splices the comment mark across the matched range.
*
* Matching has to be robust because the agent supplies the selection as plain
* text while the document stores rich inline content: a selection can span
* several adjacent text nodes (inline code / bold / links each become their own
* text node), and the document may use smart/typographic quotes, dash variants,
* non-breaking spaces, or collapsed runs of whitespace that the agent typed as
* ASCII quotes/hyphens/single spaces. We therefore normalize both sides before
* comparing and match across maximal runs of consecutive text nodes within a
* single block, while mapping every normalized character back to its raw index
* so the mark lands on the exact original characters.
*/
/** Typographic double-quote variants mapped to ASCII `"`. */
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
const SINGLE_QUOTES = "‘’‚‛";
/** Dash variants mapped to ASCII `-`. */
const DASHES = "–—―−‐‑‒";
/** Guard against pathological/cyclic documents in the depth-first walk. */
const MAX_DEPTH = 200;
/** The comment mark Docmost stores on anchored text. */
function makeCommentMark(commentId) {
// The comment mark schema declares both commentId and resolved; include
// resolved:false for completeness so the stored mark matches the editor's.
return { type: "comment", attrs: { commentId, resolved: false } };
}
/** True for any character we collapse/replace with a single normal space. */
function isWhitespaceChar(ch) {
// Regular ASCII whitespace plus the special spaces called out in the spec:
// nbsp, narrow nbsp, en/em/thin/hair/figure spaces, etc. \s covers tab and
// newline; the explicit code points cover the non-breaking variants \s misses
// in some engines, so list them for determinism.
return (/\s/.test(ch) ||
ch === " " || // no-break space
ch === " " || // figure space
ch === " " || // narrow no-break space
ch === " " || // thin space
ch === " " || // hair space
ch === " " || // en space
ch === " " // em space
);
}
/**
* Normalize a string for matching and return both the normalized text and a
* `map` where `map[i]` is the index into the ORIGINAL `s` of the i-th
* normalized character.
*
* Rules: map smart quotes / dashes / special spaces to their ASCII forms,
* collapse any run of whitespace to a SINGLE space (whose map entry points at
* the FIRST raw whitespace char of the run), and DO NOT lowercase (anchoring is
* case-sensitive to match the exact document text).
*/
export function normalizeForMatch(s) {
let norm = "";
const map = [];
let i = 0;
while (i < s.length) {
const ch = s[i];
if (isWhitespaceChar(ch)) {
// Collapse the whole whitespace run to one space mapped to the run start.
const runStart = i;
while (i < s.length && isWhitespaceChar(s[i]))
i++;
norm += " ";
map.push(runStart);
continue;
}
let mapped = ch;
if (DOUBLE_QUOTES.indexOf(ch) !== -1)
mapped = '"';
else if (SINGLE_QUOTES.indexOf(ch) !== -1)
mapped = "'";
else if (DASHES.indexOf(ch) !== -1)
mapped = "-";
norm += mapped;
map.push(i);
i++;
}
return { norm, map };
}
/**
* Find a selection inside a SINGLE block's direct `content` array.
*
* Builds maximal runs of consecutive `text` nodes (any non-text inline node,
* e.g. a mention, breaks the run), normalizes each run and the selection the
* same way, then searches each run for the normalized selection. Returns the
* child/offset range of the FIRST matching run, or `null` if none match.
*/
export function findAnchorInBlock(blockContent, selection) {
if (!Array.isArray(blockContent))
return null;
const normSelObj = normalizeForMatch(selection);
// Trim leading/trailing spaces on the NORMALIZED selection only.
const normSel = normSelObj.norm.trim();
if (normSel.length === 0)
return null;
let i = 0;
while (i < blockContent.length) {
const node = blockContent[i];
if (!node || typeof node !== "object" || node.type !== "text") {
i++;
continue;
}
// Accumulate a maximal run of consecutive text nodes.
let rawRun = "";
const rawToChild = [];
let j = i;
while (j < blockContent.length) {
const n = blockContent[j];
if (!n || typeof n !== "object" || n.type !== "text")
break;
const text = typeof n.text === "string" ? n.text : "";
for (let k = 0; k < text.length; k++) {
rawToChild.push({ childIdx: j, offset: k });
}
rawRun += text;
j++;
}
// Try to match within this run.
const { norm, map } = normalizeForMatch(rawRun);
const idx = norm.indexOf(normSel);
if (idx !== -1) {
const rawStart = map[idx];
const rawEndExclusive = idx + normSel.length < map.length
? map[idx + normSel.length]
: rawRun.length;
const startLoc = rawToChild[rawStart];
// rawEndExclusive points at the raw char AFTER the match; the last matched
// raw char is at rawEndExclusive-1, so endOffset is its offset + 1.
const lastLoc = rawToChild[rawEndExclusive - 1];
return {
startChild: startLoc.childIdx,
startOffset: startLoc.offset,
endChild: lastLoc.childIdx,
endOffset: lastLoc.offset + 1,
};
}
// No match in this run: continue scanning AFTER it.
i = j > i ? j : i + 1;
}
return null;
}
/**
* Reconstruct the RAW text spanned by an AnchorMatch inside one block's
* `content` array. `startChild..endChild` are all text nodes (guaranteed by
* findAnchorInBlock, which only builds runs of `text` nodes), so concatenate
* each node's text slice: from `startOffset` on the first node, up to
* `endOffset` on the last, and the whole `.text` for any node fully inside the
* range. Mirrors spliceCommentMark's per-node slicing so the string returned
* here is EXACTLY the characters the comment mark will cover.
*/
function reconstructRawText(blockContent, match) {
const { startChild, startOffset, endChild, endOffset } = match;
let out = "";
for (let k = startChild; k <= endChild; k++) {
const n = blockContent[k];
const text = typeof n.text === "string" ? n.text : "";
const sliceStart = k === startChild ? startOffset : 0;
const sliceEnd = k === endChild ? endOffset : text.length;
out += text.slice(sliceStart, sliceEnd);
}
return out;
}
/**
* Return the RAW document substring that `selection` would anchor to the exact
* characters the comment mark will cover or `null` when the selection cannot
* be anchored anywhere in `doc`.
*
* This mirrors canAnchorInDoc / applyAnchorInDoc EXACTLY (same depth-first,
* document-order traversal and the same findAnchorInBlock match on the FIRST
* matching block), but instead of a boolean / an in-place mutation it
* reconstructs the raw text spanned by the matched range. Because
* findAnchorInBlock maps the normalized selection back to raw text-node
* positions, the returned string is the document's ORIGINAL characters (smart
* quotes, em-dashes, nbsp, collapsed whitespace) NOT the normalized ASCII
* agent input.
*
* Callers store THIS as the comment's `selection` so the stored value equals the
* text actually under the mark, which is what the apply-suggestion equality
* check (replaceYjsMarkedText's `joinedText !== expectedText`) compares against.
* Without it a suggestion whose anchor only matched via normalization would be
* un-appliable (spurious 409).
*/
export function getAnchoredText(doc, selection) {
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return null;
if (!Array.isArray(node.content))
return null;
const match = findAnchorInBlock(node.content, selection);
if (match)
return reconstructRawText(node.content, match);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
const found = visit(child, depth + 1);
if (found !== null)
return found;
}
}
return null;
};
return visit(doc, 0);
}
/**
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc`. At each node with an array `content`, first try to match
* within that node's own content, then recurse into children that themselves
* have a `content` array.
*/
export function canAnchorInDoc(doc, selection) {
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return false;
if (!Array.isArray(node.content))
return false;
if (findAnchorInBlock(node.content, selection))
return true;
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
if (visit(child, depth + 1))
return true;
}
}
return false;
};
return visit(doc, 0);
}
/**
* Split the matched text nodes and splice the comment mark across the range.
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
* nodes (guaranteed by findAnchorInBlock building runs of text nodes).
*/
function spliceCommentMark(blockContent, match, commentId) {
const { startChild, startOffset, endChild, endOffset } = match;
const commentMark = makeCommentMark(commentId);
const fragments = [];
for (let k = startChild; k <= endChild; k++) {
const n = blockContent[k];
const text = typeof n.text === "string" ? n.text : "";
const sliceStart = k === startChild ? startOffset : 0;
const sliceEnd = k === endChild ? endOffset : text.length;
const before = k === startChild ? text.slice(0, startOffset) : "";
const marked = text.slice(sliceStart, sliceEnd);
const after = k === endChild ? text.slice(endOffset) : "";
// Process per-node so each node's OWN marks/attrs are preserved.
const ownMarks = Array.isArray(n.marks) ? n.marks : [];
// Drop any pre-existing comment mark from the marked fragment so it ends up
// with exactly one comment mark (the new one) rather than two.
const markedBaseMarks = ownMarks.filter((m) => !(m && m.type === "comment"));
if (before.length > 0) {
fragments.push({ ...n, text: before, marks: [...ownMarks] });
}
if (marked.length > 0) {
fragments.push({
...n,
text: marked,
marks: [...markedBaseMarks, commentMark],
});
}
if (after.length > 0) {
fragments.push({ ...n, text: after, marks: [...ownMarks] });
}
}
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
}
/**
* Count how many times `selection` occurs across the whole document, using the
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
* at the first hit: every non-overlapping occurrence within each block's text
* runs is counted and summed across all blocks (depth-first, the same traversal
* as canAnchorInDoc).
*
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
* silently edit the wrong place, so a suggestion is only allowed when this
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
* occurrences inside one block are correctly reported as 2.)
*/
export function countAnchorMatches(doc, selection) {
const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0)
return 0;
// Count non-overlapping occurrences of the normalized selection within a
// single block's direct content, matching findAnchorInBlock's run building.
const countInBlock = (blockContent) => {
if (!Array.isArray(blockContent))
return 0;
let count = 0;
let i = 0;
while (i < blockContent.length) {
const node = blockContent[i];
if (!node || typeof node !== "object" || node.type !== "text") {
i++;
continue;
}
// Accumulate a maximal run of consecutive text nodes.
let rawRun = "";
let j = i;
while (j < blockContent.length) {
const n = blockContent[j];
if (!n || typeof n !== "object" || n.type !== "text")
break;
rawRun += typeof n.text === "string" ? n.text : "";
j++;
}
const norm = normalizeForMatch(rawRun).norm;
// Count every non-overlapping occurrence in this run.
let from = 0;
for (;;) {
const idx = norm.indexOf(normSel, from);
if (idx === -1)
break;
count++;
from = idx + normSel.length;
}
i = j > i ? j : i + 1;
}
return count;
};
let total = 0;
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return;
if (!Array.isArray(node.content))
return;
total += countInBlock(node.content);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
visit(child, depth + 1);
}
}
};
visit(doc, 0);
return total;
}
/**
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
* whose content matches `selection`, splice the comment mark across the matched
* range in place and return true. Returns false (and does NOT mutate) when no
* block matches.
*/
export function applyAnchorInDoc(doc, selection, commentId) {
const visit = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== "object")
return false;
if (!Array.isArray(node.content))
return false;
const match = findAnchorInBlock(node.content, selection);
if (match) {
spliceCommentMark(node.content, match, commentId);
return true;
}
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
if (visit(child, depth + 1))
return true;
}
}
return false;
};
return visit(doc, 0);
}
-423
View File
@@ -1,423 +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.
*/
import { Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
import { docmostSchema } from "./docmost-schema.js";
/** 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;
}
/** Count footnoteReference nodes anywhere under a node (reading order). */
function countFootnoteRefs(node) {
if (!node || typeof node !== "object")
return 0;
let n = node.type === "footnoteReference" ? 1 : 0;
if (Array.isArray(node.content)) {
for (const child of node.content)
n += countFootnoteRefs(child);
}
return n;
}
/**
* Ordered list of footnote marker numbers found in the BODY only (every
* top-level block before the first "Примечания..." notes heading; if no such
* heading, the whole doc), in reading order.
*
* Supports BOTH representations:
* - real `footnoteReference` nodes (the current footnote feature) numbered
* 1..n by reading position, since their visible number is derived;
* - legacy `[N]` text markers (older translated docs) the literal N.
*/
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;
// Real footnoteReference nodes take precedence: when present, number them by
// reading position (their displayed number is not stored).
let refCount = 0;
for (const block of bodyBlocks)
refCount += countFootnoteRefs(block);
if (refCount > 0) {
return Array.from({ length: refCount }, (_, i) => i + 1);
}
// Fallback: legacy `[N]` text markers.
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
*/
export 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 = Node.fromJSON(docmostSchema, oldDocJson);
const newNode = Node.fromJSON(docmostSchema, newDocJson);
const tr = recreateTransform(oldNode, newNode, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
const simplified = simplifyChanges(changeSet.changes, newNode);
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) };
}
/**
* Recursively walk every `text` node and tally the count of each mark by
* `mark.type` (e.g. `{ bold: 5, strike: 3, link: 2 }`). Pure and never throws.
*/
function markCounts(doc) {
const counts = {};
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 && typeof m.type === "string") {
counts[m.type] = (counts[m.type] || 0) + 1;
}
}
}
if (Array.isArray(node.content))
for (const c of node.content)
visit(c);
};
visit(doc);
return counts;
}
/**
* Build a VerifyReport for a content mutation. Pure and never throws on any
* internal error it returns a minimal "changed (diff unavailable)" report so it
* can NEVER break a write.
*
* `changed` is VALUE-based, not JSON-string-based: it is derived from the actual
* deltas (text chars, blocks, mark counts, structural integrity counts), so two
* value-equal docs that differ only in JSON key order report cleanly as
* `changed:false` / "no content change" rather than a misleading +0/-0 change.
*
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
* makes `changed` true for an image/table/callout/link count change that diffs
* to zero text closing a verify blind spot for insert_image, delete_node on a
* table, etc.
*/
export function summarizeChange(before, after) {
try {
const diff = diffDocs(before, after);
// Per-mark-type delta: include a type only when its count actually changed.
const beforeMarks = markCounts(before);
const afterMarks = markCounts(after);
const marks = {};
for (const type of new Set([
...Object.keys(beforeMarks),
...Object.keys(afterMarks),
])) {
const b = beforeMarks[type] || 0;
const a = afterMarks[type] || 0;
if (b !== a)
marks[type] = [b, a];
}
// Structural integrity delta from diffDocs: count-based [old,new] tuples for
// images/links/tables/callouts. Include a type only when old != new.
const integrity = diff.integrity;
const structure = {};
const countTypes = [
"images",
"links",
"tables",
"callouts",
];
for (const type of countTypes) {
const [b, a] = integrity[type];
if (b !== a)
structure[type] = [b, a];
}
const textInserted = diff.summary.inserted;
const textDeleted = diff.summary.deleted;
const blocksChanged = diff.summary.blocksChanged;
const hasMarkDelta = Object.keys(marks).length > 0;
const hasStructureDelta = Object.keys(structure).length > 0;
// VALUE-based change decision: ignore JSON key-order no-ops entirely.
const changed = textInserted > 0 ||
textDeleted > 0 ||
blocksChanged > 0 ||
hasMarkDelta ||
hasStructureDelta;
if (!changed) {
return {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no content change",
};
}
const parts = [];
// Only mention text/blocks when they actually changed (avoid a misleading
// "+0/-0 chars, 0 block(s)" prefix on a pure mark/structure change).
if (textInserted > 0 || textDeleted > 0 || blocksChanged > 0) {
parts.push(`+${textInserted}/-${textDeleted} chars, ${blocksChanged} block(s)`);
}
const markParts = Object.entries(marks).map(([type, [b, a]]) => `${type} ${b}${a}`);
if (markParts.length > 0)
parts.push(`marks: ${markParts.join(", ")}`);
const structureParts = Object.entries(structure).map(([type, [b, a]]) => `${type} ${b}${a}`);
if (structureParts.length > 0)
parts.push(structureParts.join(", "));
// `changed` is true here, so at least one group is present and parts is non-empty.
const summary = `changed: ${parts.join("; ")}`;
const report = {
changed: true,
textInserted,
textDeleted,
blocksChanged,
marks,
summary,
};
if (hasStructureDelta)
report.structure = structure;
return report;
}
catch {
// A pathological pair must never break a write: degrade to a minimal report.
return {
changed: true,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "changed (diff unavailable)",
};
}
}
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
/**
* Filter functions to extract only relevant information from API responses
* for better agent consumption
*/
export function filterWorkspace(data) {
return {
id: data.id,
name: data.name,
description: data.description,
defaultSpaceId: data.defaultSpaceId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
deletedAt: data.deletedAt,
};
}
export function filterSpace(space) {
return {
id: space.id,
name: space.name,
description: space.description,
slug: space.slug,
visibility: space.visibility,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
deletedAt: space.deletedAt,
};
}
export function filterGroup(group) {
return {
id: group.id,
name: group.name,
description: group.description,
workspaceId: group.workspaceId,
createdAt: group.createdAt,
updatedAt: group.updatedAt,
deletedAt: group.deletedAt,
};
}
export function filterPage(page, content, subpages) {
return {
id: page.id,
slugId: page.slugId,
title: page.title,
parentPageId: page.parentPageId,
spaceId: page.spaceId,
isLocked: page.isLocked,
createdAt: page.createdAt,
updatedAt: page.updatedAt,
deletedAt: page.deletedAt,
// Include converted markdown content if valid string (even empty)
...(typeof content === "string" && { content }),
// Include subpages if provided
...(subpages &&
subpages.length > 0 && {
subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
}),
};
}
export function filterComment(comment, markdownContent) {
return {
id: comment.id,
pageId: comment.pageId,
content: markdownContent ?? comment.content,
selection: comment.selection || null,
type: comment.type || "page",
parentCommentId: comment.parentCommentId || null,
creatorId: comment.creatorId,
creatorName: comment.creator?.name || null,
createdAt: comment.createdAt,
editedAt: comment.editedAt || null,
resolvedAt: comment.resolvedAt || null,
resolvedById: comment.resolvedById || null,
// Suggestion state: the proposed replacement text (if any) and, once a human
// applies it via the UI, when and by whom.
suggestedText: comment.suggestedText || null,
suggestionAppliedAt: comment.suggestionAppliedAt || null,
suggestionAppliedById: comment.suggestionAppliedById || null,
};
}
export function filterSearchResult(result) {
return {
id: result.id,
title: result.title,
parentPageId: result.parentPageId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
rank: result.rank,
highlight: result.highlight,
spaceId: result.space?.id,
spaceName: result.space?.name,
};
}
-101
View File
@@ -1,101 +0,0 @@
/**
* Footnote diagnostics for imported Markdown (issue #166).
*
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
* conversion path, so it reports the same problems for `create_page`,
* `update_page` and `import_page_markdown`). It never changes the document the
* importer still creates the page; this only surfaces footnote problems to the
* caller so an agent can fix its own markup instead of shipping broken footnotes.
*
* Detected problems:
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
* first is kept on import first-wins; see extractFootnotes).
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
* the line, trimmed, starts with `|`) footnotes in table cells often do not
* render as expected.
*/
import { lexFootnoteLines, forEachFootnoteReference, } from "./footnote-lex.js";
/**
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
*/
export function analyzeFootnotes(markdown) {
// Distinct reference ids in first-appearance order, plus the set of ids seen
// inside a table row.
const refIds = [];
const refIdSet = new Set();
const referencesInTables = new Set();
const addRef = (id, inTable) => {
if (!refIdSet.has(id)) {
refIdSet.add(id);
refIds.push(id);
}
if (inTable)
referencesInTables.add(id);
};
// Definition texts per id, in first-appearance order of the id.
const defTextsById = new Map();
// Same lexer the importer uses, so the analysis matches exactly what import
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
for (const tok of lexFootnoteLines(markdown)) {
if (tok.inFence)
continue;
if (tok.definition) {
const { id, text } = tok.definition;
const arr = defTextsById.get(id);
if (arr)
arr.push(text);
else
defTextsById.set(id, [text]);
// A definition's TEXT can itself reference another footnote (`[^a]: see
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
forEachFootnoteReference(text, (rid) => addRef(rid, false));
continue;
}
const inTable = tok.line.trimStart().startsWith("|");
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
}
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
const duplicateDefinitions = [];
const emptyDefinitions = [];
for (const [id, texts] of defTextsById) {
if (texts.length >= 2)
duplicateDefinitions.push(id);
// First-wins: the kept definition is the first one; flag it if it is blank.
if ((texts[0] ?? "").trim().length === 0)
emptyDefinitions.push(id);
}
const tableRefs = [...referencesInTables];
const warnings = [];
const list = (ids) => ids.map((id) => `[^${id}]`).join(", ");
if (danglingReferences.length > 0) {
warnings.push(`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`);
}
if (emptyDefinitions.length > 0) {
warnings.push(`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`);
}
if (duplicateDefinitions.length > 0) {
warnings.push(`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`);
}
if (tableRefs.length > 0) {
warnings.push(`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`);
}
return {
danglingReferences,
emptyDefinitions,
duplicateDefinitions,
referencesInTables: tableRefs,
warnings,
};
}
/**
* The optional `footnoteWarnings` field for a page-write tool result: present
* (with the warning lines) only when `markdown` has footnote problems, omitted
* otherwise. One helper so all three call sites (create/update/import) attach the
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
*/
export function footnoteWarningsField(markdown) {
const { warnings } = analyzeFootnotes(markdown);
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
}
@@ -1,88 +0,0 @@
/**
* Inline-authoring helpers for footnotes (MCP).
*
* These build/identify footnote DEFINITION nodes for the author-inline tool
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
*
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
* canonicalizer has no dependency on these.
*/
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
function cloneJson(v) {
if (typeof structuredClone === "function")
return structuredClone(v);
return JSON.parse(JSON.stringify(v));
}
/**
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
*
* Two definitions with the same key are the SAME footnote so the inline
* authoring tool reuses one id (one number, one definition, several references)
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
* read the same but differ in formatting (one bold, one plain) are NOT merged.
* Conservative: only an exact match merges.
*/
export function footnoteContentKey(defNode) {
const parts = [];
const visit = (n) => {
if (!n || typeof n !== "object")
return;
if (n.type === "text" && typeof n.text === "string") {
const marks = Array.isArray(n.marks)
? n.marks.map((m) => m?.type).filter(Boolean).sort().join(",")
: "";
parts.push(`${n.text}${marks}`);
}
if (Array.isArray(n.content))
for (const c of n.content)
visit(c);
};
visit(defNode);
// Collapse the assembled text's whitespace and trim, keeping the mark
// signature attached so formatting differences still distinguish notes.
return parts
.join("")
.replace(/[ \t\r\n]+/g, " ")
.trim();
}
/**
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
*/
export function makeFootnoteDefinition(id, inlineNodes) {
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
return {
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [{ type: "paragraph", content }],
};
}
/**
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
*/
export function generateFootnoteId() {
const now = Date.now();
const timeHex = now.toString(16).padStart(12, "0");
const rand = (length) => {
let s = "";
for (let i = 0; i < length; i++)
s += Math.floor(Math.random() * 16).toString(16);
return s;
};
const versioned = "7" + rand(3);
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
const variant = variantNibble + rand(3);
return (timeHex.slice(0, 8) +
"-" +
timeHex.slice(8, 12) +
"-" +
versioned +
"-" +
variant +
"-" +
rand(12));
}
@@ -1,215 +0,0 @@
/**
* Server-side footnote canonicalizer (MCP mirror PURE).
*
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
* `footnoteSyncPlugin` end-state, identical in behaviour to
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here rather
* than imported from editor-ext for the SAME reason `footnote-lex.ts` and the
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
* decoupled from the browser/React-heavy editor barrel and operates on plain
* JSON. The editor-ext copy owns the golden test against the live plugin; this
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
* both test suites, pins that see `test/unit/footnote-corpus.mjs`).
*
* This module is the pure MIRROR only. The inline-authoring helpers
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
* file is compositionally symmetric to the editor-ext copy.
*
* Why it exists: every NON-editor write path (markdown import, update_page_json,
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
* editor's footnote plugins never run and the canonical topology (sequential
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
* was never enforced. Running this at the end of every write path closes that
* gap; because it is idempotent, it is a no-op when the footnotes are already
* canonical (no spurious mutations / git-sync churn).
*
* ENFORCEMENT RULE (#228): any NEW FULL-document persist path MUST call
* `canonicalizeFootnotes(doc)` before writing the current callers are
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
* reference-less definition), `update_page_json`, `docmost_transform`,
* `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
* and comment-vs-page nuances make a single naive wrapper unsafe).
*/
const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
const FOOTNOTES_LIST_NAME = "footnotesList";
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
function cloneJson(v) {
if (typeof structuredClone === "function")
return structuredClone(v);
return JSON.parse(JSON.stringify(v));
}
function isEmptyParagraph(node) {
return (!!node &&
node.type === "paragraph" &&
(!Array.isArray(node.content) || node.content.length === 0));
}
function collectReferenceIds(node, out, seen) {
if (!node || typeof node !== "object")
return;
if (node.type === FOOTNOTE_REFERENCE_NAME) {
const id = node?.attrs?.id;
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
if (Array.isArray(node.content)) {
for (const child of node.content)
collectReferenceIds(child, out, seen);
}
}
function collectDefinitions(node, out) {
if (!node || typeof node !== "object")
return;
if (node.type === FOOTNOTE_DEFINITION_NAME)
out.push(node);
if (Array.isArray(node.content)) {
for (const child of node.content)
collectDefinitions(child, out);
}
}
function emptyDefinition(id) {
return {
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [{ type: "paragraph" }],
};
}
/**
* Deep equality over plain JSON: arrays are compared POSITIONALLY
* (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity
* is required for correctness here a reordered `footnotesList.content` must
* compare UNEQUAL so the canonical rebuild fires instead of leaving it in place.
*/
function deepEqualJson(a, b) {
if (a === b)
return true;
if (a == null || b == null || typeof a !== typeof b)
return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqualJson(a[i], b[i]))
return false;
}
return true;
}
if (typeof a === "object") {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length)
return false;
for (const k of ka) {
if (!Object.prototype.hasOwnProperty.call(b, k))
return false;
if (!deepEqualJson(a[k], b[k]))
return false;
}
return true;
}
return false;
}
/**
* Canonicalize footnotes in a ProseMirror-JSON document. See the file header and
* the editor-ext twin for the full contract. Pure (deep-clones input,
* deterministic, idempotent).
*/
export function canonicalizeFootnotes(doc) {
if (doc == null ||
typeof doc !== "object" ||
!Array.isArray(doc.content)) {
return doc;
}
const out = cloneJson(doc);
// 1) Distinct reference ids in document order (deep — refs can live in
// callouts, tables, list items, ...). The ordering/numbering truth.
const referenceIds = [];
collectReferenceIds(out, referenceIds, new Set());
// 2) Every definition node in document order (deep).
const defNodes = [];
collectDefinitions(out, defNodes);
// 3) First definition per id wins; later duplicates carry the SAME id, so they
// cannot be referenced separately and would be orphans — they are dropped.
const defById = new Map();
for (const d of defNodes) {
const id = d?.attrs?.id;
if (id && !defById.has(id))
defById.set(id, d);
}
// 4) Build the ordered definition list: one per referenced id, in REFERENCE
// order, reusing the existing node (shallow-copied, id normalized — `out` is
// already deep-cloned and the old lists are cut) or synthesizing an empty
// one. Definitions whose id is not referenced are orphans and never added.
const orderedDefs = [];
for (const id of referenceIds) {
const existing = defById.get(id);
if (existing) {
orderedDefs.push({
...existing,
attrs: { ...(existing.attrs ?? {}), id },
});
}
else {
orderedDefs.push(emptyDefinition(id));
}
}
// 5) No references -> there must be NO list at all (at any depth).
if (referenceIds.length === 0) {
stripFootnotesListsDeep(out);
return out;
}
// 6) Placement parity with the live plugin: when the document is ALREADY in the
// canonical single-list state, leave that list exactly where it sits rather
// than cutting and re-inserting it at the end (the plugin never repositions a
// sole correct list, so moving it would silently reorder any content that
// follows the list on the first write).
const topLevelLists = out.content.filter((n) => n && n.type === FOOTNOTES_LIST_NAME);
if (topLevelLists.length === 1 &&
defNodes.length === orderedDefs.length &&
deepEqualJson(topLevelLists[0].content, orderedDefs)) {
return out;
}
// 7) Otherwise rebuild: strip every footnotesList AND every bare
// footnoteDefinition at ANY depth (collectDefinitions gathers defs
// recursively, so a list nested in a callout/blockquote — or a bare
// definition outside any list — would otherwise have its defs copied into the
// rebuilt list while the original survives in place → duplicates) and
// re-insert exactly one list after the last meaningful (non-empty paragraph)
// top-level block.
stripFootnotesListsDeep(out);
stripFootnoteDefinitionsDeep(out);
const top = out.content;
let insertAt = top.length;
while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1]))
insertAt--;
top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs });
out.content = top;
return out;
}
/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */
function stripFootnotesListsDeep(node) {
if (!node || typeof node !== "object" || !Array.isArray(node.content))
return;
node.content = node.content.filter((c) => !(c && c.type === FOOTNOTES_LIST_NAME));
for (const child of node.content)
stripFootnotesListsDeep(child);
}
/**
* Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given
* clone). Runs only in the rebuild path AFTER the lists are stripped, so it
* targets definitions that were sitting outside a list (e.g. hand-authored via a
* raw-JSON write path and nested in a callout); their content was already copied
* into the rebuilt list, so leaving the originals would duplicate them.
*/
function stripFootnoteDefinitionsDeep(node) {
if (!node || typeof node !== "object" || !Array.isArray(node.content))
return;
node.content = node.content.filter((c) => !(c && c.type === FOOTNOTE_DEFINITION_NAME));
for (const child of node.content)
stripFootnoteDefinitionsDeep(child);
}
-55
View File
@@ -1,55 +0,0 @@
/**
* Shared, fence-aware line lexer for footnote markdown (MCP-internal).
*
* Both the importer (`extractFootnotes` in collaboration.ts, which strips
* definition lines and rebuilds a footnotes section) and the diagnostics
* (`analyzeFootnotes` in footnote-analyze.ts) must agree EXACTLY on which lines
* are definitions and which lines are inert (inside a code fence). Sharing one
* lexer makes "the analyzer sees what the importer leaves" a structural property
* instead of two hand-kept copies that can drift (#166 review).
*
* NOTE: this is deliberately NOT shared with editor-ext's
* `extractFootnoteDefinitions` that lives in a different package and the
* decoupling between the editor and the MCP mirror is intentional.
*/
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
/** Opening/closing code fence marker (``` or ~~~). */
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
export function lexFootnoteLines(markdown) {
const out = [];
let fence = null;
for (const line of markdown.split("\n")) {
const fenceMatch = FENCE_RE.exec(line);
if (fenceMatch) {
const marker = fenceMatch[2][0];
if (fence === null)
fence = marker; // opening fence
else if (marker === fence)
fence = null; // matching closing fence
out.push({ line, inFence: true, definition: null });
continue;
}
if (fence !== null) {
out.push({ line, inFence: true, definition: null });
continue;
}
const m = FOOTNOTE_DEF_RE.exec(line);
out.push({
line,
inFence: false,
definition: m ? { id: m[1], text: m[2] } : null,
});
}
return out;
}
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
export function forEachFootnoteReference(line, onRef) {
FOOTNOTE_REF_RE_G.lastIndex = 0;
let m;
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null)
onRef(m[1]);
}
@@ -1,110 +0,0 @@
// Detection + collection of INTERNAL Docmost file URLs inside a ProseMirror doc.
//
// An internal file URL is a relative path served by Docmost's authenticated
// attachment route (`GET /api/files/:fileId/:fileName`). It is useless to an
// external consumer (relative + needs a Docmost session), so the stash tool
// mirrors every such resource into the blob sandbox and rewrites its `src`.
//
// The criterion is "internal file URL", NOT the node TYPE: image, drawio,
// excalidraw, video and file nodes all carry such a `src`, so a type-agnostic
// walker covers them all. External http(s) srcs (CDNs) are left untouched.
//
// Mirrors editor-ext's isInternalFileUrl / normalizeFileUrl (kept as a local
// dup so the ESM mcp package does not depend on the editor-ext build).
function isInternalFileUrl(url) {
if (typeof url !== "string")
return false;
const normalized = url.trim();
return (normalized.startsWith("/api/files/") || normalized.startsWith("/files/"));
}
/** Normalize a bare `/files/...` src to the canonical `/api/files/...` form. */
export function normalizeFileUrl(src) {
const trimmed = src.trim();
if (trimmed.startsWith("/files/"))
return "/api" + trimmed;
return trimmed;
}
/**
* Resolve a page-content `src` into the safe, `/api`-relative path the stash
* tool may fetch over the authenticated loopback client or THROW.
*
* SECURITY (SSRF / path-traversal): `src` comes from page content and is fully
* attacker-controllable. The mirroring fetch runs through the AUTHENTICATED
* loopback axios client whose baseURL ends in `/api`, so a naive
* `src.replace(/^\/api/, "")` lets a crafted value like
* `/api/files/../auth/whoami` collapse (via axios/WHATWG URL `..` resolution)
* into an ARBITRARY internal GET endpoint, whose authed response would then be
* stored in the anonymous sandbox (SSRF + data exfiltration). A prefix-only
* `startsWith("/api/files/")` check does NOT defend against this because the
* `..` segments are still present in the raw string and resolved later.
*
* This function defeats that by resolving the canonical pathname FIRST and only
* then asserting it still lives under `/api/files/`:
* - it rejects any percent-encoded dot/slash (`%2e` / `%2f`): the WHATWG URL
* parser collapses LITERAL `../` but does NOT decode `%2f` separators, so a
* content-controlled src must never be allowed to smuggle those past the
* canonicalization;
* - it resolves `new URL(trimmed, "http://internal.invalid").pathname`, which
* normalizes `..`/`.` segments (e.g. `/api/files/../auth/whoami`
* `/api/auth/whoami`);
* - it then requires the canonical pathname to start with `/api/files/`, so a
* traversal that escaped that subtree is rejected.
*
* Returns the path RELATIVE to the `/api` base (e.g. `/files/<id>/<name>`),
* ready to hand to the loopback client. The throw happens BEFORE any network
* call, so a rejected src is counted as a failed mirror and its original src is
* kept (the per-image try/catch in stashPage never aborts the whole document).
*/
export function resolveInternalFilePath(src) {
const trimmed = src.trim();
// Percent-encoded dot/slash must never reach the URL canonicalizer: the
// WHATWG parser does NOT decode `%2f` into a path separator, so an encoded
// `..%2fauth` would survive canonicalization and still escape /api/files/.
if (/%2e|%2f/i.test(trimmed)) {
throw new Error(`Refusing internal file src with percent-encoded path segment: "${src}"`);
}
let pathname;
try {
// The base host is irrelevant (never contacted); it only lets the parser
// resolve a relative `src` and normalize `..`/`.` segments.
pathname = new URL(trimmed, "http://internal.invalid").pathname;
}
catch {
throw new Error(`Invalid internal file src: "${src}"`);
}
if (!pathname.startsWith("/api/files/")) {
throw new Error(`Refusing internal file src that escapes /api/files/: "${src}"`);
}
// Strip the `/api` base prefix; the loopback client's baseURL already ends
// in `/api`, so it expects the path relative to that (e.g. /files/<id>/<f>).
return pathname.replace(/^\/api/, "");
}
/**
* Recursively collect every node whose `attrs.src` is an internal file URL.
* Returns references to the live nodes (so the caller can rewrite `attrs.src`
* in place on its clone). Descends `content` arrays, covering callouts, tables,
* details and any other nested container.
*/
export function collectInternalFileNodes(doc) {
const out = [];
const visit = (node) => {
if (!node)
return;
if (Array.isArray(node)) {
for (const child of node)
visit(child);
return;
}
if (typeof node !== "object")
return;
if (node.attrs && isInternalFileUrl(node.attrs.src)) {
out.push(node);
}
if (Array.isArray(node.content)) {
for (const child of node.content)
visit(child);
}
};
visit(doc);
return out;
}
-393
View File
@@ -1,393 +0,0 @@
/**
* Surgical text edits on a ProseMirror document without re-importing it.
*
* Each edit replaces an exact substring of a block's inline text, preserving
* every node id, mark and attribute around it. Matching works at the
* INLINE-CONTAINER (block) level: a block's text nodes are flattened into a
* per-character array, so a `find` may freely cross bold/italic/link
* boundaries (separate text nodes). The replacement inherits marks from the
* unchanged common prefix/suffix of the match, so editing plain text next to a
* bold word keeps the bold word bold, and editing the inside of a bold word
* keeps the inserted text bold. This is the safe alternative to a full markdown
* re-import for small wording fixes.
*/
import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
/** Placeholder code unit standing in for one opaque (non-text) inline node. */
const ATOM_PLACEHOLDER = ""; // OBJECT REPLACEMENT CHARACTER
/**
* Find every VALID occurrence of `needle` in a block's flattened slots.
*
* A candidate occurrence at slot range [start, start+needle.length) is valid
* ONLY IF none of the slots in that range are atoms (non-text inline nodes).
* This makes atom matching collision-safe against the U+FFFC placeholder: an
* atom slot can never be part of a match, while a real text node containing a
* literal U+FFFC code unit still matches normally (its slot has no `.atom`).
*
* Overlapping candidates that touch an atom are skipped (not counted, not
* spliced); the scan resumes one code unit past the rejected start so a valid
* match that begins just after an atom is not missed.
*/
function findValidMatches(chars, plain, needle) {
if (!needle)
return [];
const positions = [];
let idx = plain.indexOf(needle);
while (idx !== -1) {
const end = idx + needle.length;
let hasAtom = false;
for (let i = idx; i < end; i++) {
if (chars[i] && chars[i].atom) {
hasAtom = true;
break;
}
}
if (!hasAtom) {
positions.push(idx);
// Non-overlapping: skip past this match.
idx = plain.indexOf(needle, end);
}
else {
// This candidate crosses an atom: reject it and resume one unit later so
// an overlapping valid match starting after the atom is still found.
idx = plain.indexOf(needle, idx + 1);
}
}
return positions;
}
/** Order-sensitive deep-equality of two marks arrays. */
function marksEqual(a, b) {
if (a === b)
return true;
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (JSON.stringify(a[i]) !== JSON.stringify(b[i]))
return false;
}
return true;
}
/** A block is any node that DIRECTLY contains at least one inline text child. */
function isInlineBlock(node) {
return (Array.isArray(node?.content) &&
node.content.some((child) => child && child.type === "text"));
}
/** Flatten a block's inline content into a per-code-unit slot array. */
function flattenBlock(node) {
const chars = [];
for (const child of node.content || []) {
if (child && child.type === "text" && typeof child.text === "string") {
const marks = child.marks || [];
// Iterate by UTF-16 code unit so indices align with String.indexOf.
for (let i = 0; i < child.text.length; i++) {
chars.push({ ch: child.text[i], marks });
}
}
else {
// Any non-text inline node becomes one opaque slot.
chars.push({
ch: ATOM_PLACEHOLDER,
marks: (child && child.marks) || [],
atom: child,
});
}
}
return chars;
}
/** Re-tokenize a slot array back into ProseMirror inline nodes. */
function tokenizeChars(chars) {
const out = [];
let buffer = "";
let bufferMarks = null;
const flush = () => {
if (buffer.length === 0)
return;
const textNode = { type: "text", text: buffer };
if (bufferMarks && bufferMarks.length > 0)
textNode.marks = bufferMarks;
out.push(textNode);
buffer = "";
bufferMarks = null;
};
for (const slot of chars) {
if (slot.atom) {
flush();
out.push(slot.atom);
continue;
}
if (bufferMarks !== null && !marksEqual(bufferMarks, slot.marks)) {
flush();
}
if (bufferMarks === null)
bufferMarks = slot.marks;
buffer += slot.ch;
}
flush();
return out;
}
/** Longest common prefix length of two strings. */
function commonPrefixLen(a, b) {
const max = Math.min(a.length, b.length);
let i = 0;
while (i < max && a[i] === b[i])
i++;
return i;
}
/** Longest common suffix length of two strings, capped so it can't overlap. */
function commonSuffixLen(a, b, cap) {
const max = Math.min(a.length, b.length, cap);
let i = 0;
while (i < max && a[a.length - 1 - i] === b[b.length - 1 - i])
i++;
return i;
}
/**
* Apply one edit to one block's flattened slot array.
*
* The caller passes only VALID (atom-free) match positions (see
* findValidMatches), so no match range can overlap an atom slot here.
*/
function applyEditToChars(chars, edit, matchPositions) {
// Pre-compute the diff slices once (find/replace are constant per edit).
const p = commonPrefixLen(edit.find, edit.replace);
const s = commonSuffixLen(edit.find, edit.replace, Math.min(edit.find.length, edit.replace.length) - p);
const insertText = edit.replace.slice(p, edit.replace.length - s);
// Rebuild the slot array in a single left-to-right pass, splicing at each
// match start. Offsets into `chars` stay valid because we copy through.
const newChars = [];
let cursor = 0;
let spliced = 0;
for (const mStart of matchPositions) {
const mEnd = mStart + edit.find.length;
const changedStart = mStart + p;
const changedEnd = mEnd - s;
// Copy through everything up to the changed region (incl. the prefix).
for (; cursor < changedStart; cursor++)
newChars.push(chars[cursor]);
const removed = chars.slice(changedStart, changedEnd);
// Choose the marks for the inserted characters.
let chosenMarks = [];
if (removed.length > 0 &&
removed.every((r) => marksEqual(r.marks, removed[0].marks))) {
// Uniform removed region: inherit its marks directly.
chosenMarks = removed[0].marks;
}
else {
// Empty or non-uniform removed region: inherit from the nearest TEXT
// neighbour, skipping atom slots (an atom carries marks that do not
// belong on inserted text). Scan left first, then right; fall back to [].
let inherited = null;
for (let i = changedStart - 1; i >= 0; i--) {
if (!chars[i].atom) {
inherited = chars[i].marks;
break;
}
}
if (inherited === null) {
for (let i = changedEnd; i < chars.length; i++) {
if (!chars[i].atom) {
inherited = chars[i].marks;
break;
}
}
}
chosenMarks = inherited === null ? [] : inherited;
}
// Emit the inserted text (one slot per code unit).
for (let i = 0; i < insertText.length; i++) {
newChars.push({ ch: insertText[i], marks: chosenMarks });
}
// Skip the removed region.
cursor = changedEnd;
spliced++;
}
// Copy through the tail.
for (; cursor < chars.length; cursor++)
newChars.push(chars[cursor]);
return { newChars, spliced };
}
/**
* Apply text edits to a ProseMirror doc (operates on a deep copy, returns it).
*
* Returns { doc, results, failed }:
* - results: edits that applied (replacements >= 1).
* - failed: edits that matched zero times, were ambiguous (multi-match
* without replaceAll), or whose changed region crosses a non-text inline
* node. These do NOT throw they are recorded so the caller can surface an
* actionable message and still keep the edits that did apply.
*
* Edits apply IN ORDER to the same working copy, so a later edit can target
* text produced by an earlier one. The input doc is never mutated. The only
* thrown error is for invalid input (an empty `edit.find`).
*/
export function applyTextEdits(doc, edits) {
const copy = JSON.parse(JSON.stringify(doc));
const results = [];
const failed = [];
for (const edit of edits) {
if (!edit.find)
throw new Error("edit.find must be a non-empty string");
// HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and
// writes the replacement verbatim, so it cannot add/remove marks. We refuse
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
// replace:"**M5Stack**" which would write literal `**`).
//
// The detector is the STRICT stripBalancedWrappers, NOT the lenient locator
// stripInlineMarkdown: the lenient one also trims whitespace/emoji and
// collapses lone `*`/`_` runs, which gives false positives on ordinary
// plain-text edits (trailing-space trim, snake_case, `2 * 3 * 4`, URLs with
// underscores) and wrongly refuses them. Comparing the strict strip of both
// sides symmetrically catches every real formatting toggle while leaving
// plain text alone; a typo fix wrapped in markdown still applies because its
// stripped find != stripped replace.
const formattingOnly = edit.find !== edit.replace &&
stripBalancedWrappers(edit.find) === stripBalancedWrappers(edit.replace);
if (formattingOnly) {
failed.push({
find: edit.find,
reason: "edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.",
});
continue;
}
// Gather every inline block in document order (recurse the whole tree so
// nested containers — callouts, list items, table cells, blockquotes — are
// all covered).
const blocks = [];
(function collect(node) {
if (isInlineBlock(node))
blocks.push(node);
for (const child of node.content || [])
collect(child);
})(copy);
// Find every VALID (atom-free) occurrence per block. A candidate whose slot
// range overlaps a non-text inline atom is never a match (collision-safe vs
// the U+FFFC placeholder), so it is excluded from both the uniqueness count
// and the splicing.
const blockChars = blocks.map((b) => flattenBlock(b));
const blockPlain = blockChars.map((chars) => chars.map((c) => c.ch).join(""));
// EXACT MATCH WINS: try the verbatim locator first.
let effectiveFind = edit.find;
let normalized = false;
let validPerBlock = blockChars.map((chars, b) => findValidMatches(chars, blockPlain[b], edit.find));
let total = 0;
for (const positions of validPerBlock)
total += positions.length;
// FALLBACK: only if the verbatim locator matched nothing, retry with the
// markdown-stripped form. `edit.replace` is never touched — this only
// changes what we LOCATE, not what we insert.
const stripped = stripInlineMarkdown(edit.find);
if (total === 0 && stripped !== edit.find && stripped.length > 0) {
const strippedPerBlock = blockChars.map((chars, b) => findValidMatches(chars, blockPlain[b], stripped));
let strippedTotal = 0;
for (const positions of strippedPerBlock)
strippedTotal += positions.length;
if (strippedTotal >= 1) {
validPerBlock = strippedPerBlock;
total = strippedTotal;
effectiveFind = stripped;
normalized = true;
}
}
if (total === 0) {
// Distinguish "the text exists but only across an atom" from a plain
// not-found: if a raw substring scan (atoms included) WOULD have hit —
// for EITHER the verbatim or the stripped locator — the only thing
// blocking the edit is the atom, so report that.
const existsAcrossAtom = blockPlain.some((plain) => plain.indexOf(edit.find) !== -1 ||
(stripped !== edit.find && plain.indexOf(stripped) !== -1));
let reason;
if (existsAcrossAtom) {
reason =
"match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes.";
}
else {
// Append a bounded "closest text" hint: find the FIRST block that
// contains the longest whitespace-delimited token (>= 3 chars) of the
// (stripped, then raw) locator, and quote that block's plain text.
reason = "text not found in the document.";
const tokenSource = stripped.length > 0 ? stripped : edit.find;
const longestToken = tokenSource
.split(/\s+/)
.filter((t) => t.length >= 3)
.sort((a, b) => b.length - a.length)[0];
if (longestToken) {
const hitBlock = blockPlain.find((plain) => plain.includes(longestToken));
if (hitBlock) {
// Truncate by code point (spread iterates by code point) so a
// surrogate pair is never split; append the ellipsis only when the
// text was actually longer than the limit.
const points = [...hitBlock];
const snippet = points.length > 120
? points.slice(0, 120).join("") + "…"
: hitBlock;
reason += ` Closest block text: "${snippet}".`;
}
}
}
failed.push({ find: edit.find, reason });
continue;
}
if (total > 1 && !edit.replaceAll) {
failed.push({
find: edit.find,
reason: `matches ${total} times. Provide a longer, unique fragment or set replaceAll: true.`,
});
continue;
}
// Plan the splices from the valid positions. For a non-replaceAll edit we
// splice only the first valid match (left-to-right across blocks); for
// replaceAll we splice every valid match.
const plannedPerBlock = blockChars.map(() => []);
let takenFirst = false;
for (let b = 0; b < validPerBlock.length; b++) {
for (const idx of validPerBlock[b]) {
if (edit.replaceAll) {
plannedPerBlock[b].push(idx);
}
else if (!takenFirst) {
plannedPerBlock[b].push(idx);
takenFirst = true;
break;
}
else {
break;
}
}
if (!edit.replaceAll && takenFirst)
break;
}
// Apply the splices block-by-block and re-tokenize changed blocks. The
// local edit uses `effectiveFind` (verbatim or normalized) so the
// prefix/suffix diff is computed against the ACTUALLY matched text, while
// `edit.replace` stays literal — never stripped.
const effectiveEdit = {
find: effectiveFind,
replace: edit.replace,
replaceAll: edit.replaceAll,
};
let spliced = 0;
for (let b = 0; b < blocks.length; b++) {
if (plannedPerBlock[b].length === 0)
continue;
const { newChars, spliced: n } = applyEditToChars(blockChars[b], effectiveEdit, plannedPerBlock[b]);
spliced += n;
blocks[b].content = tokenizeChars(newChars);
}
// Keep `find: edit.find` (the original) so the caller can correlate.
const result = { find: edit.find, replacements: spliced };
if (normalized)
result.normalized = true;
results.push(result);
}
// Safety net: drop any empty text nodes (ProseMirror forbids them). The
// re-tokenizer never emits empty text nodes, but untouched blocks could in
// principle carry one in from upstream.
(function prune(node) {
if (Array.isArray(node.content)) {
node.content = node.content.filter((child) => !(child.type === "text" && child.text === ""));
for (const child of node.content)
prune(child);
}
})(copy);
return { doc: copy, results, failed };
}
@@ -1,835 +0,0 @@
/**
* Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions
*/
export function convertProseMirrorToMarkdown(content) {
if (!content || !content.content)
return "";
// Escape a value interpolated into an HTML double-quoted attribute value
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
// ATTRIBUTE context only the quote that delimits the value and the ampersand
// that starts an entity are special, so we escape ONLY & " (and ' for safety
// when single-quoted delimiters are used). We deliberately do NOT escape < or
// >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode
// &lt;/&gt; back inside attribute values, so escaping them would corrupt the
// stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on
// every round-trip (`a < b` -> `a &lt; b` -> `a &amp;lt; b`). Escaping & "
// keeps the value inert against attribute-injection while staying idempotent.
// NOTE: escape ONLY & and " here. The value is always wrapped in double
// quotes, so " is the only delimiter; ' is NOT special in a double-quoted
// value, and parse5 does not decode &#39; back inside attribute values, so
// escaping ' would (like < >) corrupt the value and accumulate &amp; on every
// round-trip. Escaping & and " is idempotent (parse5 decodes them back).
const escapeAttr = (value) => String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
// Escape a value placed as HTML element TEXT content (between tags), where
// <, >, and & are all significant. Used for text rendered inside raw-HTML
// blocks (table cells / columns) so stored characters cannot inject markup.
const escapeHtmlText = (value) => String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Percent-encode characters that would break out of a markdown URL target
// (...) — whitespace/newlines and parentheses — so a stored src stays a
// single inert token (used for image/video/youtube srcs).
const encodeMdUrl = (value) => String(value || "")
.replace(/\s/g, (c) => (c === " " ? "%20" : encodeURIComponent(c)))
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
const processNode = (node) => {
const type = node.type;
const nodeContent = node.content || [];
switch (type) {
case "doc":
return nodeContent.map(processNode).join("\n\n");
case "paragraph":
const text = nodeContent.map(processNode).join("");
const align = node.attrs?.textAlign;
if (align && align !== "left") {
return `<div align="${escapeAttr(align)}">${text}</div>`;
}
return text || "";
case "heading":
const level = node.attrs?.level || 1;
const headingText = nodeContent.map(processNode).join("");
return "#".repeat(level) + " " + headingText;
case "text":
let textContent = node.text || "";
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
// Markdown code spans (`...`) cannot carry inner formatting, so when a
// run has the `code` mark alongside ANY other mark, backtick syntax
// would leak literal ** / []() into the code text. In that case emit
// nested HTML (<code> innermost, the other marks wrapping it as HTML)
// so the output is at least well-formed and re-parseable.
//
// NOTE: this does NOT round-trip both marks. The schema's `code` mark
// has `excludes: "_"` (it excludes every other mark), so on import the
// co-occurring mark is always dropped — the run comes back as `code`
// only. We keep the emission simple and accept that the other mark is
// lost; preserving both is impossible while `code` excludes them.
// Only use the backtick form when `code` is the sole mark.
const markTypes = node.marks.map((m) => m.type);
const hasCode = markTypes.includes("code");
const codeCombined = hasCode && markTypes.length > 1;
for (const mark of node.marks) {
switch (mark.type) {
case "bold":
textContent = codeCombined
? `<strong>${textContent}</strong>`
: `**${textContent}**`;
break;
case "italic":
textContent = codeCombined
? `<em>${textContent}</em>`
: `*${textContent}*`;
break;
case "code":
// When combined with another mark, wrap as <code> so the
// surrounding HTML marks can nest around it; otherwise use the
// plain backtick span.
textContent = codeCombined
? `<code>${textContent}</code>`
: `\`${textContent}\``;
break;
case "link": {
const href = mark.attrs?.href || "";
const title = mark.attrs?.title;
if (codeCombined) {
// Emit an HTML anchor so it can wrap the nested <code>.
const safeHref = escapeAttr(href);
if (title) {
textContent = `<a href="${safeHref}" title="${escapeAttr(String(title))}">${textContent}</a>`;
}
else {
textContent = `<a href="${safeHref}">${textContent}</a>`;
}
}
else if (title) {
// Emit the optional markdown link title; escape an embedded
// double-quote so it cannot terminate the title string early.
const safeTitle = String(title).replace(/"/g, '\\"');
textContent = `[${textContent}](${href} "${safeTitle}")`;
}
else {
textContent = `[${textContent}](${href})`;
}
break;
}
case "strike":
textContent = codeCombined
? `<s>${textContent}</s>`
: `~~${textContent}~~`;
break;
case "underline":
textContent = `<u>${textContent}</u>`;
break;
case "subscript":
textContent = `<sub>${textContent}</sub>`;
break;
case "superscript":
textContent = `<sup>${textContent}</sup>`;
break;
case "highlight": {
// Preserve a null/empty color as a plain highlight (a bare
// <mark> with no background-color); only emit the style when a
// color is actually set, so a plain highlight is not forced to
// yellow on export.
const color = mark.attrs?.color;
textContent = color
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
: `<mark>${textContent}</mark>`;
break;
}
case "textStyle":
if (mark.attrs?.color) {
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
}
break;
case "comment": {
// Emit the inline comment anchor so highlights round-trip. The
// schema's Comment mark parses span[data-comment-id] (attrs
// commentId/resolved).
const cid = mark.attrs?.commentId;
if (cid) {
const resolvedAttr = mark.attrs?.resolved
? ` data-resolved="true"`
: "";
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
}
break;
}
case "spoiler":
// Markdown has no native spoiler syntax, so emit the same
// lossless raw HTML the editor-ext turndown rule produces; the
// schema's Spoiler mark parses span[data-spoiler] back on import.
textContent = `<span data-spoiler="true">${textContent}</span>`;
break;
}
}
}
return textContent;
case "codeBlock":
const language = node.attrs?.language || "";
// Strip ALL trailing newlines so the export is idempotent: marked
// re-adds exactly one trailing "\n" on import, so trimming only one
// here would let the text grow by "\n" on each round-trip. Removing
// every trailing newline makes repeated cycles stable.
const code = nodeContent
.map(processNode)
.join("")
.replace(/\n+$/, "");
return "```" + language + "\n" + code + "\n```";
case "bulletList":
return nodeContent
.map((item) => processListItem(item, "-"))
.join("\n");
case "orderedList":
return nodeContent
.map((item, index) => processListItem(item, `${index + 1}.`))
.join("\n");
case "taskList":
return nodeContent.map((item) => processTaskItem(item)).join("\n");
case "taskItem":
// Delegate to the same helper used by taskList so multi-block and
// nested task items render and indent consistently.
return processTaskItem(node);
case "listItem":
return nodeContent.map(processNode).join("\n");
case "blockquote":
// Prefix EVERY line of EVERY child with "> " and separate block-level
// children with a blank ">" line so code blocks / multi-paragraph
// quotes round-trip correctly.
return nodeContent
.map((n) => processNode(n)
.split("\n")
.map((line) => (line.length ? `> ${line}` : ">"))
.join("\n"))
.join("\n>\n");
case "horizontalRule":
return "---";
case "hardBreak":
// Two trailing spaces before the newline encode a markdown hard break;
// a bare "\n" would be reimported as a soft break and lost.
return " \n";
case "image": {
const imgAlt = node.attrs?.alt || "";
const imgCaption = node.attrs?.caption || "";
if (imgCaption) {
// ![]() can't carry a caption, so (symmetric to video) emit a raw
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
// HTML and generateJSON runs the image extension's parseHTML, which
// restores the caption from data-caption.
const parts = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
if (imgAlt)
parts.push(`alt="${escapeAttr(imgAlt)}"`);
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
return `<div><img ${parts.join(" ")}></div>`;
}
// Neutralize characters that could break out of the markdown image
// URL: spaces/newlines and parentheses would terminate the (...) target
// and let a stored src inject following markdown/HTML. Percent-encode
// them so the URL stays a single inert token.
const imgSrc = encodeMdUrl(node.attrs?.src);
return `![${imgAlt}](${imgSrc})`;
}
case "video": {
// Emit the schema-matching <video> element so generateJSON rebuilds the
// node with its attrs intact. The schema's parseHTML reads src/aria-label
// from the standard attributes and the remaining attrs from data-*.
const attrs = node.attrs || {};
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt)
parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.width != null)
parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
// Wrap in a block <div> so marked treats it as a block (a bare <video>
// is inline-level HTML and marked wraps it in <p>, leaving a spurious
// empty paragraph beside the hoisted block atom). The wrapper has no
// data-type, so the schema parser ignores it and just hoists the video.
return `<div><video ${parts.join(" ")}></video></div>`;
}
case "youtube": {
// Emit the schema-matching div[data-type="youtube"]; the schema reads
// src from data-src and width/height/align from data-* attributes.
const attrs = node.attrs || {};
const parts = [
`data-type="youtube"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "table": {
// A GFM pipe table cannot represent merged cells. If ANY cell carries
// colspan>1 or rowspan>1, a pipe table would corrupt the grid on
// re-import, so emit the WHOLE table as raw HTML <table> instead: the
// schema's table family parseHTML (tag table/tr/td/th, with colspan/
// rowspan read from the same-named HTML attrs and align via parseHTML)
// round-trips it faithfully. Otherwise keep the lighter GFM pipe table.
const tableRows = nodeContent;
if (tableRows.length === 0)
return "";
const hasSpan = tableRows.some((row) => (row.content || []).some((cell) => (cell.attrs?.colspan ?? 1) > 1 || (cell.attrs?.rowspan ?? 1) > 1));
if (hasSpan) {
// Render each cell's block children to HTML (marked does NOT parse
// markdown inside a raw HTML block, so emitting markdown here would
// leak literal ** / `` into the cell). blockToHtml mirrors the schema
// HTML so inner formatting re-parses into the right marks/nodes.
const renderHtmlCell = (cell) => {
const tag = cell.type === "tableHeader" ? "th" : "td";
const a = cell.attrs || {};
const cellParts = [];
if ((a.colspan ?? 1) > 1)
cellParts.push(`colspan="${escapeAttr(a.colspan)}"`);
if ((a.rowspan ?? 1) > 1)
cellParts.push(`rowspan="${escapeAttr(a.rowspan)}"`);
if (a.align)
cellParts.push(`align="${escapeAttr(a.align)}"`);
const open = cellParts.length
? `<${tag} ${cellParts.join(" ")}>`
: `<${tag}>`;
const inner = (cell.content || [])
.map((block) => blockToHtml(block))
.join("");
return `${open}${inner}</${tag}>`;
};
const htmlRows = tableRows
.map((row) => `<tr>${(row.content || []).map(renderHtmlCell).join("")}</tr>`)
.join("");
return `<table><tbody>${htmlRows}</tbody></table>`;
}
// No merged cells: emit a GFM table (header row + separator) so the
// markdown can be parsed back into a table on re-import.
const rows = tableRows.map(processNode);
const headerCells = tableRows[0]?.content || [];
const columns = headerCells.length || 1;
// Derive alignment markers (:--, :-:, --:) from each header cell.
const markers = Array.from({ length: columns }, (_, i) => {
const align = headerCells[i]?.attrs?.align;
switch (align) {
case "left":
return ":--";
case "center":
return ":-:";
case "right":
return "--:";
default:
return "---";
}
});
const separator = "| " + markers.join(" | ") + " |";
return [rows[0], separator, ...rows.slice(1)].join("\n");
}
case "tableRow":
return "| " + nodeContent.map(processNode).join(" | ") + " |";
case "tableCell":
case "tableHeader": {
// Join multiple block children with a space (not "") so adjacent blocks
// like a paragraph followed by a list don't collide into "line1- a".
// Then collapse newlines and escape pipes so a cell containing "|" or a
// line break cannot corrupt the surrounding GFM row.
return nodeContent
.map(processNode)
.join(" ")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|");
}
case "callout":
const calloutType = node.attrs?.type || "info";
const calloutContent = nodeContent.map(processNode).join("\n");
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
case "details":
return nodeContent.map(processNode).join("\n");
case "detailsSummary":
const summaryText = nodeContent.map(processNode).join("");
return `<details>\n<summary>${summaryText}</summary>\n`;
case "detailsContent":
const detailsText = nodeContent.map(processNode).join("\n");
return `${detailsText}\n</details>`;
case "mathInline": {
// The schema's `text` attribute has no parseHTML, so TipTap's default
// parser reads it from the `text` HTML attribute (NOT the element's text
// content). Emit span[data-type="mathInline"] carrying the LaTeX in a
// `text="..."` attribute so it round-trips. marked cannot parse $...$
// back, so the previous form was lossy.
const inlineMath = node.attrs?.text || "";
return `<span data-type="mathInline" data-katex="true" text="${escapeAttr(inlineMath)}"></span>`;
}
case "mathBlock": {
// Same as mathInline: the LaTeX must ride in the `text` HTML attribute
// for the schema's default parser to recover it.
const blockMath = node.attrs?.text || "";
return `<div data-type="mathBlock" data-katex="true" text="${escapeAttr(blockMath)}"></div>`;
}
case "mention": {
// Emit span[data-type="mention"] with the schema's data-* attributes so
// generateJSON rebuilds the mention node instead of leaving "@label"
// plain text that cannot re-parse.
const attrs = node.attrs || {};
const parts = [`data-type="mention"`];
if (attrs.id)
parts.push(`data-id="${escapeAttr(attrs.id)}"`);
if (attrs.label)
parts.push(`data-label="${escapeAttr(attrs.label)}"`);
if (attrs.entityType)
parts.push(`data-entity-type="${escapeAttr(attrs.entityType)}"`);
if (attrs.entityId)
parts.push(`data-entity-id="${escapeAttr(attrs.entityId)}"`);
if (attrs.slugId)
parts.push(`data-slug-id="${escapeAttr(attrs.slugId)}"`);
if (attrs.creatorId)
parts.push(`data-creator-id="${escapeAttr(attrs.creatorId)}"`);
if (attrs.anchorId)
parts.push(`data-anchor-id="${escapeAttr(attrs.anchorId)}"`);
// Keep the label as visible text content too; the schema reads attrs
// from data-*, so the inner text is purely cosmetic and harmless.
const mentionLabel = attrs.label || attrs.id || "";
// The label is visible element TEXT content here (the data-* attrs above
// carry the real values), so escape it for the text context, not attrs.
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
}
case "footnoteReference": {
// Pandoc/GFM inline marker. The number is derived (not stored), so the
// id is the stable anchor.
const fnId = node.attrs?.id || "";
return fnId ? `[^${fnId}]` : "";
}
case "footnotesList":
// The container renders its definitions, each on its own `[^id]: ...`
// line. A blank line separates the body from the notes block.
return nodeContent.map(processNode).join("\n");
case "footnoteDefinition": {
const defId = node.attrs?.id || "";
// Collapse the definition's paragraphs into a single line; multi-line
// footnotes are a v2 refinement.
const defText = nodeContent
.map(processNode)
.join(" ")
.replace(/\s*\n+\s*/g, " ")
.trim();
return defId ? `[^${defId}]: ${defText}` : "";
}
case "attachment": {
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
// the schema stores name/url (plus mime/size/attachmentId). Emit the
// schema-matching div[data-type="attachment"] with data-attachment-*
// attrs so the node round-trips instead of degrading to a markdown link.
const attrs = node.attrs || {};
const parts = [
`data-type="attachment"`,
`data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
];
if (attrs.name)
parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
if (attrs.mime)
parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
if (attrs.size != null)
parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "drawio":
case "excalidraw": {
// Emit the schema-matching div[data-type=...] carrying the diagram's
// attrs as data-* (the schema's diagramAttributes reads src/title/alt/
// width/height/size/aspectRatio/align/attachmentId from data-*), so the
// diagram round-trips instead of degrading to a lossy placeholder.
const attrs = node.attrs || {};
const parts = [
`data-type="${type}"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.title != null)
parts.push(`data-title="${escapeAttr(attrs.title)}"`);
if (attrs.alt != null)
parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "embed": {
// Emit the schema-matching div[data-type="embed"]; the schema reads
// src/provider/align/width/height from data-* attributes so the node
// (and its provider iframe info) survives the round-trip.
const attrs = node.attrs || {};
const parts = [
`data-type="embed"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
];
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "audio": {
// Emit the schema-matching <audio> element (was emitting nothing). The
// schema reads src from src and attachmentId/size from data-*.
const attrs = node.attrs || {};
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
// Wrap in a block <div> for the same reason as video: a bare <audio> is
// inline-level HTML that marked would wrap in <p>.
return `<div><audio ${parts.join(" ")}></audio></div>`;
}
case "pdf": {
// Emit the schema-matching div[data-type="pdf"] (was emitting nothing).
// The schema reads src/width/height from standard attrs and name/
// attachmentId/size from data-*.
const attrs = node.attrs || {};
const parts = [
`data-type="pdf"`,
`src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.name)
parts.push(`data-name="${escapeAttr(attrs.name)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.width != null)
parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "columns": {
// Emit the schema-matching div[data-type="columns"] wrapper so the
// multi-column layout survives. Without a case the children were
// concatenated with no separator and the text merged. The schema reads
// layout from data-layout and widthMode from data-width-mode. The whole
// block is raw HTML, so render children via blockToHtml (NOT markdown,
// which marked would not re-parse inside a raw HTML block).
const attrs = node.attrs || {};
const parts = [`data-type="columns"`];
if (attrs.layout)
parts.push(`data-layout="${escapeAttr(attrs.layout)}"`);
if (attrs.widthMode && attrs.widthMode !== "normal")
parts.push(`data-width-mode="${escapeAttr(attrs.widthMode)}"`);
const inner = nodeContent.map((n) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "column": {
// Emit the schema-matching div[data-type="column"]; the schema reads the
// column width from data-width. Children are rendered as HTML so their
// formatting survives inside this raw HTML block.
const attrs = node.attrs || {};
const parts = [`data-type="column"`];
if (attrs.width)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
const inner = nodeContent.map((n) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "subpages":
return "{{SUBPAGES}}";
default:
// Fallback: process children
return nodeContent.map(processNode).join("");
}
};
// Render inline content (text runs + their marks) to HTML. Used by the raw
// HTML fallbacks (spanned tables, columns) where marked will NOT re-parse
// markdown, so backtick/asterisk/bracket syntax would otherwise leak as
// literal characters. Each mark is mirrored to the HTML the schema's parseHTML
// accepts so it re-imports as the matching ProseMirror mark.
const inlineToHtml = (inlineNodes) => (inlineNodes || [])
.map((n) => {
if (n.type === "hardBreak")
return "<br>";
if (n.type !== "text") {
// Inline atoms (mention, mathInline) already emit schema HTML.
return processNode(n);
}
let t = escapeHtmlText(n.text || "");
for (const mark of n.marks || []) {
switch (mark.type) {
case "bold":
t = `<strong>${t}</strong>`;
break;
case "italic":
t = `<em>${t}</em>`;
break;
case "code":
t = `<code>${t}</code>`;
break;
case "strike":
t = `<s>${t}</s>`;
break;
case "underline":
t = `<u>${t}</u>`;
break;
case "subscript":
t = `<sub>${t}</sub>`;
break;
case "superscript":
t = `<sup>${t}</sup>`;
break;
case "link":
t = `<a href="${escapeAttr(mark.attrs?.href || "")}">${t}</a>`;
break;
case "highlight":
t = mark.attrs?.color
? `<mark style="background-color: ${escapeAttr(mark.attrs.color)}">${t}</mark>`
: `<mark>${t}</mark>`;
break;
case "textStyle":
if (mark.attrs?.color)
t = `<span style="color: ${escapeAttr(mark.attrs.color)}">${t}</span>`;
break;
case "comment":
// Inline comment anchor inside a raw-HTML container (columns /
// spanned table cells), so commented text there also round-trips.
if (mark.attrs?.commentId) {
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
}
break;
}
}
return t;
})
.join("");
// Emit the schema-matching <img> for an image node. Shared so the image is
// emitted as real HTML wherever a raw-HTML container needs it (inside a column
// or a spanned table cell), where markdown `![](...)` would NOT be re-parsed
// and would survive as literal text. The Image extension reads src/alt from
// the standard attributes; the Docmost extra attrs (width/height/align/size/
// attachmentId/aspectRatio) are global attributes read from same-named DOM
// attributes, so emit them by name.
const imageToHtml = (node) => {
const attrs = node.attrs || {};
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt)
parts.push(`alt="${escapeAttr(attrs.alt)}"`);
if (attrs.caption)
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
if (attrs.title)
parts.push(`title="${escapeAttr(attrs.title)}"`);
if (attrs.width != null)
parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`height="${escapeAttr(attrs.height)}"`);
if (attrs.align)
parts.push(`align="${escapeAttr(attrs.align)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
return `<img ${parts.join(" ")}>`;
};
// Emit the schema-matching div[data-type="callout"] for a callout node. The
// schema reads the banner type from data-callout-type. Children are rendered
// as HTML so they survive inside a raw-HTML container.
const calloutToHtml = (node) => {
const type = (node.attrs?.type || "info").toLowerCase();
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="callout" data-callout-type="${escapeAttr(type)}">${inner}</div>`;
};
// Emit a schema-matching <details> tree. The schema parses <details>,
// summary[data-type="detailsSummary"], and div[data-type="detailsContent"].
const detailsToHtml = (node) => {
const inner = (node.content || []).map(blockToHtml).join("");
return `<details>${inner}</details>`;
};
const detailsSummaryToHtml = (node) => `<summary data-type="detailsSummary">${inlineToHtml(node.content || [])}</summary>`;
const detailsContentToHtml = (node) => {
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="detailsContent">${inner}</div>`;
};
// Emit the schema-matching taskList/taskItem HTML. bridgeTaskLists (in
// collaboration.ts) recognizes ul[data-type="taskList"] with
// li[data-type="taskItem"][data-checked]; emitting that directly here keeps
// task lists inside columns/cells from degrading to literal "- [ ]" text.
const taskListToHtml = (node) => {
const items = (node.content || [])
.map((it) => {
const checked = it.attrs?.checked ? "true" : "false";
return `<li data-type="taskItem" data-checked="${checked}">${blockChildrenToHtml(it)}</li>`;
})
.join("");
return `<ul data-type="taskList">${items}</ul>`;
};
// Render a block node to HTML for the raw-HTML containers (spanned tables,
// columns). marked does NOT re-parse markdown inside a raw-HTML block, so
// EVERY block type that can appear inside a column or a spanned cell must be
// emitted as schema-matching HTML here — never as markdown, or it would land
// as literal text on re-import. Nodes whose processNode case already produces
// schema-matching HTML (math/media/embed/attachment/nested columns/spanned
// table) are delegated to processNode; the markdown-emitting cases
// (image/blockquote/callout/details/hr/taskList) get explicit HTML here.
const blockToHtml = (block) => {
const children = block.content || [];
switch (block.type) {
case "paragraph":
return `<p>${inlineToHtml(children)}</p>`;
case "heading": {
const level = block.attrs?.level || 1;
return `<h${level}>${inlineToHtml(children)}</h${level}>`;
}
case "bulletList":
return `<ul>${children
.map((li) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ul>`;
case "orderedList":
return `<ol>${children
.map((li) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ol>`;
case "codeBlock": {
const lang = block.attrs?.language || "";
// The code itself is element TEXT content (between <code> tags), so it
// must escape < > & — NOT the attribute escaper. The language rides in
// a class ATTRIBUTE, so it uses escapeAttr.
const code = escapeHtmlText(children
.map(processNode)
.join("")
.replace(/\n+$/, ""));
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
return `<pre><code${cls}>${code}</code></pre>`;
}
case "image":
return imageToHtml(block);
case "blockquote":
return `<blockquote>${children.map(blockToHtml).join("")}</blockquote>`;
case "horizontalRule":
return "<hr>";
case "callout":
return calloutToHtml(block);
case "details":
return detailsToHtml(block);
case "detailsSummary":
return detailsSummaryToHtml(block);
case "detailsContent":
return detailsContentToHtml(block);
case "taskList":
return taskListToHtml(block);
case "taskItem":
// A bare taskItem (outside a taskList) still needs a wrapping list so
// the schema parses it; wrap it in a single-item taskList.
return taskListToHtml({ content: [block] });
// table (incl. spanned), columns/column, math, media, embed, attachment,
// mention, etc. already emit schema-matching HTML from processNode.
case "table":
case "columns":
case "column":
case "mathBlock":
case "video":
case "audio":
case "pdf":
case "youtube":
case "embed":
case "attachment":
case "drawio":
case "excalidraw":
return processNode(block);
default:
// Any still-unhandled block type: NEVER fall back to markdown inside a
// raw-HTML block (it would become literal text). Wrap its rendered
// children in a <div> so their content is preserved; if it has no block
// children, render its inline content instead.
if (children.length && children.some((c) => c.type !== "text")) {
return `<div>${children.map(blockToHtml).join("")}</div>`;
}
return `<div>${inlineToHtml(children)}</div>`;
}
};
// Render the block children of a list item to HTML (a listItem holds block+
// content). Mirrors processListItem but for the HTML fallback path.
const blockChildrenToHtml = (item) => (item.content || []).map((b) => blockToHtml(b)).join("");
// Indent the rendered children of a list item under a marker prefix.
// Each child block is a (possibly multi-line) string. The very first physical
// line of the first child carries the marker (e.g. "- " or "1. "); EVERY
// other line — the remaining lines of the first child AND all lines of every
// subsequent child (nested lists, code blocks, extra paragraphs) — is indented
// to align under the marker. Without indenting these continuation lines, the
// 2nd/3rd line of a nested child collapses to column 0 and escapes the list.
//
// The continuation indent MUST equal the LIST marker width, which is not the
// same as the visible prefix width:
// - bullet "- " -> 2 columns
// - task "- [ ] " -> marker is still "- " (the "[ ] " is content), 2
// - ordered "1. "/"10. " -> 3/4 columns, scaling with the number's digits
// CommonMark anchors nested content to the marker column, so an ordered item
// indented to only 2 columns would be re-parsed as a sibling/loose content on
// re-import. Callers therefore pass the exact indent width to use.
const indentItemChildren = (childStrings, prefix, indentWidth) => {
const indent = " ".repeat(indentWidth);
const lines = [];
childStrings.forEach((child, childIndex) => {
child.split("\n").forEach((line, lineIndex) => {
if (childIndex === 0 && lineIndex === 0) {
// First physical line of the first block gets the marker.
lines.push(`${prefix} ${line}`);
}
else {
// Indent every continuation line by the marker width; keep blank
// lines blank rather than emitting trailing whitespace.
lines.push(line.length ? `${indent}${line}` : "");
}
});
});
return lines.join("\n");
};
const processListItem = (item, prefix) => {
const itemContent = item.content || [];
const childStrings = itemContent.map(processNode);
if (childStrings.length === 0)
return prefix;
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
// and thus the continuation indent — is prefix.length + 1. This is correct
// for both bullet ("-" -> 2) and ordered ("1." -> 3, "10." -> 4) markers,
// since for those the visible prefix IS the list marker.
return indentItemChildren(childStrings, prefix, prefix.length + 1);
};
const processTaskItem = (item) => {
const checked = item.attrs?.checked || false;
const checkbox = checked ? "[x]" : "[ ]";
const prefix = `- ${checkbox}`;
const itemContent = item.content || [];
const childStrings = itemContent.map(processNode);
// An empty task item still needs its checkbox marker; without this guard
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
if (childStrings.length === 0)
return prefix;
// The list marker for a task item is just "- " (2 columns); the "[ ] "/"[x] "
// checkbox is item content, NOT part of the marker. So the continuation
// indent is a fixed 2 — do NOT derive it from the wider prefix.length.
return indentItemChildren(childStrings, prefix, 2);
};
return processNode(content).trim();
}
-104
View File
@@ -1,104 +0,0 @@
/**
* Self-contained Docmost-flavoured Markdown document (custom extensions).
*
* A single `.md` file that packages everything needed to losslessly round-trip
* a page through "download -> edit body -> re-upload":
* - a leading `docmost:meta` block: a one-line JSON object with page identity;
* - the Markdown body (carrying inline comment anchors and diagrams as HTML);
* - a trailing `docmost:comments` block: a one-line JSON array of comment
* threads.
*
* Both metadata blocks are HTML comments on purpose: `marked`/`generateJSON`
* drop HTML comments, so even if the WHOLE file were ever fed straight to the
* importer without first stripping the blocks, the metadata cannot leak into the
* document. (A fenced ```docmost-comments``` block would WRONGLY become a
* codeBlock node, so a fenced block is deliberately NOT used.)
*
* The delimiter literals may legitimately appear in the BODY too (e.g. a user
* re-pastes an exported `.md` into a page, or a page documents this very
* format). To stay robust, parsing treats only the FINAL, document-ending
* `docmost:comments` block as metadata: it is the last `<!-- docmost:comments`
* opener whose closing `-->` sits at the very end of the file. Any earlier
* literal occurrence is left in the body untouched.
*
* NOTE on comments: in this version the comment THREAD records are preserved in
* the file but are NOT pushed back to the server on import only the inline
* comment marks (anchors) embedded in the body are restored. Managing comment
* records stays with the comment tools/UI.
*/
// Match the leading meta block (allow leading whitespace). Capture group 1 is
// the JSON text between the markers.
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
// Match a `docmost:comments` opener. Used globally to scan for the LAST opener
// rather than end-anchoring a single regex (which would mis-capture across a
// literal opener that appears earlier in the body).
const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
/**
* Assemble the full self-contained markdown file: meta block, body, and the
* comments block. The meta block is always emitted; the comments block is always
* emitted too (with `[]` when there are no comments) so the format stays uniform
* and parsing stays simple.
*/
export function serializeDocmostMarkdown(meta, body, comments) {
const metaJson = JSON.stringify(meta);
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
const trimmedBody = (body ?? "").trim();
return (`<!-- docmost:meta\n${metaJson}\n-->\n\n` +
`${trimmedBody}\n\n` +
`<!-- docmost:comments\n${commentsJson}\n-->\n`);
}
/**
* Split a self-contained file back into its parts. Tolerant: if the meta or
* comments block is missing (e.g. a hand-written plain-markdown file), the
* corresponding value is returned as `null` and the whole input is treated as
* the body. This never throws on a MISSING block; only a `JSON.parse` failure
* inside a block that IS present is surfaced as a thrown Error with a clear
* message. Robust to `\r\n` line endings.
*/
export function parseDocmostMarkdown(full) {
// Normalize line endings so the anchored regexes work regardless of CRLF.
const normalized = (full ?? "").replace(/\r\n/g, "\n");
// Extract the leading meta block (start-anchored — already unambiguous).
let meta = null;
let metaEnd = 0;
const metaMatch = normalized.match(META_RE);
if (metaMatch) {
try {
meta = JSON.parse(metaMatch[1]);
}
catch (e) {
throw new Error(`Invalid docmost:meta JSON block: ${e instanceof Error ? e.message : String(e)}`);
}
// Body starts right after the matched meta block.
metaEnd = (metaMatch.index ?? 0) + metaMatch[0].length;
}
// Find the LAST `<!-- docmost:comments` opener; the real file-level block is
// the final one whose closing `-->` ends the document. Any earlier literal
// occurrence inside the body (e.g. a re-pasted export) is left in the body.
let lastOpenStart = -1;
let lastOpenEnd = -1;
let m;
COMMENTS_OPEN_RE.lastIndex = 0;
while ((m = COMMENTS_OPEN_RE.exec(normalized)) !== null) {
lastOpenStart = m.index;
lastOpenEnd = m.index + m[0].length;
}
let comments = null;
let bodyEnd = normalized.length;
if (lastOpenStart !== -1) {
const rest = normalized.slice(lastOpenEnd);
const close = rest.match(/\r?\n-->[ \t]*\r?\n?\s*$/); // closer must end the doc
if (close) {
const jsonText = rest.slice(0, close.index);
try {
comments = JSON.parse(jsonText);
}
catch (e) {
throw new Error(`Invalid docmost:comments JSON block: ${e instanceof Error ? e.message : String(e)}`);
}
bodyEnd = lastOpenStart; // strip from the opener to end of document
}
}
const body = normalized.slice(metaEnd, bodyEnd).trim();
return { meta, body, comments };
}
-821
View File
@@ -1,821 +0,0 @@
/**
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
* tree by node id.
*
* A ProseMirror node here is a plain JSON object of the shape produced by
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
* table cells hold their children in `content` just like any other block, so a
* single recursive walk reaches them all.
*
* Every exported function operates on a DEEP CLONE of the input document and
* returns the new document. The input doc and any `newNode`/`node` argument are
* never mutated. All functions are defensively null-safe: missing/!Array
* `content`, non-object nodes, and absent `attrs` are tolerated.
*/
import { stripInlineMarkdown } from "./text-normalize.js";
/** Deep-clone a JSON-serializable value without mutating the original. */
function clone(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
// Fallback for environments without structuredClone.
return JSON.parse(JSON.stringify(value));
}
/** True if `value` is a non-null object (and not an array). */
function isObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/** True if `node` carries the given id in `node.attrs.id`. */
function matchesId(node, nodeId) {
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
}
/**
* Recursively concatenate all text contained in a node.
*
* Text nodes contribute their `text` string; container nodes contribute the
* joined `blockPlainText` of their `content` children. Returns "" for nullish
* or non-object inputs.
*/
export function blockPlainText(node) {
if (!isObject(node))
return "";
let out = "";
if (typeof node.text === "string") {
out += node.text;
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
out += blockPlainText(child);
}
}
return out;
}
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
function truncate(text, n) {
return text.length > n ? text.slice(0, n) + "…" : text;
}
/**
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
* table cells compactness is the point; use `getNodeByRef` to drill into a
* specific block.
*
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
* cell texts as `header`; list blocks (types ending in "List") add `items`.
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
* a missing or non-object doc/content yields `[]`.
*/
export function buildOutline(doc) {
if (!isObject(doc) || !Array.isArray(doc.content))
return [];
const out = [];
for (let i = 0; i < doc.content.length; i++) {
const block = doc.content[i];
const type = isObject(block) ? block.type : undefined;
const entry = {
index: i,
type,
id: isObject(block) && isObject(block.attrs)
? (block.attrs.id ?? null)
: null,
firstText: truncate(blockPlainText(block), 100),
};
if (type === "heading") {
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
}
else if (type === "table") {
const headerRow = block.content?.[0]?.content ?? [];
entry.rows = block.content?.length ?? 0;
entry.cols = block.content?.[0]?.content?.length ?? 0;
entry.header = headerRow.map((cell) => truncate(blockPlainText(cell), 40));
}
else if (typeof type === "string" && type.endsWith("List")) {
entry.items = block.content?.length ?? 0;
}
out.push(entry);
}
return out;
}
/**
* Resolve a single node by reference and return `{ node, path, type }`, or
* `null` when nothing matches.
*
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
* `n` in `doc.content`. This is the only way to address table/tableRow/
* tableCell nodes, which carry no `attrs.id`.
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
* tree with `attrs.id === ref` is returned.
*
* `path` is the array of child indices from the doc root down to the node
* (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.
*/
export function getNodeByRef(doc, ref) {
if (!isObject(doc))
return null;
// "#<n>": index into the top-level content array.
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
if (!isObject(block))
return null;
return { node: clone(block), path: [index], type: block.type };
}
// Otherwise: depth-first search for the first node with attrs.id === ref.
const search = (node, trail) => {
if (!isObject(node))
return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const path = [...trail, i];
if (matchesId(child, ref)) {
return { node: clone(child), path, type: child.type };
}
const hit = search(child, path);
if (hit != null)
return hit;
}
}
return null;
};
return search(doc, []);
}
/**
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
* `newNode`, anywhere in the tree (including inside callouts and table cells).
*
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
* is the number of nodes substituted. A fresh clone of `newNode` is used for
* each match so they do not share references.
*/
export function replaceNodeById(doc, nodeId, newNode) {
const out = clone(doc);
let replaced = 0;
// Walk a content array, replacing direct matches and recursing into the
// (possibly new) children of non-matching nodes.
const walkContent = (content) => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, nodeId)) {
content[i] = clone(newNode);
replaced++;
// Do not recurse into a freshly substituted node.
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, replaced };
}
/**
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
* array, anywhere in the tree (recursive, including callouts and tables).
*
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
* the number of nodes removed.
*/
export function deleteNodeById(doc, nodeId) {
const out = clone(doc);
let deleted = 0;
// Filter a content array in place, dropping matches and recursing into the
// surviving children.
const walkContent = (content) => {
const kept = [];
for (const child of content) {
if (matchesId(child, nodeId)) {
deleted++;
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
child.content = walkContent(child.content);
}
kept.push(child);
}
return kept;
};
if (isObject(out) && Array.isArray(out.content)) {
out.content = walkContent(out.content);
}
return { doc: out, deleted };
}
/**
* Throw a clear, model-actionable error when a node-id write op did NOT match
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
* "ambiguous, refused" Docmost duplicates block ids on copy/paste, so a write
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
* changed. No-op for the unambiguous single-match case.
*/
export function assertUnambiguousMatch(op, verb, count, nodeId, pageId) {
if (count === 0) {
throw new Error(`${op}: no node with id "${nodeId}" found on page ${pageId}`);
}
if (count > 1) {
throw new Error(`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`);
}
}
/**
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
* "Unexpected content type" when asked to store an `undefined` attribute value).
*
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
* legitimate JSON-storable values and are preserved. Operates on a clone and
* returns it; the input is never mutated. Defensively null-safe like the rest
* of the file.
*/
export function sanitizeForYjs(doc) {
const out = clone(doc);
// Drop every key whose value is strictly `undefined` from an attrs object.
const stripUndefined = (attrs) => {
if (!isObject(attrs))
return;
for (const key of Object.keys(attrs)) {
if (attrs[key] === undefined) {
delete attrs[key];
}
}
};
const walk = (node) => {
if (!isObject(node))
return;
stripUndefined(node.attrs);
if (Array.isArray(node.marks)) {
for (const mark of node.marks) {
if (isObject(mark))
stripUndefined(mark.attrs);
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
};
walk(out);
return out;
}
/**
* Diagnostics helper: walk the tree and return a human-readable path string for
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
* cannot store i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
* every attribute is storable. Null-safe.
*/
export function findUnstorableAttr(doc) {
const isUnstorable = (value) => {
if (value === undefined)
return "undefined";
const t = typeof value;
if (t === "function")
return "function";
if (t === "symbol")
return "symbol";
if (t === "bigint")
return "bigint";
return null;
};
// Check an attrs object; return the offending sub-path or null.
const checkAttrs = (attrs, basePath) => {
if (!isObject(attrs))
return null;
for (const key of Object.keys(attrs)) {
const kind = isUnstorable(attrs[key]);
if (kind != null)
return `${basePath}.${key} (${kind})`;
}
return null;
};
const walk = (node, path) => {
if (!isObject(node))
return null;
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
if (attrHit != null)
return attrHit;
if (Array.isArray(node.marks)) {
for (let i = 0; i < node.marks.length; i++) {
const markHit = checkAttrs(node.marks[i]?.attrs, `${path}.marks[${i}].attrs`);
if (markHit != null)
return markHit;
}
}
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const childHit = walk(node.content[i], `${path}.content[${i}]`);
if (childHit != null)
return childHit;
}
}
return null;
};
// The root doc node carries no useful index, so start the path at "doc".
if (!isObject(doc))
return null;
const attrHit = checkAttrs(doc.attrs, "attrs");
if (attrHit != null)
return attrHit;
if (Array.isArray(doc.content)) {
for (let i = 0; i < doc.content.length; i++) {
const childHit = walk(doc.content[i], `content[${i}]`);
if (childHit != null)
return childHit;
}
}
return null;
}
/**
* Table structural node types and the container each must live directly inside.
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
* rather than blindly into the anchor's direct parent (which would corrupt the
* table's nesting).
*/
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
const REQUIRED_CONTAINER = {
tableRow: "table",
tableCell: "tableRow",
tableHeader: "tableRow",
};
/**
* Find the index of the first TOP-LEVEL block whose plain text includes the
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
*
* Two passes preserve "exact wins globally":
* - Pass 1: first block containing the verbatim `anchorText`.
* - Pass 2 (only if pass 1 found nothing): first block containing the
* markdown-stripped anchor, when stripping actually changed it.
*/
function findAnchorTextIndex(content, anchorText) {
if (!Array.isArray(content))
return -1;
// Pass 1: exact.
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(anchorText))
return i;
}
// Pass 2: markdown-stripped fallback.
const a = stripInlineMarkdown(anchorText);
if (a !== anchorText && a.length > 0) {
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(a))
return i;
}
}
return -1;
}
/**
* Locate an anchor and return its ancestor chain (from `doc` down to and
* including the matched node). Each chain entry is `{ node, index }` where
* `index` is the node's position inside its parent's `content` array (the root
* doc has index -1). Returns `null` when the anchor cannot be resolved.
*/
function findAnchorChain(doc, opts) {
if (!isObject(doc))
return null;
// DFS by id anywhere in the tree, accumulating the path.
if (opts.anchorNodeId != null) {
const targetId = opts.anchorNodeId;
const search = (node, index, trail) => {
if (!isObject(node))
return null;
const here = [...trail, { node, index }];
if (matchesId(node, targetId))
return here;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const hit = search(node.content[i], i, here);
if (hit != null)
return hit;
}
}
return null;
};
return search(doc, -1, []);
}
// By text: only top-level blocks are scanned (same rule as the JSON path).
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && Array.isArray(doc.content)) {
const i = findAnchorTextIndex(doc.content, opts.anchorText);
if (i !== -1) {
return [
{ node: doc, index: -1 },
{ node: doc.content[i], index: i },
];
}
}
return null;
}
/**
* Insert a deep clone of `node` relative to an anchor.
*
* - position "append": push the node onto the top-level `doc.content`.
* - position "before"/"after": locate the anchor and splice the node into the
* anchor's parent `content` array immediately before / after it.
*
* Anchor resolution for before/after:
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
* anywhere in the tree (recursive);
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
* false when the anchor could not be resolved (the doc is returned unchanged
* apart from being cloned).
*/
export function insertNodeRelative(doc, node, opts) {
const out = clone(doc);
const fresh = clone(node);
// Defensive: stay null-safe like the other exports — a missing opts means
// there is nothing actionable to do.
if (!isObject(opts))
return { doc: out, inserted: false };
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
// "append": top-level push.
if (opts.position === "append") {
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
// top level — appending one would produce invalid nesting.
if (isStructural) {
throw new Error(`insert_node: cannot append a ${node.type} at the top level; use ` +
`position before/after with an anchor inside the target table`);
}
if (isObject(out)) {
if (!Array.isArray(out.content))
out.content = [];
out.content.push(fresh);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
}
const offset = opts.position === "after" ? 1 : 0;
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
// into the nearest enclosing table/tableRow rather than the anchor's direct
// parent, so the row/cell lands at the correct level of the table.
if (isStructural) {
const containerType = REQUIRED_CONTAINER[node.type];
const chain = findAnchorChain(out, opts);
// Anchor not resolved at all — keep the existing "anchor not found" path.
if (chain == null)
return { doc: out, inserted: false };
// Find the DEEPEST ancestor (including the anchor itself) of the required
// container type.
let containerIdx = -1;
for (let i = chain.length - 1; i >= 0; i--) {
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
containerIdx = i;
break;
}
}
if (containerIdx === -1) {
throw new Error(`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
`that lives inside the target table.`);
}
const container = chain[containerIdx].node;
if (!Array.isArray(container.content))
container.content = [];
if (containerIdx === chain.length - 1) {
// The matched container IS the anchor node itself (e.g. anchorText
// resolved to the table block): append/prepend within it.
const at = opts.position === "after" ? container.content.length : 0;
container.content.splice(at, 0, fresh);
}
else {
// The immediate child on the path leading to the anchor is the row/cell
// to splice next to.
const enclosingChildIndex = chain[containerIdx + 1].index;
container.content.splice(enclosingChildIndex + offset, 0, fresh);
}
return { doc: out, inserted: true };
}
// Resolve by id anywhere in the tree: splice into the parent content array.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content) => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId)) {
content.splice(i + offset, 0, fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted)
return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact
// match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
}
// ===========================================================================
// Table editing helpers
//
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
// table -> { type:"table", content:[tableRow...] }
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
// content:[paragraph...] }
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
// of the input doc (via `clone`) and never mutate their inputs.
// ===========================================================================
/**
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
* `makeFreshId` so generated paragraph ids never collide with existing ones.
*/
function collectIds(node, used) {
if (!isObject(node))
return;
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
used.add(node.attrs.id);
}
if (Array.isArray(node.content)) {
for (const child of node.content)
collectIds(child, used);
}
}
/**
* Fresh-id generator: returns a random Docmost-style id (12 chars from
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
* exact string, so randomness is fine and unlike a module-local counter it
* needs no reset and cannot become predictable across calls.
*/
function makeFreshId(used) {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let id;
do {
id = "";
for (let i = 0; i < 12; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
} while (used.has(id) || id === "");
used.add(id);
return id;
}
/**
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
* its index path. Returns null when no table matches.
*
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
* ancestor chain to the nearest `type === "table"` ancestor.
*/
function locateTable(rootClone, tableRef) {
if (!isObject(rootClone))
return null;
// "#<n>": index into the top-level content array; must be a table.
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(rootClone.content)
? rootClone.content[index]
: undefined;
if (isObject(block) && block.type === "table") {
return { table: block, path: [index] };
}
return null;
}
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
// climb to the nearest enclosing table.
const search = (node, trail) => {
if (!isObject(node))
return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const here = [...trail, { node: child, index: i }];
if (matchesId(child, tableRef)) {
// Walk UP to the nearest table ancestor (including the match itself).
for (let j = here.length - 1; j >= 0; j--) {
if (isObject(here[j].node) && here[j].node.type === "table") {
return {
table: here[j].node,
path: here.slice(0, j + 1).map((e) => e.index),
};
}
}
return null; // id found but no enclosing table
}
const hit = search(child, here);
if (hit != null)
return hit;
}
}
return null;
};
return search(rootClone, []);
}
/** Build the plain-text → single-paragraph cell content used by all writers. */
function makeCellParagraph(id, text) {
return {
type: "paragraph",
attrs: { id, indent: 0 },
// Empty string → a paragraph with an empty content array.
content: text ? [{ type: "text", text }] : [],
};
}
/**
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
*
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
* Tables may be ragged (rows of differing length), so `cols` reflects only
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
* width.
* - `cells`: `string[][]` of each cell's `blockPlainText`.
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
* so callers can `patch_node` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc.
*/
export function readTable(doc, tableRef) {
const root = clone(doc);
const located = locateTable(root, tableRef);
if (located == null)
return null;
const { table, path } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const cols = rowNodes[0]?.content?.length ?? 0;
const cells = [];
const cellIds = [];
for (const rowNode of rowNodes) {
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
const rowText = [];
const rowIds = [];
for (const cellNode of cellNodes) {
rowText.push(blockPlainText(cellNode));
// The cell's first paragraph carries the id used for patch_node.
const firstPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
const id = isObject(firstPara) && isObject(firstPara.attrs)
? (firstPara.attrs.id ?? null)
: null;
rowIds.push(id);
}
cells.push(rowText);
cellIds.push(rowIds);
}
return { rows, cols, cells, cellIds, path };
}
/**
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
*
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
* MORE cells than columns throws. Each new cell copies `colwidth` for its
* column from the header row when present, gets a fresh-id paragraph, and a
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
* the row there; otherwise the row is appended at the end.
*/
export function insertTableRow(doc, tableRef, cells, index) {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null)
return { doc: out, inserted: false };
const { table } = located;
if (!Array.isArray(table.content))
table.content = [];
const rows = table.content.length;
const headerRow = table.content[0];
const headerCells = Array.isArray(headerRow?.content)
? headerRow.content
: [];
// Column count is the WIDEST existing row, so the guard below stays
// meaningful for ragged tables and the new row matches the table's width.
// Fall back to the supplied cell count only when the table has no rows.
let colCount = 0;
for (const r of table.content) {
if (isObject(r) && Array.isArray(r.content))
colCount = Math.max(colCount, r.content.length);
}
if (colCount === 0)
colCount = Array.isArray(cells) ? cells.length : 0;
if (Array.isArray(cells) && cells.length > colCount) {
throw new Error(`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`);
}
// Resolve the landing index up front so the cell-type decision and the splice
// below agree: a valid integer in [0, rows] splices there, else we append.
const landingIndex = typeof index === "number" &&
Number.isInteger(index) &&
index >= 0 &&
index <= rows
? index
: rows;
// Seed the id generator with every id already in the doc so the new cell
// paragraph ids are unique within the whole document.
const used = new Set();
collectIds(out, used);
const newCells = [];
for (let i = 0; i < colCount; i++) {
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
const attrs = { colspan: 1, rowspan: 1 };
// Copy this column's colwidth from the header row's cell when present.
const colwidth = headerCells[i]?.attrs?.colwidth;
if (colwidth !== undefined)
attrs.colwidth = colwidth;
// A row landing at index 0 becomes the new header row, so inherit the
// current header cell's type per column (Docmost uses "tableHeader" there);
// every other position is a plain data cell.
const cellType = landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
newCells.push({
type: cellType,
attrs,
content: [makeCellParagraph(makeFreshId(used), text)],
});
}
const newRow = { type: "tableRow", content: newCells };
// Splice at the resolved landing index (append when index was omitted/invalid).
table.content.splice(landingIndex, 0, newRow);
return { doc: out, inserted: true };
}
/**
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
* `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.
*/
export function deleteTableRow(doc, tableRef, index) {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null)
return { doc: out, deleted: false };
const { table } = located;
if (!Array.isArray(table.content))
table.content = [];
const rows = table.content.length;
if (!Number.isInteger(index) || index < 0 || index >= rows) {
throw new Error(`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`);
}
if (rows <= 1) {
throw new Error("table_delete_row: refusing to delete the only row of the table");
}
table.content.splice(index, 1);
return { doc: out, deleted: true };
}
/**
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
* that reuses the cell's existing first-paragraph id when present, else a fresh
* one.
*/
export function updateTableCell(doc, tableRef, row, col, text) {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null)
return { doc: out, updated: false };
const { table } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const rowNode = rowNodes[row];
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
? rowNode.content.length
: 0;
if (!Number.isInteger(row) ||
row < 0 ||
row >= rows ||
!Number.isInteger(col) ||
col < 0 ||
col >= cols) {
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
}
const cellNode = rowNode.content[col];
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
const existingPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
let id = isObject(existingPara) && isObject(existingPara.attrs)
? existingPara.attrs.id
: undefined;
if (typeof id !== "string" || id.length === 0) {
const used = new Set();
collectIds(out, used);
id = makeFreshId(used);
}
cellNode.content = [makeCellParagraph(id, text)];
return { doc: out, updated: true };
}
-31
View File
@@ -1,31 +0,0 @@
/**
* Per-page async mutex.
*
* Content writes over the collaboration websocket must never overlap for the
* same page: two concurrent full-document replaces would race on the live Yjs
* fragment. We serialize them with a per-pageId promise chain each new
* operation waits for the previous one on that page to settle (success or
* failure) before it runs. Different pages never block each other.
*/
const chains = new Map();
// The returned promise carries the real result/rejection of `fn` and MUST be
// awaited/handled by the caller; only the internal chaining tail swallows
// errors (purely to gate ordering).
export function withPageLock(pageId, fn) {
// Wait for the previous op on this page; swallow its error so a failure does
// not poison the queue for the next caller.
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => { });
const run = prev.then(fn);
// The tail used for chaining must also swallow errors (it only gates order).
const tail = run.catch(() => { });
chains.set(pageId, tail);
// Drop the map entry once this op is the tail and has settled, to avoid an
// unbounded map of resolved promises.
tail.then(() => {
if (chains.get(pageId) === tail) {
chains.delete(pageId);
}
});
// Callers get the real result/rejection of fn.
return run;
}
-15
View File
@@ -1,15 +0,0 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node /
// insert_node (and the analogous update_page_json content parsing).
export function parseNodeArg(node, errMsg = "node was a string but not valid JSON") {
if (typeof node === "string") {
try {
return JSON.parse(node);
}
catch {
throw new Error(errMsg);
}
}
return node;
}
-108
View File
@@ -1,108 +0,0 @@
/**
* Locator normalization: strip inline markdown wrappers and trailing
* decoration from a LOCATOR string so a find/anchor that the model wrote with
* markdown (or a stray emoji) can still match the document's plain text.
*
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
* it is never applied to replacement text or inserted node content, so no
* formatting is ever lost.
*/
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
const MAX_PASSES = 8;
/**
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
* the string stops changing (nested wrappers like `**_x_**`).
*/
const WRAPPER_PATTERNS = [
/\*\*([^*]+?)\*\*/g, // **x**
/__([^_]+?)__/g, // __x__
/~~([^~]+?)~~/g, // ~~x~~
/\*([^*]+?)\*/g, // *x*
/_([^_]+?)_/g, // _x_
/``([^`]+?)``/g, // ``x``
/`([^`]+?)`/g, // `x`
];
/** Links/images -> their visible text. `!?` covers both `[t](u)` and `![a](s)`. */
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
/**
* Apply ONLY the two balanced/link passes shared by both normalizers: first
* collapse links/images to their visible text, then collapse balanced inline
* wrappers repeatedly until stable. Does NOT trim decoration, does NOT guard
* against an empty result it returns exactly the transformed string.
*/
function stripWrappersAndLinks(s) {
// 1. Links/images -> their visible text.
let out = s.replace(LINK_IMAGE_RE, "$1");
// 2. Strip balanced wrappers, repeating until the string is stable so nested
// wrappers (`**_x_**`) and adjacent runs both collapse.
for (let pass = 0; pass < MAX_PASSES; pass++) {
const before = out;
for (const re of WRAPPER_PATTERNS) {
out = out.replace(re, "$1");
}
if (out === before)
break;
}
return out;
}
/**
* STRICT formatting detector distinct from the lenient locator
* normalization below. It strips ONLY what unambiguously is markdown markup:
* 1. links/images `[text](url)` -> `text`, `![alt](src)` -> `alt`, and
* 2. balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers (repeat-until-stable),
* and DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone
* marker chars (the lenient extras `stripInlineMarkdown` does in its step 3).
*
* It exists ONLY to recognize formatting-vs-plain INTENT in `applyTextEdits`
* (deciding whether find/replace differ purely by markdown markers). Because it
* skips the lenient trimming, ordinary plain-text edits are NOT misread as
* formatting: a trailing-space trim, snake_case (`my_var_name`), math (`2 * 3`),
* and identifiers/URLs with underscores all stay untouched here (their `_x_` /
* `*x*` runs are only collapsed when actually balanced, and even then they are
* compared symmetrically, so plain text never collapses to a different string).
*
* Do NOT use this for LOCATING the locator fallback must keep using the
* lenient `stripInlineMarkdown` (it trims stray decoration so a find still
* matches the document's plain text).
*/
export function stripBalancedWrappers(s) {
if (typeof s !== "string" || s.length === 0)
return s;
return stripWrappersAndLinks(s);
}
/**
* Conservatively strip inline markdown from a locator string.
*
* Deterministic, order-fixed steps:
* 1. Links/images: `[text](url)` -> `text`, `![alt](src)` -> `alt`.
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
* applied repeatedly until stable for nested cases.
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
* etc.) are NEVER trimmed.
*
* If the result is empty (e.g. the input was only markers like `***`), the
* ORIGINAL string is returned so a locator can never normalize down to "" and
* match everything.
*/
export function stripInlineMarkdown(s) {
if (typeof s !== "string" || s.length === 0)
return s;
// 1 + 2. Shared link/image and balanced-wrapper passes.
let out = stripWrappersAndLinks(s);
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
// Anchored runs only — interior text and sentence punctuation are untouched.
const DECORATION = "[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
out = out
.replace(new RegExp("^" + DECORATION, "u"), "")
.replace(new RegExp(DECORATION + "$", "u"), "");
// 4. Never normalize a locator down to nothing.
if (out.length === 0)
return s;
return out;
}
-631
View File
@@ -1,631 +0,0 @@
/**
* Pure, network-free transform primitives for a ProseMirror/TipTap document
* tree, plus one higher-level orchestration (commentsToFootnotes).
*
* A ProseMirror node here is a plain JSON object of the shape produced by
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
* `content` array; callouts, tables, lists all hold their children in
* `content`, so a single recursive walk reaches them all.
*
* Conventions (matching node-ops.ts):
* - functions that produce a new document deep-clone their input and return a
* `{ doc, ... }` object; the caller's objects are never mutated.
* - functions are defensively null-safe.
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
*/
import { blockPlainText } from "./node-ops.js";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, } from "./footnote-authoring.js";
export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
/** Deep-clone a JSON-serializable value without mutating the original. */
function clone(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
// Fallback for environments without structuredClone.
return JSON.parse(JSON.stringify(value));
}
/** True if `value` is a non-null object (and not an array). */
function isObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* Plain text of a node (re-export of node-ops' blockPlainText so transform
* authors have a single import surface). Recurses through nested content.
*/
export function blockText(node) {
return blockPlainText(node);
}
/**
* Depth-first visit of every node in the tree, including the root and the
* nested content of callouts, tables, lists, etc. `fn` is called once per node.
* Null-safe: a nullish or non-object node is ignored.
*/
export function walk(node, fn) {
if (!isObject(node))
return;
fn(node);
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child, fn);
}
}
}
/**
* Find the FIRST node (depth-first) matching `predicate`, anywhere in the tree.
* Works even when the node carries no `attrs.id` (it searches the raw tree, not
* an id index). Returns the live node reference inside `doc` (NOT a clone), or
* null when nothing matches. Typical use: `getList(doc, n => n.type ===
* "orderedList")`.
*/
export function getList(doc, predicate) {
let found = null;
walk(doc, (node) => {
if (found == null && predicate(node)) {
found = node;
}
});
return found;
}
/**
* Textblocks that hold raw text but do NOT accept inline atom nodes. A
* `footnoteReference` is `group:"inline", atom:true`; `codeBlock` is
* `content:"text*"` (text only), so splicing a footnoteReference into it yields
* an invalid document. (paragraph/heading/detailsSummary are `inline*` and DO
* accept it; footnote definitions live inside a footnotesList which the
* footnote inserter excludes via `beforeBlock`.)
*/
const INLINE_ATOM_FORBIDDEN_BLOCKS = new Set(["codeBlock"]);
/**
* Footnote-notes subtrees the inline footnote inserter must never split into (at
* any depth): a `footnotesList` and the `footnoteDefinition`s it holds. Anchoring
* a reference inside one of these would later be dropped as an orphan by the
* canonicalizer, taking the existing definition's text with it.
*/
const FOOTNOTE_NOTES_SUBTREES = new Set([
"footnotesList",
"footnoteDefinition",
]);
/** True if `node` IS, or contains at any depth, a footnotesList/footnoteDefinition. */
function containsFootnoteNotes(node) {
if (!isObject(node))
return false;
if (FOOTNOTE_NOTES_SUBTREES.has(node.type))
return true;
if (Array.isArray(node.content)) {
return node.content.some((c) => containsFootnoteNotes(c));
}
return false;
}
/**
* Insert `marker` as a PLAIN (unmarked) text run right after the first
* occurrence of `anchor`.
*
* The text run that contains the END of the anchor is SPLIT at the anchor end,
* so all existing marks (links, bold, ...) on the surrounding text are
* preserved, while the inserted marker run carries NO marks. The marker is
* inserted as a leading-space-padded run (`" " + marker`) so it visually
* separates from the preceding word.
*
* The anchor is matched against the concatenated plain text of each top-level
* block (so an anchor that spans several text/mark runs still matches). The
* insertion happens inside the inline content array that holds the anchor's
* final character.
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
* false when the anchor text was not found in any in-scope block.
*/
export function insertMarkerAfter(doc, anchor, marker, opts = {}) {
// A plain marker is a leading-space-padded unmarked text run.
return insertNodesAfterAnchor(doc, anchor, () => [{ type: "text", text: " " + marker }], opts);
}
/**
* Mark-safe insertion CORE: split the inline text run that holds the END of
* `anchor` (preserving the surrounding marks) and splice the nodes produced by
* `makeMiddle()` in at the split point. `insertMarkerAfter` (plain text marker)
* and `insertInlineFootnote` (a `footnoteReference` node) are both thin callers
* the only difference is WHAT is inserted (a space-padded text run vs. a node
* that should hug the preceding word), which is exactly what `makeMiddle`
* decides. Operates on a clone; returns `{ doc, inserted }`.
*/
function insertNodesAfterAnchor(doc, anchor, makeMiddle, opts = {}) {
const out = clone(doc);
if (!isObject(out) || !Array.isArray(out.content) || !anchor) {
return { doc: out, inserted: false };
}
const limit = typeof opts.beforeBlock === "number"
? Math.min(opts.beforeBlock, out.content.length)
: out.content.length;
for (let b = 0; b < limit; b++) {
const block = out.content[b];
if (!isObject(block))
continue;
// Quick reject: skip blocks whose plain text cannot contain the anchor.
if (!blockPlainText(block).includes(anchor))
continue;
// Walk the inline content arrays inside this block, tracking a running
// character offset so we can locate the inline array + text run that holds
// the END of the anchor's first occurrence.
let inserted = false;
let offset = 0; // characters of plain text seen so far in this block
const anchorEnd = (() => blockPlainText(block).indexOf(anchor) + anchor.length)();
// Recurse into inline-bearing containers (paragraph, heading, table cell,
// callout child paragraphs, ...). We only split inside an array of inline
// nodes (text/inline atoms); the FIRST array whose cumulative range covers
// anchorEnd receives the split + marker.
const visit = (container) => {
if (inserted || !isObject(container) || !Array.isArray(container.content)) {
return;
}
// Skip a forbidden subtree entirely (e.g. footnotesList/footnoteDefinition):
// never split into it, but keep `offset` aligned for any sibling text after
// it within this block.
if (opts.skipSubtreeTypes && opts.skipSubtreeTypes.has(container.type)) {
offset += blockPlainText(container).length;
return;
}
const inline = container.content;
// Detect whether this array is an inline array (contains text nodes).
const hasText = inline.some((n) => isObject(n) && n.type === "text");
if (hasText) {
// Refuse a textblock whose content spec cannot hold the inserted nodes
// (e.g. a codeBlock for an inline atom). Keep `offset` aligned for any
// sibling textblocks in this same block, then bail so the search falls
// through to the next candidate block.
if (opts.forbidBlockTypes && opts.forbidBlockTypes.has(container.type)) {
offset += blockPlainText(container).length;
return;
}
for (let i = 0; i < inline.length; i++) {
const n = inline[i];
const len = isObject(n) ? blockPlainText(n).length : 0;
const runStart = offset;
const runEnd = offset + len;
// The run that contains the anchor end (anchorEnd lands inside this
// run, i.e. runStart < anchorEnd <= runEnd) is the split point.
if (!inserted &&
isObject(n) &&
n.type === "text" &&
typeof n.text === "string" &&
anchorEnd > runStart &&
anchorEnd <= runEnd) {
const cut = anchorEnd - runStart; // split index within this text run
const before = n.text.slice(0, cut);
const after = n.text.slice(cut);
const marks = Array.isArray(n.marks) ? n.marks : [];
const parts = [];
if (before.length > 0) {
parts.push({ ...n, text: before, marks: [...marks] });
}
// The inserted nodes are caller-decided (a space-padded marker run,
// or a node that hugs the word). They carry no copied marks.
parts.push(...makeMiddle());
if (after.length > 0) {
parts.push({ ...n, text: after, marks: [...marks] });
}
inline.splice(i, 1, ...parts);
inserted = true;
return;
}
offset = runEnd;
}
}
else {
// Not an inline array: recurse into children (e.g. callout -> paragraph).
for (const child of inline) {
visit(child);
if (inserted)
return;
}
}
};
visit(block);
if (inserted) {
return { doc: out, inserted: true };
}
// If the block matched in plain text but we could not split (e.g. anchor
// lands inside an atom), fall through to the next block rather than failing.
}
return { doc: out, inserted: false };
}
/**
* In the disclaimer callout, replace a `[1]…[K]` range marker with `[1]…[n]`.
*
* Docmost translations use a callout that states the footnote range, e.g.
* "[1]…[5]". When the number of notes changes, this rewrites the trailing
* number of any `[1]…[K]` (or `[1]...[K]`, ASCII ellipsis) occurrence found in a
* callout's text nodes to `[1]…[n]`. Operates on a clone; returns
* `{ doc, changed }` where `changed` is the number of text nodes rewritten.
*/
export function setCalloutRange(doc, n) {
const out = clone(doc);
let changed = 0;
// Match "[1]" + (… or ...) + "[<digits>]"; rewrite the last number to n.
const rangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/g;
walk(out, (node) => {
if (node.type === "callout") {
walk(node, (inner) => {
if (inner.type === "text" &&
typeof inner.text === "string" &&
rangeRe.test(inner.text)) {
rangeRe.lastIndex = 0;
inner.text = inner.text.replace(rangeRe, `$1${n}$2`);
changed++;
}
rangeRe.lastIndex = 0;
});
}
});
return { doc: out, changed };
}
/**
* Generate a short random id for a new block's `attrs.id`. Docmost uses nanoid;
* a base36 random string is sufficient here (uniqueness within one document).
*/
function freshId() {
return (Math.random().toString(36).slice(2, 12) +
Math.random().toString(36).slice(2, 6));
}
/**
* Wrap inline ProseMirror nodes in a list item:
* { type:"listItem", content:[{ type:"paragraph", attrs:{id}, content: inlineNodes }] }
* with a fresh random block id on the paragraph. The inline nodes are cloned so
* the result shares no references with the caller's input.
*/
export function noteItem(inlineNodes) {
const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
return {
type: "listItem",
content: [
{
type: "paragraph",
attrs: { id: freshId() },
content,
},
],
};
}
/**
* Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id:
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
*
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts);
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
* and the canonicalizer preserves attrs as-is). Single factory, one place to
* change the definition shape.
*/
export function footnoteDefinition(id, inlineNodes) {
const node = makeFootnoteDefinition(id, inlineNodes);
node.content[0].attrs = { id: freshId() };
return node;
}
/**
* Replace every `[N]` body marker and `\u0000FN<i>\u0000` comment placeholder in
* an inline content array with a real `footnoteReference` node, in reading
* order. `onMarker` is called for each replaced marker (with the original `[N]`
* number or the placeholder index) and returns the fresh footnote id to attach
* to the inserted node. Mutates `inline` in place.
*/
function replaceMarkersWithReferences(inline, onMarker) {
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
for (let i = 0; i < inline.length; i++) {
const n = inline[i];
if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") {
continue;
}
if (!re.test(n.text))
continue;
re.lastIndex = 0;
const marks = Array.isArray(n.marks) ? n.marks : [];
const parts = [];
let last = 0;
let m;
while ((m = re.exec(n.text)) !== null) {
if (m.index > last) {
parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] });
}
const oldNum = m[1] != null ? Number(m[1]) : undefined;
const phIdx = m[2] != null ? Number(m[2]) : undefined;
const fnId = onMarker({ oldNum, phIdx });
parts.push({ type: "footnoteReference", attrs: { id: fnId } });
last = m.index + m[0].length;
}
if (last < n.text.length) {
parts.push({ ...n, text: n.text.slice(last), marks: [...marks] });
}
// Drop any zero-length text runs the slicing may have produced.
const cleaned = parts.filter((p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0));
inline.splice(i, 1, ...cleaned);
i += cleaned.length - 1;
}
}
/**
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
* ProseMirror nodes.
*
* A leading `комментарий: ` (case-insensitive) or `N. ` numeric prefix is
* stripped first. Then a minimal bold-split is applied: a leading
* `**bold lead**` run becomes a text node with a bold mark, and the remainder
* becomes a plain text node. This keeps the conversion synchronous (the
* transform sandbox runs synchronously) and dependency-free; the existing
* async markdownToProseMirror is intentionally NOT used here.
*/
export function mdToInlineNodes(markdown) {
let md = typeof markdown === "string" ? markdown : "";
// Strip a leading "комментарий: " prefix (case-insensitive) or a "N. " prefix.
md = md.replace(/^\s*комментарий\s*:\s*/i, "");
md = md.replace(/^\s*\d+\.\s+/, "");
md = md.trim();
if (md === "")
return [];
const nodes = [];
// Leading bold lead: **...** at the very start.
const leadMatch = /^\*\*([^*]+)\*\*\s*/.exec(md);
if (leadMatch) {
const leadText = leadMatch[1];
nodes.push({
type: "text",
text: leadText,
marks: [{ type: "bold" }],
});
const rest = md.slice(leadMatch[0].length);
if (rest.length > 0) {
// Preserve the separating space that followed the bold lead.
const sep = /^\*\*[^*]+\*\*(\s*)/.exec(md);
const spacing = sep ? sep[1] : "";
nodes.push({ type: "text", text: spacing + rest });
}
return nodes;
}
// No bold lead: emit the whole thing as a single plain text node, with any
// remaining **bold** spans split out inline.
return splitInlineBold(md);
}
/**
* Split a string with inline `**bold**` spans into text nodes, bolding the
* spans. Used as the no-lead fallback in mdToInlineNodes.
*/
function splitInlineBold(text) {
const nodes = [];
const re = /\*\*([^*]+)\*\*/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
nodes.push({ type: "text", text: text.slice(last, m.index) });
}
nodes.push({ type: "text", text: m[1], marks: [{ type: "bold" }] });
last = m.index + m[0].length;
}
if (last < text.length) {
nodes.push({ type: "text", text: text.slice(last) });
}
return nodes.length > 0 ? nodes : [{ type: "text", text }];
}
/**
* Turn inline comments into numbered footnotes.
*
* For each inline comment that carries a `selection`:
* 1. insert a placeholder marker (a NUL-delimited "\u0000FN<i>\u0000"
* sentinel) right after the selection text in the BODY (before the
* notes heading);
* 2. build a note list item from the comment's markdown content.
*
* Then RENUMBER every footnote marker in the body by reading order: existing
* `[N]` markers and the new "\u0000FN<i>\u0000" placeholders are both replaced by a
* sequential `[seq]`, and the notes orderedList is reordered so each note lines
* up with its marker's reading-order position. Finally the disclaimer callout
* range is synced to the new note count.
*
* Returns `{ doc, consumed }` where `consumed` lists the ids of comments that
* were successfully anchored (their selection was found and a placeholder
* inserted). Operates on a clone of `doc`.
*/
export function commentsToFootnotes(doc, comments, opts = {}) {
let working = clone(doc);
const notesHeading = opts.notesHeading ?? "Примечания переводчика";
const top = Array.isArray(working.content) ? working.content : [];
const notesIdx = top.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading);
if (notesIdx < 0) {
throw new Error(`heading "${notesHeading}" not found`);
}
// The notes orderedList lives at or after the heading.
const notesList = top
.slice(notesIdx)
.find((n) => isObject(n) && n.type === "orderedList");
if (!notesList) {
throw new Error("notes orderedList not found");
}
const consumed = [];
const noteInlineByPh = new Map();
(Array.isArray(comments) ? comments : []).forEach((c, i) => {
if (!c || !c.selection)
return;
// Collision-proof sentinel delimited by NUL control chars, which never occur
// in real Docmost prose - so the marker regex cannot mistake any body text
// (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
// transient: the placeholder is inserted here and replaced by a
// footnoteReference node below; it never persists in a returned document.
const ph = `\u0000FN${i}\u0000`;
// insertMarkerAfter returns a NEW cloned doc; reassign `working`.
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
beforeBlock: notesIdx,
});
if (!r.inserted)
return;
working = r.doc;
noteInlineByPh.set(ph, mdToInlineNodes(c.content));
consumed.push(c.id);
});
// Re-resolve references into the (possibly re-cloned) working doc.
const top2 = Array.isArray(working.content) ? working.content : [];
const notesIdx2 = top2.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading);
const oldListIndex = top2.findIndex((n) => isObject(n) && n.type === "orderedList");
const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null;
if (!notesList2) {
throw new Error("notes orderedList not found");
}
// Inline content of each existing note (listItem -> paragraph -> inline).
const oldNoteInline = (Array.isArray(notesList2.content)
? notesList2.content
: []).map((item) => {
const para = isObject(item) && Array.isArray(item.content)
? item.content.find((c) => isObject(c) && c.type === "paragraph")
: null;
return para && Array.isArray(para.content) ? para.content : [];
});
// Walk the body in reading order, turning each "[N]" / placeholder marker into
// a real footnoteReference node and collecting its definition inline content.
const definitions = [];
const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/;
// Recursively visit inline arrays inside a block (paragraph, heading, callout
// child paragraphs, table cells, ...), preserving document reading order.
const visitInlineArrays = (container) => {
if (!isObject(container) || !Array.isArray(container.content))
return;
const hasText = container.content.some((n) => isObject(n) && n.type === "text");
if (hasText) {
replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => {
const fnId = freshId();
if (oldNum != null) {
const inline = oldNoteInline[oldNum - 1];
// Every existing body marker MUST map to a real note. An out-of-range
// marker means the document is internally inconsistent; fail loudly.
if (inline === undefined) {
throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`);
}
definitions.push(footnoteDefinition(fnId, inline));
}
else {
const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || [];
definitions.push(footnoteDefinition(fnId, inline));
}
return fnId;
});
}
else {
for (const child of container.content)
visitInlineArrays(child);
}
};
const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex;
for (let i = 0; i < notesBoundary; i++) {
// Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote
// marker and is synced separately by setCalloutRange.
if (isObject(top2[i]) &&
top2[i].type === "callout" &&
disclaimerRangeRe.test(blockText(top2[i]))) {
continue;
}
visitInlineArrays(top2[i]);
}
// Replace the old orderedList with a real footnotesList of the collected
// definitions (reading order). If there are no definitions, drop the list.
if (definitions.length > 0) {
top2[oldListIndex] = {
type: "footnotesList",
content: definitions,
};
}
else {
top2.splice(oldListIndex, 1);
}
// Sync the disclaimer callout range to the new note count.
const synced = setCalloutRange(working, definitions.length);
return { doc: synced.doc, consumed };
}
/**
* AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and
* WHAT (markdown text); numbering and the bottom list are derived server-side by
* `canonicalizeFootnotes`. The caller never sees or edits `footnotesList`, never
* assigns a number, and cannot desync orphans / out-of-order lists / raw
* `[^id]` markdown are structurally impossible.
*
* Content DEDUP (#3 in the issue): if an existing definition has the SAME
* normalized content key, its id is REUSED (the new reference points at it: one
* number, one definition, several references). Otherwise a fresh uuid id is
* minted and a new definition added. Conservative only an exact content match
* merges.
*
* Mechanics: the `footnoteReference` node is inserted DIRECTLY at the anchor via
* the same mark-safe split as `insertMarkerAfter` (the shared
* `insertNodesAfterAnchor` core), so it hugs the preceding word with no text
* sentinel round-trip. The whole document is then canonicalized.
*
* Operates on a clone of `doc`. When the anchor is not found, returns the input
* unchanged with `inserted:false`.
*/
export function insertInlineFootnote(doc, opts) {
const inline = mdToInlineNodes(opts.text ?? "");
// footnoteContentKey only reads `.content`, so key off the inline array
// directly instead of building a throwaway definition node.
const key = footnoteContentKey({ content: inline });
// Content dedup: reuse an existing definition's id when its key matches.
let footnoteId = null;
let reused = false;
if (key !== "") {
walk(doc, (n) => {
if (footnoteId == null &&
isObject(n) &&
n.type === "footnoteDefinition" &&
n.attrs &&
typeof n.attrs.id === "string" &&
n.attrs.id !== "" &&
footnoteContentKey(n) === key) {
footnoteId = n.attrs.id;
reused = true;
}
});
}
if (footnoteId == null)
footnoteId = generateFootnoteId();
// Insert the footnoteReference node directly after the anchor (mark-safe
// split); it hugs the preceding word with no leading space. Two guards keep the
// inline atom out of the notes section and out of blocks that cannot hold it:
// - beforeBlock bounds the search to the BODY, before the first top-level block
// that IS or CONTAINS (at any depth) a footnotesList/footnoteDefinition — so
// a NESTED list or a bare definition also bounds the search, not just a
// top-level list;
// - skipSubtreeTypes refuses to descend into any footnotesList/footnoteDefinition
// subtree, so a reference is never glued inside an existing definition (which
// the canonicalizer would then drop as an orphan, losing that definition's
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
// schema-invalid doc; insert_footnote skips validateDocStructure).
// When the only anchor match is in such a place, the insert is refused and the
// write aborts cleanly (inserted:false) instead of destroying content.
const boundaryIdx = Array.isArray(doc?.content)
? doc.content.findIndex((n) => containsFootnoteNotes(n))
: -1;
const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], {
...(boundaryIdx >= 0 ? { beforeBlock: boundaryIdx } : {}),
forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS,
skipSubtreeTypes: FOOTNOTE_NOTES_SUBTREES,
});
if (!r.inserted) {
return { doc: clone(doc), inserted: false, footnoteId, reused };
}
let working = r.doc;
// Add a NEW definition (canonicalize will order/place it); a reused id needs
// no new definition (the existing one is shared).
if (!reused) {
appendDefinition(working, makeFootnoteDefinition(footnoteId, inline));
}
// Derive numbering + the single bottom list deterministically.
working = canonicalizeFootnotes(working);
return { doc: working, inserted: true, footnoteId, reused };
}
/**
* Append a definition node so the canonicalizer can order/place it: into the
* first existing footnotesList, or a new trailing list when none exists.
*/
function appendDefinition(doc, defNode) {
const existingList = getList(doc, (n) => isObject(n) && n.type === "footnotesList");
if (existingList && Array.isArray(existingList.content)) {
existingList.content.push(defNode);
return;
}
if (Array.isArray(doc.content)) {
doc.content.push({ type: "footnotesList", content: [defNode] });
}
}
-89
View File
@@ -1,89 +0,0 @@
/**
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
* by `enumerateSpacePages`) into a nested tree.
*
* Input: a flat array of nodes. Each node is expected to carry at least
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
*
* Output: an array of ROOT nodes, each shaped as
* { id, slugId, title, children? }
* where `children` is the array of child nodes (same shape, recursively). The
* `children` key is OMITTED entirely when a node has no children consistent
* with how `filterPage` omits an empty `subpages` array to keep the payload
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
* are intentionally dropped from the output).
*
* Linking rule: a node is attached as a child of `parentPageId` only when that
* parent id is actually present in the input. Otherwise including a null /
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
* the node is promoted to a ROOT. So "orphan whose parent is missing" is the
* defined behavior: it surfaces at the top level rather than disappearing.
*
* Ordering rule: the roots array and every `children` array are sorted ascending
* by the node's `position` string. The comparator is a plain code-unit (byte)
* comparison NOT localeCompare because the server orders sidebar pages by
* `collate "C"` (byte order), which a raw `<`/`>` compare approximates for the
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
* `position` sort last.
*
* Pure: no I/O, no network, deterministic.
*/
export function buildPageTree(nodes) {
// Map id -> output node. Build the lean output shape up front.
const byId = new Map();
// Preserve the original position string for sorting (kept off the output).
const positionById = new Map();
for (const node of nodes) {
if (!node || typeof node !== "object" || !node.id)
continue;
// Defensive against duplicate ids: last one wins (overwrites the earlier
// entry). `enumerateSpacePages` already dedups, so this is belt-and-braces.
byId.set(node.id, {
id: node.id,
slugId: node.slugId,
title: node.title,
});
positionById.set(node.id, node.position);
}
// Stable comparator on the position string: code-unit order, missing last.
const byPosition = (aId, bId) => {
const a = positionById.get(aId);
const b = positionById.get(bId);
if (a === undefined || a === null)
return b === undefined || b === null ? 0 : 1;
if (b === undefined || b === null)
return -1;
if (a < b)
return -1;
if (a > b)
return 1;
return 0;
};
const roots = [];
const childrenIdsByParent = new Map();
for (const node of nodes) {
if (!node || typeof node !== "object" || !node.id)
continue;
const parentId = node.parentPageId;
// Child only when the parent is actually present in the input; otherwise
// (null/undefined parent, or parent capped out of the walk) -> root.
if (parentId && byId.has(parentId)) {
const list = childrenIdsByParent.get(parentId) ?? [];
list.push(node.id);
childrenIdsByParent.set(parentId, list);
}
else {
roots.push(node.id);
}
}
// Attach sorted children arrays to each parent, omitting empty ones.
for (const [parentId, childIds] of childrenIdsByParent) {
const parent = byId.get(parentId);
if (!parent)
continue;
childIds.sort(byPosition);
parent.children = childIds.map((id) => byId.get(id));
}
roots.sort(byPosition);
return roots.map((id) => byId.get(id));
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createDocmostMcpServer } from "./index.js";
// Standalone stdio entrypoint. This restores the original behavior of the
// package when run as a CLI (`docmost-mcp`): it reads credentials from the
// environment and serves the MCP protocol over stdin/stdout. The factory in
// index.ts stays side-effect-free; all the process/transport lifecycle lives
// here.
const API_URL = process.env.DOCMOST_API_URL;
const EMAIL = process.env.DOCMOST_EMAIL;
const PASSWORD = process.env.DOCMOST_PASSWORD;
if (!API_URL || !EMAIL || !PASSWORD) {
console.error("Error: DOCMOST_API_URL, DOCMOST_EMAIL, and DOCMOST_PASSWORD environment variables are required.");
process.exit(1);
}
async function run() {
// Global safety nets so a stray rejection/exception cannot silently kill
// the stdio server. Per-tool errors still flow through the SDK and are not
// affected by these handlers; these only catch errors raised OUTSIDE a tool
// call (e.g. a transient ws/collab socket "error" event). Such errors must
// NOT tear down the whole stdio server, so we log only and keep running.
// Genuine startup failures are still fatal via run().catch(...) below.
process.on("unhandledRejection", (reason) => {
console.error("Unhandled promise rejection:", reason);
});
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
});
const server = createDocmostMcpServer({
apiUrl: API_URL,
email: EMAIL,
password: PASSWORD,
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
run().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
-315
View File
@@ -1,315 +0,0 @@
// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server
// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service
// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally
// imports NO zod: each consumer passes its OWN zod namespace into buildShape,
// because the two packages are on different zod majors (v3 here, v4 in the
// server) and a zod schema object built with one major cannot be reused by the
// other. The builders below only touch z.string()/.min()/.optional()/.describe(),
// z.array() and z.object() — API identical across v3 and v4 — so a single
// builder works with either namespace.
//
// Only tools whose snake_case/camelCase name, input schema AND model-facing
// description are genuinely identical across both layers live here. Tools that
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
// per-layer and are NOT represented here.
export const SHARED_TOOL_SPECS = {
// --- no-argument read tools ---
getWorkspace: {
mcpName: 'get_workspace',
inAppKey: 'getWorkspace',
description: 'Fetch metadata about the current workspace (name, settings).',
},
listSpaces: {
mcpName: 'list_spaces',
inAppKey: 'listSpaces',
description: 'List the spaces the current user can access. Returns the array of ' +
'spaces (id, name, slug, ...).',
},
listShares: {
mcpName: 'list_shares',
inAppKey: 'listShares',
description: 'List all public shares in the workspace with page titles and public URLs.',
},
// --- single-pageId read tools ---
getPageJson: {
mcpName: 'get_page_json',
inAppKey: 'getPageJson',
description: 'Get page details with the raw ProseMirror JSON content (lossless: ' +
'includes block ids, callouts, tables, link/image attributes) plus the ' +
'slugId used in URLs. Use the block ids it returns to make precise ' +
'structural edits or surgical text edits without resending the page.',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
},
getOutline: {
mcpName: 'get_outline',
inAppKey: 'getOutline',
description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
'id, level, firstText}; tables add rows/cols/header; lists add item ' +
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
'and grab block ids cheaply before fetching, patching or inserting ' +
'individual blocks.',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
},
// --- two-id read tool ---
getNode: {
mcpName: 'get_node',
inAppKey: 'getNode',
description: "Fetch a single node's full ProseMirror subtree (lossless) without " +
'pulling the whole document. `nodeId` is a block id from the page ' +
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
'`#<index>` to fetch a top-level block by its outline index — use the ' +
'`#<index>` form for tables/rows/cells, which carry no id.',
buildShape: (z) => ({
pageId: z.string().min(1),
nodeId: z.string().min(1),
}),
},
// --- node delete ---
deleteNode: {
mcpName: 'delete_node',
inAppKey: 'deleteNode',
description: 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
'resending the whole document.',
buildShape: (z) => ({
pageId: z.string().min(1),
nodeId: z.string().min(1),
}),
},
// --- single-block structural write (patch / insert) ---
//
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
// the whole document" + "cheaper/safer than a full-document replace" guidance
// AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose
// those siblings under different (snake_case vs camelCase) identifiers.
patchNode: {
mcpName: 'patch_node',
inAppKey: 'patchNode',
description: 'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z
.string()
.min(1)
.describe('attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)'),
node: z
.any()
.describe('ProseMirror node to put in place of the node with this id, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
}),
},
insertNode: {
mcpName: 'insert_node',
inAppKey: 'insertNode',
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
"`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' +
'structural table nodes. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
'JSON object or a JSON string (both accepted). Reversible via page history.',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.any()
.describe('ProseMirror node to insert, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.'),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe("Anchor text fragment (for before/after), matched against the " +
"block's literal rendered plain text (no markdown). Markdown/emoji " +
'are tolerated as a fallback; prefer plain text or anchorNodeId.'),
}),
},
// --- share management ---
unsharePage: {
mcpName: 'unshare_page',
inAppKey: 'unsharePage',
description: 'Remove the public share of a page (revokes the public URL).',
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page to unshare'),
}),
},
// --- version history ---
diffPageVersions: {
mcpName: 'diff_page_versions',
inAppKey: 'diffPageVersions',
description: 'Diff two versions of a page and return a Docmost-equivalent change set ' +
'(inserted/deleted text, integrity counts for images/links/tables/' +
'callouts/footnote markers, and a human-readable markdown summary). ' +
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
'current content (defaults: from=current, to=current — pass a historyId ' +
'from the page-history list to compare against the live page).',
buildShape: (z) => ({
pageId: z.string().min(1),
from: z
.string()
.optional()
.describe("historyId, or 'current'/omit for current content"),
to: z
.string()
.optional()
.describe("historyId, or 'current'/omit for current content"),
}),
},
listPageHistory: {
mcpName: 'list_page_history',
inAppKey: 'listPageHistory',
description: "List a page's saved versions (Docmost auto-snapshots on every save), " +
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
"item's id is the historyId to pass to the page diff or restore tools.",
buildShape: (z) => ({
pageId: z.string().min(1),
cursor: z
.string()
.optional()
.describe('Pagination cursor from a previous nextCursor'),
}),
},
restorePageVersion: {
mcpName: 'restore_page_version',
inAppKey: 'restorePageVersion',
description: 'Restore a page to a saved version: writes that version\'s content back ' +
'as the page\'s current content (Docmost has no restore endpoint, so ' +
'this creates a NEW history snapshot — the restore is itself revertible). ' +
'Get the historyId from the page-history list.',
buildShape: (z) => ({
historyId: z.string().min(1),
}),
},
// --- markdown round-trip ---
importPageMarkdown: {
mcpName: 'import_page_markdown',
inAppKey: 'importPageMarkdown',
description: "Replace a page's content from a self-contained Docmost-flavoured " +
'Markdown file produced by the page-Markdown export tool. Restores comment ' +
'highlight anchors and diagrams from their inline HTML. NOTE: comment ' +
'thread records are NOT created/updated/deleted on the server by this ' +
'tool — only the page body + inline comment marks are written; manage ' +
'comment threads via the comment tools/UI.',
buildShape: (z) => ({
pageId: z.string().min(1),
markdown: z.string().min(1),
}),
},
// --- server-side content copy ---
copyPageContent: {
mcpName: 'copy_page_content',
inAppKey: 'copyPageContent',
description: "Replace targetPageId's content with a copy of sourcePageId's content, " +
'entirely server-side — the document is NOT sent through the model. The ' +
'target keeps its own title and slug; only its body is replaced. Ideal ' +
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
buildShape: (z) => ({
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
targetPageId: z
.string()
.min(1)
.describe('Page whose content is REPLACED (title/slug kept)'),
}),
},
// --- surgical text edit (folds in the documented drift-bug fix) ---
//
// CANONICAL description is the CORRECTED in-app wording: a formatting-only
// change is REFUSED into failed[] (not silently stripped-and-retried). The
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
// fallback" is intentionally absent here.
editPageText: {
mcpName: 'edit_page_text',
inAppKey: 'editPageText',
description: "Surgical find/replace inside a page's text, preserving all block " +
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
'replacement inherits marks from the unchanged common prefix/suffix ' +
'(so editing plain text next to a bold word keeps it bold, and ' +
'editing inside a bold word keeps the new text bold). Each find must ' +
'match exactly once unless replaceAll is set. The batch applies what ' +
'it can and returns applied[] + failed[] plus a verify change-report ' +
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
'your edit landed; do not assume success); a fully-unmatched batch ' +
'writes nothing and errors. find and replace are LITERAL text, not ' +
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
'formatting marks: a formatting change — find/replace that differ only ' +
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
'failed[]. To change bold/italic/strike/code/link, read the block as ' +
'page JSON and use a structural node patch/update to set its marks. ' +
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
'world",replace:"Hello there"}] (crosses a bold boundary).',
buildShape: (z) => ({
pageId: z.string().describe('ID of the page to edit'),
edits: z
.array(z.object({
find: z.string().describe('Exact text to find'),
replace: z.string().describe('Replacement text (may be empty)'),
replaceAll: z
.boolean()
.optional()
.describe('Replace every occurrence (default: must match once)'),
}))
.min(1)
.describe('List of find/replace operations, applied in order'),
}),
},
// --- hand a large page to an external consumer without bloating context ---
stashPage: {
mcpName: 'stash_page',
inAppKey: 'stashPage',
description: 'Serialize a whole page (the full ProseMirror JSON, as get_page_json ' +
'returns) into an ephemeral in-memory blob and return ONLY a short ' +
'anonymous URL to it — the body NEVER enters the model context, so this ' +
'is the way to hand a large page (or its images) to an external consumer ' +
'without truncation. Every internal file/image attachment is mirrored ' +
'into the same sandbox and its src rewritten to a sandbox URL, so the ' +
'consumer can fetch the images anonymously too; external http(s) images ' +
'are left untouched. Returns { uri, size, sha256, images:{mirrored, ' +
'failed} }. Integrity: the blob is served with ETag = its sha256, so a ' +
'truncated/corrupted fetch is detectable. Blobs are RAM-only: they expire ' +
'after a short TTL (~1h) and are cleared on restart — consume the URL ' +
'within the TTL and one uptime, or re-stash. A blob is bound to the ' +
'server instance that created it: in a multi-replica deployment without ' +
'sticky sessions a blob stored on one instance is not retrievable via the ' +
'sandbox URL on another (it 404s like an expired one).',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
},
};
+1
View File
@@ -32,6 +32,7 @@
"author": "Moritz Krause",
"license": "MIT",
"dependencies": {
"@docmost/prosemirror-markdown": "workspace:*",
"@fellow/prosemirror-recreate-transform": "^1.2.3",
"@hocuspocus/provider": "^3.4.4",
"@hocuspocus/transformer": "^3.4.4",
+58 -7
View File
@@ -807,8 +807,14 @@ export class DocmostClient {
await this.ensureAuthenticated();
const resultData = await this.getPageRaw(pageId);
// Agent read: hide resolved-comment anchors so the agent sees only active
// discussions. Active anchors are kept. (The lossless export_page_markdown
// round-trip deliberately does NOT pass this flag — resolved anchors there
// must be preserved.)
let content = resultData.content
? convertProseMirrorToMarkdown(resultData.content)
? convertProseMirrorToMarkdown(resultData.content, {
dropResolvedCommentAnchors: true,
})
: "";
// Always fetch subpages to provide context to the agent
@@ -1774,7 +1780,10 @@ export class DocmostClient {
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
let comments: any[] = [];
try {
comments = await this.listComments(pageId);
// Lossless export: include RESOLVED threads so the export -> import
// round-trip preserves every comment. This is exactly why the active-only
// filter is an opt-in (default false) on listComments.
comments = (await this.listComments(pageId, true)).items;
} catch (e) {
// A comments fetch failure must not lose the body; export with [] and let
// the caller see the (empty) comments block. Log under DEBUG only.
@@ -2343,8 +2352,21 @@ export class DocmostClient {
}
}
/** List all comments on a page (cursor-paginated), content as markdown. */
async listComments(pageId: string) {
/**
* List comments on a page (cursor-paginated), content as markdown.
*
* DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the
* agent sees only active discussions: a top-level comment with `resolvedAt`
* set AND every reply under it (a reply of a closed thread is part of the
* closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how
* many resolved top-level threads were hidden so the agent can re-query with
* `includeResolved: true` to see everything. Active threads always stay.
*
* Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) callers that
* need the full feed (lossless export, transformPage, checkNewComments) pass
* `includeResolved: true` and read `.items`.
*/
async listComments(pageId: string, includeResolved = false) {
await this.ensureAuthenticated();
let allComments: any[] = [];
let cursor: string | null = null;
@@ -2360,7 +2382,7 @@ export class DocmostClient {
cursor = data.meta?.nextCursor || null;
} while (cursor);
return allComments.map((comment: any) => {
const mapped = allComments.map((comment: any) => {
const markdown = comment.content
? convertProseMirrorToMarkdown(
this.parseCommentContent(comment.content),
@@ -2368,6 +2390,31 @@ export class DocmostClient {
: "";
return filterComment(comment, markdown);
});
if (includeResolved) {
return { items: mapped, resolvedThreadsHidden: 0 };
}
// Ids of RESOLVED top-level threads (a top-level comment has no
// parentCommentId). A whole thread is hidden when its root is resolved.
const resolvedRootIds = new Set(
mapped
.filter((c) => !c.parentCommentId && c.resolvedAt != null)
.map((c) => c.id),
);
const items = mapped.filter((c) => {
// Hide the resolved root itself and every reply anchored to it. A reply's
// own resolvedAt is irrelevant — its membership follows the parent thread.
// ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId
// always points at the thread ROOT (no reply-of-reply nesting), so a single
// level of parent lookup covers a whole thread. If nested replies are ever
// introduced, a deep reply of a resolved thread would need a root-walk here.
if (!c.parentCommentId) return !resolvedRootIds.has(c.id);
return !resolvedRootIds.has(c.parentCommentId);
});
return { items, resolvedThreadsHidden: resolvedRootIds.size };
}
async getComment(commentId: string) {
@@ -2742,7 +2789,9 @@ export class DocmostClient {
const results: any[] = [];
for (const page of pagesInScope) {
try {
const comments = await this.listComments(page.id);
// Full feed (incl. resolved): a "new comments since" scan reports all
// recent activity; the active-only filter is scoped to list_comments.
const comments = (await this.listComments(page.id, true)).items;
const newComments = comments.filter(
(c: any) => new Date(c.createdAt) > sinceDate,
);
@@ -3488,7 +3537,9 @@ export class DocmostClient {
const deleteComments = opts.deleteComments ?? false;
await this.ensureAuthenticated();
const comments = await this.listComments(pageId);
// Full feed (incl. resolved): a page transform (e.g. comments -> footnotes)
// must operate on every comment, so it opts into the unfiltered feed.
const comments = (await this.listComments(pageId, true)).items;
// ctx handed to the sandbox. consume() records ids; helpers are the pure
// transform primitives. log is captured from console.log inside the sandbox.
+45 -16
View File
@@ -37,11 +37,20 @@ const VERSION = packageJson.version;
// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
//
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
// this guide so the new tool is routed by intent. This is enforced by
// test/unit/server-instructions.test.mjs, which fails when a registered tool
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
// Exported for that test.
export const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
// Helper to format JSON responses
const jsonContent = (data: any) => ({
@@ -147,7 +156,9 @@ server.registerTool(
description:
"Get page details with content converted to Markdown. The conversion is " +
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
"lossless representation use get_page_json.",
"lossless representation use get_page_json. Inline <span data-comment-id> " +
"tags in the markdown are comment highlight anchors (also present for " +
"RESOLVED threads) — treat them as markup, not page text.",
inputSchema: {
pageId: z.string().min(1),
},
@@ -288,7 +299,8 @@ server.registerTool(
"create_page",
{
description:
"Create a new page with content (automatically moves it to the correct hierarchy).",
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
"it under a parent; omit it to create at the space root.",
inputSchema: {
title: z.string().min(1).describe("Title of the page"),
content: z.string().min(1).describe("Markdown content"),
@@ -587,7 +599,8 @@ server.registerTool(
{
description:
"Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
"page content to ANYONE with the URL — do it only when explicitly asked.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to share"),
searchIndexing: z
@@ -619,7 +632,7 @@ server.registerTool(
"move_page",
{
description:
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
"Move a page under a new parent (nesting) or to the space root.",
inputSchema: {
pageId: z.string().min(1),
parentPageId: z
@@ -675,7 +688,9 @@ server.registerTool(
server.registerTool(
"delete_page",
{
description: "Delete a single page by ID.",
description:
"Delete a single page by ID. SOFT delete only: the page is moved to " +
"trash and can be restored; nothing is permanently deleted.",
inputSchema: {
pageId: z.string().min(1),
},
@@ -697,13 +712,24 @@ server.registerTool(
"list_comments",
{
description:
"List all comments on a page (paginated). Content is returned as Markdown.",
"List comments on a page in one call (pagination is handled " +
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
"threads (a resolved top-level comment and all its replies) are hidden " +
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
"with `includeResolved: true` to see everything. Returns " +
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
inputSchema: {
pageId: z.string().describe("ID of the page"),
includeResolved: z
.boolean()
.optional()
.describe(
"default only active threads; true — include resolved",
),
},
},
async ({ pageId }) => {
const comments = await docmostClient.listComments(pageId);
async ({ pageId, includeResolved }) => {
const comments = await docmostClient.listComments(pageId, includeResolved);
return jsonContent(comments);
},
);
@@ -913,8 +939,9 @@ server.registerTool(
"search",
{
description:
"Search for pages and content. Results are bounded by `limit` " +
"(default applied by the client, max 100).",
"Full-text search for pages and content across the whole workspace. " +
"Results are bounded by `limit` (1-100; when omitted the server applies " +
"its own default).",
inputSchema: {
query: z.string().min(1).describe("Search query"),
limit: z
@@ -970,7 +997,9 @@ server.registerTool(
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
"plain '[N]' text in the body; the notes are an orderedList under a " +
"heading whose text is 'Примечания переводчика'. The transform runs " +
"heading whose text is 'Примечания переводчика' (that is only the DEFAULT " +
"notesHeading — pass the notesHeading option to the helpers to use a " +
"heading matching the page's language). The transform runs " +
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
"{type:'doc'} node.",
inputSchema: {
+28 -377
View File
@@ -2,18 +2,24 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import { marked } from "marked";
import { generateJSON } from "@tiptap/html";
import { Node as PMNode } from "@tiptap/pm/model";
import { updateYFragment } from "y-prosemirror";
import { JSDOM } from "jsdom";
// #293 STEP 5: the pure markdown -> ProseMirror import path is now owned by the
// shared package (canonical `^[…]` footnotes, `$…$` math, `==` highlight, the
// media-family md forms, comment-directive attrs, callouts and task lists all
// handled there). MCP consumes it directly instead of maintaining its own
// drifted marked pipeline; only the collab/yjs write glue and the footnote
// canonicalization wrapper stay mcp-side.
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
import { lexFootnoteLines } from "./footnote-lex.js";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { summarizeChange, VerifyReport } from "./diff.js";
export { markdownToProseMirror };
/**
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
@@ -51,382 +57,27 @@ global.WebSocket = WebSocket;
// global.navigator = dom.window.navigator;
/**
* Hard ceiling above which we skip callout preprocessing entirely. The linear
* scanner below has no quadratic blow-up, but we still cap input defensively so
* a pathological multi-megabyte payload cannot tie up the event loop; in that
* case the markdown is passed through verbatim (callouts are simply not
* detected) rather than risking a slow scan.
*/
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
/** Matches a bare closing callout fence: `:::`. */
const CALLOUT_CLOSE_RE = /^:::\s*$/;
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
/**
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
* callout blocks (the syntax our markdown export produces) into HTML
* divs that the callout extension parses. The inner content is rendered
* through marked as regular markdown.
* Page-write variant of the package's `markdownToProseMirror`: imports markdown
* then re-runs mcp's footnote canonicalizer over the result.
*
* Implemented as a single linear pass over the lines (no quadratic regex
* rescan). It:
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
* `:::` line that lives inside a code fence as a callout delimiter, so a
* callout body that itself contains a fenced code block with a `:::` line is
* no longer corrupted;
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
* nesting level, supporting NESTED callouts via a depth counter (an inner
* `:::type` opens a deeper level and consumes a matching `:::`);
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
* (inner rendered through marked) as the previous regex implementation.
*/
async function preprocessCallouts(markdown: string): Promise<string> {
// Defensive cap: skip preprocessing for pathologically large inputs.
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return markdown;
}
// Recursively transform a slice of lines, converting top-level callouts in
// that slice into <div> blocks and rendering their inner content (which may
// itself contain nested callouts) through this same function.
const transform = async (lines: string[]): Promise<string> => {
const out: string[] = [];
let inCodeFence = false;
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Inside a code fence, only its matching closing fence is significant;
// everything else (including `:::` lines) is copied through verbatim.
if (inCodeFence) {
out.push(line);
const fence = line.match(CODE_FENCE_RE);
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
fence[2].length >= codeFenceMarker.length) {
inCodeFence = false;
codeFenceMarker = "";
}
i++;
continue;
}
// A code fence opening outside any callout body: enter code-fence mode.
const fenceOpen = line.match(CODE_FENCE_RE);
if (fenceOpen) {
inCodeFence = true;
codeFenceMarker = fenceOpen[2];
out.push(line);
i++;
continue;
}
// An opening callout fence: scan forward (with code-fence and nested
// callout awareness) for its matching closing `:::` at the same level.
const open = line.match(CALLOUT_OPEN_RE);
if (open) {
const type = open[1].toLowerCase();
const bodyLines: string[] = [];
let depth = 1;
let innerInCodeFence = false;
let innerCodeFenceMarker = "";
let j = i + 1;
for (; j < lines.length; j++) {
const bl = lines[j];
if (innerInCodeFence) {
const f = bl.match(CODE_FENCE_RE);
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
f[2].length >= innerCodeFenceMarker.length) {
innerInCodeFence = false;
innerCodeFenceMarker = "";
}
bodyLines.push(bl);
continue;
}
const innerFence = bl.match(CODE_FENCE_RE);
if (innerFence) {
innerInCodeFence = true;
innerCodeFenceMarker = innerFence[2];
bodyLines.push(bl);
continue;
}
if (CALLOUT_OPEN_RE.test(bl)) {
depth++;
bodyLines.push(bl);
continue;
}
if (CALLOUT_CLOSE_RE.test(bl)) {
depth--;
if (depth === 0) break; // matching close for THIS callout
bodyLines.push(bl);
continue;
}
bodyLines.push(bl);
}
if (j < lines.length) {
// Found the matching closing fence: render the body (recursively, so
// nested callouts are handled) and emit the callout div.
const inner = await transform(bodyLines);
const renderedInner = await marked.parse(inner);
out.push(
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j + 1; // skip past the closing `:::`
continue;
}
// No matching close (unterminated callout): treat the opener as a
// literal line and continue, preserving the original text.
out.push(line);
i++;
continue;
}
out.push(line);
i++;
}
return out.join("\n");
};
return transform(markdown.split("\n"));
}
/**
* Bridge marked's checkbox lists to TipTap task lists.
* Footnote layering after #293 STEP 5:
* - The package's `markdownToProseMirror` already ASSEMBLES footnotes on import
* (canon #2): inline `^[body]` markers become the schema's
* `footnoteReference` + a single doc-level `footnotesList`, with ids assigned
* sequentially (`fn-1`, `fn-2`, ) in first-reference order and identical
* bodies merged. So the import output is ALREADY in canonical footnote
* topology.
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
* with every other full-document persist path (`update_page_json`,
* `docmost_transform`, `insert_footnote`, ). Because the package output is
* already canonical, this layer is a no-op here (idempotent) it exists so
* the page-write contract is enforced uniformly regardless of how the PM doc
* was produced, not because the import needs fixing.
*
* marked renders GitHub task list items (`- [x] done`) as a plain
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
* into the shape those extensions expect:
* TaskList parseHTML matches `ul[data-type="taskList"]`,
* TaskItem matches `li[data-type="taskItem"]`,
* the checked state is read from `data-checked === "true"`.
*
* A list is only converted when it has at least one `<li>` and EVERY direct
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
*/
function bridgeTaskLists(html: string): string {
// Cheap early-out: if the markup contains no checkbox input at all there is
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
// common case (most pages have no task lists).
if (!/type=["']?checkbox/i.test(html)) {
return html;
}
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
// pathologically large inputs rather than running a second expensive JSDOM
// parse on a multi-megabyte payload. The markup is passed through verbatim.
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
return html;
}
const dom = new JSDOM(html);
const document = dom.window.document;
// 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 (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
// bullet <li> that merely contains a nested task sublist is not misdetected.
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
// ALL of them so none survive into the converted item.
const directCheckboxes = (li: Element): Element[] => {
const found: Element[] = [];
for (const child of Array.from(li.children)) {
if (
child.tagName === "INPUT" &&
child.getAttribute("type") === "checkbox"
) {
found.push(child);
continue;
}
if (child.tagName === "P") {
for (const inp of Array.from(
child.querySelectorAll(":scope > input[type='checkbox']"),
)) {
found.push(inp);
}
}
}
return found;
};
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
// its own checkbox is a numbered checklist that must also become a taskList.
const lists = Array.from(document.querySelectorAll("ul, ol"));
for (const list of lists) {
// Only consider DIRECT child <li> elements; nested lists are handled by
// their own iteration of the outer loop.
const items = Array.from(list.children).filter(
(child) => child.tagName === "LI",
);
if (items.length === 0) continue;
const itemCheckboxes = items.map((li) => directCheckboxes(li));
// Convert only when every direct <li> carries at least one OWN checkbox.
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
// <ol> while tagging it data-type="taskList": generateJSON would then match
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
// emitting a phantom empty orderedList beside the real taskList. So rename a
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
let target: Element = list;
if (list.tagName === "OL") {
const ul = document.createElement("ul");
// Carry over existing attributes (e.g. class) so nothing is silently lost.
for (const attr of Array.from(list.attributes)) {
ul.setAttribute(attr.name, attr.value);
}
// Move every child node (including the <li>s we collected) into the <ul>.
while (list.firstChild) {
ul.appendChild(list.firstChild);
}
list.replaceWith(ul);
target = ul;
}
target.setAttribute("data-type", "taskList");
items.forEach((li, index) => {
const boxes = itemCheckboxes[index];
// The first checkbox determines the checked state (matches the previous
// single-checkbox behaviour); any extras only need removing.
const input = boxes[0] ?? null;
li.setAttribute("data-type", "taskItem");
const checked =
input != null &&
(input.hasAttribute("checked") || (input as any).checked);
li.setAttribute("data-checked", checked ? "true" : "false");
// Remove ALL direct checkbox inputs so none survive into the content
// (a raw-inline-HTML <li> may carry more than one).
for (const box of boxes) {
box.remove();
}
});
}
return document.body.innerHTML;
}
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
// definition lines are collected into a single <section data-footnotes>.
// Definition detection + fence handling are shared with analyzeFootnotes via
// lexFootnoteLines (footnote-lex.js). FOOTNOTE_REF_RE is the inline tokenizer's.
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
function escapeFootnoteAttr(value: string): string {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
const footnoteRefMarkedExtension = {
name: "footnoteRef",
level: "inline" as const,
start(src: string) {
return src.match(/\[\^/)?.index ?? -1;
},
tokenizer(src: string) {
const match = FOOTNOTE_REF_RE.exec(src);
if (match && match.index === 0) {
return { type: "footnoteRef", raw: match[0], id: match[1] };
}
return undefined;
},
renderer(token: any) {
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(
token.id,
)}"></sup>`;
},
};
marked.use({ extensions: [footnoteRefMarkedExtension] });
/**
* Pull `[^id]: text` definition lines out of the body and render a single
* <section data-footnotes> for them (or "" when there are none).
*/
function extractFootnotes(markdown: string): {
body: string;
section: string;
} {
const bodyLines: string[] = [];
const defs: Array<{ id: string; text: string }> = [];
// Shared lexer (footnote-lex): a `[^id]: ...` line inside a ``` / ~~~ code
// block is inert and stays in the body verbatim; only real definition lines
// are pulled out. analyzeFootnotes() consumes the SAME lexer so its diagnostics
// match exactly what import keeps/strips (#166).
for (const tok of lexFootnoteLines(markdown)) {
if (!tok.inFence && tok.definition) defs.push(tok.definition);
else bodyLines.push(tok.line);
}
if (defs.length === 0) return { body: markdown, section: "" };
// Duplicate definition ids: FIRST WINS, the rest are DROPPED (mirror of
// editor-ext extractFootnoteDefinitions). Reference markers are left untouched
// so repeated `[^a]` references reuse the single footnote (Pandoc semantics,
// #166). The dropped duplicate is surfaced to the caller via analyzeFootnotes
// (`duplicateDefinitions`), not silently lost. MUST stay in sync with the
// editor-ext mirror.
const firstById = new Map<string, string>(); // id -> first definition text
for (const def of defs) {
if (!firstById.has(def.id)) firstById.set(def.id, def.text);
}
const inner = [...firstById.entries()]
.map(
([id, text]) =>
`<div data-footnote-def data-id="${escapeFootnoteAttr(
id,
)}"><p>${marked.parseInline(text || "")}</p></div>`,
)
.join("");
return {
body: bodyLines.join("\n"),
section: `<section data-footnotes>${inner}</section>`,
};
}
/**
* Convert markdown to a ProseMirror doc using the full Docmost schema.
*
* This conversion does NOT canonicalize footnotes it is the shared, content-
* preserving primitive used by BOTH page write paths and COMMENT bodies
* (createComment / updateComment). Canonicalization MUST NOT run on a comment
* body: a comment may legitimately contain a footnote-definition line
* (`[^1]: text`) with no matching reference, and the canonicalizer drops a
* reference-less footnotesList which would silently delete the comment's text.
*
* Page write paths that DO need the canonical footnote topology call
* `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown
* path). Keep this function reference-loss-free.
*/
export async function markdownToProseMirror(
markdownContent: string,
): Promise<any> {
const withCallouts = await preprocessCallouts(markdownContent);
const { body, section } = extractFootnotes(withCallouts);
const html = (await marked.parse(body)) + section;
const bridged = bridgeTaskLists(html);
return generateJSON(bridged, docmostExtensions);
}
/**
* Page-write variant of `markdownToProseMirror`: converts markdown then enforces
* the canonical footnote topology. The footnote `section` markdown is emitted in
* DEFINITION order, but numbering derives from REFERENCE order, so without this
* the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and
* duplicate lists are also normalized. Idempotent a no-op once canonical, and a
* no-op for footnote-free content.
*
* Use this ONLY for full-document PAGE writes (never for comment bodies, where it
* would drop a reference-less footnote definition see `markdownToProseMirror`).
* Use this ONLY for full-document PAGE writes. Comment bodies call the package's
* plain `markdownToProseMirror` (no canonicalization) safe now because inline
* `^[body]` footnotes carry their body at the reference point, so a comment can
* no longer produce a reference-less footnote definition to be dropped.
*/
export async function markdownToProseMirrorCanonical(
markdownContent: string,
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,5 +1,5 @@
/**
* Footnote diagnostics for imported Markdown (issue #166).
* Legacy footnote diagnostics for imported Markdown (issue #166).
*
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
* conversion path, so it reports the same problems for `create_page`,
@@ -7,11 +7,18 @@
* importer still creates the page; this only surfaces footnote problems to the
* caller so an agent can fix its own markup instead of shipping broken footnotes.
*
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
* cannot arise. This scan therefore targets the LEGACY reference-style
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
* text). The warnings remain useful as an advisory nudge when an agent still
* authors the old syntax, but they no longer describe what the importer builds.
*
* Detected problems:
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
* first is kept on import first-wins; see extractFootnotes).
* first would have been kept under the old first-wins import).
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
* the line, trimmed, starts with `|`) footnotes in table cells often do not
* render as expected.
+9 -7
View File
@@ -1,12 +1,14 @@
/**
* Shared, fence-aware line lexer for footnote markdown (MCP-internal).
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
*
* Both the importer (`extractFootnotes` in collaboration.ts, which strips
* definition lines and rebuilds a footnotes section) and the diagnostics
* (`analyzeFootnotes` in footnote-analyze.ts) must agree EXACTLY on which lines
* are definitions and which lines are inert (inside a code fence). Sharing one
* lexer makes "the analyzer sees what the importer leaves" a structural property
* instead of two hand-kept copies that can drift (#166 review).
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
* advisory warnings (duplicate/orphan definitions) about content that is now
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
* NOT a definition) is the property the analyzer relies on.
*
* NOTE: this is deliberately NOT shared with editor-ext's
* `extractFootnoteDefinitions` that lives in a different package and the
+13 -901
View File
@@ -1,903 +1,15 @@
/**
* Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions
* ProseMirror -> Docmost-flavoured Markdown converter.
*
* #293 STEP 5: the converter CORE now lives in the shared
* `@docmost/prosemirror-markdown` package (the canonical, lossless
* implementation carrying every git-sync fix and the #293 canon decisions).
* MCP consumes it directly instead of keeping its own drifted copy, so the two
* can never diverge again. This file is a thin re-export shim kept only so the
* many existing `./markdown-converter.js` importers (client.ts, tests) do not
* have to move.
*/
export function convertProseMirrorToMarkdown(content: any): string {
if (!content || !content.content) return "";
// Escape a value interpolated into an HTML double-quoted attribute value
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
// ATTRIBUTE context only the quote that delimits the value and the ampersand
// that starts an entity are special, so we escape ONLY & " (and ' for safety
// when single-quoted delimiters are used). We deliberately do NOT escape < or
// >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode
// &lt;/&gt; back inside attribute values, so escaping them would corrupt the
// stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on
// every round-trip (`a < b` -> `a &lt; b` -> `a &amp;lt; b`). Escaping & "
// keeps the value inert against attribute-injection while staying idempotent.
// NOTE: escape ONLY & and " here. The value is always wrapped in double
// quotes, so " is the only delimiter; ' is NOT special in a double-quoted
// value, and parse5 does not decode &#39; back inside attribute values, so
// escaping ' would (like < >) corrupt the value and accumulate &amp; on every
// round-trip. Escaping & and " is idempotent (parse5 decodes them back).
const escapeAttr = (value: unknown): string =>
String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
// Escape a value placed as HTML element TEXT content (between tags), where
// <, >, and & are all significant. Used for text rendered inside raw-HTML
// blocks (table cells / columns) so stored characters cannot inject markup.
const escapeHtmlText = (value: unknown): string =>
String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Percent-encode characters that would break out of a markdown URL target
// (...) — whitespace/newlines and parentheses — so a stored src stays a
// single inert token (used for image/video/youtube srcs).
const encodeMdUrl = (value: unknown): string =>
String(value || "")
.replace(/\s/g, (c: string) => (c === " " ? "%20" : encodeURIComponent(c)))
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
const processNode = (node: any): string => {
const type = node.type;
const nodeContent = node.content || [];
switch (type) {
case "doc":
return nodeContent.map(processNode).join("\n\n");
case "paragraph":
const text = nodeContent.map(processNode).join("");
const align = node.attrs?.textAlign;
if (align && align !== "left") {
return `<div align="${escapeAttr(align)}">${text}</div>`;
}
return text || "";
case "heading":
const level = node.attrs?.level || 1;
const headingText = nodeContent.map(processNode).join("");
return "#".repeat(level) + " " + headingText;
case "text":
let textContent = node.text || "";
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
// Markdown code spans (`...`) cannot carry inner formatting, so when a
// run has the `code` mark alongside ANY other mark, backtick syntax
// would leak literal ** / []() into the code text. In that case emit
// nested HTML (<code> innermost, the other marks wrapping it as HTML)
// so the output is at least well-formed and re-parseable.
//
// NOTE: this does NOT round-trip both marks. The schema's `code` mark
// has `excludes: "_"` (it excludes every other mark), so on import the
// co-occurring mark is always dropped — the run comes back as `code`
// only. We keep the emission simple and accept that the other mark is
// lost; preserving both is impossible while `code` excludes them.
// Only use the backtick form when `code` is the sole mark.
const markTypes = node.marks.map((m: any) => m.type);
const hasCode = markTypes.includes("code");
const codeCombined = hasCode && markTypes.length > 1;
for (const mark of node.marks) {
switch (mark.type) {
case "bold":
textContent = codeCombined
? `<strong>${textContent}</strong>`
: `**${textContent}**`;
break;
case "italic":
textContent = codeCombined
? `<em>${textContent}</em>`
: `*${textContent}*`;
break;
case "code":
// When combined with another mark, wrap as <code> so the
// surrounding HTML marks can nest around it; otherwise use the
// plain backtick span.
textContent = codeCombined
? `<code>${textContent}</code>`
: `\`${textContent}\``;
break;
case "link": {
const href = mark.attrs?.href || "";
const title = mark.attrs?.title;
if (codeCombined) {
// Emit an HTML anchor so it can wrap the nested <code>.
const safeHref = escapeAttr(href);
if (title) {
textContent = `<a href="${safeHref}" title="${escapeAttr(String(title))}">${textContent}</a>`;
} else {
textContent = `<a href="${safeHref}">${textContent}</a>`;
}
} else if (title) {
// Emit the optional markdown link title; escape an embedded
// double-quote so it cannot terminate the title string early.
const safeTitle = String(title).replace(/"/g, '\\"');
textContent = `[${textContent}](${href} "${safeTitle}")`;
} else {
textContent = `[${textContent}](${href})`;
}
break;
}
case "strike":
textContent = codeCombined
? `<s>${textContent}</s>`
: `~~${textContent}~~`;
break;
case "underline":
textContent = `<u>${textContent}</u>`;
break;
case "subscript":
textContent = `<sub>${textContent}</sub>`;
break;
case "superscript":
textContent = `<sup>${textContent}</sup>`;
break;
case "highlight": {
// Preserve a null/empty color as a plain highlight (a bare
// <mark> with no background-color); only emit the style when a
// color is actually set, so a plain highlight is not forced to
// yellow on export.
const color = mark.attrs?.color;
textContent = color
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
: `<mark>${textContent}</mark>`;
break;
}
case "textStyle":
if (mark.attrs?.color) {
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
}
break;
case "comment": {
// Emit the inline comment anchor so highlights round-trip. The
// schema's Comment mark parses span[data-comment-id] (attrs
// commentId/resolved).
const cid = mark.attrs?.commentId;
if (cid) {
const resolvedAttr = mark.attrs?.resolved
? ` data-resolved="true"`
: "";
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
}
break;
}
case "spoiler":
// Markdown has no native spoiler syntax, so emit the same
// lossless raw HTML the editor-ext turndown rule produces; the
// schema's Spoiler mark parses span[data-spoiler] back on import.
textContent = `<span data-spoiler="true">${textContent}</span>`;
break;
}
}
}
return textContent;
case "codeBlock":
const language = node.attrs?.language || "";
// Strip ALL trailing newlines so the export is idempotent: marked
// re-adds exactly one trailing "\n" on import, so trimming only one
// here would let the text grow by "\n" on each round-trip. Removing
// every trailing newline makes repeated cycles stable.
const code = nodeContent
.map(processNode)
.join("")
.replace(/\n+$/, "");
return "```" + language + "\n" + code + "\n```";
case "bulletList":
return nodeContent
.map((item: any) => processListItem(item, "-"))
.join("\n");
case "orderedList":
return nodeContent
.map((item: any, index: number) =>
processListItem(item, `${index + 1}.`),
)
.join("\n");
case "taskList":
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
case "taskItem":
// Delegate to the same helper used by taskList so multi-block and
// nested task items render and indent consistently.
return processTaskItem(node);
case "listItem":
return nodeContent.map(processNode).join("\n");
case "blockquote":
// Prefix EVERY line of EVERY child with "> " and separate block-level
// children with a blank ">" line so code blocks / multi-paragraph
// quotes round-trip correctly.
return nodeContent
.map((n: any) =>
processNode(n)
.split("\n")
.map((line: string) => (line.length ? `> ${line}` : ">"))
.join("\n"),
)
.join("\n>\n");
case "horizontalRule":
return "---";
case "hardBreak":
// Two trailing spaces before the newline encode a markdown hard break;
// a bare "\n" would be reimported as a soft break and lost.
return " \n";
case "image": {
const imgAlt = node.attrs?.alt || "";
const imgCaption = node.attrs?.caption || "";
if (imgCaption) {
// ![]() can't carry a caption, so (symmetric to video) emit a raw
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
// HTML and generateJSON runs the image extension's parseHTML, which
// restores the caption from data-caption.
const parts: string[] = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
if (imgAlt) parts.push(`alt="${escapeAttr(imgAlt)}"`);
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
return `<div><img ${parts.join(" ")}></div>`;
}
// Neutralize characters that could break out of the markdown image
// URL: spaces/newlines and parentheses would terminate the (...) target
// and let a stored src inject following markdown/HTML. Percent-encode
// them so the URL stays a single inert token.
const imgSrc = encodeMdUrl(node.attrs?.src);
return `![${imgAlt}](${imgSrc})`;
}
case "video": {
// Emit the schema-matching <video> element so generateJSON rebuilds the
// node with its attrs intact. The schema's parseHTML reads src/aria-label
// from the standard attributes and the remaining attrs from data-*.
const attrs = node.attrs || {};
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt) parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
if (attrs.attachmentId)
parts.push(
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
);
if (attrs.width != null)
parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
// Wrap in a block <div> so marked treats it as a block (a bare <video>
// is inline-level HTML and marked wraps it in <p>, leaving a spurious
// empty paragraph beside the hoisted block atom). The wrapper has no
// data-type, so the schema parser ignores it and just hoists the video.
return `<div><video ${parts.join(" ")}></video></div>`;
}
case "youtube": {
// Emit the schema-matching div[data-type="youtube"]; the schema reads
// src from data-src and width/height/align from data-* attributes.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="youtube"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "table": {
// A GFM pipe table cannot represent merged cells. If ANY cell carries
// colspan>1 or rowspan>1, a pipe table would corrupt the grid on
// re-import, so emit the WHOLE table as raw HTML <table> instead: the
// schema's table family parseHTML (tag table/tr/td/th, with colspan/
// rowspan read from the same-named HTML attrs and align via parseHTML)
// round-trips it faithfully. Otherwise keep the lighter GFM pipe table.
const tableRows: any[] = nodeContent;
if (tableRows.length === 0) return "";
const hasSpan = tableRows.some((row: any) =>
(row.content || []).some(
(cell: any) =>
(cell.attrs?.colspan ?? 1) > 1 || (cell.attrs?.rowspan ?? 1) > 1,
),
);
if (hasSpan) {
// Render each cell's block children to HTML (marked does NOT parse
// markdown inside a raw HTML block, so emitting markdown here would
// leak literal ** / `` into the cell). blockToHtml mirrors the schema
// HTML so inner formatting re-parses into the right marks/nodes.
const renderHtmlCell = (cell: any): string => {
const tag = cell.type === "tableHeader" ? "th" : "td";
const a = cell.attrs || {};
const cellParts: string[] = [];
if ((a.colspan ?? 1) > 1)
cellParts.push(`colspan="${escapeAttr(a.colspan)}"`);
if ((a.rowspan ?? 1) > 1)
cellParts.push(`rowspan="${escapeAttr(a.rowspan)}"`);
if (a.align) cellParts.push(`align="${escapeAttr(a.align)}"`);
const open = cellParts.length
? `<${tag} ${cellParts.join(" ")}>`
: `<${tag}>`;
const inner = (cell.content || [])
.map((block: any) => blockToHtml(block))
.join("");
return `${open}${inner}</${tag}>`;
};
const htmlRows = tableRows
.map(
(row: any) =>
`<tr>${(row.content || []).map(renderHtmlCell).join("")}</tr>`,
)
.join("");
return `<table><tbody>${htmlRows}</tbody></table>`;
}
// No merged cells: emit a GFM table (header row + separator) so the
// markdown can be parsed back into a table on re-import.
const rows = tableRows.map(processNode);
const headerCells = tableRows[0]?.content || [];
const columns = headerCells.length || 1;
// Derive alignment markers (:--, :-:, --:) from each header cell.
const markers = Array.from({ length: columns }, (_, i) => {
const align = headerCells[i]?.attrs?.align;
switch (align) {
case "left":
return ":--";
case "center":
return ":-:";
case "right":
return "--:";
default:
return "---";
}
});
const separator = "| " + markers.join(" | ") + " |";
return [rows[0], separator, ...rows.slice(1)].join("\n");
}
case "tableRow":
return "| " + nodeContent.map(processNode).join(" | ") + " |";
case "tableCell":
case "tableHeader": {
// Join multiple block children with a space (not "") so adjacent blocks
// like a paragraph followed by a list don't collide into "line1- a".
// Then collapse newlines and escape pipes so a cell containing "|" or a
// line break cannot corrupt the surrounding GFM row.
return nodeContent
.map(processNode)
.join(" ")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|");
}
case "callout":
const calloutType = node.attrs?.type || "info";
const calloutContent = nodeContent.map(processNode).join("\n");
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
case "details":
return nodeContent.map(processNode).join("\n");
case "detailsSummary":
const summaryText = nodeContent.map(processNode).join("");
return `<details>\n<summary>${summaryText}</summary>\n`;
case "detailsContent":
const detailsText = nodeContent.map(processNode).join("\n");
return `${detailsText}\n</details>`;
case "mathInline": {
// The schema's `text` attribute has no parseHTML, so TipTap's default
// parser reads it from the `text` HTML attribute (NOT the element's text
// content). Emit span[data-type="mathInline"] carrying the LaTeX in a
// `text="..."` attribute so it round-trips. marked cannot parse $...$
// back, so the previous form was lossy.
const inlineMath = node.attrs?.text || "";
return `<span data-type="mathInline" data-katex="true" text="${escapeAttr(inlineMath)}"></span>`;
}
case "mathBlock": {
// Same as mathInline: the LaTeX must ride in the `text` HTML attribute
// for the schema's default parser to recover it.
const blockMath = node.attrs?.text || "";
return `<div data-type="mathBlock" data-katex="true" text="${escapeAttr(blockMath)}"></div>`;
}
case "mention": {
// Emit span[data-type="mention"] with the schema's data-* attributes so
// generateJSON rebuilds the mention node instead of leaving "@label"
// plain text that cannot re-parse.
const attrs = node.attrs || {};
const parts: string[] = [`data-type="mention"`];
if (attrs.id) parts.push(`data-id="${escapeAttr(attrs.id)}"`);
if (attrs.label)
parts.push(`data-label="${escapeAttr(attrs.label)}"`);
if (attrs.entityType)
parts.push(`data-entity-type="${escapeAttr(attrs.entityType)}"`);
if (attrs.entityId)
parts.push(`data-entity-id="${escapeAttr(attrs.entityId)}"`);
if (attrs.slugId)
parts.push(`data-slug-id="${escapeAttr(attrs.slugId)}"`);
if (attrs.creatorId)
parts.push(`data-creator-id="${escapeAttr(attrs.creatorId)}"`);
if (attrs.anchorId)
parts.push(`data-anchor-id="${escapeAttr(attrs.anchorId)}"`);
// Keep the label as visible text content too; the schema reads attrs
// from data-*, so the inner text is purely cosmetic and harmless.
const mentionLabel = attrs.label || attrs.id || "";
// The label is visible element TEXT content here (the data-* attrs above
// carry the real values), so escape it for the text context, not attrs.
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
}
case "footnoteReference": {
// Pandoc/GFM inline marker. The number is derived (not stored), so the
// id is the stable anchor.
const fnId = node.attrs?.id || "";
return fnId ? `[^${fnId}]` : "";
}
case "footnotesList":
// The container renders its definitions, each on its own `[^id]: ...`
// line. A blank line separates the body from the notes block.
return nodeContent.map(processNode).join("\n");
case "footnoteDefinition": {
const defId = node.attrs?.id || "";
// Collapse the definition's paragraphs into a single line; multi-line
// footnotes are a v2 refinement.
const defText = nodeContent
.map(processNode)
.join(" ")
.replace(/\s*\n+\s*/g, " ")
.trim();
return defId ? `[^${defId}]: ${defText}` : "";
}
case "attachment": {
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
// the schema stores name/url (plus mime/size/attachmentId). Emit the
// schema-matching div[data-type="attachment"] with data-attachment-*
// attrs so the node round-trips instead of degrading to a markdown link.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="attachment"`,
`data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
];
if (attrs.name)
parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
if (attrs.mime)
parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
if (attrs.size != null)
parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
);
return `<div ${parts.join(" ")}></div>`;
}
case "drawio":
case "excalidraw": {
// Emit the schema-matching div[data-type=...] carrying the diagram's
// attrs as data-* (the schema's diagramAttributes reads src/title/alt/
// width/height/size/aspectRatio/align/attachmentId from data-*), so the
// diagram round-trips instead of degrading to a lossy placeholder.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="${type}"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.title != null)
parts.push(`data-title="${escapeAttr(attrs.title)}"`);
if (attrs.alt != null) parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.attachmentId)
parts.push(
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
);
return `<div ${parts.join(" ")}></div>`;
}
case "embed": {
// Emit the schema-matching div[data-type="embed"]; the schema reads
// src/provider/align/width/height from data-* attributes so the node
// (and its provider iframe info) survives the round-trip.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="embed"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
];
if (attrs.align)
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "audio": {
// Emit the schema-matching <audio> element (was emitting nothing). The
// schema reads src from src and attachmentId/size from data-*.
const attrs = node.attrs || {};
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.attachmentId)
parts.push(
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
// Wrap in a block <div> for the same reason as video: a bare <audio> is
// inline-level HTML that marked would wrap in <p>.
return `<div><audio ${parts.join(" ")}></audio></div>`;
}
case "pdf": {
// Emit the schema-matching div[data-type="pdf"] (was emitting nothing).
// The schema reads src/width/height from standard attrs and name/
// attachmentId/size from data-*.
const attrs = node.attrs || {};
const parts: string[] = [
`data-type="pdf"`,
`src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.name) parts.push(`data-name="${escapeAttr(attrs.name)}"`);
if (attrs.attachmentId)
parts.push(
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
);
if (attrs.size != null)
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.width != null)
parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
case "columns": {
// Emit the schema-matching div[data-type="columns"] wrapper so the
// multi-column layout survives. Without a case the children were
// concatenated with no separator and the text merged. The schema reads
// layout from data-layout and widthMode from data-width-mode. The whole
// block is raw HTML, so render children via blockToHtml (NOT markdown,
// which marked would not re-parse inside a raw HTML block).
const attrs = node.attrs || {};
const parts: string[] = [`data-type="columns"`];
if (attrs.layout)
parts.push(`data-layout="${escapeAttr(attrs.layout)}"`);
if (attrs.widthMode && attrs.widthMode !== "normal")
parts.push(`data-width-mode="${escapeAttr(attrs.widthMode)}"`);
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "column": {
// Emit the schema-matching div[data-type="column"]; the schema reads the
// column width from data-width. Children are rendered as HTML so their
// formatting survives inside this raw HTML block.
const attrs = node.attrs || {};
const parts: string[] = [`data-type="column"`];
if (attrs.width)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
return `<div ${parts.join(" ")}>${inner}</div>`;
}
case "subpages":
return "{{SUBPAGES}}";
default:
// Fallback: process children
return nodeContent.map(processNode).join("");
}
};
// Render inline content (text runs + their marks) to HTML. Used by the raw
// HTML fallbacks (spanned tables, columns) where marked will NOT re-parse
// markdown, so backtick/asterisk/bracket syntax would otherwise leak as
// literal characters. Each mark is mirrored to the HTML the schema's parseHTML
// accepts so it re-imports as the matching ProseMirror mark.
const inlineToHtml = (inlineNodes: any[]): string =>
(inlineNodes || [])
.map((n: any) => {
if (n.type === "hardBreak") return "<br>";
if (n.type !== "text") {
// Inline atoms (mention, mathInline) already emit schema HTML.
return processNode(n);
}
let t = escapeHtmlText(n.text || "");
for (const mark of n.marks || []) {
switch (mark.type) {
case "bold":
t = `<strong>${t}</strong>`;
break;
case "italic":
t = `<em>${t}</em>`;
break;
case "code":
t = `<code>${t}</code>`;
break;
case "strike":
t = `<s>${t}</s>`;
break;
case "underline":
t = `<u>${t}</u>`;
break;
case "subscript":
t = `<sub>${t}</sub>`;
break;
case "superscript":
t = `<sup>${t}</sup>`;
break;
case "link":
t = `<a href="${escapeAttr(mark.attrs?.href || "")}">${t}</a>`;
break;
case "highlight":
t = mark.attrs?.color
? `<mark style="background-color: ${escapeAttr(mark.attrs.color)}">${t}</mark>`
: `<mark>${t}</mark>`;
break;
case "textStyle":
if (mark.attrs?.color)
t = `<span style="color: ${escapeAttr(mark.attrs.color)}">${t}</span>`;
break;
case "comment":
// Inline comment anchor inside a raw-HTML container (columns /
// spanned table cells), so commented text there also round-trips.
if (mark.attrs?.commentId) {
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
}
break;
}
}
return t;
})
.join("");
// Emit the schema-matching <img> for an image node. Shared so the image is
// emitted as real HTML wherever a raw-HTML container needs it (inside a column
// or a spanned table cell), where markdown `![](...)` would NOT be re-parsed
// and would survive as literal text. The Image extension reads src/alt from
// the standard attributes; the Docmost extra attrs (width/height/align/size/
// attachmentId/aspectRatio) are global attributes read from same-named DOM
// attributes, so emit them by name.
const imageToHtml = (node: any): string => {
const attrs = node.attrs || {};
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt) parts.push(`alt="${escapeAttr(attrs.alt)}"`);
if (attrs.caption)
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
if (attrs.title) parts.push(`title="${escapeAttr(attrs.title)}"`);
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
if (attrs.align) parts.push(`align="${escapeAttr(attrs.align)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
return `<img ${parts.join(" ")}>`;
};
// Emit the schema-matching div[data-type="callout"] for a callout node. The
// schema reads the banner type from data-callout-type. Children are rendered
// as HTML so they survive inside a raw-HTML container.
const calloutToHtml = (node: any): string => {
const type = (node.attrs?.type || "info").toLowerCase();
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="callout" data-callout-type="${escapeAttr(type)}">${inner}</div>`;
};
// Emit a schema-matching <details> tree. The schema parses <details>,
// summary[data-type="detailsSummary"], and div[data-type="detailsContent"].
const detailsToHtml = (node: any): string => {
const inner = (node.content || []).map(blockToHtml).join("");
return `<details>${inner}</details>`;
};
const detailsSummaryToHtml = (node: any): string =>
`<summary data-type="detailsSummary">${inlineToHtml(node.content || [])}</summary>`;
const detailsContentToHtml = (node: any): string => {
const inner = (node.content || []).map(blockToHtml).join("");
return `<div data-type="detailsContent">${inner}</div>`;
};
// Emit the schema-matching taskList/taskItem HTML. bridgeTaskLists (in
// collaboration.ts) recognizes ul[data-type="taskList"] with
// li[data-type="taskItem"][data-checked]; emitting that directly here keeps
// task lists inside columns/cells from degrading to literal "- [ ]" text.
const taskListToHtml = (node: any): string => {
const items = (node.content || [])
.map((it: any) => {
const checked = it.attrs?.checked ? "true" : "false";
return `<li data-type="taskItem" data-checked="${checked}">${blockChildrenToHtml(it)}</li>`;
})
.join("");
return `<ul data-type="taskList">${items}</ul>`;
};
// Render a block node to HTML for the raw-HTML containers (spanned tables,
// columns). marked does NOT re-parse markdown inside a raw-HTML block, so
// EVERY block type that can appear inside a column or a spanned cell must be
// emitted as schema-matching HTML here — never as markdown, or it would land
// as literal text on re-import. Nodes whose processNode case already produces
// schema-matching HTML (math/media/embed/attachment/nested columns/spanned
// table) are delegated to processNode; the markdown-emitting cases
// (image/blockquote/callout/details/hr/taskList) get explicit HTML here.
const blockToHtml = (block: any): string => {
const children = block.content || [];
switch (block.type) {
case "paragraph":
return `<p>${inlineToHtml(children)}</p>`;
case "heading": {
const level = block.attrs?.level || 1;
return `<h${level}>${inlineToHtml(children)}</h${level}>`;
}
case "bulletList":
return `<ul>${children
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ul>`;
case "orderedList":
return `<ol>${children
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
.join("")}</ol>`;
case "codeBlock": {
const lang = block.attrs?.language || "";
// The code itself is element TEXT content (between <code> tags), so it
// must escape < > & — NOT the attribute escaper. The language rides in
// a class ATTRIBUTE, so it uses escapeAttr.
const code = escapeHtmlText(
children
.map(processNode)
.join("")
.replace(/\n+$/, ""),
);
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
return `<pre><code${cls}>${code}</code></pre>`;
}
case "image":
return imageToHtml(block);
case "blockquote":
return `<blockquote>${children.map(blockToHtml).join("")}</blockquote>`;
case "horizontalRule":
return "<hr>";
case "callout":
return calloutToHtml(block);
case "details":
return detailsToHtml(block);
case "detailsSummary":
return detailsSummaryToHtml(block);
case "detailsContent":
return detailsContentToHtml(block);
case "taskList":
return taskListToHtml(block);
case "taskItem":
// A bare taskItem (outside a taskList) still needs a wrapping list so
// the schema parses it; wrap it in a single-item taskList.
return taskListToHtml({ content: [block] });
// table (incl. spanned), columns/column, math, media, embed, attachment,
// mention, etc. already emit schema-matching HTML from processNode.
case "table":
case "columns":
case "column":
case "mathBlock":
case "video":
case "audio":
case "pdf":
case "youtube":
case "embed":
case "attachment":
case "drawio":
case "excalidraw":
return processNode(block);
default:
// Any still-unhandled block type: NEVER fall back to markdown inside a
// raw-HTML block (it would become literal text). Wrap its rendered
// children in a <div> so their content is preserved; if it has no block
// children, render its inline content instead.
if (children.length && children.some((c: any) => c.type !== "text")) {
return `<div>${children.map(blockToHtml).join("")}</div>`;
}
return `<div>${inlineToHtml(children)}</div>`;
}
};
// Render the block children of a list item to HTML (a listItem holds block+
// content). Mirrors processListItem but for the HTML fallback path.
const blockChildrenToHtml = (item: any): string =>
(item.content || []).map((b: any) => blockToHtml(b)).join("");
// Indent the rendered children of a list item under a marker prefix.
// Each child block is a (possibly multi-line) string. The very first physical
// line of the first child carries the marker (e.g. "- " or "1. "); EVERY
// other line — the remaining lines of the first child AND all lines of every
// subsequent child (nested lists, code blocks, extra paragraphs) — is indented
// to align under the marker. Without indenting these continuation lines, the
// 2nd/3rd line of a nested child collapses to column 0 and escapes the list.
//
// The continuation indent MUST equal the LIST marker width, which is not the
// same as the visible prefix width:
// - bullet "- " -> 2 columns
// - task "- [ ] " -> marker is still "- " (the "[ ] " is content), 2
// - ordered "1. "/"10. " -> 3/4 columns, scaling with the number's digits
// CommonMark anchors nested content to the marker column, so an ordered item
// indented to only 2 columns would be re-parsed as a sibling/loose content on
// re-import. Callers therefore pass the exact indent width to use.
const indentItemChildren = (
childStrings: string[],
prefix: string,
indentWidth: number,
): string => {
const indent = " ".repeat(indentWidth);
const lines: string[] = [];
childStrings.forEach((child, childIndex) => {
child.split("\n").forEach((line, lineIndex) => {
if (childIndex === 0 && lineIndex === 0) {
// First physical line of the first block gets the marker.
lines.push(`${prefix} ${line}`);
} else {
// Indent every continuation line by the marker width; keep blank
// lines blank rather than emitting trailing whitespace.
lines.push(line.length ? `${indent}${line}` : "");
}
});
});
return lines.join("\n");
};
const processListItem = (item: any, prefix: string): string => {
const itemContent = item.content || [];
const childStrings = itemContent.map(processNode);
if (childStrings.length === 0) return prefix;
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
// and thus the continuation indent — is prefix.length + 1. This is correct
// for both bullet ("-" -> 2) and ordered ("1." -> 3, "10." -> 4) markers,
// since for those the visible prefix IS the list marker.
return indentItemChildren(childStrings, prefix, prefix.length + 1);
};
const processTaskItem = (item: any): string => {
const checked = item.attrs?.checked || false;
const checkbox = checked ? "[x]" : "[ ]";
const prefix = `- ${checkbox}`;
const itemContent = item.content || [];
const childStrings = itemContent.map(processNode);
// An empty task item still needs its checkbox marker; without this guard
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
if (childStrings.length === 0) return prefix;
// The list marker for a task item is just "- " (2 columns); the "[ ] "/"[x] "
// checkbox is item content, NOT part of the marker. So the continuation
// indent is a fixed 2 — do NOT derive it from the wider prefix.length.
return indentItemChildren(childStrings, prefix, 2);
};
return processNode(content).trim();
}
export {
convertProseMirrorToMarkdown,
type ConvertProseMirrorToMarkdownOptions,
} from "@docmost/prosemirror-markdown";
+12 -133
View File
@@ -1,136 +1,15 @@
/**
* Self-contained Docmost-flavoured Markdown document (custom extensions).
* Self-contained Docmost-flavoured Markdown document envelope (`docmost:meta` /
* `docmost:comments` blocks).
*
* A single `.md` file that packages everything needed to losslessly round-trip
* a page through "download -> edit body -> re-upload":
* - a leading `docmost:meta` block: a one-line JSON object with page identity;
* - the Markdown body (carrying inline comment anchors and diagrams as HTML);
* - a trailing `docmost:comments` block: a one-line JSON array of comment
* threads.
*
* Both metadata blocks are HTML comments on purpose: `marked`/`generateJSON`
* drop HTML comments, so even if the WHOLE file were ever fed straight to the
* importer without first stripping the blocks, the metadata cannot leak into the
* document. (A fenced ```docmost-comments``` block would WRONGLY become a
* codeBlock node, so a fenced block is deliberately NOT used.)
*
* The delimiter literals may legitimately appear in the BODY too (e.g. a user
* re-pastes an exported `.md` into a page, or a page documents this very
* format). To stay robust, parsing treats only the FINAL, document-ending
* `docmost:comments` block as metadata: it is the last `<!-- docmost:comments`
* opener whose closing `-->` sits at the very end of the file. Any earlier
* literal occurrence is left in the body untouched.
*
* NOTE on comments: in this version the comment THREAD records are preserved in
* the file but are NOT pushed back to the server on import only the inline
* comment marks (anchors) embedded in the body are restored. Managing comment
* records stays with the comment tools/UI.
* #293 STEP 5: this envelope is now owned by the shared
* `@docmost/prosemirror-markdown` package (the mcp copy was byte-identical to
* the package's, so re-exporting is lossless). Kept as a thin shim so the
* existing `./markdown-document.js` importers (client.ts, tests) do not move.
*/
export interface DocmostMdMeta {
version: number;
pageId?: string;
slugId?: string;
title?: string;
spaceId?: string;
parentPageId?: string | null;
}
// Match the leading meta block (allow leading whitespace). Capture group 1 is
// the JSON text between the markers.
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
// Match a `docmost:comments` opener. Used globally to scan for the LAST opener
// rather than end-anchoring a single regex (which would mis-capture across a
// literal opener that appears earlier in the body).
const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
/**
* Assemble the full self-contained markdown file: meta block, body, and the
* comments block. The meta block is always emitted; the comments block is always
* emitted too (with `[]` when there are no comments) so the format stays uniform
* and parsing stays simple.
*/
export function serializeDocmostMarkdown(
meta: DocmostMdMeta,
body: string,
comments: any[],
): string {
const metaJson = JSON.stringify(meta);
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
const trimmedBody = (body ?? "").trim();
return (
`<!-- docmost:meta\n${metaJson}\n-->\n\n` +
`${trimmedBody}\n\n` +
`<!-- docmost:comments\n${commentsJson}\n-->\n`
);
}
/**
* Split a self-contained file back into its parts. Tolerant: if the meta or
* comments block is missing (e.g. a hand-written plain-markdown file), the
* corresponding value is returned as `null` and the whole input is treated as
* the body. This never throws on a MISSING block; only a `JSON.parse` failure
* inside a block that IS present is surfaced as a thrown Error with a clear
* message. Robust to `\r\n` line endings.
*/
export function parseDocmostMarkdown(full: string): {
meta: DocmostMdMeta | null;
body: string;
comments: any[] | null;
} {
// Normalize line endings so the anchored regexes work regardless of CRLF.
const normalized = (full ?? "").replace(/\r\n/g, "\n");
// Extract the leading meta block (start-anchored — already unambiguous).
let meta: DocmostMdMeta | null = null;
let metaEnd = 0;
const metaMatch = normalized.match(META_RE);
if (metaMatch) {
try {
meta = JSON.parse(metaMatch[1]);
} catch (e) {
throw new Error(
`Invalid docmost:meta JSON block: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
// Body starts right after the matched meta block.
metaEnd = (metaMatch.index ?? 0) + metaMatch[0].length;
}
// Find the LAST `<!-- docmost:comments` opener; the real file-level block is
// the final one whose closing `-->` ends the document. Any earlier literal
// occurrence inside the body (e.g. a re-pasted export) is left in the body.
let lastOpenStart = -1;
let lastOpenEnd = -1;
let m: RegExpExecArray | null;
COMMENTS_OPEN_RE.lastIndex = 0;
while ((m = COMMENTS_OPEN_RE.exec(normalized)) !== null) {
lastOpenStart = m.index;
lastOpenEnd = m.index + m[0].length;
}
let comments: any[] | null = null;
let bodyEnd = normalized.length;
if (lastOpenStart !== -1) {
const rest = normalized.slice(lastOpenEnd);
const close = rest.match(/\r?\n-->[ \t]*\r?\n?\s*$/); // closer must end the doc
if (close) {
const jsonText = rest.slice(0, close.index);
try {
comments = JSON.parse(jsonText);
} catch (e) {
throw new Error(
`Invalid docmost:comments JSON block: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
bodyEnd = lastOpenStart; // strip from the opener to end of document
}
}
const body = normalized.slice(metaEnd, bodyEnd).trim();
return { meta, body, comments };
}
export {
serializeDocmostMarkdown,
parseDocmostMarkdown,
serializeDocmostMarkdownBody,
} from "@docmost/prosemirror-markdown";
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
+11 -4
View File
@@ -13,6 +13,11 @@
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
// per-layer and are NOT represented here.
//
// MAINTENANCE RULE: adding, renaming, or removing a spec here (or an inline
// registerTool in index.ts) REQUIRES updating SERVER_INSTRUCTIONS in
// packages/mcp/src/index.ts — the intent-routing guide MCP clients receive on
// initialize. Enforced by test/unit/server-instructions.test.mjs.
// Loose on purpose — see the comment above. The two zod majors expose different
// static type surfaces, so typing this precisely would couple the registry to
@@ -111,8 +116,8 @@ export const SHARED_TOOL_SPECS = {
mcpName: 'delete_node',
inAppKey: 'deleteNode',
description:
'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
'resending the whole document.',
'Remove a single block by its attrs.id (from the page outline or ' +
'page-JSON view) WITHOUT resending the whole document.',
buildShape: (z) => ({
pageId: z.string().min(1),
nodeId: z.string().min(1),
@@ -134,7 +139,8 @@ export const SHARED_TOOL_SPECS = {
description:
'Replace a single content block identified by its attrs.id with a new ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
'keeps the same node id. Get the block id from the page-JSON view, then ' +
'keeps the same node id. Get the block id from the page outline (cheap) ' +
'or the page-JSON view, then ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
'heading {"type":"heading","attrs":{"level":2},"content":' +
@@ -169,7 +175,8 @@ export const SHARED_TOOL_SPECS = {
'Insert a block before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page-JSON view. Avoids resending the whole document. Can also insert ' +
'page outline or the page-JSON view. Avoids resending the whole document. ' +
'Can also insert ' +
'table structure: to add a tableRow, pass a tableRow node with position ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
'block/cell in it, or anchorText matching the table; to add a ' +
+6 -4
View File
@@ -462,7 +462,7 @@ async function main() {
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
const list = await client.listComments(pageId);
const list = (await client.listComments(pageId)).items;
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
const got = await client.getComment(c1.data.id);
@@ -472,17 +472,19 @@ async function main() {
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
const resolvedRes = await client.resolveComment(c1.data.id, true);
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
const listResolved = await client.listComments(pageId);
// c1 is now resolved; the default feed hides resolved threads, so pass
// includeResolved:true to still see it and assert its resolvedAt (#328).
const listResolved = (await client.listComments(pageId, true)).items;
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
const reopenedRes = await client.resolveComment(c1.data.id, false);
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
const listReopened = await client.listComments(pageId);
const listReopened = (await client.listComments(pageId)).items;
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
await client.deleteComment(reply.data.id);
await client.deleteComment(c1.data.id);
const listAfter = await client.listComments(pageId);
const listAfter = (await client.listComments(pageId)).items;
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
} finally {
if (pageId) {
@@ -0,0 +1,160 @@
// gitmost #328 Channel 2: DocmostClient.listComments hides RESOLVED THREADS
// wholesale by default (a resolved top-level comment AND every reply under it),
// returning `{ items, resolvedThreadsHidden }`. `includeResolved: true` returns
// the full feed. These tests stand a local http.createServer in for Docmost and
// mock the /auth/login + /comments (paginated) routes.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => closeServer(s)));
});
// A minimal ProseMirror comment body (a paragraph of text).
const body = (t) => ({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
});
// Feed: an ACTIVE thread whose REPLY is resolved (root active, reply resolved —
// the thread must STAY, because a thread is gated only by its ROOT's resolvedAt)
// and a RESOLVED thread (root + reply).
const FEED = [
{
id: "a",
pageId: "page-1",
parentCommentId: null,
resolvedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
creatorId: "u1",
content: body("active root"),
},
{
id: "a1",
pageId: "page-1",
parentCommentId: "a",
// A RESOLVED reply under an ACTIVE root: the thread is NOT hidden (only a
// resolved ROOT hides a thread), so this reply survives the default filter.
resolvedAt: "2026-02-15T00:00:00.000Z",
createdAt: "2026-01-01T01:00:00.000Z",
creatorId: "u1",
content: body("resolved reply of an active thread"),
},
{
id: "r",
pageId: "page-1",
parentCommentId: null,
resolvedAt: "2026-02-01T00:00:00.000Z",
createdAt: "2026-01-02T00:00:00.000Z",
creatorId: "u1",
content: body("resolved root"),
},
{
id: "r1",
pageId: "page-1",
parentCommentId: "r",
resolvedAt: null,
createdAt: "2026-01-02T01:00:00.000Z",
creatorId: "u1",
content: body("resolved reply"),
},
];
function commentsServer() {
return spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/comments") {
// Single page, no cursor.
sendJson(res, 200, { data: { items: FEED, meta: { nextCursor: null } } });
return;
}
sendJson(res, 404, { message: "not found" });
});
}
test("default hides the resolved thread (root + its reply) and counts it", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
assert.equal(Array.isArray(result.items), true, "returns { items, ... }");
const ids = result.items.map((c) => c.id).sort();
assert.deepEqual(ids, ["a", "a1"], "only the active thread remains");
assert.equal(result.resolvedThreadsHidden, 1, "one resolved thread hidden");
});
test("includeResolved:true returns EVERYTHING with zero hidden", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1", true);
const ids = result.items.map((c) => c.id).sort();
assert.deepEqual(ids, ["a", "a1", "r", "r1"], "all four comments returned");
assert.equal(result.resolvedThreadsHidden, 0, "nothing hidden with the flag");
});
test("the reply of a resolved thread is hidden with the thread", async () => {
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
const ids = result.items.map((c) => c.id);
assert.equal(ids.includes("r1"), false, "the resolved thread's reply is gone");
assert.equal(ids.includes("r"), false, "the resolved root is gone");
});
test("an ACTIVE thread whose REPLY is resolved is NOT hidden", async () => {
// A thread is gated only by its ROOT's resolvedAt. `a1` is a resolved reply
// under the active root `a`, so both must survive the default filter and the
// thread must not be counted as hidden.
const { baseURL } = await commentsServer();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.listComments("page-1");
const ids = result.items.map((c) => c.id).sort();
assert.equal(ids.includes("a"), true, "active root stays");
assert.equal(ids.includes("a1"), true, "its resolved reply stays with the thread");
assert.equal(result.resolvedThreadsHidden, 1, "only the resolved-root thread is hidden");
});
+38 -20
View File
@@ -127,36 +127,54 @@ test("markdownToProseMirror: an aligned GFM table maps header alignment", async
});
// Comment-body data-loss guard (#228 review #4): markdownToProseMirror is reused
// for COMMENT bodies (createComment/updateComment), so it must NOT canonicalize
// a comment may legitimately carry a standalone footnote definition with no
// matching reference, and canonicalization would drop the whole list (the text
// would vanish). The page-write variant DOES canonicalize.
test("markdownToProseMirror (comment path) PRESERVES a reference-less footnote definition", async () => {
// for COMMENT bodies (createComment/updateComment), so it must NOT canonicalize.
// Under the #293 canon, footnotes are INLINE (`^[body]`), so a comment can no
// longer carry a reference-less definition to be dropped — but the comment path
// must still (a) leave a legacy reference-style `[^id]:` line as harmless literal
// TEXT (never silently deleted) and (b) preserve an inline footnote it does
// contain (no canonicalization stripping it). The page-write variant canonicalizes.
test("markdownToProseMirror (comment path) keeps a legacy `[^id]:` line as literal text", async () => {
// A reference-style `[^1]:` line is not canonical footnote syntax anymore, so it
// is not parsed into a footnote node — but its TEXT must survive verbatim (no
// data loss on the comment write path).
const md = "A comment.\n\n[^1]: a standalone footnote definition";
const doc = await markdownToProseMirror(md);
const defs = findAll(doc, "footnoteDefinition");
assert.equal(defs.length, 1, "the footnote definition must be preserved");
assert.equal(
findAll(doc, "footnoteDefinition").length,
0,
"reference-style line is not a footnote node",
);
assert.match(
JSON.stringify(doc),
/a standalone footnote definition/,
"the definition text must survive the comment write path",
"the text must survive the comment write path",
);
});
test("markdownToProseMirrorCanonical (page path) DROPS a reference-less footnote definition", async () => {
// Same input through the PAGE variant: with no reference, the canonical doc has
// no footnotesList (this is the page-side behavior the comment path must avoid).
const md = "A page.\n\n[^1]: a standalone footnote definition";
const doc = await markdownToProseMirrorCanonical(md);
assert.equal(findAll(doc, "footnotesList").length, 0);
assert.equal(findAll(doc, "footnoteDefinition").length, 0);
test("markdownToProseMirror (comment path) PRESERVES an inline footnote (no canonicalization)", async () => {
// An inline `^[body]` footnote in a comment imports to a real footnote node and
// is NOT dropped: the comment path must never canonicalize away content.
const md = "A comment.\n\n^[an inline footnote]";
const doc = await markdownToProseMirror(md);
assert.equal(findAll(doc, "footnoteDefinition").length, 1);
assert.equal(findAll(doc, "footnotesList").length, 1);
assert.match(JSON.stringify(doc), /an inline footnote/);
});
test("markdownToProseMirrorCanonical still canonicalizes a real page footnote (order)", async () => {
// Page path must STILL canonicalize: refs b,a -> definitions reorder to b,a.
const md = "See[^b] then[^a].\n\n[^a]: alpha\n[^b]: bravo";
test("markdownToProseMirrorCanonical (page path) yields a single reference-ordered list", async () => {
// Page path produces the canonical footnote topology: one trailing
// `footnotesList`, definitions in FIRST-REFERENCE order, ids assigned
// sequentially. Inline `^[body]` footnotes carry the body at the reference
// point, so the bottom list is inherently reference-ordered.
const md = "See^[bravo] then^[alpha].";
const doc = await markdownToProseMirrorCanonical(md);
const defs = findAll(doc, "footnoteDefinition").map((d) => d.attrs.id);
assert.deepEqual(defs, ["b", "a"]);
const defs = findAll(doc, "footnoteDefinition");
assert.deepEqual(
defs.map((d) => d.attrs.id),
["fn-1", "fn-2"],
);
assert.equal(findAll(doc, "footnotesList").length, 1);
// Bodies stay in reference order (bravo referenced before alpha).
assert.match(JSON.stringify(defs[0]), /bravo/);
assert.match(JSON.stringify(defs[1]), /alpha/);
});
@@ -210,13 +210,17 @@ test("drawio round-trips through export and import", () => {
],
};
// #293 canon #8: the media family (image/video/audio/drawio/excalidraw)
// serializes to the markdown image form `![alt](src)` plus a trailing
// discriminator comment `<!--drawio {json}-->` carrying the non-src attrs.
const body = convertProseMirrorToMarkdown(doc);
assert.match(body, /data-type="drawio"/);
assert.match(body, /data-src="https:\/\/example\/diagram\.xml"/);
assert.match(body, /!\[\]\(https:\/\/example\/diagram\.xml\)/);
assert.match(body, /<!--drawio \{"attachmentId":"att-7"\}-->/);
return markdownToProseMirror(body).then((rebuilt) => {
const diagram = find(rebuilt, "drawio");
assert.ok(diagram, "expected a drawio node after import");
assert.equal(diagram.attrs.src, "https://example/diagram.xml");
assert.equal(diagram.attrs.attachmentId, "att-7");
});
});
@@ -253,22 +253,23 @@ test("insertInlineFootnote: anchor in body BEFORE a nested list still inserts",
assert.equal(findAll(r.doc, "footnotesList").length, 1);
});
test("markdown import (page path): out-of-order definitions render as a reference-ordered list", async () => {
// References appear b, a, c in the body; definitions are written in a, b, c
// order (the import order). The PAGE import path (markdownToProseMirrorCanonical)
// canonicalizes so the bottom list follows REFERENCE order — numbers read 1, 2,
// 3 down the list. (The non-canonicalizing markdownToProseMirror, used for
// comment bodies, would keep the import order; see collaboration.test.mjs.)
const md = [
"See[^b] then[^a] then[^c].",
"",
"[^a]: alpha",
"[^b]: bravo",
"[^c]: charlie",
].join("\n");
test("markdown import (page path): inline footnotes render as a reference-ordered list", async () => {
// Inline `^[body]` footnotes carry their body at the reference point, so the
// PAGE import path (markdownToProseMirrorCanonical) materializes the bottom
// list in REFERENCE order — numbers read 1, 2, 3 down the list — with ids
// assigned sequentially (fn-1, fn-2, fn-3).
const md = "See^[bravo] then^[alpha] then^[charlie].";
const json = await markdownToProseMirrorCanonical(md);
assert.deepEqual(defIds(json), ["b", "a", "c"]);
assert.deepEqual(defIds(json), ["fn-1", "fn-2", "fn-3"]);
assert.equal(findAll(json, "footnotesList").length, 1);
// Bodies materialize in reference order (bravo, alpha, charlie).
const defsJson = JSON.stringify(findAll(json, "footnoteDefinition"));
assert.ok(
defsJson.indexOf("bravo") <
defsJson.indexOf("alpha") &&
defsJson.indexOf("alpha") < defsJson.indexOf("charlie"),
"definitions follow reference order",
);
});
test("generateFootnoteId: valid uuidv7 shape (version 7, variant 8..b) and unique", () => {
+31 -30
View File
@@ -49,15 +49,19 @@ const footnoteDoc = {
],
};
test("JSON -> Markdown emits pandoc footnote syntax", () => {
test("JSON -> Markdown emits canonical inline footnote syntax (#293 canon #2)", () => {
// Canonical markdown form is Pandoc/Obsidian INLINE footnotes: the note body is
// written at the reference point as `^[body]`. There is NO `[^id]` reference
// marker and NO trailing `[^id]: …` definition list; the schema id never
// reaches markdown.
const md = convertProseMirrorToMarkdown(footnoteDoc);
assert.match(md, /\[\^fn1\]/);
assert.match(md, /\[\^fn2\]/);
assert.match(md, /\[\^fn1\]: First note\./);
assert.match(md, /\[\^fn2\]: Second note\./);
assert.match(md, /\^\[First note\.\]/);
assert.match(md, /\^\[Second note\.\]/);
assert.doesNotMatch(md, /\[\^/); // no reference-style markers
assert.doesNotMatch(md, /^\[\^.+\]:/m); // no bottom definition lines
});
test("Markdown -> JSON rebuilds footnote nodes", async () => {
test("Markdown -> JSON rebuilds footnote nodes with sequential fn-N ids", async () => {
const md = convertProseMirrorToMarkdown(footnoteDoc);
const json = await markdownToProseMirror(md);
@@ -65,42 +69,39 @@ test("Markdown -> JSON rebuilds footnote nodes", async () => {
const list = findAll(json, "footnotesList");
const defs = findAll(json, "footnoteDefinition");
// Structure is preserved; ids are (re)assigned sequentially in first-reference
// order by the importer (fn-1, fn-2, …) — the concrete id is never carried in
// markdown, so it is derived on import.
assert.equal(refs.length, 2);
assert.deepEqual(
refs.map((r) => r.attrs.id),
["fn1", "fn2"],
["fn-1", "fn-2"],
);
assert.equal(list.length, 1);
assert.equal(defs.length, 2);
assert.deepEqual(
defs.map((d) => d.attrs.id),
["fn1", "fn2"],
["fn-1", "fn-2"],
);
});
test("JSON -> MD -> JSON preserves footnote ids and text", async () => {
test("JSON -> MD -> JSON is byte-stable and preserves footnote body text", async () => {
const md = convertProseMirrorToMarkdown(footnoteDoc);
const json = await markdownToProseMirror(md);
const md2 = convertProseMirrorToMarkdown(json);
// The second markdown serialization carries the same markers + definitions.
assert.match(md2, /\[\^fn1\]/);
assert.match(md2, /\[\^fn2\]/);
assert.match(md2, /\[\^fn1\]: First note\./);
assert.match(md2, /\[\^fn2\]: Second note\./);
// The round trip is byte-stable (ids are not written to markdown, so the
// concrete import id cannot perturb the output) and the bodies survive.
assert.equal(md2, md);
assert.match(md2, /\^\[First note\.\]/);
assert.match(md2, /\^\[Second note\.\]/);
});
test("repeated references REUSE one footnote; duplicate definitions are first-wins (#166)", async () => {
// Reuse semantics: many `[^d]` references + several `[^d]:` definitions import
// as ONE footnote — the references all keep id "d" (reuse), and only the FIRST
// definition is kept (first-wins). Deterministic and stable across re-imports.
const md = [
"See[^d] one[^d] two[^d].",
"",
"[^d]: first",
"[^d]: second",
"[^d]: third",
].join("\n");
test("identical footnote bodies MERGE to one shared definition (#293 canon #2)", async () => {
// Two references whose bodies are byte-identical import as ONE definition
// shared by both references (dedup on the exact body text). Two DIFFERENT
// bodies stay distinct. Deterministic and stable across re-imports.
const md = "See^[same] and^[same], but^[other].";
const idsOf = async () => {
const json = await markdownToProseMirror(md);
@@ -120,11 +121,11 @@ test("repeated references REUSE one footnote; duplicate definitions are first-wi
// Stable across runs.
assert.deepEqual(a, b);
// Reuse: all three reference markers stay "d".
assert.deepEqual(a.refs, ["d", "d", "d"]);
// First-wins: a single definition "d" with the FIRST text.
assert.deepEqual(a.defIds, ["d"]);
assert.equal(a.defText, "first");
// Merge: the two "same" references share fn-1; the "other" reference is fn-2.
assert.deepEqual(a.refs, ["fn-1", "fn-1", "fn-2"]);
// One definition per unique body, in first-reference order.
assert.deepEqual(a.defIds, ["fn-1", "fn-2"]);
assert.equal(a.defText, "same|other");
});
test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => {
@@ -70,7 +70,7 @@ test("hardBreak -> trailing two-spaces+newline", () => {
assert.equal(convertProseMirrorToMarkdown(input), "line1 \nline2");
});
test("table cell with two block children joined by a space (and a pipe escaped)", () => {
test("table cell with two block children falls back to a raw HTML table", () => {
const input = doc({
type: "table",
content: [
@@ -86,11 +86,12 @@ test("table cell with two block children joined by a space (and a pipe escaped)"
],
});
// Single-column header row + separator. The cell joins its two paragraphs
// with a space ("a|b c") then escapes the pipe -> "a\|b c".
// A pipe-table cell cannot represent two block children, so the canonical
// converter emits the whole table as raw HTML (lossless) rather than lossily
// flattening the paragraphs into one cell.
assert.equal(
convertProseMirrorToMarkdown(input),
"| a\\|b c |\n| --- |",
"<table><tbody><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>",
);
});
@@ -108,20 +109,20 @@ test("code block trailing newline trimmed", () => {
);
});
test("textAlign value: delimiting double-quote escaped (attribute-safe, idempotent; < > left literal/inert)", () => {
test("textAlign is carried in a trailing attached-comment directive (JSON-encoded, safe)", () => {
const input = doc({
type: "paragraph",
attrs: { textAlign: 'right"><b' },
content: [text("body")],
});
// Attribute values escape only & and " so the value cannot break out of the
// quoted attribute. < and > are left literal: parse5/jsdom does NOT decode
// &lt;/&gt; inside attribute values, so escaping them would corrupt the value
// and accumulate on every round-trip. The literal < > are inert inside quotes.
// #293 canon #9: paragraph textAlign has no native markdown syntax, so it is
// attached as a trailing `<!--attrs {json}-->` comment on the block. The value
// is JSON-encoded, so a hostile value (`"`, `<`, `>`) is carried verbatim and
// inert — it cannot break out of the comment.
assert.equal(
convertProseMirrorToMarkdown(input),
'<div align="right&quot;><b">body</div>',
'body <!--attrs {"textAlign":"right\\"><b"}-->',
);
});
@@ -150,10 +151,10 @@ test("empty task item still emits its marker", () => {
assert.equal(convertProseMirrorToMarkdown(input), "- [ ]\n- [x]");
});
// Image captions (issue #221). An image WITHOUT a caption stays the lossy-free
// `![alt](src)`; WITH a caption it is emitted as a raw <img data-caption>
// wrapped in a block <div> (symmetric to video) so the round-trip md -> html ->
// json restores the caption via the image extension's parseHTML.
// Image captions (issue #221 / #293 canon #8). An image WITHOUT a caption stays
// the plain `![alt](src)`; WITH a caption (or any other non-src attr) the extra
// attrs ride in a trailing `<!--img {json}-->` discriminator comment on the
// markdown image form, so the round-trip md -> json restores them.
test("image without a caption emits plain ![alt](src)", () => {
const input = doc({
type: "image",
@@ -162,24 +163,24 @@ test("image without a caption emits plain ![alt](src)", () => {
assert.equal(convertProseMirrorToMarkdown(input), "![cat](/files/a.png)");
});
test("image with a caption emits a raw <img data-caption> in a block div", () => {
test("image with a caption emits ![alt](src) plus an <!--img--> directive", () => {
const input = doc({
type: "image",
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
});
assert.equal(
convertProseMirrorToMarkdown(input),
'<div><img src="/files/a.png" alt="cat" data-caption="A grey cat"></div>',
'![cat](/files/a.png) <!--img {"caption":"A grey cat"}-->',
);
});
test("image caption escapes & and \" in the data-caption attribute", () => {
test("image caption is JSON-encoded in the <!--img--> directive (& and \" safe)", () => {
const input = doc({
type: "image",
attrs: { src: "/files/a.png", caption: 'Tom & "Jerry"' },
});
assert.equal(
convertProseMirrorToMarkdown(input),
'<div><img src="/files/a.png" data-caption="Tom &amp; &quot;Jerry&quot;"></div>',
'![](/files/a.png) <!--img {"caption":"Tom & \\"Jerry\\""}-->',
);
});
@@ -55,8 +55,10 @@ test("round-trip: drawio diagram survives with src, title, dimensions, align, at
},
"drawio",
);
// The converter must emit the schema-matching div[data-type="drawio"].
assert.match(md, /data-type="drawio"/);
// #293 canon #8: the media family serializes to the markdown image form plus a
// trailing discriminator comment carrying the non-src attrs.
assert.match(md, /^!\[\]\(\/api\/files\/d\.drawio\)/);
assert.match(md, /<!--drawio \{.*"attachmentId":"dz1".*\}-->/);
assert.equal(found.length, 1, "drawio node must survive the round-trip");
const a = found[0].attrs;
assert.equal(a.src, "/api/files/d.drawio");
@@ -123,13 +125,19 @@ test("round-trip: pdf preserves width/height (standard attrs) plus name", async
});
// ---------------------------------------------------------------------------
// Escaping: a src containing a double quote must survive the attribute-quoted
// HTML emission (escapeAttr) and re-parse to the exact original value, with no
// node loss and no HTML injection.
// Escaping: a src containing a double quote must survive the markdown image form
// with no node loss and no injection. In the `![](src)` link the URL is
// normalized (a raw `"` percent-encodes to `%22`) on import — a semantically
// equivalent, IDEMPOTENT normalization (it does not drift on further round
// trips), not data loss.
// ---------------------------------------------------------------------------
test("round-trip: a src containing a double quote is escaped and recovered intact", async () => {
test("round-trip: a src containing a double quote is normalized (idempotent) and survives", async () => {
const tricky = 'https://e.com/x?a="b"&c=1';
const normalized = "https://e.com/x?a=%22b%22&c=1";
const { found } = await roundtrip({ type: "youtube", attrs: { src: tricky } }, "youtube");
assert.equal(found.length, 1, "node must survive a quote-bearing src");
assert.equal(found[0].attrs.src, tricky, "the exact src is recovered");
assert.equal(found[0].attrs.src, normalized, "the quote is percent-encoded in the URL");
// Idempotent: a second round trip from the normalized node is byte-stable.
const again = await roundtrip({ type: "youtube", attrs: { src: normalized } }, "youtube");
assert.equal(again.found[0].attrs.src, normalized);
});
+22 -27
View File
@@ -25,19 +25,15 @@ const findAll = (node, type, acc = []) => {
};
// ---------------------------------------------------------------------------
// DATA-LOSS: atom block nodes with no converter case serialize to "" and the
// whole block disappears from markdown export.
//
// markdown-converter.ts has a `default` branch (~line 601) that renders a node
// as `nodeContent.map(processNode).join("")`. For a leaf/atom node (no
// content) that yields "" — so the node (and ALL its attributes) is dropped.
// `htmlEmbed` and `pageBreak` are both block atoms in docmost-schema.ts with no
// case in the converter, so they vanish on markdown export.
//
// These tests assert the CURRENT (buggy) behavior and name it, so that when a
// converter case is added the failing assertion flags the test for an update.
// #293 canon: atom block nodes with no NATIVE markdown syntax are preserved via
// dedicated converter forms (they used to serialize to "" and vanish — the old
// mcp converter's data-loss gap, now fixed by consuming the shared package):
// - htmlEmbed -> a raw `<div data-type="htmlEmbed" data-source=… data-height=…>`
// block (source base64-encoded so arbitrary HTML is inert);
// - pageBreak -> a standalone `<!--pagebreak-->` machinery comment (#5).
// Both survive markdown export AND a full PM -> markdown -> PM round-trip.
// ---------------------------------------------------------------------------
test("DATA-LOSS: an htmlEmbed block is silently dropped from markdown export (no converter case)", () => {
test("htmlEmbed block survives markdown export (source + height preserved)", () => {
const input = doc(
para(text("before")),
{ type: "htmlEmbed", attrs: { source: "<b>hi</b>", height: 200 } },
@@ -45,32 +41,31 @@ test("DATA-LOSS: an htmlEmbed block is silently dropped from markdown export (no
);
const md = convertProseMirrorToMarkdown(input);
// BUG: the htmlEmbed block, including its `source` and `height` attrs, is
// gone — only the surrounding paragraphs survive. If a future fix adds an
// htmlEmbed case, update this test to assert the block (or a placeholder)
// survives instead.
assert.equal(md, "before\n\n\n\nafter", "htmlEmbed currently disappears");
assert.ok(!md.includes("<b>hi</b>"), "the embed source is NOT preserved (data-loss)");
assert.match(md, /data-type="htmlEmbed"/);
assert.match(md, /data-height="200"/);
// The raw source is base64-encoded in data-source (not emitted verbatim), so
// the surrounding markdown cannot be corrupted by hostile embed HTML.
assert.match(md, /data-source="[^"]+"/);
assert.ok(md.includes("before") && md.includes("after"));
});
test("DATA-LOSS: an htmlEmbed does NOT round-trip (PM -> markdown -> PM loses the node)", async () => {
test("htmlEmbed round-trips PM -> markdown -> PM (node + source recovered)", async () => {
const input = doc(
para(text("x")),
{ type: "htmlEmbed", attrs: { source: "<i>raw</i>", height: 120 } },
);
const out = await markdownToProseMirror(convertProseMirrorToMarkdown(input));
assert.equal(
findAll(out, "htmlEmbed").length,
0,
"htmlEmbed is lost across a markdown round-trip (known data-loss gap)",
);
const embeds = findAll(out, "htmlEmbed");
assert.equal(embeds.length, 1, "htmlEmbed survives the markdown round-trip");
assert.equal(embeds[0].attrs.source, "<i>raw</i>", "source recovered intact");
});
test("DATA-LOSS: a pageBreak block is silently dropped from markdown export (no converter case)", () => {
test("pageBreak block survives markdown export and round-trips", async () => {
const input = doc(para(text("a")), { type: "pageBreak" }, para(text("b")));
const md = convertProseMirrorToMarkdown(input);
// BUG: pageBreak (a block atom with no converter case) disappears.
assert.equal(md, "a\n\n\n\nb", "pageBreak currently disappears");
assert.match(md, /<!--pagebreak-->/);
const out = await markdownToProseMirror(md);
assert.equal(findAll(out, "pageBreak").length, 1);
});
// ---------------------------------------------------------------------------
+64
View File
@@ -165,3 +165,67 @@ test("import: a colored mention span keeps the mention node", async () => {
const out = await markdownToProseMirror('<span data-type="mention" data-id="u1" data-label="Alice" style="color: blue">@Alice</span>');
assert.equal(findNodes(out, "mention").length, 1, "mention node must survive a colored span");
});
// ---------------------------------------------------------------------------
// #293 STEP 5 canon safety net. These assert STRUCTURE/content preservation
// (format-agnostic: the node/mark and its value survive PM -> markdown -> PM,
// and the markdown is idempotent), NOT the exact markdown bytes — so they stay
// valid regardless of the concrete canonical spelling. They cover the node/mark
// types whose canonical markdown form changed in #293 (highlight-without-color,
// textAlign, subpages, inline footnotes) and complement the existing math /
// media / mention / column round-trips above.
// ---------------------------------------------------------------------------
test("round-trip: highlight WITHOUT a color survives as a highlight mark (==)", async () => {
const input = doc(para(text("hi", [{ type: "highlight", attrs: { color: null } }])));
const md = convertProseMirrorToMarkdown(input);
const out = await roundtrip(input);
const hit = findNodes(out, "text").find(
(n) => n.text === "hi" && (n.marks || []).some((m) => m.type === "highlight"),
);
assert.ok(hit, "the highlight mark must survive a color-less round-trip");
// Idempotent markdown.
assert.equal(convertProseMirrorToMarkdown(out), md);
});
test("round-trip: paragraph textAlign survives via the attached-comment directive", async () => {
const input = doc({
type: "paragraph",
attrs: { textAlign: "center" },
content: [text("mid")],
});
const md = convertProseMirrorToMarkdown(input);
const out = await roundtrip(input);
const p = findNodes(out, "paragraph").find((n) => n.attrs && n.attrs.textAlign === "center");
assert.ok(p, "textAlign must be restored on the paragraph");
assert.equal(convertProseMirrorToMarkdown(out), md, "textAlign round-trip is idempotent");
});
test("round-trip: subpages atom survives", async () => {
const input = doc({ type: "subpages" });
const out = await roundtrip(input);
assert.equal(findNodes(out, "subpages").length, 1, "subpages node must survive");
});
test("round-trip: inline footnote survives with body text (canonical structure)", async () => {
const input = doc(
para(text("Claim"), { type: "footnoteReference", attrs: { id: "fnA" } }),
{
type: "footnotesList",
content: [
{
type: "footnoteDefinition",
attrs: { id: "fnA" },
content: [para(text("the evidence"))],
},
],
},
);
const md = convertProseMirrorToMarkdown(input);
const out = await roundtrip(input);
assert.equal(findNodes(out, "footnoteReference").length, 1);
assert.equal(findNodes(out, "footnotesList").length, 1);
assert.equal(findNodes(out, "footnoteDefinition").length, 1);
assert.match(JSON.stringify(out), /the evidence/, "footnote body survives");
// Byte-stable (the schema id is never written to markdown).
assert.equal(convertProseMirrorToMarkdown(out), md);
});
@@ -0,0 +1,82 @@
// Guard: every tool the MCP server registers must be routed by intent in
// SERVER_INSTRUCTIONS — the editing guide clients receive in the initialize
// result. Without this, new tools silently rot out of the guide and agents
// never learn to pick them (the guide once omitted 17 of 41 tools, including
// get_outline, which pushed agents into fetching whole documents for block
// ids). Tool names are extracted from the SOURCE (index.ts inline
// registrations + tool-specs.ts shared specs) so a registration added either
// way is caught; the guide text itself is imported from the build so the test
// checks what actually ships.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, "..", "..", "src");
// Tools DELIBERATELY absent from the guide. Keep this list minimal and
// justify every entry — the default is: every tool gets routed.
const EXCEPTIONS = new Set([
// Trivial and self-explanatory; carries no routing decision.
"get_workspace",
]);
/**
* Extract every registered tool name from the source. Two registration
* mechanisms exist and both are covered:
* - inline `server.registerTool("name", ...)` calls in index.ts;
* - shared specs in tool-specs.ts (`mcpName: 'name'`), registered via
* registerShared(SHARED_TOOL_SPECS.x, ...).
*/
function registeredToolNames() {
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
const names = new Set();
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
names.add(m[1]);
}
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) {
names.add(m[1]);
}
return names;
}
test("every registered tool is mentioned in SERVER_INSTRUCTIONS", () => {
const names = registeredToolNames();
// Sanity: if extraction regressed (regex drift), fail loudly rather than
// vacuously passing on an empty set.
assert.ok(
names.size >= 40,
`sanity: expected to extract 40+ registered tools, got ${names.size}` +
"the extraction regexes in this test likely drifted from the source",
);
const missing = [...names]
.filter((n) => !EXCEPTIONS.has(n))
// \b<name>\b: `_` is a word char, so \bget_page\b does NOT match inside
// get_page_json — a tool can't hide behind a longer sibling's mention.
.filter((n) => !new RegExp(`\\b${n}\\b`).test(SERVER_INSTRUCTIONS))
.sort();
assert.deepEqual(
missing,
[],
`tools missing from SERVER_INSTRUCTIONS: ${missing.join(", ")}` +
"update the guide in packages/mcp/src/index.ts (see its MAINTENANCE " +
"RULE comment), or add a justified entry to EXCEPTIONS here",
);
});
test("EXCEPTIONS entries are real registered tools", () => {
// A stale exception (tool renamed/removed) must be cleaned up, otherwise
// the list quietly grows past its purpose.
const names = registeredToolNames();
for (const name of EXCEPTIONS) {
assert.ok(
names.has(name),
`EXCEPTIONS entry "${name}" is not a registered tool — remove it`,
);
}
});
@@ -0,0 +1,45 @@
{
"name": "@docmost/prosemirror-markdown",
"version": "0.1.0",
"description": "Pure ProseMirror <-> Markdown converter + schema mirror (headless, framework-free).",
"private": true,
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.20.4",
"@tiptap/extension-highlight": "3.20.4",
"@tiptap/extension-image": "3.20.4",
"@tiptap/extension-subscript": "3.20.4",
"@tiptap/extension-superscript": "3.20.4",
"@tiptap/extension-task-item": "3.20.4",
"@tiptap/extension-task-list": "3.20.4",
"@tiptap/html": "3.20.4",
"@tiptap/pm": "3.20.4",
"@tiptap/starter-kit": "3.20.4",
"jsdom": "25.0.0",
"marked": "17.0.5",
"zod": "4.3.6"
},
"devDependencies": {
"@docmost/editor-ext": "workspace:*",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.0.0",
"fast-check": "^4.8.0",
"typescript": "^5.0.0",
"vitest": "4.1.6"
}
}
@@ -0,0 +1,9 @@
/**
* Public surface of `@docmost/prosemirror-markdown`.
*
* A headless, framework-free ProseMirror <-> Markdown converter plus the
* Docmost schema mirror. Everything lives under `lib/` (the converter core);
* this top-level barrel simply re-exports that surface so the package entry is
* the converter surface.
*/
export * from "./lib/index.js";
@@ -0,0 +1,124 @@
/**
* Attached-comment convention (#293 canon).
*
* Some block-level attributes have no native markdown syntax (paragraph/heading
* `textAlign` #9; image/media attrs #4/#8). Rather than HTML-wrapping the
* whole block (the old `<div align>` / `<p style>` forms, which the maintainer
* had to patch repeatedly and which did not round-trip cleanly), we ATTACH a
* compact HTML comment at the END of the block's rendered line:
*
* Some paragraph text <!--attrs {"textAlign":"center"}-->
*
* The comment is invisible in any markdown renderer and is dropped by the
* DOM/generateJSON import stage, so it can never leak into the document body.
* The importer intercepts it BEFORE that stage (see markdown-to-prosemirror's
* applyAttachedComments) and re-applies the encoded attributes to the node.
*
* This module holds the two PURE, reusable primitives of the convention so the
* serializer, the parser, and future decisions (#4 image, #8 media) share ONE
* implementation:
* - `attachedCommentFor(name, json)` build the comment string.
* - `parseAttachedComment(data)` parse a comment node's data back.
*/
/**
* A parsed attached comment: the leading `name` token and the decoded JSON
* object payload (empty object when the comment carried no JSON body).
*/
export interface AttachedComment {
name: string;
attrs: Record<string, unknown>;
}
/**
* Grammar of an attached comment's DATA (the text between `<!--` and `-->`):
* a leading name token (`attrs`, `img`, ) optionally followed by whitespace
* and a single JSON object. The name deliberately does NOT allow `:` so the
* file-level envelope comments (`docmost:meta` / `docmost:comments`) never match
* and stay inert here.
*/
const ATTACHED_COMMENT_RE = /^\s*([A-Za-z][\w-]*)(?:\s+(\{[\s\S]*\}))?\s*$/;
/**
* Build an attached HTML comment `<!--name {compact-json}-->` for `json`.
*
* The JSON is emitted compactly (no spaces) via `JSON.stringify`. A string value
* may legitimately contain two consecutive hyphens `--`, which would prematurely
* close the HTML comment (`-->`). We defuse that WITHOUT changing the decoded
* value: each hyphen of every `--` pair is rewritten as the JSON unicode escape
* `-`, so `JSON.parse` on the reading side restores the exact original
* hyphens. `--` can only occur inside a JSON string (structural JSON never
* produces it), so a blanket replace over the stringified payload is safe.
*/
export function attachedCommentFor(name: string, json: object): string {
return `<!--${name} ${escapeCommentJson(json)}-->`;
}
/**
* Compactly stringify `json` and defuse any `--` pair so the payload can never
* close the HTML comment early. Shared by `attachedCommentFor` (attached form)
* and `standaloneCommentFor` (standalone form) so both stay in sync.
*
* A string value may legitimately contain two consecutive hyphens `--`, which
* would prematurely close the comment (`-->`). We defuse that WITHOUT changing
* the decoded value: each hyphen of every `--` pair is rewritten as the JSON
* unicode escape `-`, so `JSON.parse` on the reading side restores the exact
* original hyphens. `--` can only occur inside a JSON string (structural JSON
* never produces it), so a blanket replace over the stringified payload is safe.
* Scanning left-to-right and replacing each `--` handles odd runs too (`---` ->
* two escapes + one bare `-`, still `---` after JSON.parse).
*/
function escapeCommentJson(json: object): string {
return JSON.stringify(json).replace(/--/g, "\\u002d\\u002d");
}
/**
* Build a STANDALONE machinery comment (#293 canon #5) for a block node that
* lives on its OWN line, e.g. `<!--pagebreak-->` or `<!--subpages-->`.
*
* Grammar is identical to the attached form (`<!--name {JSON?}-->`), but the
* JSON body is emitted ONLY when there are real attributes to carry:
* - `standaloneCommentFor("pagebreak")` -> `<!--pagebreak-->`
* - `standaloneCommentFor("subpages")` -> `<!--subpages-->`
* - `standaloneCommentFor("subpages", {recursive:true})`
* -> `<!--subpages {"recursive":true}-->`
*
* When `attrs` is undefined/null/empty-object the comment is name-only (no JSON,
* which parses back to default attrs). Otherwise the JSON body is emitted with
* the SAME `--`-escaping as `attachedCommentFor` (via `escapeCommentJson`), so
* the standalone and attached encoders can never diverge.
*/
export function standaloneCommentFor(name: string, attrs?: object | null): string {
if (!attrs || Object.keys(attrs).length === 0) {
return `<!--${name}-->`;
}
return `<!--${name} ${escapeCommentJson(attrs)}-->`;
}
/**
* Parse the DATA of a comment node into `{ name, attrs }`, or `null` when it is
* not a well-formed attached comment.
*
* Fail-open by design (maintainer spec): a comment whose name token is missing,
* whose JSON body is malformed, or whose body is not a plain object returns
* `null` so the caller ignores it and keeps default attributes. Unknown keys in
* a valid object are preserved here and filtered by the caller.
*/
export function parseAttachedComment(data: string): AttachedComment | null {
const m = ATTACHED_COMMENT_RE.exec(data);
if (!m) return null;
const name = m[1];
if (m[2] === undefined) {
// Name-only comment (no JSON body): a valid attached marker with no attrs.
return { name, attrs: {} };
}
try {
const parsed = JSON.parse(m[2]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { name, attrs: parsed as Record<string, unknown> };
}
return null; // fail-open: payload is not a plain object
} catch {
return null; // fail-open: malformed JSON -> ignore the comment
}
}
@@ -45,9 +45,11 @@
* converter coercing numeric `width`/`height` to strings, which is outside
* canonicalize's scope.
*
* NOTE: `image` has NO non-null align default its `align` defaults to `null`
* (docmost-schema.ts L174), so it is already handled by the null-drop rule and
* is intentionally NOT listed here.
* NOTE: `image` align now defaults to `"center"` unified with editor-ext
* (#293 canon #4). It is listed below so a canonical image drops `align` when
* it equals "center" (absent default), exactly like the diagram/media nodes.
* A null align is likewise dropped by the null-drop rule and re-imports as the
* "center" default, so bare `![](src)` images stay canonically clean.
*/
const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
// mark types
@@ -62,6 +64,9 @@ const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
orderedList: {
start: 1,
},
image: {
align: "center",
},
drawio: {
align: "center",
},
@@ -256,7 +256,22 @@ const DocmostAttributes = Extension.create({
{
types: ["image"],
attributes: {
align: { default: null },
// #293 canon #4: the image `align` default is unified to "center"
// (matching editor-ext, the source of real user documents) so an
// editor-authored image — which is always align="center" — serializes
// as the clean `![](src)` form with NO attached comment, and only a
// genuinely non-default alignment (left/right) emits an `<!--img-->`
// comment. The DOM attribute name stays `align` (imageToHtml already
// round-trips it as align="…"); only the DEFAULT value changed from
// null to "center". parseHTML reads the `align` attribute so a bare
// <img> with no align falls back to "center", and <img align="left">
// reads "left".
align: {
default: "center",
parseHTML: (el: HTMLElement) => el.getAttribute("align") || "center",
renderHTML: (attrs: Record<string, any>) =>
attrs.align && attrs.align !== "center" ? { align: attrs.align } : {},
},
// imageToHtml emits these Docmost-specific image attrs as data-*; map
// them back explicitly so a top-level image (or one inside a column)
// round-trips them. Without a parseHTML the default reads the bare
@@ -0,0 +1,61 @@
/**
* #293 canon #2: inline footnotes `^[text]`.
*
* Shared, side-effect-free helpers used by BOTH the serializer
* (markdown-converter.ts) and the importer (markdown-to-prosemirror.ts) so the
* two directions cannot drift.
*
* The canonical markdown form is Pandoc/Obsidian inline footnotes: the note body
* is written AT the reference point as `^[body]`; there is no separate
* `[^id]: …` definition line and no bottom `<section>` list in the markdown. On
* import the body is re-assembled into the schema's doc-level
* `footnotesList`/`footnoteDefinition` so the editor sees the usual three-node
* footnote model, while identical bodies MERGE to a single definition shared by
* every reference. Ids are assigned by the importer's assembleFootnotes pass
* (dedup on the EXACT body text -> sequential `fn-N`), NOT derived from a hash,
* so two DIFFERENT bodies can never collide onto one definition (F1). The id is
* never written to markdown (`^[body]` carries only text), so the round trip
* stays byte-stable regardless of the concrete id.
*/
/**
* Split an ENCODED footnote body (the inner captured between `^[` and its
* matching `]`, or the value of a `data-fn-text` attribute) into its paragraph
* markdown strings.
*
* Paragraph boundaries are the two-character literal separator `\n` (backslash +
* n); a REAL backslash-n in the body was encoded as `\\n` (an escaped backslash
* followed by n) by the serializer, so it must NOT split. The scan therefore
* treats any `\<char>` as an escaped pair kept verbatim (so `\\` `n` stays a
* literal backslash-then-n and the trailing `n` is plain), and only an
* UNescaped `\n` is a separator. Every other backslash escape (`\=`, `\$`,
* `\[`, ) is preserved untouched so the per-paragraph `parseInline` decodes it.
*/
export function splitFootnoteParagraphs(encoded: string): string[] {
const paragraphs: string[] = [];
let current = "";
let i = 0;
while (i < encoded.length) {
const c = encoded[i];
if (c === "\\" && i + 1 < encoded.length) {
const next = encoded[i + 1];
if (next === "n") {
// Unescaped backslash-n: a paragraph separator.
paragraphs.push(current);
current = "";
i += 2;
continue;
}
// Any other escaped pair (including `\\`) is kept verbatim; consuming
// BOTH chars is what makes an encoded real `\n` (`\\n`) safe — the `\\`
// pair is taken here, leaving the following `n` as an ordinary literal.
current += c + next;
i += 2;
continue;
}
current += c;
i++;
}
paragraphs.push(current);
return paragraphs;
}
@@ -16,9 +16,29 @@ export {
export type { DocmostMdMeta } from "./markdown-document.js";
export { convertProseMirrorToMarkdown } from "./markdown-converter.js";
export type { ConvertProseMirrorToMarkdownOptions } from "./markdown-converter.js";
export { markdownToProseMirror } from "./markdown-to-prosemirror.js";
// The Docmost tiptap schema mirror. Exposed so consumers (and the sync
// engine's schema-validity regression tests) can build the exact ProseMirror
// schema the converter targets.
export { docmostExtensions } from "./docmost-schema.js";
// Schema-adjacent sanitizers used by consumers (mcp) so the single canonical,
// alias-aware / allowlist implementations live ONLY here (no drifting copies).
export { clampCalloutType, sanitizeCssColor } from "./docmost-schema.js";
// Attached-comment convention (#293 canon #9/#4/#8): the reusable primitives
// the serializer/parser use to encode attrs that have no native markdown syntax
// as trailing `<!--name {json}-->` comments.
export {
attachedCommentFor,
standaloneCommentFor,
parseAttachedComment,
} from "./attached-comment.js";
export type { AttachedComment } from "./attached-comment.js";
export {
canonicalizeContent,
docsCanonicallyEqual,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
/**
* Shared inline-math boundary rule (#293 canon #6).
*
* Pandoc's inline-math rule lives here because it is used in TWO directions that
* MUST agree byte-for-byte on which `$$` spans are math:
*
* - the IMPORT tokenizer (markdown-to-prosemirror.ts) that turns `$LaTeX$`
* into a `mathInline` node, and
* - the EXPORT escaper (markdown-converter.ts) that backslash-escapes a
* would-be-math `$$` span sitting in PROSE text so it re-imports as literal
* text instead of silently materializing a phantom math node.
*
* Defining the rule ONCE guarantees the two directions never drift: a span the
* tokenizer would match is EXACTLY a span the escaper neutralizes, so a prose
* `$x$` round-trips as literal text and math `$x^2$` round-trips as math.
*
* The rule (currency-safe, from pandoc): an opening `$` is NOT followed by
* whitespace; the closing `$` is NOT preceded by whitespace AND NOT immediately
* followed by a digit; the inner run is non-empty, single-line, and may embed an
* escaped `\$` (which never counts as the closer). Under this rule `$5`,
* `$5 and $10`, `price is $5`, `a $5 b $6 c` all stay literal (no VALID closing
* `$` exists the `$` before a space-preceded amount fails the "not preceded by
* whitespace" test, and a lone `$` has no closer), while `$x^2$` is math.
*/
// Core pattern (unanchored). Escaping note for the string form:
// \\$ -> a literal `$`
// (?!\s) -> opening `$` NOT followed by whitespace (also forces a
// non-empty inner: the next char must exist and be non-space)
// (?:\\\\\\$|[^$\n])+? -> inner: shortest run of either an escaped `\$`
// (consumed as a unit so it is never the closer) or any char
// that is neither an unescaped `$` nor a newline
// (?<!\s) -> the char before the closing `$` is NOT whitespace
// \\$ -> closing `$`
// (?![0-9]) -> closing `$` NOT immediately followed by a digit (currency)
export const INLINE_MATH_SOURCE =
"\\$(?!\\s)((?:\\\\\\$|[^$\\n])+?)(?<!\\s)\\$(?![0-9])";
/** Global matcher for the export-side prose escaper. */
export const inlineMathGlobalRe = (): RegExp =>
new RegExp(INLINE_MATH_SOURCE, "g");
/** Anchored matcher for the import-side marked tokenizer. */
export const inlineMathAnchoredRe = (): RegExp =>
new RegExp("^" + INLINE_MATH_SOURCE);
/** Decode a tokenizer-captured inner LaTeX: an escaped `\$` becomes `$`. */
export const decodeInlineMathLatex = (inner: string): string =>
inner.replace(/\\\$/g, "$");
/** Escape LaTeX for the `$…$` inline form so a literal `$` cannot close early. */
export const encodeInlineMathLatex = (latex: string): string =>
latex.replace(/\$/g, "\\$");
/**
* Whether a `mathInline` node's LaTeX can be safely serialized as `$LaTeX$`
* (vs. the always-lossless schema-HTML `<span>` fallback). Requires:
* - non-empty (an empty span has no readable `$$` form),
* - non-whitespace edges (pandoc's opening/closing whitespace rules),
* - single line (inline math never spans lines),
* - no pre-existing `\$` and no trailing `\` — either would make the
* `$``\$` escape ambiguous on decode (a `\\$` sequence, or an escaped
* closing `$`), so those rare cases take the `<span>` fallback instead.
* NOTE: a following-sibling digit (which would also break the pandoc closing
* rule) cannot be seen from the node alone; that case is handled by the
* serializer's inline-children pass, not here.
*/
export const inlineMathSerializable = (latex: string): boolean =>
latex.length > 0 &&
!/^\s/.test(latex) &&
!/\s$/.test(latex) &&
!/[\r\n]/.test(latex) &&
!latex.includes("\\$") &&
!/\\$/.test(latex);
/** Escape a value for an HTML double-quoted attribute (only & and " matter). */
export const escapeMathAttr = (value: string): string =>
value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
@@ -0,0 +1,172 @@
/**
* Shared schema-HTML builders for the media/discriminator family (#293 canon
* #8).
*
* Canon #8 gives ten node types (youtube/video/audio/drawio/excalidraw
* image-form; pdf/attachment/embed link-form; pageEmbed/transclusionReference
* standalone) a readable markdown TOP-LEVEL form (`![](src)`/`[text](src)`/a
* bare comment) plus a discriminator `<!--name {…}-->` comment. But TWO other
* paths still need the RAW SCHEMA-HTML form of each node:
*
* 1. The serializer's raw-HTML/columns path (`blockToHtml`): a comment node is
* dropped by the DOM parse stage that reads a raw-HTML block back, so inside
* a column/cell these nodes MUST stay schema HTML or they vanish (data loss).
* 2. The importer's `applyCommentDirectives`: to materialize the discriminator
* comment it rebuilds the SAME schema element the raw-HTML path emits, then
* swaps it in for the `<img>`/`<a>`/comment.
*
* Keeping these builders in ONE module means the serializer's raw-HTML path and
* the importer's materialization can never drift: both call the same function.
* Each builder reproduces BYTE-FOR-BYTE the schema HTML the top-level
* `processNode` cases previously returned (so existing columns/raw-HTML goldens
* stay green), and each output round-trips through the matching schema parseHTML
* in docmost-schema.ts.
*/
/**
* Escape a value interpolated into an HTML double-quoted attribute value.
* Identical semantics to markdown-converter's `escapeAttr`: escape ONLY `&` and
* `"` (idempotent; parse5 decodes both back). `<`/`>`/`'` are deliberately left
* alone so values never accumulate escapes across round-trips.
*/
const escapeAttr = (value: unknown): string =>
String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
/**
* Uploaded `<video>` player. Emits `<div><video …></video></div>`; the outer
* `<div>` (no data-type) forces block treatment so marked does not wrap the
* inline `<video>` in a `<p>`. Mirrors the Video schema: src/aria-label standard
* attrs, the rest as data-*.
*/
export function videoToHtml(attrs: Record<string, any>): string {
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.alt) parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
return `<div><video ${parts.join(" ")}></video></div>`;
}
/** YouTube embed. Emits `div[data-type="youtube"]` (src via data-src). */
export function youtubeToHtml(attrs: Record<string, any>): string {
const parts: string[] = [
`data-type="youtube"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/** Uploaded `<audio>` player. Emits `<div><audio …></audio></div>`. */
export function audioToHtml(attrs: Record<string, any>): string {
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
return `<div><audio ${parts.join(" ")}></audio></div>`;
}
/**
* draw.io / excalidraw diagram (shared diagramAttributes). Emits
* `div[data-type="drawio"|"excalidraw"]` carrying src/title/alt/width/height/
* size/aspectRatio/align/attachmentId as data-*.
*/
export function diagramToHtml(
type: "drawio" | "excalidraw",
attrs: Record<string, any>,
): string {
const parts: string[] = [
`data-type="${type}"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.title != null) parts.push(`data-title="${escapeAttr(attrs.title)}"`);
if (attrs.alt != null) parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.aspectRatio != null)
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/** Generic provider embed. Emits `div[data-type="embed"]` (src/provider/… data-*). */
export function embedToHtml(attrs: Record<string, any>): string {
const parts: string[] = [
`data-type="embed"`,
`data-src="${escapeAttr(attrs.src ?? "")}"`,
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
];
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
if (attrs.width != null)
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null)
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/** Uploaded file attachment. Emits `div[data-type="attachment"]` (data-attachment-*). */
export function attachmentToHtml(attrs: Record<string, any>): string {
const parts: string[] = [
`data-type="attachment"`,
`data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
];
if (attrs.name)
parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
if (attrs.mime)
parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
if (attrs.size != null)
parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/** Embedded PDF viewer. Emits `div[data-type="pdf"]` (src std, name/… data-*). */
export function pdfToHtml(attrs: Record<string, any>): string {
const parts: string[] = [
`data-type="pdf"`,
`src="${escapeAttr(attrs.src ?? "")}"`,
];
if (attrs.name) parts.push(`data-name="${escapeAttr(attrs.name)}"`);
if (attrs.attachmentId)
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/** Whole-page live embed. Emits `div[data-type="pageEmbed"]` (data-source-page-id). */
export function pageEmbedToHtml(attrs: Record<string, any>): string {
const parts: string[] = [`data-type="pageEmbed"`];
if (attrs.sourcePageId)
parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
/**
* Live transclusion reference. Emits `div[data-type="transclusionReference"]`
* (data-source-page-id + data-transclusion-id).
*/
export function transclusionReferenceToHtml(attrs: Record<string, any>): string {
const parts: string[] = [`data-type="transclusionReference"`];
if (attrs.sourcePageId)
parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
if (attrs.transclusionId)
parts.push(`data-transclusion-id="${escapeAttr(attrs.transclusionId)}"`);
return `<div ${parts.join(" ")}></div>`;
}
@@ -6,7 +6,8 @@ import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client';
// ---------------------------------------------------------------------------
// Gaps NOT covered by canonicalize.test.ts (test-strategy report §2 diff):
// - the *.align family (drawio/excalidraw/video/youtube/embed): a "center"
// - the *.align family (drawio/excalidraw/video/youtube/embed AND image, whose
// align default is unified to "center" per #293 canon #4): a "center"
// default is dropped, a non-default value is kept;
// - comment.resolved: TRUE is PRESERVED (only resolved:false is normalized);
// - link.target / link.rel NON-default values are kept;
@@ -39,21 +40,27 @@ describe('canonicalizeContent — *.align default family', () => {
});
}
it('image align is NOT in KNOWN_DEFAULTS: a non-null align survives, null is dropped', () => {
// image.align defaults to null, so it is handled by the null-drop rule and
// a real value ("left") must be kept (no spurious default match).
it('image align default is now "center" (#293 canon #4): center/null dropped, left kept', () => {
// A real non-default value ("left") must be kept.
const kept = canonicalizeContent({
type: 'image',
attrs: { id: 'i-1', src: '/a.png', align: 'left' },
});
expect(kept.attrs).toEqual({ src: '/a.png', align: 'left' });
// An image with align:"center" must KEEP it (center is NOT a default for
// image, only for the diagram/media family) — guards against over-matching.
// #293 canon #4 unified the image align default to "center" (matching
// editor-ext), so a center image now DROPS align exactly like the diagram/
// media family — bare `![](src)` images stay canonically clean.
const center = canonicalizeContent({
type: 'image',
attrs: { id: 'i-2', src: '/b.png', align: 'center' },
});
expect(center.attrs).toEqual({ src: '/b.png', align: 'center' });
expect(center.attrs).toEqual({ src: '/b.png' });
// A null align is likewise dropped (null-drop rule) and re-imports as center.
const nullAlign = canonicalizeContent({
type: 'image',
attrs: { id: 'i-3', src: '/c.png', align: null },
});
expect(nullAlign.attrs).toEqual({ src: '/c.png' });
});
});
@@ -32,15 +32,16 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// Exact serialized form: numbers render as bare data-* values; attribute
// order follows the converter's emit order (src, then width/height/size/
// aspect-ratio/align, then attachment-id).
// #293 canon #8 (image-form): src is the markdown target; every OTHER
// non-default attr rides in the ALWAYS-emitted `drawio` discriminator comment
// (numerics stringified, stable key order width/height/size/aspectRatio then
// attachmentId). align="center" is the schema default, so it is OMITTED.
expect(md1).toBe(
'<div data-type="drawio" data-src="/d.drawio" data-width="640" data-height="480" data-size="1234" data-aspect-ratio="1.777" data-align="center" data-attachment-id="att-1"></div>',
'![](/d.drawio)<!--drawio {"width":"640","height":"480","size":"1234","aspectRatio":"1.777","attachmentId":"att-1"}-->',
);
// A second export reproduces the first byte-for-byte (drawio align default
// is already "center", so nothing new materializes on import).
// A second export reproduces the first byte-for-byte: align="center"
// re-materializes as the schema default on import and is omitted again.
expect(md2).toBe(md1);
// Re-import coerces every numeric attr to a STRING because parseHTML reads
@@ -64,10 +65,10 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
});
// SPEC case 2: minimal excalidraw atom with ONLY string attrs (no align, no
// numeric attrs). Locks the one-time export divergence (align='center'
// default materializes only on import) plus escapeAttr of title/alt through
// the data-title/data-alt path.
it('excalidraw materializes align default only on import and escapes title/alt', async () => {
// numeric attrs). #293 canon #8 image-form: title/alt ride in the comment JSON
// (JSON-encoded, NOT HTML-escaped) and align='center' is omitted as the
// schema default — so the one-time divergence the OLD div-form had is GONE.
it('excalidraw round-trips title/alt via the discriminator comment (byte-stable, align default omitted)', async () => {
const input = doc({
type: 'excalidraw',
attrs: {
@@ -81,21 +82,18 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// First export: no align emitted (the input doc carries no align), and the
// " in title becomes &quot;, the & in alt becomes &amp; via escapeAttr.
// #293 canon #8: src in the target; title/alt in the ALWAYS-emitted
// `excalidraw` comment as compact JSON (the " in title is JSON-escaped as \",
// the & in alt stays literal — JSON, not HTML). No align emitted (default).
expect(md1).toBe(
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My &quot;Diagram&quot;" data-alt="a&amp;b"></div>',
'![](/e.excalidraw)<!--excalidraw {"title":"My \\"Diagram\\"","alt":"a&b"}-->',
);
// Second export: align='center' has now materialized (the schema's
// diagramAttributes default), so md2 gains a data-align="center" suffix and
// is NOT byte-equal to md1. This one-time divergence is the diagram quirk.
expect(md2).toBe(
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My &quot;Diagram&quot;" data-alt="a&amp;b" data-align="center"></div>',
);
expect(md2).not.toBe(md1);
// Byte-stable: align='center' re-materializes as the schema default on import
// and is omitted again on export #2, so md2 === md1 (no diagram quirk now).
expect(md2).toBe(md1);
// Re-import decodes the escaped entities back to the original characters.
// Re-import decodes the JSON payload back to the original characters.
const attrs2 = doc2.content[0].attrs;
expect(attrs2.title).toBe('My "Diagram"');
expect(attrs2.alt).toBe('a&b');

Some files were not shown because too many files have changed in this diff Show More