Compare commits

..

4 Commits

Author SHA1 Message Date
claude code agent 227
4c7b671950 docs(#193): correct contract-guard comment — interface is a subset, not superset
The DocmostClientLike mirror covers only methods the in-app adapter consumes;
the standalone MCP transport calls additional client methods not tracked here
(covered by its own typecheck). Fixes the misleading 'superset' wording (F2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:59:10 +03:00
claude code agent 227
4131deaabb test(mcp): robustify the client-host contract drift-guard parser
Architect-review hardening of the bidirectional DocmostClientLike <->
HOST_CONTRACT_METHODS guard (test-only, no production change):

- Interface method-name regex now accepts full TS identifiers
  (digits/_/$) and generic signatures (method<T>(), avoiding a future
  benign false-FAIL.
- Skip /* ... */ block comments in the interface body so a `name(` line
  inside one is not falsely parsed as a method.
- Wrap the cross-package readFileSync with a clear "expected monorepo
  layout" error instead of a bare ENOENT when run outside the monorepo.
- Narrow the guard's comments/error to state plainly it checks the
  method-NAME set only; signature parity remains the deferred staged-plan
  item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:54:04 +03:00
claude code agent 227
5b88e3dddf test(mcp): drift-guard HOST_CONTRACT_METHODS against DocmostClientLike both ways
The contract test only checked one direction (each name in
HOST_CONTRACT_METHODS exists on the real DocmostClient). But
HOST_CONTRACT_METHODS is itself a hand-copy of the server's
DocmostClientLike interface (docmost-client.loader.ts), and that
list<->interface link was untested: a method added to the interface +
consumed by the adapter but forgotten in the list (or removed from the
interface but left in the list) would escape both the server typecheck
(the pkg emits no .d.ts) and the existing test (name not in the list) ->
a runtime "x is not a function" in a tool call.

Parse the method names from the DocmostClientLike interface body (read
the .ts source via import.meta.url, scan member-signature lines) and
assert.deepEqual them against HOST_CONTRACT_METHODS BOTH ways. Lists are
currently identical (39=39), so this is a coverage hole closed, not a
live bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:36:22 +03:00
claude code agent 227
d0ca127d83 refactor(ai-chat): drift-guard the DocmostClientLike hand-mirror (#193)
Issue #193's tool-half has two open items. The shared, zod-agnostic tool-spec
registry (SHARED_TOOL_SPECS) for the identical tools is already merged
(f3fa15e7) and consumed by both layers, so that subset is done. The remaining
items are: (a) deriving the layer-3 hand-mirror `DocmostClientLike` from the
real client type, and (b) folding more tools into the registry. Both were
deferred as risky, and that deferral still holds (verified, see below) — so
this change ships the safest concrete increment instead of forcing the risk.

What this adds (behaviour-neutral, test-only + a doc comment):

- packages/mcp/test/unit/client-host-contract.test.mjs: pins the layer-3
  contract from the ESM side, where the real DocmostClient is importable. It
  asserts every method the in-app `DocmostClientLike` mirror declares exists as
  a function on a real DocmostClient instance (constructor is side-effect-free).
  A rename/removal in client.ts now fails this test instead of silently shipping
  a runtime "x is not a function" into an agent tool call. Negative-case
  verified (a bogus method name is detected).

- docmost-client.loader.ts: replaces the vague mirror comment with a pointer to
  the guard test and a concrete, empirically-grounded staged plan for the full
  type-derivation. Verified blockers kept it deferred: @docmost/mcp emits no
  .d.ts (no `declaration`, no `types` export) and the server has no path mapping
  for it, so there is no type to import today; and the real methods' inferred
  CONCRETE return types conflict with the in-app adapter's loose
  Record<string,unknown> + `as`-cast result handling (deriving the exact type
  breaks the build / forces pervasive double-casts and full-surface test stubs).

Out of scope (noted in the issue): the PM<->Markdown converter unification.

Verified: server tsc clean; mcp tsc clean; mcp tests 369 pass (367 + 2 new);
ai-chat tools specs 51 pass. No behaviour change; committed mcp build untouched
(no mcp src changed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:07:43 +03:00
22 changed files with 363 additions and 621 deletions

View File

@@ -12,11 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Editable captions for images.** Images gain an optional caption shown
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
losslessly through markdown as a `data-caption` attribute on the image, so
they survive export/import unchanged. (#221)
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),

View File

@@ -286,9 +286,6 @@
"Alt text": "Alt text",
"Describe this for accessibility.": "Describe this for accessibility.",
"Add a description": "Add a description",
"Caption": "Caption",
"Add a caption": "Add a caption",
"Shown below the image.": "Shown below the image.",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",

View File

@@ -1,7 +1,16 @@
import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import {
ActionIcon,
Button,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { IconAlt } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const ALT_MAX_LENGTH = 300;
@@ -18,25 +27,113 @@ type UseAltTextControlArgs = {
currentAlt: string;
};
// Thin wrapper over the shared image text-field popover; see
// useImageTextFieldControl. The t("...") literals stay here so they remain
// statically extractable for i18n.
export function useAltTextControl({
editor,
nodeName,
currentAlt,
}: UseAltTextControlArgs) {
const { t } = useTranslation();
return useImageTextFieldControl({
editor,
nodeName,
currentValue: currentAlt,
attrName: "alt",
sanitize: sanitizeAlt,
maxLength: ALT_MAX_LENGTH,
icon: <IconAlt size={18} />,
label: t("Alt text"),
description: t("Describe this for accessibility."),
placeholder: t("Add a description"),
});
const [showInput, setShowInput] = useState(false);
const [draft, setDraft] = useState("");
const open = useCallback(() => {
setDraft(currentAlt || "");
setShowInput(true);
}, [currentAlt]);
useEffect(() => {
const handler = () => {
if (!editor.isActive(nodeName)) {
setShowInput(false);
}
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor, nodeName]);
const cancel = useCallback(() => {
setShowInput(false);
}, []);
const save = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
.run();
setShowInput(false);
}, [editor, nodeName, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
},
[save, cancel],
);
const button = (
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
<ActionIcon
onClick={open}
size="lg"
aria-label={t("Alt text")}
variant="subtle"
>
<IconAlt size={18} />
</ActionIcon>
</Tooltip>
);
const panel = showInput ? (
<Paper
withBorder
shadow="md"
radius={6}
p="sm"
w={320}
style={{ position: "relative", zIndex: 100 }}
>
<Text size="sm" fw={600} mb={2}>
{t("Alt text")}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("Describe this for accessibility.")}
</Text>
<Textarea
size="xs"
placeholder={t("Add a description")}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
autoFocus
autosize
minRows={2}
maxRows={5}
maxLength={ALT_MAX_LENGTH}
/>
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{draft.length}/{ALT_MAX_LENGTH}
</Text>
<Group gap="xs">
<Button size="compact-xs" variant="default" onClick={cancel}>
{t("Cancel")}
</Button>
<Button size="compact-xs" onClick={save}>
{t("Save")}
</Button>
</Group>
</Group>
</Paper>
) : null;
return { button, panel, isEditing: showInput };
}

View File

@@ -1,59 +0,0 @@
import { describe, it, expect } from "vitest";
import { sanitizeCaption } from "@/features/editor/components/common/use-caption-control.tsx";
/**
* `sanitizeCaption` = collapse every whitespace run to a single space + trim +
* cap at 500 chars. Captions are plain visible text, so this is a softer
* normalization than alt-text sanitization.
*/
describe("sanitizeCaption", () => {
it("trims leading and trailing whitespace", () => {
expect(sanitizeCaption(" hello ")).toBe("hello");
});
it("collapses internal whitespace runs to a single space", () => {
expect(sanitizeCaption("a b c")).toBe("a b c");
});
it("treats tab, newline and CRLF as whitespace", () => {
expect(sanitizeCaption("a\tb")).toBe("a b");
expect(sanitizeCaption("a\nb")).toBe("a b");
expect(sanitizeCaption("a\r\nb")).toBe("a b");
expect(sanitizeCaption("line1\n\n\nline2")).toBe("line1 line2");
});
it("treats unicode whitespace (no-break space) as a separator", () => {
// U+00A0 NO-BREAK SPACE is matched by the \s class.
expect(sanitizeCaption("a b")).toBe("a b");
});
it("returns empty string for whitespace-only input", () => {
expect(sanitizeCaption(" ")).toBe("");
expect(sanitizeCaption("")).toBe("");
});
it("keeps a caption at the 500-char limit unchanged", () => {
const exact = "x".repeat(500);
expect(sanitizeCaption(exact)).toHaveLength(500);
expect(sanitizeCaption(exact)).toBe(exact);
});
it("slices a caption longer than 500 chars down to 500", () => {
const tooLong = "y".repeat(600);
const result = sanitizeCaption(tooLong);
expect(result).toHaveLength(500);
expect(result).toBe("y".repeat(500));
});
it("collapses whitespace before applying the 500-char cap", () => {
// 120 "a b " groups (600 raw chars) collapse to "a b a b ..." = 479 chars
// after trimming the trailing space, which stays under the 500 cap — so only
// the collapse is exercised here, no slice. (See the dedicated >500 test
// above for the slice boundary.)
const input = "a b ".repeat(120); // lots of double spaces
const result = sanitizeCaption(input);
expect(result).toHaveLength(479);
expect(result.length).toBeLessThanOrEqual(500);
expect(result).not.toMatch(/\s{2,}/);
});
});

View File

@@ -1,42 +0,0 @@
import { Editor } from "@tiptap/react";
import { IconTextCaption } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const CAPTION_MAX_LENGTH = 500;
// Caption is plain visible text (not a markdown link target like alt), so it is
// sanitized more softly than alt: collapse runs of whitespace/newlines into a
// single space and trim, keeping the limit generous.
export function sanitizeCaption(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
}
type UseCaptionControlArgs = {
editor: Editor;
nodeName: string;
currentCaption: string;
};
// Thin wrapper over the shared image text-field popover; see
// useImageTextFieldControl. The t("...") literals stay here so they remain
// statically extractable for i18n.
export function useCaptionControl({
editor,
nodeName,
currentCaption,
}: UseCaptionControlArgs) {
const { t } = useTranslation();
return useImageTextFieldControl({
editor,
nodeName,
currentValue: currentCaption,
attrName: "caption",
sanitize: sanitizeCaption,
maxLength: CAPTION_MAX_LENGTH,
icon: <IconTextCaption size={18} />,
label: t("Caption"),
description: t("Shown below the image."),
placeholder: t("Add a caption"),
});
}

View File

@@ -1,145 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import {
ActionIcon,
Button,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
// caption, ...). Each field is the same popover — an ActionIcon that opens a
// titled Paper with a counted Textarea and Cancel/Save — differing only in the
// node attribute it writes, its sanitizer, length cap, icon and labels. The
// label/description/placeholder are passed already translated so the literal
// t("...") calls stay in the thin wrappers and remain extractable; the shared
// Cancel/Save strings are translated here.
type UseImageTextFieldControlArgs = {
editor: Editor;
nodeName: string;
currentValue: string;
attrName: string;
sanitize: (value: string) => string;
maxLength: number;
icon: React.ReactNode;
label: string;
description: string;
placeholder: string;
};
export function useImageTextFieldControl({
editor,
nodeName,
currentValue,
attrName,
sanitize,
maxLength,
icon,
label,
description,
placeholder,
}: UseImageTextFieldControlArgs) {
const { t } = useTranslation();
const [showInput, setShowInput] = useState(false);
const [draft, setDraft] = useState("");
const open = useCallback(() => {
setDraft(currentValue || "");
setShowInput(true);
}, [currentValue]);
useEffect(() => {
const handler = () => {
if (!editor.isActive(nodeName)) {
setShowInput(false);
}
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor, nodeName]);
const cancel = useCallback(() => {
setShowInput(false);
}, []);
const save = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateAttributes(nodeName, { [attrName]: sanitize(draft) || undefined })
.run();
setShowInput(false);
}, [editor, nodeName, attrName, sanitize, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
},
[save, cancel],
);
const button = (
<Tooltip position="top" label={label} withinPortal={false}>
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
{icon}
</ActionIcon>
</Tooltip>
);
const panel = showInput ? (
<Paper
withBorder
shadow="md"
radius={6}
p="sm"
w={320}
style={{ position: "relative", zIndex: 100 }}
>
<Text size="sm" fw={600} mb={2}>
{label}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{description}
</Text>
<Textarea
size="xs"
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
autoFocus
autosize
minRows={2}
maxRows={5}
maxLength={maxLength}
/>
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{draft.length}/{maxLength}
</Text>
<Group gap="xs">
<Button size="compact-xs" variant="default" onClick={cancel}>
{t("Cancel")}
</Button>
<Button size="compact-xs" onClick={save}>
{t("Save")}
</Button>
</Group>
</Group>
</Paper>
) : null;
return { button, panel, isEditing: showInput };
}

View File

@@ -23,7 +23,6 @@ import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { useCaptionControl } from "@/features/editor/components/common/use-caption-control.tsx";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
@@ -48,7 +47,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
};
},
});
@@ -170,16 +168,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
currentAlt: editorState?.alt || "",
});
const {
button: captionButton,
panel: captionPanel,
isEditing: isEditingCaption,
} = useCaptionControl({
editor,
nodeName: "image",
currentCaption: editorState?.caption || "",
});
return (
<BaseBubbleMenu
editor={editor}
@@ -195,8 +183,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
>
{isEditingAlt ? (
altTextPanel
) : isEditingCaption ? (
captionPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
@@ -263,8 +249,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
{altTextButton}
{captionButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}>

View File

@@ -9,9 +9,7 @@ import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, alt, caption, aspectRatio, placeholder } =
node.attrs;
const captionText = (caption || "").trim();
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -31,7 +29,6 @@ export default function ImageView(props: NodeViewProps) {
return (
<NodeViewWrapper data-drag-handle>
<figure style={{ margin: 0 }}>
<div
className={clsx(
selected && "ProseMirror-selectednode",
@@ -69,15 +66,6 @@ export default function ImageView(props: NodeViewProps) {
</Group>
)}
</div>
{captionText && (
<Text
component="figcaption"
className="image-caption"
>
{captionText}
</Text>
)}
</figure>
</NodeViewWrapper>
);
}

View File

@@ -33,15 +33,6 @@
}
}
.image-caption {
text-align: center;
font-size: 0.875em;
color: var(--mantine-color-dimmed);
margin-top: 0.4em;
line-height: 1.35;
word-break: break-word;
}
.uploading-text {
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);

View File

@@ -5,6 +5,34 @@ import { pathToFileURL } from 'node:url';
* ESM-only `@docmost/mcp` package. We only need the constructor + the read/write
* methods used by the per-user tool adapter; the full client surface lives in
* `packages/mcp/src/client.ts`. Signatures here mirror that file exactly.
*
* DRIFT GUARD: the method NAMES below are runtime-checked against the real
* `DocmostClient` by `packages/mcp/test/unit/client-host-contract.test.mjs`
* (which can import the ESM class directly). If you rename/remove a method here
* or in client.ts, that test fails — so a stale mirror cannot silently ship a
* runtime "x is not a function" into an agent tool call. Keep the two in sync.
*
* STAGED PLAN — full derivation `DocmostClientLike = <real DocmostClient type>`
* (issue #193, layer 3) is intentionally NOT done; it stays a hand-mirror for
* now because of two verified blockers across the ESM(mcp)/CJS(server) boundary:
* 1. `@docmost/mcp` emits NO declaration files (its tsconfig has no
* `declaration`, package.json has no `types`/types-export) and the server
* tsconfig has no path mapping for it — the server only loads it via the
* runtime `import()` trick below, so there is no type to import today.
* 2. The real client methods have inferred, CONCRETE return types; the in-app
* tool adapter reads results through loose `Record<string,unknown>` returns
* + `as` casts (e.g. `(result?.data ?? {}) as { title?: string }`).
* Deriving the exact type would make those casts non-overlapping ("may be a
* mistake") and break the build, and `Partial<DocmostClientLike>` test stubs
* would have to satisfy the full concrete surface.
* To do it safely later (incrementally): (a) turn on `declaration: true` in
* packages/mcp/tsconfig.json + add a `types` export condition and commit the
* emitted `.d.ts`; (b) `import type { DocmostClient } from '@docmost/mcp'` here
* and replace this interface with a `Pick<DocmostClient, ...>` of the consumed
* methods; (c) audit every `as` cast in ai-chat-tools.service.ts against the now
* concrete return types (double-cast through `unknown` only where genuinely
* needed); (d) keep the runtime guard test as a belt-and-braces check. Until
* then the guard test above is the cheap, behaviour-neutral protection.
*/
export interface DocmostClientLike {
// --- read ---

View File

@@ -1,46 +0,0 @@
import { describe, it, expect } from "vitest";
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
import { markdownToHtml } from "../markdown/utils/marked.utils";
// Lossless markdown round-trip for image captions (issue #221). An image WITH a
// caption can't be expressed as `![alt](src)`, so it is emitted as a raw <img>
// (carrying data-caption) wrapped in a block <div>, the same trick the <video>
// rule uses. marked passes the raw HTML through, so markdownToHtml keeps the
// data-caption, and the image extension's parseHTML restores the attribute.
describe("image caption markdown round-trip", () => {
it("HTML -> Markdown emits a raw <img data-caption> for captioned images", () => {
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
const md = htmlToMarkdown(html);
expect(md).toContain("data-caption=\"A grey cat\"");
expect(md).toContain('src="/files/a.png"');
expect(md).toContain('alt="cat"');
// It must NOT degrade to the lossy ![]() form.
expect(md).not.toContain("![cat]");
});
it("Markdown -> HTML restores data-caption on the <img>", async () => {
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
const md = htmlToMarkdown(html);
const back = await markdownToHtml(md);
expect(back).toContain('data-caption="A grey cat"');
expect(back).toContain('src="/files/a.png"');
});
it("special characters in the caption survive the round-trip (escaped)", async () => {
const html = `<p><img src="/files/a.png" data-caption='Tom &amp; &quot;Jerry&quot;'></p>`;
const md = htmlToMarkdown(html);
const back = await markdownToHtml(md);
// parse5 keeps the entity-encoded form inside the attribute value.
expect(back).toContain("data-caption=");
expect(back).toContain("Jerry");
expect(back).toContain("Tom");
});
it("caption-less images stay a clean ![alt](src) with no raw HTML", () => {
const html = `<p><img src="/files/a.png" alt="cat"></p>`;
const md = htmlToMarkdown(html);
expect(md).toContain("![cat](/files/a.png)");
expect(md).not.toContain("data-caption");
expect(md).not.toContain("<img");
});
});

View File

@@ -1,16 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { applyAlignment, TiptapImage } from "./image";
// CONTRACT tests for the image node's `caption` attribute (issue #221). The
// caption is a plain-text string stored on the image atom and serialized as
// `data-caption` on the <img>. If this mapping drifts, captions saved to HTML
// (and thus to native storage / search / markdown) are silently lost.
const extensions = [Document, Paragraph, Text, TiptapImage];
import { applyAlignment } from "./image";
// applyAlignment is a pure DOM mutation: it sets the float / padding /
// justify-content / data-image-align on an image node-view container per the
@@ -76,56 +65,3 @@ describe("applyAlignment", () => {
expect(el.style.justifyContent).toBe("flex-start");
});
});
describe("image schema", () => {
it("registers the image node and keeps it an atom", () => {
const schema = getSchema(extensions);
expect(schema.nodes.image).toBeTruthy();
expect(schema.nodes.image.spec.atom).toBe(true);
});
});
describe("image caption parse/render round-trip", () => {
it("recovers caption from data-caption on parse (HTML -> JSON)", () => {
const html = `<img src="/files/a.png" alt="cat" data-caption="A grey cat">`;
const json = generateJSON(html, extensions);
const node = json.content?.[0];
expect(node?.type).toBe("image");
expect(node?.attrs?.caption).toBe("A grey cat");
expect(node?.attrs?.alt).toBe("cat");
});
it("emits data-caption on render when set (JSON -> HTML)", () => {
const json = {
type: "doc",
content: [
{
type: "image",
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
},
],
};
const html = generateHTML(json, extensions);
expect(html).toContain('data-caption="A grey cat"');
});
it("omits data-caption when there is no caption (caption-less images stay clean)", () => {
const json = {
type: "doc",
content: [{ type: "image", attrs: { src: "/files/a.png", alt: "cat" } }],
};
const html = generateHTML(json, extensions);
expect(html).not.toContain("data-caption");
});
it("full HTML -> JSON -> HTML round-trip preserves the caption", () => {
const html = `<img src="/files/a.png" alt="cat" data-caption="Caption with &amp; &quot;quotes&quot;">`;
const json = generateJSON(html, extensions);
expect(json.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
const out = generateHTML(json, extensions);
const back = generateJSON(out, extensions);
expect(back.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
});
});

View File

@@ -32,7 +32,6 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes {
src?: string;
alt?: string;
caption?: string;
align?: string;
attachmentId?: string;
size?: number;
@@ -126,13 +125,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
alt: attributes.alt,
}),
},
caption: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-caption") || undefined,
// Emit data-caption only when set, so caption-less images stay clean.
renderHTML: (attributes: ImageAttributes) =>
attributes.caption ? { "data-caption": attributes.caption } : {},
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
@@ -312,10 +304,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
el.alt = updatedNode.attrs.alt || "";
}
if (updatedNode.attrs.caption !== currentNode.attrs.caption) {
applyCaption(updatedNode.attrs.caption);
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
@@ -347,28 +335,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
const dom = nodeView.dom as HTMLElement;
// Re-parent the resizable wrapper into a <figure> so the caption sits BELOW
// the image, OUTSIDE nodeView.wrapper. onCommit measures the img's
// offsetHeight for the persisted height/aspectRatio, and the left/right
// resize handles span the wrapper — both must cover the image only. The
// <figure> stays the single flex child of the container, so applyAlignment
// and the float modes keep working. This path also drives read-only/share.
const figure = document.createElement("figure");
figure.style.margin = "0";
figure.style.display = "inline-block"; // shrink-to-fit to image width
figure.appendChild(nodeView.wrapper);
dom.appendChild(figure);
const figcaption = document.createElement("figcaption");
figcaption.className = "image-caption";
const applyCaption = (text?: string) => {
const value = (text || "").trim();
figcaption.textContent = value;
figcaption.style.display = value ? "block" : "none";
};
applyCaption(node.attrs.caption);
figure.appendChild(figcaption);
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");

View File

@@ -12,14 +12,6 @@ function sanitizeMdLinkText(value: string): string {
.replace(/[\r\n]+/g, ' ');
}
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
// data-caption in the raw-HTML image fallback). Only & and " are special in
// that context; escaping them is idempotent because parse5/marked decode them
// back on re-import.
function escapeHtmlAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
// Tags turndown treats as void (self-closing). Footnote references render as an
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
// without marking it void, turndown's blank-node removal drops it before our
@@ -266,17 +258,6 @@ function image(turndownService: _TurndownService) {
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
if (!src) return '';
const caption = node.getAttribute('data-caption') || '';
if (caption) {
// ![]() can't carry a caption, so emit a raw <img> wrapped in a block
// <div>. marked passes it through and the image extension's parseHTML
// restores the caption from data-caption.
const parts = [`src="${escapeHtmlAttr(src)}"`];
const alt = node.getAttribute('alt') || '';
if (alt) parts.push(`alt="${escapeHtmlAttr(alt)}"`);
parts.push(`data-caption="${escapeHtmlAttr(caption)}"`);
return `<div><img ${parts.join(' ')}></div>`;
}
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
const title = node.getAttribute('title') || '';
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';

View File

@@ -1070,24 +1070,7 @@ export const docmostExtensions = [
heading: {},
link: { openOnClick: false },
}),
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
// through this schema would drop the data-caption the client TiptapImage
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
// data-caption and re-renders it only when set (caption-less images stay
// clean), keeping the MCP markdown round-trip lossless.
Image.extend({
addAttributes() {
const parent = this.parent?.() ?? {};
return {
...parent,
caption: {
default: undefined,
parseHTML: (el) => el.getAttribute("data-caption") || undefined,
renderHTML: (attrs) => attrs.caption ? { "data-caption": attrs.caption } : {},
},
};
},
}).configure({ inline: false }),
Image.configure({ inline: false }),
TaskList,
TaskItem.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into

View File

@@ -207,27 +207,16 @@ export function convertProseMirrorToMarkdown(content) {
// 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": {
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);
// No "caption" attribute exists in the Docmost image schema, so we do
// not emit one (the previous caption branch was dead).
return `![${imgAlt}](${imgSrc})`;
}
case "video": {
// Emit the schema-matching <video> element so generateJSON rebuilds the
// node with its attrs intact. The schema's parseHTML reads src/aria-label
@@ -629,8 +618,6 @@ export function convertProseMirrorToMarkdown(content) {
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)

View File

@@ -1164,26 +1164,7 @@ export const docmostExtensions = [
heading: {},
link: { openOnClick: false },
}),
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
// through this schema would drop the data-caption the client TiptapImage
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
// data-caption and re-renders it only when set (caption-less images stay
// clean), keeping the MCP markdown round-trip lossless.
Image.extend({
addAttributes() {
const parent = this.parent?.() ?? {};
return {
...parent,
caption: {
default: undefined,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-caption") || undefined,
renderHTML: (attrs: Record<string, any>) =>
attrs.caption ? { "data-caption": attrs.caption } : {},
},
};
},
}).configure({ inline: false }),
Image.configure({ inline: false }),
TaskList,
TaskItem.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into

View File

@@ -228,26 +228,16 @@ export function convertProseMirrorToMarkdown(content: any): string {
// a bare "\n" would be reimported as a soft break and lost.
return " \n";
case "image": {
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);
// No "caption" attribute exists in the Docmost image schema, so we do
// not emit one (the previous caption branch was dead).
return `![${imgAlt}](${imgSrc})`;
}
case "video": {
// Emit the schema-matching <video> element so generateJSON rebuilds the
@@ -688,8 +678,6 @@ export function convertProseMirrorToMarkdown(content: 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)}"`);

View File

@@ -0,0 +1,212 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { DocmostClient } from "../../build/index.js";
// Drift guard for the THIRD hand-written layer of the AI tool set (issue #193,
// layer 3): the in-app server hand-mirrors the DocmostClient method signatures
// it consumes as the `DocmostClientLike` interface in
// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts ("Signatures here
// mirror that file exactly"). That mirror lives across the ESM(mcp)/CJS(server)
// boundary and the package ships NO .d.ts, so the server typecheck cannot verify
// the names against the real class — a rename/removal in client.ts would surface
// only as a runtime "x is not a function" inside an agent tool call.
//
// SCOPE: this guard checks the method-NAME set only, not signatures. It pins the
// contract from the mcp side (ESM, where the real class is directly importable):
// every method the embedding host depends on MUST exist as a function on a real
// DocmostClient instance. If you rename/remove a client method, this fails here
// AND you must update DocmostClientLike to match. It does NOT verify parameter or
// return-type parity — signature drift between the hand-mirror and client.ts can
// still ship silently; full signature/type parity is the deferred staged-plan
// item below.
//
// Keep the HOST_CONTRACT_METHODS NAME list aligned with the method NAMES declared
// in the server's DocmostClientLike interface (the in-app per-user tool adapter
// only — it is a SUBSET of the DocmostClient surface — covers only what the in-app adapter
// consumes; the standalone MCP transport (packages/mcp/src/index.ts) calls additional
// client methods (insertImage/replaceImage/deleteComment/updateComment/insertFootnote/
// uploadImage) that this guard does NOT track — the MCP transport's own typecheck covers those). Full type-derivation
// of DocmostClientLike from this class is deferred (see the staged plan in
// docmost-client.loader.ts): the package emits no declarations and the real
// (inferred, concrete) return types conflict with the host's loose
// `Record<string,unknown>` + `as`-cast result handling.
const HOST_CONTRACT_METHODS = [
// read
"search",
"getPage",
"getWorkspace",
"getSpaces",
"listPages",
"listSidebarPages",
"getOutline",
"getPageJson",
"getNode",
"getTable",
"listComments",
"getComment",
"checkNewComments",
"listShares",
"listPageHistory",
"getPageHistory",
"diffPageVersions",
"exportPageMarkdown",
// write (page)
"createPage",
"updatePage",
"renamePage",
"movePage",
"deletePage",
"editPageText",
"patchNode",
"insertNode",
"deleteNode",
"updatePageJson",
"tableInsertRow",
"tableDeleteRow",
"tableUpdateCell",
"copyPageContent",
"importPageMarkdown",
"sharePage",
"unsharePage",
"restorePageVersion",
"transformPage",
// write (comment)
"createComment",
"resolveComment",
];
test("DocmostClient implements every method the in-app DocmostClientLike mirror declares", () => {
// The constructor is side-effect-free (no network/login on construction): it
// only stores config and creates an axios instance, so it is safe to build a
// throwaway instance here with a dummy token provider.
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "test-token",
});
const missing = HOST_CONTRACT_METHODS.filter(
(name) => typeof client[name] !== "function",
);
assert.deepEqual(
missing,
[],
`DocmostClient is missing host-contract method(s): ${missing.join(", ")}. ` +
`Update packages/mcp/src/client.ts and/or the server's DocmostClientLike ` +
`interface (apps/server/src/core/ai-chat/tools/docmost-client.loader.ts) ` +
`so the hand-mirrored method NAMES stay aligned (this guards names only, ` +
`not signatures).`,
);
});
test("HOST_CONTRACT_METHODS has no duplicates", () => {
assert.equal(
new Set(HOST_CONTRACT_METHODS).size,
HOST_CONTRACT_METHODS.length,
);
});
// Parse the method names declared in the server's `DocmostClientLike` interface
// body. We read the .ts source as plain text (no TS compiler dep, and the file
// lives in the CJS server tree across the ESM boundary): scan from the
// `export interface DocmostClientLike {` line to its closing brace at column 0,
// matching member-signature lines like ` methodName(`. Nested param-object
// braces (`opts: { ... }`) are indented, so only the interface's own closing
// `}` (column 0) ends the scan.
function parseDocmostClientLikeMethods() {
const here = dirname(fileURLToPath(import.meta.url));
// packages/mcp/test/unit -> repo root is four levels up.
const loaderPath = resolve(
here,
"../../../../apps/server/src/core/ai-chat/tools/docmost-client.loader.ts",
);
let source;
try {
source = readFileSync(loaderPath, "utf8");
} catch (err) {
if (err && err.code === "ENOENT") {
throw new Error(
`Expected monorepo layout; server tree at ${loaderPath} not found. ` +
`This drift-guard reads the server's DocmostClientLike interface via a ` +
`fixed relative path and must run from inside the monorepo checkout.`,
);
}
throw err;
}
const lines = source.split(/\r?\n/);
const startIdx = lines.findIndex((l) =>
/^export interface DocmostClientLike\s*\{/.test(l),
);
assert.notEqual(
startIdx,
-1,
`Could not find "export interface DocmostClientLike {" in ${loaderPath}. ` +
`If the interface was renamed/moved, update this drift-guard test.`,
);
const methods = [];
let closed = false;
// Track whether we are inside a `/* ... */` block comment. Inner lines of a
// block comment need NOT start with `*`, so a `name(` line inside one would be
// falsely parsed as an interface method without this. (`//` line comments can
// never match the method regex below since they start with `/`.)
let inBlockComment = false;
for (let i = startIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (inBlockComment) {
// Stay in the block until we see its closing `*/`.
if (line.includes("*/")) inBlockComment = false;
continue;
}
// Enter a block comment only when it opens without closing on the same line;
// a self-contained `/* ... */` on one line cannot precede a method name we
// care about (such lines start with `/`, so the method regex won't match).
if (line.includes("/*") && !line.includes("*/")) {
inBlockComment = true;
continue;
}
if (/^\}/.test(line)) {
closed = true;
break;
}
// Method-name match: a TS identifier (letters/digits/`_`/`$`, not starting
// with a digit) optionally followed by a generic clause (`method<T>(`), then
// the opening paren of the signature.
const m = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\(/.exec(line);
if (m) methods.push(m[1]);
}
assert.ok(
closed,
`Did not find the closing brace of DocmostClientLike in ${loaderPath}.`,
);
assert.ok(
methods.length > 0,
`Parsed zero methods from DocmostClientLike in ${loaderPath} — the parser ` +
`is likely out of date with the interface formatting.`,
);
return methods;
}
// The point of the guard is to protect the DocmostClientLike mirror <-> client.ts
// link, but HOST_CONTRACT_METHODS is itself a HAND-COPY of that interface kept in
// sync manually. The list<->interface link must be tested too: a method consumed
// by the adapter and added to DocmostClientLike but forgotten here (or removed
// from the interface but left here) would otherwise escape both the server
// typecheck (pkg emits no .d.ts) and the first test above (name not in the list).
// Assert the two agree BOTH ways.
test("HOST_CONTRACT_METHODS exactly mirrors the server's DocmostClientLike interface", () => {
const interfaceMethods = parseDocmostClientLikeMethods();
assert.deepEqual(
[...HOST_CONTRACT_METHODS].sort(),
[...interfaceMethods].sort(),
`HOST_CONTRACT_METHODS has drifted from the DocmostClientLike interface in ` +
`apps/server/src/core/ai-chat/tools/docmost-client.loader.ts. Add/remove ` +
`method names in HOST_CONTRACT_METHODS so it lists EXACTLY the methods ` +
`declared in that interface (both directions are checked).`,
);
});

View File

@@ -149,37 +149,3 @@ 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.
test("image without a caption emits plain ![alt](src)", () => {
const input = doc({
type: "image",
attrs: { src: "/files/a.png", alt: "cat" },
});
assert.equal(convertProseMirrorToMarkdown(input), "![cat](/files/a.png)");
});
test("image with a caption emits a raw <img data-caption> in a block div", () => {
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>',
);
});
test("image caption escapes & and \" in the data-caption attribute", () => {
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>',
);
});

