Files
gitmost/packages/prosemirror-markdown/test/markdown-roundtrip-spoiler-caption.test.ts
T
claude code agent 227 d7d8db2102 feat(prosemirror-markdown): images as ![alt](src) + attached img-comment (#293 canon #4)
Every image now serializes as `![alt](src)`; non-default layout/identity attrs
that markdown cannot express ride along in an attached `<!--img {…}-->` comment
on the same line, replacing the prior "image-with-attrs -> raw <img>" split for
the top-level path:
  ![схема](/s.png) <!--img {"width":"420","align":"left","attachmentId":"…"}-->

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:16:28 +03:00

132 lines
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
} from "docmost-client";
// Round-trip coverage for the two editor features git-sync's converter
// predated and must now preserve losslessly:
// - the `spoiler` inline mark (issue #259), emitted as raw inline HTML
// `<span data-spoiler="true">…</span>` (Markdown has no native syntax);
// - the image `caption` attribute (issue #221). A top-level image now emits
// it in an attached `<!--img {…}-->` comment (#293 canon #4); a raw <img>
// with `data-caption` in incoming Markdown still parses it back too.
// We exercise the real export -> import -> export cycle: a PM doc must survive
// PM -> MD -> PM unchanged, and the raw-HTML forms in incoming Markdown must
// parse back to the mark/attribute.
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
const text = (t: string, marks?: any[]) =>
marks ? { type: "text", text: t, marks } : { type: "text", text: t };
const para = (...inline: any[]) => ({ type: "paragraph", content: inline });
// Count text nodes carrying a `spoiler` mark anywhere in a PM JSON doc.
function countSpoilerMarks(node: any): number {
let count = 0;
const walk = (n: any) => {
if (!n || typeof n !== "object") return;
if (Array.isArray(n.marks)) {
for (const mark of n.marks) if (mark?.type === "spoiler") count++;
}
if (Array.isArray(n.content)) n.content.forEach(walk);
};
walk(node);
return count;
}
// Find the first image node anywhere in a PM JSON doc.
function findImage(node: any): any | null {
if (!node || typeof node !== "object") return null;
if (node.type === "image") return node;
if (Array.isArray(node.content)) {
for (const child of node.content) {
const hit = findImage(child);
if (hit) return hit;
}
}
return null;
}
describe("spoiler mark round-trip (#259)", () => {
it("survives export -> import -> export unchanged", async () => {
const source = doc(
para(
text("before "),
text("hidden", [{ type: "spoiler" }]),
text(" after"),
),
);
const md1 = convertProseMirrorToMarkdown(source);
// Lossless raw inline HTML form.
expect(md1).toContain('<span data-spoiler="true">hidden</span>');
const doc2 = await markdownToProseMirror(md1);
// The spoiler mark was recovered on import.
expect(countSpoilerMarks(doc2)).toBe(1);
expect(JSON.stringify(doc2)).toContain("hidden");
// Byte-stable: a second export reproduces the first exactly.
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1);
});
it("keeps the spoiler intact when it intersects a bold mark", async () => {
const source = doc(
para(text("secret", [{ type: "bold" }, { type: "spoiler" }])),
);
const md1 = convertProseMirrorToMarkdown(source);
expect(md1).toContain('data-spoiler="true"');
const doc2 = await markdownToProseMirror(md1);
expect(countSpoilerMarks(doc2)).toBe(1);
// Bold survives alongside the spoiler.
expect(JSON.stringify(doc2)).toContain('"bold"');
});
it("parses a raw <span data-spoiler> in incoming Markdown back to the mark", async () => {
const incoming = 'before <span data-spoiler="true">hidden</span> after';
const parsed = await markdownToProseMirror(incoming);
expect(countSpoilerMarks(parsed)).toBe(1);
});
});
describe("image caption round-trip (#221)", () => {
it("survives export -> import -> export with the caption preserved", async () => {
const source = doc({
type: "image",
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
});
const md1 = convertProseMirrorToMarkdown(source);
// #293 canon #4: a top-level captioned image now serializes as the clean
// `![alt](src)` plus an attached `<!--img {…}-->` comment carrying caption.
expect(md1).toBe('![cat](/files/a.png) <!--img {"caption":"A grey cat"}-->');
const doc2 = await markdownToProseMirror(md1);
const img = findImage(doc2);
expect(img).toBeTruthy();
expect(img.attrs?.caption).toBe("A grey cat");
// Byte-stable: a second export reproduces the first exactly.
const md2 = convertProseMirrorToMarkdown(doc2);
expect(md2).toBe(md1);
});
it("parses a raw <img data-caption> in incoming Markdown back to the caption", async () => {
const incoming = '<img src="/files/a.png" alt="cat" data-caption="A grey cat">';
const parsed = await markdownToProseMirror(incoming);
const img = findImage(parsed);
expect(img).toBeTruthy();
expect(img.attrs?.caption).toBe("A grey cat");
});
it("leaves a caption-less image on the lighter markdown form", () => {
const md = convertProseMirrorToMarkdown(
doc({ type: "image", attrs: { src: "/files/a.png", alt: "cat" } }),
);
expect(md).toBe("![cat](/files/a.png)");
});
});