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