/** * ProseMirror to Portable Text Converter * * Converts TipTap's ProseMirror JSON format to Portable Text for storage. */ import { sanitizeGalleryImages } from "./gallery.js"; import type { ProseMirrorDocument, ProseMirrorNode, ProseMirrorMark, PortableTextBlock, PortableTextTextBlock, PortableTextSpan, PortableTextMarkDef, PortableTextImageBlock, PortableTextGalleryBlock, PortableTextCodeBlock, PortableTextHtmlBlock, } from "./types.js"; /** * Generate a unique key for Portable Text blocks */ function generateKey(): string { return Math.random().toString(36).substring(2, 11); } /** * Convert ProseMirror document to Portable Text */ export function prosemirrorToPortableText(doc: ProseMirrorDocument): PortableTextBlock[] { if (!doc || doc.type !== "doc" || !doc.content) { return []; } const blocks: PortableTextBlock[] = []; for (const node of doc.content) { const converted = convertNode(node); if (converted) { if (Array.isArray(converted)) { blocks.push(...converted); } else { blocks.push(converted); } } } return blocks; } /** * Convert a single ProseMirror node to Portable Text block(s) */ function convertNode(node: ProseMirrorNode): PortableTextBlock | PortableTextBlock[] | null { switch (node.type) { case "paragraph": return convertParagraph(node); case "heading": return convertHeading(node); case "bulletList": return convertList(node, "bullet"); case "orderedList": return convertList(node, "number"); case "blockquote": return convertBlockquote(node); case "codeBlock": return convertCodeBlock(node); case "htmlBlock": return convertHtmlBlock(node); case "image": return convertImage(node); case "gallery": return convertGallery(node); case "horizontalRule": return { _type: "break", _key: generateKey(), style: "lineBreak", }; default: // Preserve unknown blocks return { _type: node.type, _key: generateKey(), ...node.attrs, _pmContent: node.content, }; } } /** * Convert paragraph to Portable Text block */ function convertParagraph(node: ProseMirrorNode): PortableTextTextBlock | null { const { children, markDefs } = convertInlineContent(node.content || []); // Skip empty paragraphs if (children.length === 0) { return null; } const ta = node.attrs?.textAlign; const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: generateKey(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } /** Map heading level number to Portable Text style */ function headingLevelToStyle(level: number): PortableTextTextBlock["style"] { switch (level) { case 1: return "h1"; case 2: return "h2"; case 3: return "h3"; case 4: return "h4"; case 5: return "h5"; case 6: return "h6"; default: return "h1"; } } /** * Convert heading to Portable Text block */ function convertHeading(node: ProseMirrorNode): PortableTextTextBlock | null { const { children, markDefs } = convertInlineContent(node.content || []); const rawLevel = typeof node.attrs?.level === "number" ? node.attrs.level : 1; const style = headingLevelToStyle(rawLevel); if (children.length === 0) { return null; } const ta = node.attrs?.textAlign; const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: generateKey(), style, children, markDefs: markDefs.length > 0 ? markDefs : undefined, ...(textAlign ? { textAlign } : {}), }; } /** * Convert list to Portable Text blocks */ function convertList( node: ProseMirrorNode, listItem: "bullet" | "number", ): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; for (const item of node.content || []) { if (item.type === "listItem") { const itemBlocks = convertListItem(item, listItem, 1); blocks.push(...itemBlocks); } } return blocks; } /** * Convert list item to Portable Text blocks */ function convertListItem( item: ProseMirrorNode, listItem: "bullet" | "number", level: number, ): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; for (const child of item.content || []) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: generateKey(), style: "normal", listItem, level, children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } else if (child.type === "bulletList") { blocks.push(...convertListItemNested(child, "bullet", level + 1)); } else if (child.type === "orderedList") { blocks.push(...convertListItemNested(child, "number", level + 1)); } } return blocks; } /** * Convert nested list */ function convertListItemNested( node: ProseMirrorNode, listItem: "bullet" | "number", level: number, ): PortableTextTextBlock[] { const blocks: PortableTextTextBlock[] = []; for (const item of node.content || []) { if (item.type === "listItem") { blocks.push(...convertListItem(item, listItem, level)); } } return blocks; } /** * Convert blockquote to Portable Text blocks */ function convertBlockquote( node: ProseMirrorNode, ): PortableTextTextBlock | PortableTextTextBlock[] | null { // Blockquotes in PT are just blocks with style: "blockquote" const blocks: PortableTextTextBlock[] = []; for (const child of node.content || []) { if (child.type === "paragraph") { const { children, markDefs } = convertInlineContent(child.content || []); if (children.length > 0) { blocks.push({ _type: "block", _key: generateKey(), style: "blockquote", children, markDefs: markDefs.length > 0 ? markDefs : undefined, }); } } } return blocks.length === 1 ? blocks[0] : blocks.length > 0 ? blocks : null; } /** * Convert code block to Portable Text */ function convertCodeBlock(node: ProseMirrorNode): PortableTextCodeBlock { const code = node.content?.map((n) => n.text || "").join("") || ""; const language = typeof node.attrs?.language === "string" ? node.attrs.language : undefined; return { _type: "code", _key: generateKey(), code, language: language || undefined, }; } /** * Convert HTML block to Portable Text */ function convertHtmlBlock(node: ProseMirrorNode): PortableTextHtmlBlock { const rawHtml = node.attrs?.html; return { _type: "htmlBlock", _key: generateKey(), html: typeof rawHtml === "string" ? rawHtml : "", }; } /** * Convert image to Portable Text */ function convertImage(node: ProseMirrorNode): PortableTextImageBlock { const attrs = node.attrs; const provider = typeof attrs?.provider === "string" ? attrs.provider : undefined; const mediaId = typeof attrs?.mediaId === "string" ? attrs.mediaId : undefined; const src = typeof attrs?.src === "string" ? attrs.src : ""; const alt = typeof attrs?.alt === "string" ? attrs.alt : undefined; const title = typeof attrs?.title === "string" ? attrs.title : undefined; const width = typeof attrs?.width === "number" ? attrs.width : undefined; const height = typeof attrs?.height === "number" ? attrs.height : undefined; const displayWidth = typeof attrs?.displayWidth === "number" ? attrs.displayWidth : undefined; const displayHeight = typeof attrs?.displayHeight === "number" ? attrs.displayHeight : undefined; return { _type: "image", _key: generateKey(), asset: { // Use mediaId as _ref if available (for proper provider lookups) _ref: mediaId || src || "", // Store URL for admin preview and fallback rendering url: src || "", // Store provider for external media provider: provider && provider !== "local" ? provider : undefined, }, alt: alt || undefined, caption: title || undefined, width: width || undefined, height: height || undefined, displayWidth: displayWidth || undefined, displayHeight: displayHeight || undefined, }; } /** * Convert gallery node to Portable Text */ function convertGallery(node: ProseMirrorNode): PortableTextGalleryBlock { const columns = node.attrs?.columns; return { _type: "gallery", _key: generateKey(), images: sanitizeGalleryImages(node.attrs?.images, generateKey), ...(typeof columns === "number" ? { columns } : {}), }; } /** * Convert inline content (text nodes with marks) to Portable Text spans */ function convertInlineContent(nodes: ProseMirrorNode[]): { children: PortableTextSpan[]; markDefs: PortableTextMarkDef[]; } { const children: PortableTextSpan[] = []; const markDefs: PortableTextMarkDef[] = []; const markDefMap = new Map(); // href -> key for (const node of nodes) { if (node.type === "text" && node.text) { const marks: string[] = []; for (const mark of node.marks || []) { const markType = convertMark(mark, markDefs, markDefMap); if (markType) { marks.push(markType); } } children.push({ _type: "span", _key: generateKey(), text: node.text, marks: marks.length > 0 ? marks : undefined, }); } else if (node.type === "hardBreak") { // Hard breaks become newlines in the text if (children.length > 0) { const lastChild = children.at(-1)!; lastChild.text += "\n"; } else { children.push({ _type: "span", _key: generateKey(), text: "\n", }); } } } // Ensure at least one span exists if (children.length === 0) { children.push({ _type: "span", _key: generateKey(), text: "", }); } return { children, markDefs }; } /** * Convert a ProseMirror mark to Portable Text mark */ function convertMark( mark: ProseMirrorMark, markDefs: PortableTextMarkDef[], markDefMap: Map, ): string | null { switch (mark.type) { case "bold": case "strong": return "strong"; case "italic": case "em": return "em"; case "underline": return "underline"; case "strike": case "strikethrough": return "strike-through"; case "code": return "code"; case "link": { const href = (typeof mark.attrs?.href === "string" ? mark.attrs.href : "") || ""; // Check if we already have a mark def for this link if (markDefMap.has(href)) { return markDefMap.get(href)!; } // Create new mark def const key = generateKey(); markDefs.push({ _type: "link", _key: key, href, blank: mark.attrs?.target === "_blank", }); markDefMap.set(href, key); return key; } default: // Unknown mark - preserve as-is return mark.type; } }