/** * Portable Text to ProseMirror Converter * * Converts Portable Text to TipTap's ProseMirror JSON format for editing. */ import { sanitizeGalleryImages } from "./gallery.js"; import type { ProseMirrorDocument, ProseMirrorNode, ProseMirrorMark, PortableTextBlock, PortableTextTextBlock, PortableTextSpan, PortableTextMarkDef, PortableTextImageBlock, PortableTextGalleryBlock, PortableTextCodeBlock, } from "./types.js"; /** * Convert Portable Text to ProseMirror document */ export function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMirrorDocument { if (!blocks || blocks.length === 0) { return { type: "doc", content: [{ type: "paragraph" }], }; } const content: ProseMirrorNode[] = []; let i = 0; while (i < blocks.length) { const block = blocks[i]; // Check for list items if (isTextBlock(block) && block.listItem) { // Collect a list "run": the level=1 anchor plus everything that // nests under it (level > 1, regardless of listItem type — a number // child under a bullet parent is still part of the same tree). A // level=1 block with a different listItem ends the run. Without the // `level > 1` carve-out the run breaks on the first nested type // switch and the descendant subtree leaks out as its own top-level // list (e.g. `[bullet L1, number L2, bullet L1]` would render as // three sibling lists instead of one bullet list with a numbered // child). const listBlocks: PortableTextTextBlock[] = []; const listType = block.listItem; while (i < blocks.length) { const current = blocks[i]; if (!isTextBlock(current) || !current.listItem) break; const level = current.level || 1; if (level > 1 || current.listItem === listType) { listBlocks.push(current); i++; } else { break; } } content.push(convertList(listBlocks, listType)); } else if (isTextBlock(block) && block.style === "blockquote") { // Collect a blockquote "run": Portable Text is flat, so a // multi-paragraph quote is stored as consecutive blocks with // style "blockquote" (that's what the Gutenberg importer emits // and what prosemirrorToPortableText serializes back to). // Without this grouping each paragraph became its own quote // node, and editor merges reverted on reload (#1884). const quoteBlocks: PortableTextTextBlock[] = []; while (i < blocks.length) { const current = blocks[i]; if ( !isTextBlock(current) || current.style !== "blockquote" || current.listItem !== undefined ) { break; } quoteBlocks.push(current); i++; } content.push({ type: "blockquote", content: quoteBlocks.map((quoteBlock) => { const paragraph = convertSpans(quoteBlock.children, quoteBlock.markDefs || []); return { type: "paragraph", content: paragraph.length > 0 ? paragraph : undefined, }; }), }); } else { const converted = convertBlock(block); if (converted) { content.push(converted); } i++; } } return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }], }; } /** * Type guard for text blocks */ function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock { return block._type === "block"; } /** * Type guard for image blocks. * Checks both `_type` and that `asset` is a valid object — image blocks * without an `asset` wrapper (e.g. `{ _type: "image", url: "..." }`) are * malformed and should not be cast to `PortableTextImageBlock`. */ function isImageBlock(block: PortableTextBlock): block is PortableTextImageBlock { return ( block._type === "image" && "asset" in block && typeof block.asset === "object" && block.asset !== null ); } /** * Type guard for gallery blocks. Requires an `images` array — a gallery * without one is malformed and falls through to the unknown-block path. */ function isGalleryBlock(block: PortableTextBlock): block is PortableTextGalleryBlock { return block._type === "gallery" && "images" in block && Array.isArray(block.images); } /** * Type guard for code blocks */ function isCodeBlock(block: PortableTextBlock): block is PortableTextCodeBlock { return block._type === "code"; } /** * Convert a single Portable Text block to ProseMirror node */ function convertBlock(block: PortableTextBlock): ProseMirrorNode | null { if (isTextBlock(block)) { return convertTextBlock(block); } if (isImageBlock(block)) { return convertImage(block); } if (block._type === "image") { // Malformed image block (no asset wrapper) — extract url from top level return convertMalformedImage(block); } if (isGalleryBlock(block)) { return { type: "gallery", attrs: { images: sanitizeGalleryImages(block.images), columns: typeof block.columns === "number" ? block.columns : undefined, }, }; } if (isCodeBlock(block)) { return convertCodeBlock(block); } if (block._type === "htmlBlock") { const hb = block as PortableTextBlock & { html?: string }; return { type: "htmlBlock", attrs: { html: hb.html || "" }, }; } if (block._type === "break") { return { type: "horizontalRule" }; } // Unknown block - wrap in a div or preserve as placeholder return { type: "paragraph", content: [ { type: "text", text: `[Unknown block type: ${block._type}]`, marks: [{ type: "code" }], }, ], }; } /** * Convert text block to ProseMirror paragraph or heading */ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null { const { style = "normal", children, markDefs = [] } = block; // Convert children to ProseMirror nodes const content = convertSpans(children, markDefs); // Determine node type based on style switch (style) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const level = parseInt(style.substring(1), 10); return { type: "heading", attrs: { level, ...(block.textAlign ? { textAlign: block.textAlign } : {}), }, content: content.length > 0 ? content : undefined, }; } case "blockquote": return { type: "blockquote", content: [ { type: "paragraph", content: content.length > 0 ? content : undefined, }, ], }; case "normal": default: return { type: "paragraph", attrs: block.textAlign ? { textAlign: block.textAlign } : undefined, content: content.length > 0 ? content : undefined, }; } } /** * Convert list items to ProseMirror list */ function convertList( items: PortableTextTextBlock[], listType: "bullet" | "number", ): ProseMirrorNode { // Group items by level const rootItems: ProseMirrorNode[] = []; let i = 0; while (i < items.length) { const item = items[i]; const level = item.level || 1; if (level === 1) { // Collect nested items for this root item const nestedItems: PortableTextTextBlock[] = []; i++; while (i < items.length && (items[i].level || 1) > 1) { nestedItems.push(items[i]); i++; } rootItems.push(convertListItem(item, nestedItems, listType)); } else { // Orphan nested item - treat as root rootItems.push(convertListItem(item, [], listType)); i++; } } return { type: listType === "bullet" ? "bulletList" : "orderedList", content: rootItems, }; } /** * Convert a single list item to ProseMirror */ function convertListItem( item: PortableTextTextBlock, nestedItems: PortableTextTextBlock[], parentListType: "bullet" | "number", ): ProseMirrorNode { const content: ProseMirrorNode[] = []; // Add paragraph content const spans = convertSpans(item.children, item.markDefs || []); content.push({ type: "paragraph", content: spans.length > 0 ? spans : undefined, }); // Handle nested items if (nestedItems.length > 0) { // The shallowest level in `nestedItems` is the effective root of this // item's nested subtree. A new sub-list only starts when we hit // another block at that root level with a different `listItem` type; // deeper blocks (level > minLevel) belong to the current group as // descendants regardless of their own `listItem`. The previous // grouping broke on any type change at any depth, so a deep mixed // tree like `bullet L1 → number L2 → bullet L3 → number L2` would // emit C(L3) as a sibling list under A(L1) instead of nesting it // under B(L2), then degrade C to L2 on round-trip. let minLevel = Infinity; for (const ni of nestedItems) { const level = ni.level || 2; if (level < minLevel) minLevel = level; } let j = 0; while (j < nestedItems.length) { const anchorType: "bullet" | "number" = nestedItems[j].listItem || parentListType; const nestedGroup: PortableTextTextBlock[] = []; do { nestedGroup.push(nestedItems[j]); j++; } while ( j < nestedItems.length && ((nestedItems[j].level || 2) > minLevel || (nestedItems[j].listItem || parentListType) === anchorType) ); if (nestedGroup.length > 0) { // Decrease level for nested conversion const adjustedGroup = nestedGroup.map((ni) => ({ ...ni, level: (ni.level || 2) - 1, })); content.push(convertList(adjustedGroup, anchorType)); } } } return { type: "listItem", content, }; } /** * Convert Portable Text spans to ProseMirror text nodes */ function convertSpans( spans: PortableTextSpan[], markDefs: PortableTextMarkDef[], ): ProseMirrorNode[] { const nodes: ProseMirrorNode[] = []; const markDefsMap = new Map(markDefs.map((md) => [md._key, md])); for (const span of spans) { if (span._type !== "span") continue; // Handle newlines in text const parts = span.text.split("\n"); for (let i = 0; i < parts.length; i++) { const text = parts[i]; // Add text node if (text.length > 0) { const marks = convertMarks(span.marks || [], markDefsMap); const node: ProseMirrorNode = { type: "text", text, }; if (marks.length > 0) { node.marks = marks; } nodes.push(node); } // Add hard break between parts (not after last) if (i < parts.length - 1) { nodes.push({ type: "hardBreak" }); } } } return nodes; } /** * Convert Portable Text marks to ProseMirror marks */ function convertMarks( marks: string[], markDefs: Map, ): ProseMirrorMark[] { const pmMarks: ProseMirrorMark[] = []; for (const mark of marks) { switch (mark) { case "strong": pmMarks.push({ type: "bold" }); break; case "em": pmMarks.push({ type: "italic" }); break; case "underline": pmMarks.push({ type: "underline" }); break; case "strike-through": pmMarks.push({ type: "strike" }); break; case "code": pmMarks.push({ type: "code" }); break; default: { // Check if it's a mark definition reference const markDef = markDefs.get(mark); if (markDef) { if (markDef._type === "link") { pmMarks.push({ type: "link", attrs: { href: markDef.href, target: markDef.blank ? "_blank" : null, }, }); } else { // Unknown mark def type - preserve attrs pmMarks.push({ type: markDef._type, attrs: markDef, }); } } break; } } } return pmMarks; } /** * Convert image block to ProseMirror */ function convertImage(block: PortableTextImageBlock): ProseMirrorNode { return { type: "image", attrs: { src: block.asset.url || block.asset._ref, alt: block.alt || "", title: block.caption || "", mediaId: block.asset._ref, provider: block.asset.provider, width: block.width, height: block.height, displayWidth: block.displayWidth, displayHeight: block.displayHeight, }, }; } /** * Convert a malformed image block (missing `asset` wrapper) to ProseMirror. * Handles blocks like `{ _type: "image", url: "...", alt: "..." }` that may * originate from migrations or third-party imports. */ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { // PortableTextUnknownBlock allows indexed access via [key: string]: unknown const url = "url" in block && typeof block.url === "string" ? block.url : ""; const alt = "alt" in block && typeof block.alt === "string" ? block.alt : ""; const caption = "caption" in block && typeof block.caption === "string" ? block.caption : ""; const width = "width" in block && typeof block.width === "number" ? block.width : undefined; const height = "height" in block && typeof block.height === "number" ? block.height : undefined; const displayWidth = "displayWidth" in block && typeof block.displayWidth === "number" ? block.displayWidth : undefined; const displayHeight = "displayHeight" in block && typeof block.displayHeight === "number" ? block.displayHeight : undefined; return { type: "image", attrs: { src: url, alt, title: caption, mediaId: undefined, provider: undefined, width, height, displayWidth, displayHeight, }, }; } /** * Convert code block to ProseMirror */ function convertCodeBlock(block: PortableTextCodeBlock): ProseMirrorNode { return { type: "codeBlock", attrs: { language: block.language || null, }, content: block.code ? [{ type: "text", text: block.code }] : undefined, }; }