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/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",