/** * Server-side TipTap extensions for generateHTML(). * * Mirrors the frontend extension list but without: * - React NodeViews (not available server-side) * - Collaboration / CollaborationCursor / CollaborationUndo * - Placeholder, SlashCommands, ImageUpload, Comment (editor-only) */ import { Node, mergeAttributes, type Extensions } from "@tiptap/core"; import { SHARED_COMPONENT_DEFS, type SharedComponentDef } from "../shared/component-defs.js"; import StarterKit from "@tiptap/starter-kit"; import { CodeBlock } from "@tiptap/extension-code-block"; import Mathematics from "@tiptap/extension-mathematics"; import { Image } from "@tiptap/extension-image"; import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; // ---- Custom inline nodes (server versions, no NodeView) ---- const CitationServer = Node.create({ name: "citation", group: "inline", inline: true, atom: true, addAttributes() { return { key: { default: "" }, label: { default: "" }, }; }, parseHTML() { return [{ tag: 'span[data-type="citation"]' }]; }, renderHTML({ HTMLAttributes, node }) { const label = node.attrs.label || `[${node.attrs.key}]`; return [ "span", mergeAttributes(HTMLAttributes, { "data-type": "citation", class: "citation-node", }), label, ]; }, }); const BibliographyServer = Node.create({ name: "bibliography", group: "block", atom: true, addAttributes() { return { renderedHtml: { default: "" }, }; }, parseHTML() { return [{ tag: 'div[data-type="bibliography"]' }]; }, renderHTML({ HTMLAttributes }) { // Render an empty placeholder; postProcess in html-renderer will inject // the actual bibliography HTML from the renderedhtml attribute. return [ "div", mergeAttributes(HTMLAttributes, { "data-type": "bibliography", class: "bibliography-block", }), ]; }, }); const GlossaryServer = Node.create({ name: "glossary", group: "inline", inline: true, atom: true, addAttributes() { return { term: { default: "" }, definition: { default: "" }, }; }, parseHTML() { return [{ tag: 'span[data-type="glossary"]' }]; }, renderHTML({ HTMLAttributes, node }) { const term = (node.attrs.term as string) || ""; const definition = (node.attrs.definition as string) || ""; return [ "span", mergeAttributes(HTMLAttributes, { "data-type": "glossary", class: "glossary-node", tabindex: "0", }), term, [ "span", { class: "pub-tooltip pub-tooltip--glossary", "aria-hidden": "true" }, ["span", { class: "pub-tooltip__title" }, term], ["span", { class: "pub-tooltip__body" }, definition], ], ]; }, }); const FootnoteServer = Node.create({ name: "footnote", group: "inline", inline: true, atom: true, addAttributes() { return { content: { default: "" }, }; }, parseHTML() { return [{ tag: 'span[data-type="footnote"]' }]; }, renderHTML({ HTMLAttributes, node }) { return [ "span", mergeAttributes(HTMLAttributes, { "data-type": "footnote", class: "footnote-node", title: node.attrs.content, tabindex: "0", }), ["sup", { class: "footnote-marker" }, "*"], ]; }, }); // ---- Stack ---- const StackColumnServer = Node.create({ name: "stackColumn", group: "stackColumn", content: "block+", defining: true, isolating: true, parseHTML() { return [{ tag: 'div[data-type="stack-column"]' }]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-type": "stack-column" }), 0, ]; }, }); const StackServer = Node.create({ name: "stack", group: "block", content: "stackColumn{2,4}", defining: true, isolating: true, addAttributes() { return { layout: { default: "2-column" }, gap: { default: "medium" }, }; }, parseHTML() { return [{ tag: 'div[data-component="stack"]' }]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-component": "stack" }), 0, ]; }, }); // ---- Generic wrapper/atomic components from the shared registry ---- function makeServerWrapperExt(def: SharedComponentDef) { return Node.create({ name: def.name, group: "block", content: def.content || "block+", defining: true, isolating: true, addAttributes() { const attrs: Record = {}; for (const f of def.fields) attrs[f.name] = { default: f.default ?? null }; return attrs; }, parseHTML() { return [{ tag: `div[data-component="${def.name}"]` }]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-component": def.name }), 0, ]; }, }); } function makeServerAtomicExt(def: SharedComponentDef) { return Node.create({ name: def.name, group: "block", atom: true, addAttributes() { const attrs: Record = {}; for (const f of def.fields) attrs[f.name] = { default: f.default ?? null }; return attrs; }, parseHTML() { return [{ tag: `div[data-component="${def.name}"]` }]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-component": def.name }), ]; }, }); } export function getServerExtensions(): Extensions { const wrappers = SHARED_COMPONENT_DEFS.filter((d) => d.kind === "wrapper").map(makeServerWrapperExt); const atomics = SHARED_COMPONENT_DEFS.filter((d) => d.kind === "atomic").map(makeServerAtomicExt); return [ StarterKit.configure({ codeBlock: false, undoRedo: false, link: { openOnClick: false }, } as any), CodeBlock, Mathematics.configure({ katexOptions: { throwOnError: false } }), Image.configure({ allowBase64: true }), Table.configure({ resizable: false }), TableRow, TableCell, TableHeader, CitationServer, BibliographyServer, GlossaryServer, FootnoteServer, StackServer, StackColumnServer, ...wrappers, ...atomics, ]; }