View File

@@ -142,31 +142,3 @@ test("round-trip: pdf node survives markdown export with src + name + attachment
assert.equal(found[0].attrs?.name, "x.pdf");
assert.equal(found[0].attrs?.attachmentId, "a4");
});
// The converter emits captioned images as a raw <img data-caption="...">; for
// the caption to survive the PM -> markdown -> PM round-trip the docmost-schema
// Image node must parse data-caption back into the `caption` attr. Without that
// (stock @tiptap/extension-image), the caption is silently lost — these guard
// the "lossless" claim.
test("round-trip: image caption survives markdown export (data-caption restored)", async () => {
const found = await roundtrip(
{ type: "image", attrs: { src: "/api/files/cat.png", alt: "cat", caption: "A grey cat" } },
"image",
);
assert.equal(found.length, 1, "image node should survive");
assert.equal(found[0].attrs?.src, "/api/files/cat.png");
assert.equal(found[0].attrs?.caption, "A grey cat", "caption must round-trip");
});
test("round-trip: image caption with special chars survives markdown export", async () => {
const found = await roundtrip(
{ type: "image", attrs: { src: "/api/files/cat.png", caption: 'Tom & "Jerry"' } },
"image",
);
assert.equal(found.length, 1, "image node should survive");
assert.equal(
found[0].attrs?.caption,
'Tom & "Jerry"',
"special-char caption must round-trip unescaped",
);
});

View File

@@ -82,24 +82,6 @@ test("round-trip: image inside a column survives as an image node (not literal m
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
});
test("round-trip: captioned image inside a column preserves its caption (imageToHtml branch)", async () => {
// A captioned image in a column is emitted via the imageToHtml helper (raw
// HTML container), a different path from the top-level image case. Special
// chars in the caption exercise attribute escaping on the way out and in.
const caption = 'Tom & "Jerry"';
const input = doc({
type: "columns",
content: [
{ type: "column", content: [{ type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic", caption } }] },
{ type: "column", content: [para(text("right"))] },
],
});
const out = await roundtrip(input);
const imgs = findNodes(out, "image");
assert.equal(imgs.length, 1, "captioned image inside a column must survive");
assert.equal(imgs[0].attrs?.caption, caption, "caption (incl. special chars) must be preserved");
});
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
const input = doc({
type: "columns",