Compare commits

..

11 Commits

Author SHA1 Message Date
claude code agent 227
57308bc3f3 docs(#221): fix CHANGELOG grammar after setImageCaption removal (F8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:07:41 +03:00
claude code agent 227
1ddb386214 docs(#221): CHANGELOG — drop removed setImageCaption command mention
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:46:49 +03:00
claude code agent 227
43af3dd5f1 test(mcp): cover captioned image inside a column round-trip (F5)
A captioned image in a column is emitted via the imageToHtml helper, a
separate path from the top-level image case whose data-caption branch was
untested. Add a round-trip test with special chars (Tom & "Jerry") that
fails if the imageToHtml caption branch breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:43:18 +03:00
claude code agent 227
b02101b58a docs(mcp): correct captioned-image import comment (F6)
The comment referenced markdownToHtml, which does not exist in the mcp
package; the import path is marked.parse + generateJSON (which runs the
image extension's parseHTML). Describe the actual step and regenerate the
build artifact in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:43:13 +03:00
claude code agent 227
932bfce1d9 refactor(editor-ext): remove unused setImageCaption command (F7)
The setImageCaption command and its Commands<> declaration were dead:
captions are written via the generic updateAttributes in
useImageTextFieldControl, and a repo-wide grep finds zero callers.
Remove the speculative implementation (image.ts) and its type
declaration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:43:08 +03:00
claude code agent 227
d39b7ae67c refactor(editor): dedupe alt/caption controls via shared hook (F4)
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:48 +03:00
claude code agent 227
c124fb1f2c test(editor): fix wrong sanitizeCaption collapse-cap comment (F3)
The comment claimed 250 groups -> 499 chars -> slice past 500; the
input is 120 "a  b " groups collapsing to 479 chars, under the cap
with no slice. Correct the comment and assert the 479 length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:41 +03:00
claude code agent 227
d3ebae48cf test(mcp): cover image caption markdown round-trip (F2)
Add PM -> markdown -> PM round-trip assertions for image caption
(plain and special-char), which fail without F1 and pass with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:36 +03:00
claude code agent 227
607aed5997 fix(mcp): restore image caption on markdown round-trip (F1)
Stock @tiptap/extension-image carries no caption attribute, so
markdownToProseMirror through docmostExtensions dropped the
data-caption the client emits, breaking the lossless claim. Extend the
Image node (mirroring editor-ext image.ts and the nearby Highlight
extend) to parse/render data-caption. Rebuilt build/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:28 +03:00
a
dc14a9a540 chore(editor): address image-caption review (#221)
- docs: add CHANGELOG Unreleased/Added entry for editable image captions
- test: export sanitizeCaption and add vitest unit coverage
  (whitespace collapse, trim, 500-char boundary)
- refactor: drop duplicate .imageCaption CSS module class, keep the
  global .image-caption as the single source
- docs: fix turndown image-caption comment (video rule emits a markdown
  link, not a <div>)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:36:30 +03:00
claude code agent 227
2aa482f62d feat(editor): add editable image captions (#221)
Add a visible caption (<figcaption>) under images, editable from the
image bubble-menu and persisted across all formats: native Yjs/JSON,
HTML export, and Markdown.

- image node: new plain-text `caption` attribute (parse/render
  `data-caption` on <img>, emitted only when set) + `setImageCaption`
  command. The node stays an atom; the schema shape is unchanged, so the
  server's generateHTML/generateJSON path round-trips it for free.
- resize node-view: re-parent the resizable wrapper into a <figure> and
  render the caption in a <figcaption> BELOW it, outside nodeView.wrapper
  (so onCommit's offsetHeight measurement and the left/right resize
  handles still cover the image only). This path also drives read-only /
  share rendering. React placeholder view renders the caption too.
- bubble-menu: new useCaptionControl panel modeled on useAltTextControl
  (own icon, Caption strings, softer sanitizer, ~500 char limit).
- markdown lossless round-trip: a captioned image is emitted as a raw
  <img data-caption> wrapped in a block <div> (same trick as <video>) in
  both the editor-ext turndown rule and the MCP converter; caption-less
  images stay clean ![alt](src). Import restores the caption via the
  shared markdownToHtml + parseHTML.
- styles + i18n keys; tests for the schema attr round-trip, markdown
  round-trip (editor-ext) and the MCP converter.

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

View File

@@ -12,6 +12,11 @@ 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,6 +286,9 @@
"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,16 +1,7 @@
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;
@@ -27,113 +18,25 @@ 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();
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 };
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"),
});
}

View File

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,145 @@
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,6 +23,7 @@ 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) {
@@ -47,6 +48,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
};
},
});
@@ -168,6 +170,16 @@ 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}
@@ -183,6 +195,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
>
{isEditingAlt ? (
altTextPanel
) : isEditingCaption ? (
captionPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
@@ -249,6 +263,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
{altTextButton}
{captionButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}>

View File

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

View File

@@ -33,6 +33,15 @@
}
}
.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,34 +5,6 @@ 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

@@ -0,0 +1,46 @@
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,5 +1,16 @@
import { describe, it, expect, beforeEach } from "vitest";
import { applyAlignment } from "./image";
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];
// applyAlignment is a pure DOM mutation: it sets the float / padding /
// justify-content / data-image-align on an image node-view container per the
@@ -65,3 +76,56 @@ 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,6 +32,7 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes {
src?: string;
alt?: string;
caption?: string;
align?: string;
attachmentId?: string;
size?: number;
@@ -125,6 +126,13 @@ 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"),
@@ -304,6 +312,10 @@ 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) {
@@ -335,6 +347,28 @@ 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,6 +12,14 @@ 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
@@ -258,6 +266,17 @@ 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,7 +1070,24 @@ export const docmostExtensions = [
heading: {},
link: { openOnClick: false },
}),
Image.configure({ inline: 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 }),
TaskList,
TaskItem.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into

View File

@@ -207,16 +207,27 @@ 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
@@ -618,6 +629,8 @@ 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,7 +1164,26 @@ export const docmostExtensions = [
heading: {},
link: { openOnClick: false },
}),
Image.configure({ inline: 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 }),
TaskList,
TaskItem.configure({ nested: true }),
// Highlight stores its color unescaped and Docmost interpolates it into

View File

@@ -228,16 +228,26 @@ 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
@@ -678,6 +688,8 @@ 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

@@ -1,212 +0,0 @@
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,3 +149,37 @@ 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,3 +142,31 @@ 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,6 +82,24 @@ 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",