diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8dfa172..abd1f25c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
are RAM-only, bound to the instance that created them. Tunable via five
`SANDBOX_*` env vars (see `.env.example`). (#243)
+- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
+ can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
+ with the `||text||` input rule; the rendered span blurs until clicked to reveal.
+ The mark is preserved losslessly through Markdown export/import (as a raw
+ `…`) and on public shares. (#259)
### Changed
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 45234831..b47da764 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -352,6 +352,7 @@
"Underline": "Underline",
"Strike": "Strike",
"Code": "Code",
+ "Spoiler": "Spoiler",
"Comment": "Comment",
"Text": "Text",
"Heading 1": "Heading 1",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index efdf28ce..88629662 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -351,6 +351,7 @@
"Underline": "Подчёркнутый",
"Strike": "Перечёркнутый",
"Code": "Код",
+ "Spoiler": "Спойлер",
"Comment": "Комментарий",
"Text": "Текст",
"Heading 1": "Заголовок 1",
diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
index 5c590487..651cb2f6 100644
--- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
@@ -9,6 +9,7 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
+ IconEyeOff,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
@@ -74,6 +75,7 @@ export const EditorBubbleMenu: FC = (props) => {
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
+ isSpoiler: ctx.editor.isActive("spoiler"),
};
},
});
@@ -109,6 +111,12 @@ export const EditorBubbleMenu: FC = (props) => {
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
+ {
+ name: "Spoiler",
+ isActive: () => editorState?.isSpoiler,
+ command: () => props.editor.chain().focus().toggleSpoiler().run(),
+ icon: IconEyeOff,
+ },
];
const commentItem: BubbleMenuItem = {
diff --git a/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx b/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx
new file mode 100644
index 00000000..e92eefce
--- /dev/null
+++ b/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx
@@ -0,0 +1,20 @@
+import { MarkViewContent, MarkViewProps } from "@tiptap/react";
+import { useState } from "react";
+
+// Click-to-reveal spoiler. The revealed state is UI-only and is never written to
+// the document: toggling only adds/removes the `is-revealed` class (CSS removes
+// the blur). renderHTML never emits `is-revealed`, so it can't leak into the
+// doc/clipboard. Works the same in editor, read-only and public-share views.
+export default function SpoilerView(_props: MarkViewProps) {
+ const [revealed, setRevealed] = useState(false);
+
+ return (
+ setRevealed((v) => !v)}
+ >
+
+
+ );
+}
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 63855097..4c375cf6 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -53,6 +53,7 @@ import {
Subpages,
Heading,
Highlight,
+ Spoiler,
Indent,
UniqueID,
SharedStorage,
@@ -116,6 +117,7 @@ import mentionRenderItems from "@/features/editor/components/mention/mention-sug
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
+import SpoilerView from "@/features/editor/components/spoiler/spoiler-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
@@ -237,6 +239,11 @@ export const mainExtensions = [
Highlight.configure({
multicolor: true,
}),
+ Spoiler.configure({}).extend({
+ addMarkView() {
+ return ReactMarkViewRenderer(SpoilerView);
+ },
+ }),
Typography,
TrailingNode,
GlobalDragHandle.configure({
diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css
index 52d9268e..030c0180 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -14,6 +14,7 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
+@import "./spoiler.css";
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
diff --git a/apps/client/src/features/editor/styles/spoiler.css b/apps/client/src/features/editor/styles/spoiler.css
new file mode 100644
index 00000000..90c5f5c8
--- /dev/null
+++ b/apps/client/src/features/editor/styles/spoiler.css
@@ -0,0 +1,21 @@
+.spoiler {
+ background: rgba(0, 0, 0, 0.85);
+ border-radius: 0.25em;
+ cursor: pointer;
+ filter: blur(0.3em);
+ transition: filter 0.15s ease;
+ user-select: none;
+}
+
+.spoiler.is-revealed {
+ filter: none;
+ background: rgba(125, 125, 125, 0.18);
+ user-select: auto;
+}
+
+@media print {
+ .spoiler {
+ filter: none;
+ background: rgba(125, 125, 125, 0.18);
+ }
+}
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index a894aaea..7970051b 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -36,6 +36,7 @@ import {
Mention,
Subpages,
Highlight,
+ Spoiler,
Indent,
UniqueID,
Columns,
@@ -82,6 +83,7 @@ export const tiptapExtensions = [
Superscript,
SubScript,
Highlight,
+ Spoiler,
Typography,
TrailingNode,
TextStyle,
diff --git a/apps/server/src/core/share/share-spoiler-keep.spec.ts b/apps/server/src/core/share/share-spoiler-keep.spec.ts
new file mode 100644
index 00000000..bcc68b13
--- /dev/null
+++ b/apps/server/src/core/share/share-spoiler-keep.spec.ts
@@ -0,0 +1,129 @@
+import { ShareService } from './share.service';
+
+// Sibling of share-comment-strip.spec.ts. The public-share sanitizer strips ONLY
+// `comment` marks (internal-team metadata) via removeMarkTypeFromDoc(doc,
+// 'comment'). The `spoiler` mark is legitimate authored content (hidden text the
+// reader clicks to reveal) and MUST survive the share-strip — otherwise public
+// readers would see the secret in plain text or lose it entirely.
+//
+// We drive the SAME real seam the comment-strip test uses:
+// updatePublicAttachments -> prepareContentForShare -> removeMarkTypeFromDoc.
+
+const WS = 'ws-1';
+const PAGE = 'page-1';
+
+function buildService() {
+ const shareRepo = { findById: jest.fn() };
+ const pageRepo = { findById: jest.fn() };
+ const pagePermissionRepo = {
+ hasRestrictedAncestor: jest.fn(async () => false),
+ };
+ const tokenService = {
+ generateAttachmentToken: jest.fn(async () => 'tok'),
+ };
+ const workspaceRepo = {
+ findById: jest.fn(async () => ({ id: WS, settings: { htmlEmbed: true } })),
+ };
+
+ return new ShareService(
+ shareRepo as any,
+ pageRepo as any,
+ pagePermissionRepo as any,
+ {} as any, // db (unused on this path)
+ tokenService as any,
+ {} as any, // transclusionService (unused)
+ workspaceRepo as any,
+ );
+}
+
+// Text carrying a `spoiler` mark (no attributes; revealed state is UI-only).
+function spoilerText(text: string) {
+ return {
+ type: 'text',
+ text,
+ marks: [{ type: 'spoiler' }],
+ };
+}
+
+// Text carrying a `comment` mark with an id (the thing that DOES get stripped).
+function commentedText(text: string, commentId: string) {
+ return {
+ type: 'text',
+ text,
+ marks: [{ type: 'comment', attrs: { commentId, resolved: false } }],
+ };
+}
+
+async function sanitize(content: any) {
+ const service = buildService();
+ return service.updatePublicAttachments({
+ id: PAGE,
+ workspaceId: WS,
+ content,
+ } as any);
+}
+
+function countMarks(doc: any, type: string): number {
+ let count = 0;
+ const walk = (node: any) => {
+ if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node.marks)) {
+ for (const mark of node.marks) {
+ if (mark?.type === type) count++;
+ }
+ }
+ if (Array.isArray(node.content)) node.content.forEach(walk);
+ };
+ walk(doc);
+ return count;
+}
+
+describe('ShareService keeps spoiler marks on public shares (real code)', () => {
+ it('does NOT strip a spoiler mark', async () => {
+ const content = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'visible ' }, spoilerText('hidden')],
+ },
+ ],
+ };
+
+ expect(countMarks(content, 'spoiler')).toBe(1);
+
+ const out = await sanitize(content);
+
+ // The spoiler mark survives the share-strip.
+ expect(countMarks(out, 'spoiler')).toBe(1);
+ expect(JSON.stringify(out)).toContain('hidden');
+ });
+
+ it('strips comment marks but keeps spoiler marks in the same doc', async () => {
+ const content = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ commentedText('reviewed', 'cmt-1'),
+ { type: 'text', text: ' and ' },
+ spoilerText('secret'),
+ ],
+ },
+ ],
+ };
+
+ expect(countMarks(content, 'comment')).toBe(1);
+ expect(countMarks(content, 'spoiler')).toBe(1);
+
+ const out = await sanitize(content);
+
+ // comment is removed, spoiler is preserved.
+ expect(countMarks(out, 'comment')).toBe(0);
+ expect(countMarks(out, 'spoiler')).toBe(1);
+ const serialized = JSON.stringify(out);
+ expect(serialized).not.toContain('cmt-1');
+ expect(serialized).toContain('secret');
+ });
+});
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index 08888ddf..a2f1d0eb 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -25,6 +25,7 @@ export * from "./lib/subpages";
export * from "./lib/transclusion";
export * from "./lib/page-embed";
export * from "./lib/highlight";
+export * from "./lib/spoiler/spoiler";
export * from "./lib/indent";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
diff --git a/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts b/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts
new file mode 100644
index 00000000..32b77de9
--- /dev/null
+++ b/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } 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 { Bold } from "@tiptap/extension-bold";
+import { htmlToMarkdown } from "./turndown.utils";
+import { markdownToHtml } from "./marked.utils";
+import { Spoiler } from "../../spoiler/spoiler";
+
+// The spoiler mark has no native Markdown syntax, so it is preserved losslessly
+// as raw inline HTML (`…`), the same approach
+// htmlEmbed uses. This test drives the full editor round-trip:
+// JSON -> HTML -> Markdown -> HTML -> JSON
+// and asserts the `spoiler` mark survives end to end. We use the same
+// getSchema + @tiptap/html generateHTML/generateJSON utilities the other
+// editor-ext schema tests use.
+
+const extensions = [Document, Paragraph, Text, Bold, Spoiler];
+
+function html(md: string): string {
+ const out = markdownToHtml(md);
+ if (typeof out !== "string") throw new Error("expected sync string output");
+ return out;
+}
+
+// Count text nodes carrying a `spoiler` mark anywhere in a ProseMirror JSON doc.
+function countSpoilerMarks(doc: any): number {
+ let count = 0;
+ const walk = (node: any) => {
+ if (!node || typeof node !== "object") return;
+ if (Array.isArray(node.marks)) {
+ for (const mark of node.marks) {
+ if (mark?.type === "spoiler") count++;
+ }
+ }
+ if (Array.isArray(node.content)) node.content.forEach(walk);
+ };
+ walk(doc);
+ return count;
+}
+
+describe("Spoiler mark schema", () => {
+ it("registers the spoiler mark in the schema", () => {
+ const schema = getSchema(extensions);
+ expect(schema.marks.spoiler).toBeTruthy();
+ });
+
+ it("recovers the spoiler mark from span[data-spoiler] (HTML -> JSON)", () => {
+ const json = generateJSON(
+ 'before hidden after
',
+ extensions,
+ );
+ expect(countSpoilerMarks(json)).toBe(1);
+ });
+
+ it("emits data-spoiler + class on render (JSON -> HTML)", () => {
+ const doc = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "hidden",
+ marks: [{ type: "spoiler" }],
+ },
+ ],
+ },
+ ],
+ };
+ const out = generateHTML(doc, extensions);
+ expect(out).toContain('data-spoiler="true"');
+ expect(out).toContain('class="spoiler"');
+ });
+});
+
+describe("Spoiler Markdown round-trip is lossless", () => {
+ const docWith = (textNode: any) => ({
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "before " }, textNode, { type: "text", text: " after" }],
+ },
+ ],
+ });
+
+ it("preserves the spoiler mark through JSON -> MD -> HTML -> JSON", () => {
+ const startDoc = docWith({
+ type: "text",
+ text: "hidden",
+ marks: [{ type: "spoiler" }],
+ });
+
+ // JSON -> HTML
+ const html1 = generateHTML(startDoc, extensions);
+ expect(html1).toContain('data-spoiler="true"');
+
+ // HTML -> Markdown (raw inline HTML, lossless)
+ const md = htmlToMarkdown(html1);
+ expect(md).toContain('hidden');
+
+ // MD -> HTML -> JSON (mark restored via parseHTML)
+ const endJson = generateJSON(html(md), extensions);
+ expect(countSpoilerMarks(endJson)).toBe(1);
+ // The visible text survives.
+ expect(JSON.stringify(endJson)).toContain("hidden");
+ });
+
+ it("keeps the spoiler intact when it intersects a bold mark", () => {
+ const startDoc = docWith({
+ type: "text",
+ text: "secret",
+ marks: [{ type: "bold" }, { type: "spoiler" }],
+ });
+
+ const md = htmlToMarkdown(generateHTML(startDoc, extensions));
+ expect(md).toContain("data-spoiler=\"true\"");
+
+ const endJson = generateJSON(html(md), extensions);
+ expect(countSpoilerMarks(endJson)).toBe(1);
+ // Bold survives alongside the spoiler.
+ expect(JSON.stringify(endJson)).toContain('"bold"');
+ });
+});
diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
index 172786a3..36062b49 100644
--- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
@@ -65,6 +65,7 @@ export function htmlToMarkdown(html: string): string {
mathBlock,
iframeEmbed,
htmlEmbed,
+ spoiler,
image,
video,
footnoteReference,
@@ -101,6 +102,29 @@ function htmlEmbed(turndownService: _TurndownService) {
});
}
+/**
+ * Serialize the `spoiler` inline mark to lossless raw inline HTML.
+ *
+ * Markdown has no native spoiler syntax, so we emit the same `…` the mark renders. `marked` passes inline raw HTML
+ * through untouched, and `generateJSON` restores the mark via its parseHTML, so
+ * the round-trip MD -> HTML -> JSON keeps the spoiler intact. The UI-only
+ * `is-revealed` state is never serialized.
+ */
+function spoiler(turndownService: _TurndownService) {
+ turndownService.addRule('spoiler', {
+ filter: function (node: HTMLInputElement) {
+ return (
+ node.nodeName === 'SPAN' &&
+ node.getAttribute('data-spoiler') === 'true'
+ );
+ },
+ replacement: function (content: string) {
+ return `${content}`;
+ },
+ });
+}
+
function listParagraph(turndownService: _TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
diff --git a/packages/editor-ext/src/lib/spoiler/spoiler.ts b/packages/editor-ext/src/lib/spoiler/spoiler.ts
new file mode 100644
index 00000000..22a06f95
--- /dev/null
+++ b/packages/editor-ext/src/lib/spoiler/spoiler.ts
@@ -0,0 +1,74 @@
+import { Mark, markInputRule, mergeAttributes } from "@tiptap/core";
+
+export interface SpoilerOptions {
+ HTMLAttributes: Record;
+}
+
+declare module "@tiptap/core" {
+ interface Commands {
+ spoiler: {
+ setSpoiler: () => ReturnType;
+ toggleSpoiler: () => ReturnType;
+ unsetSpoiler: () => ReturnType;
+ };
+ }
+}
+
+// Discord-style `||text||` input rule. Requires a non-space right after the
+// opening `||` and a non-space right before the closing `||` so empty/padded
+// markers don't match.
+const inputRegex = /(?:^|\s)(\|\|(?!\s)([^|]+)(?({
+ name: "spoiler",
+
+ // Don't bleed onto text typed at the boundary (mirrors link).
+ inclusive: false,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: "span[data-spoiler]" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "span",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ "data-spoiler": "true",
+ class: "spoiler",
+ }),
+ 0,
+ ];
+ },
+
+ addCommands() {
+ return {
+ setSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.setMark(this.name),
+ toggleSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.toggleMark(this.name),
+ unsetSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.unsetMark(this.name),
+ };
+ },
+
+ addInputRules() {
+ return [markInputRule({ find: inputRegex, type: this.type })];
+ },
+
+ // No addKeyboardShortcuts: the issue's proposed `Mod-Shift-s` is already taken
+ // by the built-in Strike mark (and `Mod-Shift-h` by Highlight). The `||text||`
+ // input rule plus the bubble-menu button cover ergonomics, so we omit a hotkey
+ // rather than collide with an existing one.
+});
diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js
index 6b6c221d..579a4304 100644
--- a/packages/mcp/build/lib/docmost-schema.js
+++ b/packages/mcp/build/lib/docmost-schema.js
@@ -271,6 +271,25 @@ const TextStyle = Mark.create({
return ["span", HTMLAttributes, 0];
},
});
+/**
+ * Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
+ * document carrying a spoiler survives the MCP read -> transform -> write path
+ * (and markdown export) instead of silently dropping the unrecognized mark.
+ * packages/mcp does NOT depend on editor-ext, so the definition is kept local;
+ * it parses span[data-spoiler] and renders the same span[data-spoiler][class]
+ * the editor-ext mark emits.
+ */
+const Spoiler = Mark.create({
+ name: "spoiler",
+ // Don't bleed onto text typed at the boundary (mirrors editor-ext).
+ inclusive: false,
+ parseHTML() {
+ return [{ tag: "span[data-spoiler]" }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
+ },
+});
/**
* Passthrough definitions for the remaining Docmost-specific nodes.
*
@@ -1097,6 +1116,7 @@ export const docmostExtensions = [
// generateJSON drops , defeating the color import.
TextStyle,
Comment,
+ Spoiler,
Callout,
Table,
TableRow,
diff --git a/packages/mcp/build/lib/markdown-converter.js b/packages/mcp/build/lib/markdown-converter.js
index d5d47400..625650f3 100644
--- a/packages/mcp/build/lib/markdown-converter.js
+++ b/packages/mcp/build/lib/markdown-converter.js
@@ -160,6 +160,12 @@ export function convertProseMirrorToMarkdown(content) {
}
break;
}
+ case "spoiler":
+ // Markdown has no native spoiler syntax, so emit the same
+ // lossless raw HTML the editor-ext turndown rule produces; the
+ // schema's Spoiler mark parses span[data-spoiler] back on import.
+ textContent = `${textContent}`;
+ break;
}
}
}
diff --git a/packages/mcp/src/lib/docmost-schema.ts b/packages/mcp/src/lib/docmost-schema.ts
index 546b9844..af79a181 100644
--- a/packages/mcp/src/lib/docmost-schema.ts
+++ b/packages/mcp/src/lib/docmost-schema.ts
@@ -298,6 +298,26 @@ const TextStyle = Mark.create({
},
});
+/**
+ * Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
+ * document carrying a spoiler survives the MCP read -> transform -> write path
+ * (and markdown export) instead of silently dropping the unrecognized mark.
+ * packages/mcp does NOT depend on editor-ext, so the definition is kept local;
+ * it parses span[data-spoiler] and renders the same span[data-spoiler][class]
+ * the editor-ext mark emits.
+ */
+const Spoiler = Mark.create({
+ name: "spoiler",
+ // Don't bleed onto text typed at the boundary (mirrors editor-ext).
+ inclusive: false,
+ parseHTML() {
+ return [{ tag: "span[data-spoiler]" }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
+ },
+});
+
/**
* Passthrough definitions for the remaining Docmost-specific nodes.
*
@@ -1194,6 +1214,7 @@ export const docmostExtensions = [
// generateJSON drops , defeating the color import.
TextStyle,
Comment,
+ Spoiler,
Callout,
Table,
TableRow,
diff --git a/packages/mcp/src/lib/markdown-converter.ts b/packages/mcp/src/lib/markdown-converter.ts
index 4e35c995..36b4443d 100644
--- a/packages/mcp/src/lib/markdown-converter.ts
+++ b/packages/mcp/src/lib/markdown-converter.ts
@@ -167,6 +167,12 @@ export function convertProseMirrorToMarkdown(content: any): string {
}
break;
}
+ case "spoiler":
+ // Markdown has no native spoiler syntax, so emit the same
+ // lossless raw HTML the editor-ext turndown rule produces; the
+ // schema's Spoiler mark parses span[data-spoiler] back on import.
+ textContent = `${textContent}`;
+ break;
}
}
}
diff --git a/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs b/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs
index c80fbd53..798bac10 100644
--- a/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs
+++ b/packages/mcp/test/unit/docmost-md-roundtrip.test.mjs
@@ -167,6 +167,38 @@ test("export emits comment anchors and they round-trip back to a comment mark",
});
});
+test("export emits a spoiler span and it round-trips back to a spoiler mark", () => {
+ // A small ProseMirror doc with a text run carrying a `spoiler` mark. The MCP
+ // schema mirrors the editor-ext mark, so a spoiler must survive json -> md ->
+ // json instead of being silently dropped as an unrecognized mark.
+ const doc = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "plot: " },
+ {
+ type: "text",
+ text: "the butler did it",
+ marks: [{ type: "spoiler" }],
+ },
+ { type: "text", text: " end" },
+ ],
+ },
+ ],
+ };
+
+ const body = convertProseMirrorToMarkdown(doc);
+ assert.match(body, /the butler did it<\/span>/);
+
+ return markdownToProseMirror(body).then((rebuilt) => {
+ const spoilered = findTextWithMark(rebuilt, "spoiler");
+ assert.ok(spoilered, "expected a text node with a spoiler mark");
+ assert.equal(spoilered.text, "the butler did it");
+ });
+});
+
test("drawio round-trips through export and import", () => {
const doc = {
type: "doc",