UNPKG

@storyblok/richtext

Version:
1 lines 87.2 kB
{"version":3,"file":"index.cjs","names":["TextAlign","Blockquote","Paragraph","Heading","TableRow","BulletList","OrderedList","ListItem","CodeBlock","HardBreak","HorizontalRule","Table","TableCell","TableHeader","Image","Emoji","Node","Highlight","LinkOriginal","Mark","Document","Text","Details","DetailsContent","DetailsSummary","Bold","Italic","Strike","Underline","Code","Superscript","Subscript","callExtensionRenderHTML"],"sources":["../src/images-optimization.ts","../src/utils/index.ts","../src/types/index.ts","../src/extensions/utils.ts","../src/extensions/nodes.ts","../src/render-segments.ts","../src/extensions/marks.ts","../src/extensions/index.ts","../src/richtext.ts","../src/richtext-segment.ts","../src/utils/segment-richtext.ts","../src/index.ts"],"sourcesContent":["import type { StoryblokRichTextImageOptimizationOptions } from './types';\n\nexport function optimizeImage(src: string, options?: boolean | Partial<StoryblokRichTextImageOptimizationOptions>): { src: string; attrs: Record<string, any> } {\n if (!options) {\n return { src, attrs: {} };\n }\n let w = 0;\n let h = 0;\n const attrs: Record<string, unknown> = {};\n const filterParams: string[] = [];\n\n function validateAndPushFilterParam(value: number, min: number, max: number, filter: string, filterParams: string[]) {\n if (typeof value !== 'number' || value <= min || value >= max) {\n console.warn(`[StoryblokRichText] - ${filter.charAt(0).toUpperCase() + filter.slice(1)} value must be a number between ${min} and ${max} (inclusive)`);\n }\n else {\n filterParams.push(`${filter}(${value})`);\n }\n }\n\n if (typeof options === 'object') {\n if (options.width !== undefined) {\n if (typeof options.width === 'number' && options.width >= 0) {\n attrs.width = options.width;\n w = options.width;\n }\n else {\n console.warn('[StoryblokRichText] - Width value must be a number greater than or equal to 0');\n }\n }\n if (options.height !== undefined) {\n if (typeof options.height === 'number' && options.height >= 0) {\n attrs.height = options.height;\n h = options.height;\n }\n else {\n console.warn('[StoryblokRichText] - Height value must be a number greater than or equal to 0');\n }\n }\n if (options.height === 0 && options.width === 0) {\n delete attrs.width;\n delete attrs.height;\n console.warn('[StoryblokRichText] - Width and height values cannot both be 0');\n }\n if (options.loading && ['lazy', 'eager'].includes(options.loading)) {\n attrs.loading = options.loading;\n }\n if (options.class) {\n attrs.class = options.class;\n }\n\n if (options.filters) {\n const { filters } = options || {};\n const { blur, brightness, fill, format, grayscale, quality, rotate } = filters || {};\n\n if (blur) {\n validateAndPushFilterParam(blur, 0, 100, 'blur', filterParams);\n }\n if (quality) {\n validateAndPushFilterParam(quality, 0, 100, 'quality', filterParams);\n }\n if (brightness) {\n validateAndPushFilterParam(brightness, 0, 100, 'brightness', filterParams);\n }\n if (fill) {\n filterParams.push(`fill(${fill})`);\n }\n if (grayscale) {\n filterParams.push(`grayscale()`);\n }\n if (rotate && [0, 90, 180, 270].includes(options.filters.rotate || 0)) {\n filterParams.push(`rotate(${rotate})`);\n }\n if (format && ['webp', 'png', 'jpeg'].includes(format)) {\n filterParams.push(`format(${format})`);\n }\n }\n\n // Construct srcset attribute\n if (options.srcset) {\n attrs.srcset = options.srcset.map((entry): string | undefined => {\n if (typeof entry === 'number') {\n return `${src}/m/${entry}x0/${filterParams.length > 0 ? `filters:${filterParams.join(':')}` : ''} ${entry}w`;\n }\n if (Array.isArray(entry) && entry.length === 2) {\n const [entryWidth, entryHeight] = entry;\n return `${src}/m/${entryWidth}x${entryHeight}/${filterParams.length > 0 ? `filters:${filterParams.join(':')}` : ''} ${entryWidth}w`;\n }\n else {\n console.warn('[StoryblokRichText] - srcset entry must be a number or a tuple of two numbers');\n return undefined;\n }\n }).join(', ');\n }\n\n // Construct sizes attribute\n if (options.sizes) {\n attrs.sizes = options.sizes.join(', ');\n }\n }\n\n // server-side WebP support detection https://www.storyblok.com/docs/image-service/#optimize\n // https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg/m/\n let resultSrc = `${src}/m/`;\n if (w > 0 || h > 0) {\n resultSrc = `${resultSrc}${w}x${h}/`;\n }\n if (filterParams.length > 0) {\n resultSrc = `${resultSrc}filters:${filterParams.join(':')}`;\n }\n\n return {\n src: resultSrc,\n attrs,\n };\n}\n","import type { BlockAttributes, MarkNode, StoryblokRichTextNode, TextNode } from '../types';\n\n/**\n * Deep equality comparison for plain objects, arrays, and primitives.\n */\nexport function deepEqual(a: any, b: any): boolean {\n if (a === b) {\n return true;\n }\n if (a === null || a === undefined || b === null || b === undefined) {\n return a === b;\n }\n if (typeof a !== typeof b) {\n return false;\n }\n if (typeof a !== 'object') {\n return false;\n }\n if (Array.isArray(a) !== Array.isArray(b)) {\n return false;\n }\n if (Array.isArray(a)) {\n if (a.length !== (b as any[]).length) {\n return false;\n }\n return a.every((v: any, i: number) => deepEqual(v, (b as any[])[i]));\n }\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n if (aKeys.length !== bKeys.length) {\n return false;\n }\n return aKeys.every(k => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));\n}\n\n/** Checks if two marks are equal by comparing their type and attrs. */\nexport function markEquals<T>(a: MarkNode<T>, b: MarkNode<T>): boolean {\n return a.type === b.type && deepEqual(a.attrs, b.attrs);\n}\n\n/** Type guard: checks if a node is a text node with at least one mark. */\nexport function isMarkedTextNode<T>(node: StoryblokRichTextNode<T>): node is TextNode<T> {\n return node.type === 'text' && !!(node as TextNode<T>).marks?.length;\n}\n\n/** Returns marks unique to a node (not in the shared set), or undefined if all marks are shared. */\nexport function getUniqueMarks<T>(marks: MarkNode<T>[], shared: MarkNode<T>[]): MarkNode<T>[] | undefined {\n const unique = marks.filter(m => !shared.some(s => markEquals(s, m)));\n return unique.length ? unique : undefined;\n}\n\nexport interface MarkedTextGroup<T> {\n group: TextNode<T>[];\n shared: MarkNode<T>[];\n endIndex: number;\n}\n\n/**\n * Starting at `fromIndex`, collects adjacent marked text nodes that share at least one common mark.\n * Returns null if the node at `fromIndex` is not a marked text node.\n */\nexport function collectMarkedTextGroup<T>(\n children: StoryblokRichTextNode<T>[],\n fromIndex: number,\n): MarkedTextGroup<T> | null {\n const child = children[fromIndex];\n if (!isMarkedTextNode(child)) {\n return null;\n }\n\n const group: TextNode<T>[] = [child];\n let shared: MarkNode<T>[] = child.marks!;\n let j = fromIndex + 1;\n while (j < children.length) {\n const next = children[j];\n if (!isMarkedTextNode(next)) {\n break;\n }\n const nextShared = shared.filter(m =>\n next.marks!.some(n => markEquals(m, n)),\n );\n if (nextShared.length === 0) {\n break;\n }\n shared = nextShared;\n group.push(next);\n j++;\n }\n\n return { group, shared, endIndex: j };\n}\n\nexport const SELF_CLOSING_TAGS = [\n 'area',\n 'base',\n 'br',\n 'col',\n 'embed',\n 'hr',\n 'img',\n 'input',\n 'link',\n 'meta',\n 'param',\n 'source',\n 'track',\n 'wbr',\n];\n\nexport const BLOCK_LEVEL_TAGS = [\n 'address',\n 'article',\n 'aside',\n 'blockquote',\n 'canvas',\n 'dd',\n 'div',\n 'dl',\n 'dt',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'header',\n 'hgroup',\n 'hr',\n 'li',\n 'main',\n 'nav',\n 'noscript',\n 'ol',\n 'output',\n 'p',\n 'pre',\n 'section',\n 'table',\n 'tfoot',\n 'ul',\n 'video',\n];\n\n/**\n * Converts an object of attributes to a string.\n *\n * @param {Record<string, string>} [attrs]\n *\n * @returns {string} The string representation of the attributes.\n *\n * @example\n *\n * ```typescript\n * const attrs = {\n * class: 'text-red',\n * style: 'color: red',\n * }\n *\n * const attrsString = attrsToString(attrs)\n *\n * console.log(attrsString) // 'class=\"text-red\" style=\"color: red\"'\n *\n * ```\n *\n */\nexport const attrsToString = (attrs: BlockAttributes = {}) => {\n const { custom, ...attrsWithoutCustom } = attrs;\n const normalizedAttrs = { ...attrsWithoutCustom, ...custom };\n return Object.keys(normalizedAttrs)\n .filter(key => normalizedAttrs[key] != null)\n .map(key => `${key}=\"${String(normalizedAttrs[key]).replace(/&/g, '&amp;').replace(/\"/g, '&quot;')}\"`)\n .join(' ');\n};\n\n/**\n * Converts an object of attributes to a CSS style string.\n *\n * @param {Record<string, string>} [attrs]\n *\n * @returns {string} The string representation of the CSS styles.\n *\n * @example\n *\n * ```typescript\n * const attrs = {\n * color: 'red',\n * fontSize: '16px',\n * }\n *\n * const styleString = attrsToStyle(attrs)\n *\n * console.log(styleString) // 'color: red; font-size: 16px'\n * ```\n */\nexport const attrsToStyle = (attrs: Record<string, string> = {}) => Object.keys(attrs)\n .map(key => `${key}: ${attrs[key]}`)\n .join('; ');\n\n/**\n * Escapes HTML entities in a string.\n *\n * @param {string} unsafeText\n * @return {*} {string}\n *\n * @example\n *\n * ```typescript\n * const unsafeText = '<script>alert(\"Hello\")</script>'\n *\n * const safeText = escapeHtml(unsafeText)\n *\n * console.log(safeText) // '&lt;script&gt;alert(\"Hello\")&lt;/script&gt;'\n * ```\n */\nexport function escapeHtml(unsafeText: string): string {\n return unsafeText\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\n/**\n * Removes undefined values from an object.\n *\n * @param {Record<string, any>} obj\n * @return {*} {Record<string, any>}\n *\n * @example\n *\n * ```typescript\n * const obj = {\n * name: 'John',\n * age: undefined,\n * }\n *\n * const cleanedObj = cleanObject(obj)\n *\n * console.log(cleanedObj) // { name: 'John' }\n * ```\n *\n */\nexport const cleanObject = (obj: Record<string, any>) => {\n return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null));\n};\n","export enum BlockTypes {\n DOCUMENT = 'doc',\n HEADING = 'heading',\n PARAGRAPH = 'paragraph',\n QUOTE = 'blockquote',\n OL_LIST = 'ordered_list',\n UL_LIST = 'bullet_list',\n LIST_ITEM = 'list_item',\n CODE_BLOCK = 'code_block',\n HR = 'horizontal_rule',\n BR = 'hard_break',\n IMAGE = 'image',\n EMOJI = 'emoji',\n COMPONENT = 'blok',\n TABLE = 'table',\n TABLE_ROW = 'tableRow',\n TABLE_CELL = 'tableCell',\n TABLE_HEADER = 'tableHeader',\n}\n\nexport enum MarkTypes {\n BOLD = 'bold',\n STRONG = 'strong',\n STRIKE = 'strike',\n UNDERLINE = 'underline',\n ITALIC = 'italic',\n CODE = 'code',\n LINK = 'link',\n ANCHOR = 'anchor',\n STYLED = 'styled',\n SUPERSCRIPT = 'superscript',\n SUBSCRIPT = 'subscript',\n TEXT_STYLE = 'textStyle',\n HIGHLIGHT = 'highlight',\n}\n\nexport enum TextTypes {\n TEXT = 'text',\n}\n\nexport enum LinkTargets {\n SELF = '_self',\n BLANK = '_blank',\n}\n\nexport enum LinkTypes {\n URL = 'url',\n STORY = 'story',\n ASSET = 'asset',\n EMAIL = 'email',\n}\n\n/**\n * Represents text alignment attributes that can be applied to block-level elements.\n */\nexport interface TextAlignmentAttrs {\n textAlign?: 'left' | 'center' | 'right' | 'justify';\n}\n\n/**\n * Represents common attributes that can be applied to block-level elements.\n */\nexport interface BlockAttributes extends TextAlignmentAttrs {\n class?: string;\n id?: string;\n [key: string]: any;\n}\n\nexport interface StoryblokRichTextDocumentNode {\n type: string;\n content?: StoryblokRichTextDocumentNode[];\n attrs?: BlockAttributes;\n text?: string;\n marks?: StoryblokRichTextDocumentNode[];\n}\n\nexport type StoryblokRichTextNodeTypes = BlockTypes | MarkTypes | TextTypes;\n\nexport interface StoryblokRichTextNode<T = string> {\n type: StoryblokRichTextNodeTypes;\n content: StoryblokRichTextNode<T>[];\n children?: T;\n attrs?: BlockAttributes;\n text?: string;\n}\n\nexport interface LinkNode<T = string> extends StoryblokRichTextNode<T> {\n type: MarkTypes.LINK | MarkTypes.ANCHOR;\n linktype: LinkTypes;\n attrs: BlockAttributes;\n}\n\nexport interface MarkNode<T = string> extends StoryblokRichTextNode<T> {\n type: MarkTypes.BOLD |\n MarkTypes.ITALIC |\n MarkTypes.UNDERLINE |\n MarkTypes.STRIKE |\n MarkTypes.CODE |\n MarkTypes.LINK |\n MarkTypes.ANCHOR |\n MarkTypes.STYLED |\n MarkTypes.SUPERSCRIPT |\n MarkTypes.SUBSCRIPT |\n MarkTypes.TEXT_STYLE |\n MarkTypes.HIGHLIGHT;\n attrs?: BlockAttributes;\n}\n\nexport interface TextNode<T = string> extends StoryblokRichTextNode<T> {\n type: TextTypes.TEXT;\n text: string;\n marks?: MarkNode<T>[];\n}\n\n/**\n * Represents the configuration options for optimizing images in rich text content.\n */\nexport interface StoryblokRichTextImageOptimizationOptions {\n /**\n * CSS class to be applied to the image.\n */\n class: string;\n\n /**\n * Width of the image in pixels.\n */\n width: number;\n\n /**\n * Height of the image in pixels.\n */\n height: number;\n\n /**\n * Loading strategy for the image. 'lazy' loads the image when it enters the viewport. 'eager' loads the image immediately.\n */\n loading: 'lazy' | 'eager';\n\n /**\n * Optional filters that can be applied to the image to adjust its appearance.\n *\n * @example\n *\n * ```typescript\n * const filters: Partial<StoryblokRichTextImageOptimizationOptions['filters']> = {\n * blur: 5,\n * brightness: 150,\n * grayscale: true\n * }\n * ```\n */\n filters: Partial<{\n blur: number;\n brightness: number;\n fill: 'transparent';\n format: 'webp' | 'png' | 'jpg';\n grayscale: boolean;\n quality: number;\n rotate: 0 | 90 | 180 | 270;\n }>;\n\n /**\n * Defines a set of source set values that tell the browser different image sizes to load based on screen conditions.\n * The entries can be just the width in pixels or a tuple of width and pixel density.\n *\n * @example\n *\n * ```typescript\n * const srcset: (number | [number, number])[] = [\n * 320,\n * [640, 2]\n * ]\n * ```\n */\n srcset: (number | [number, number])[];\n\n /**\n * A list of sizes that correspond to different viewport widths, instructing the browser on which srcset source to use.\n *\n * @example\n *\n * ```typescript\n * const sizes: string[] = [\n * '(max-width: 320px) 280px',\n * '(max-width: 480px) 440px',\n * '800px'\n * ]\n * ```\n */\n sizes: string[];\n}\n\n/**\n * Represents the options for rendering rich text.\n */\nexport interface StoryblokRichTextOptions<T = string, S = (tag: string, attrs: BlockAttributes, children?: T) => T> {\n /**\n * Defines the function that will be used to render the final HTML string (vanilla) or Framework component (React, Vue).\n *\n * @example\n *\n * ```typescript\n * const renderFn = (tag: string, attrs: Record<string, any>, text?: string) => {\n * return `<${tag} ${Object.keys(attrs).map(key => `${key}=\"${attrs[key]}\"`).join(' ')}>${text}</${tag}>`\n * }\n *\n * const options: StoryblokRichTextOptions = {\n * renderFn\n * }\n * ```\n */\n renderFn?: S;\n\n /**\n * Defines the function that will be used to render HTML text.\n *\n * @example\n *\n * ```typescript\n * import { h, createTextVNode } from 'vue'\n *\n * const options: StoryblokRichTextOptions = {\n * renderFn: h,\n * textFn: createTextVNode\n * }\n * ```\n */\n textFn?: (text: string, attrs?: BlockAttributes) => T;\n\n /**\n * Defines opt-out image optimization options.\n *\n * @example\n *\n * ```typescript\n * const options: StoryblokRichTextOptions = {\n * optimizeImages: true\n * }\n * ```\n *\n * @example\n *\n * ```typescript\n * const options: StoryblokRichTextOptions = {\n * optimizeImages: {\n * class: 'my-image',\n * width: 800,\n * height: 600,\n * loading: 'lazy',\n * }\n * ```\n */\n optimizeImages?: boolean | Partial<StoryblokRichTextImageOptimizationOptions>;\n /**\n * Defines whether to use the key attribute in the resolvers for framework use cases.\n * @default false\n * @example\n *\n * ```typescript\n *\n * const options: StoryblokRichTextOptions = {\n * renderFn: h,\n * keyedResolvers: true\n * }\n * ```\n */\n keyedResolvers?: boolean;\n /**\n * Custom tiptap extensions to override or add node/mark rendering.\n * Extensions are merged with the built-in defaults, overriding by key.\n */\n tiptapExtensions?: Record<string, any>;\n}\n","import type { BlockAttributes } from '../types';\nimport { LinkTypes } from '../types';\nimport { cleanObject } from '../utils';\n\n/**\n * Processes block-level attributes, converting textAlign to inline style\n * and preserving class/id/existing style.\n */\nexport function processBlockAttrs(attrs: BlockAttributes = {}): BlockAttributes {\n const { textAlign, class: className, id: idName, style: existingStyle, ...rest } = attrs;\n const styles: string[] = [];\n\n if (existingStyle) {\n styles.push(existingStyle.endsWith(';') ? existingStyle : `${existingStyle};`);\n }\n\n if (textAlign) {\n styles.push(`text-align: ${textAlign};`);\n }\n\n return cleanObject({\n ...rest,\n class: className,\n id: idName,\n ...(styles.length > 0 ? { style: styles.join(' ') } : {}),\n });\n}\n\n/**\n * Resolves a Storyblok link's attributes into a final href and remaining attrs.\n */\nexport function resolveStoryblokLink(attrs: Record<string, any> = {}): { href: string; rest: Record<string, any> } {\n const { linktype, href, anchor, uuid, custom, ...rest } = attrs;\n\n let finalHref = href ?? '';\n switch (linktype) {\n case LinkTypes.ASSET:\n case LinkTypes.URL:\n break;\n case LinkTypes.EMAIL:\n if (finalHref && !finalHref.startsWith('mailto:')) {\n finalHref = `mailto:${finalHref}`;\n }\n break;\n case LinkTypes.STORY:\n if (anchor) {\n finalHref = `${finalHref}#${anchor}`;\n }\n break;\n default:\n break;\n }\n\n return { href: finalHref, rest: { ...rest, ...(custom || {}) } };\n}\n\n/**\n * Computes table cell attributes, converting colwidth/backgroundColor/textAlign to CSS styles.\n */\nexport function computeTableCellAttrs(attrs: Record<string, any> = {}): BlockAttributes {\n const { colspan, rowspan, colwidth, backgroundColor, textAlign, ...rest } = attrs;\n const styles: string[] = [];\n\n if (colwidth) {\n styles.push(`width: ${colwidth}px;`);\n }\n\n if (backgroundColor) {\n styles.push(`background-color: ${backgroundColor};`);\n }\n\n if (textAlign) {\n styles.push(`text-align: ${textAlign};`);\n }\n\n return cleanObject({\n ...rest,\n ...(colspan > 1 ? { colspan } : {}),\n ...(rowspan > 1 ? { rowspan } : {}),\n ...(styles.length > 0 ? { style: styles.join(' ') } : {}),\n });\n}\n\n/**\n * List of supported HTML attributes by tag name, used by the Reporter mark.\n */\nexport const supportedAttributesByTagName: Record<string, string[]> = {\n a: ['href', 'target', 'data-uuid', 'data-anchor', 'data-linktype'],\n img: ['alt', 'src', 'title'],\n span: ['class'],\n} as const;\n\n/**\n * Gets allowed style classes for an element, warning on invalid ones.\n */\nexport function getAllowedStylesForElement(element: HTMLElement, { allowedStyles }: { allowedStyles: string[] }): string[] {\n const classString = element.getAttribute('class') || '';\n const classes = classString.split(' ').filter(Boolean);\n if (!classes.length) {\n return [];\n }\n\n const invalidStyles = classes.filter(x => !allowedStyles.includes(x));\n for (const invalidStyle of invalidStyles) {\n console.warn(`[StoryblokRichText] - \\`class\\` \"${invalidStyle}\" on \\`<${element.tagName.toLowerCase()}>\\` can not be transformed to rich text.`);\n }\n\n return allowedStyles.filter(x => classes.includes(x));\n}\n","import { Node } from '@tiptap/core';\nimport { BulletList, ListItem, OrderedList } from '@tiptap/extension-list';\nimport { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details';\nimport { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';\nimport Blockquote from '@tiptap/extension-blockquote';\nimport CodeBlock from '@tiptap/extension-code-block';\nimport Document from '@tiptap/extension-document';\nimport Emoji from '@tiptap/extension-emoji';\nimport HardBreak from '@tiptap/extension-hard-break';\nimport Heading from '@tiptap/extension-heading';\nimport HorizontalRule from '@tiptap/extension-horizontal-rule';\nimport Image from '@tiptap/extension-image';\nimport Paragraph from '@tiptap/extension-paragraph';\nimport Text from '@tiptap/extension-text';\nimport { optimizeImage } from '../images-optimization';\nimport type { StoryblokRichTextImageOptimizationOptions } from '../types';\nimport { cleanObject } from '../utils';\nimport { computeTableCellAttrs, processBlockAttrs } from './utils';\nimport TextAlign from '@tiptap/extension-text-align';\n\n// Re-export unmodified extensions\nexport { Details, DetailsContent, DetailsSummary, Document, Text };\n\nexport const StoryblokTextAlign = TextAlign.configure({\n types: ['heading', 'paragraph'],\n});\n// Blockquote, Paragraph, Heading need processBlockAttrs for textAlign support\nexport const StoryblokBlockquote = Blockquote.extend({\n renderHTML({ HTMLAttributes }) {\n return ['blockquote', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\nexport const StoryblokParagraph = Paragraph.extend({\n renderHTML({ HTMLAttributes }) {\n return ['p', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\nexport const StoryblokHeading = Heading.extend({\n renderHTML({ node, HTMLAttributes }) {\n const { level, ...rest } = HTMLAttributes;\n return [`h${node.attrs.level}`, processBlockAttrs(rest), 0];\n },\n});\n\nexport const StoryblokTableRow = TableRow.extend({\n renderHTML({ HTMLAttributes }) {\n return ['tr', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\n// Storyblok uses snake_case names for some extensions\nexport const StoryblokBulletList = BulletList.extend({\n name: 'bullet_list',\n addOptions() {\n return { ...this.parent!(), itemTypeName: 'list_item' };\n },\n renderHTML({ HTMLAttributes }) {\n return ['ul', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\nexport const StoryblokOrderedList = OrderedList.extend({\n name: 'ordered_list',\n addAttributes() {\n return {\n order: {\n default: 1,\n },\n };\n },\n addOptions() {\n return { ...this.parent!(), itemTypeName: 'list_item' };\n },\n renderHTML({ HTMLAttributes }) {\n return ['ol', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\nexport const StoryblokListItem = ListItem.extend({\n name: 'list_item',\n addOptions() {\n return { ...this.parent!(), bulletListTypeName: 'bullet_list', orderedListTypeName: 'ordered_list' };\n },\n renderHTML({ HTMLAttributes }) {\n return ['li', processBlockAttrs(HTMLAttributes), 0];\n },\n});\n\nexport const StoryblokCodeBlock = CodeBlock.extend({\n name: 'code_block',\n addAttributes() {\n return {\n class: {\n default: null,\n },\n };\n },\n renderHTML({ node, HTMLAttributes }) {\n const { language: _, ...rest } = HTMLAttributes;\n const attrs = processBlockAttrs(rest);\n const lang = node.attrs.language;\n const codeAttrs = lang ? { class: `language-${lang}` } : {};\n return ['pre', attrs, ['code', codeAttrs, 0]];\n },\n});\nexport const StoryblokHardBreak = HardBreak.extend({ name: 'hard_break' });\nexport const StoryblokHorizontalRule = HorizontalRule.extend({ name: 'horizontal_rule' });\n\n// Table with custom renderHTML\n// Note: thead/tbody grouping is handled by the richtext renderer,\n// which inspects child rows to detect header vs body rows.\nexport const StoryblokTable = Table.extend({\n renderHTML({ HTMLAttributes }) {\n const attrs = processBlockAttrs(HTMLAttributes);\n return ['table', attrs, 0];\n },\n});\n\n// Table cell with custom style handling\nexport const StoryblokTableCell = TableCell.extend({\n addAttributes() {\n return {\n ...this.parent?.(),\n colspan: {\n default: 1,\n },\n rowspan: {\n default: 1,\n },\n colwidth: {\n default: null,\n parseHTML: (element: HTMLElement) => {\n const colwidth = element.getAttribute('colwidth');\n return colwidth ? colwidth.split(',').map(Number) : null;\n },\n },\n backgroundColor: {\n default: null,\n },\n };\n },\n renderHTML({ HTMLAttributes }) {\n return ['td', computeTableCellAttrs(HTMLAttributes), 0];\n },\n});\n\n// Table header with custom style handling\nexport const StoryblokTableHeader = TableHeader.extend({\n renderHTML({ HTMLAttributes }) {\n return ['th', computeTableCellAttrs(HTMLAttributes), 0];\n },\n});\n\n// Image with optimizeImages support\nexport const StoryblokImage = Image.extend<{ optimizeImages: boolean | Partial<StoryblokRichTextImageOptimizationOptions> }>({\n addOptions() {\n return { ...this.parent?.(), optimizeImages: false };\n },\n renderHTML({ HTMLAttributes }) {\n const { src, alt, title, srcset, sizes } = HTMLAttributes;\n let finalSrc = src;\n let extraAttrs = {};\n\n if (this.options.optimizeImages) {\n const result = optimizeImage(src, this.options.optimizeImages);\n finalSrc = result.src;\n extraAttrs = result.attrs;\n }\n\n return ['img', cleanObject({ src: finalSrc, alt, title, srcset, sizes, ...extraAttrs })];\n },\n});\n\n// Emoji with custom renderHTML\nexport const StoryblokEmoji = Emoji.extend({\n renderHTML({ HTMLAttributes }) {\n return ['img', {\n 'data-emoji': HTMLAttributes.emoji,\n 'data-name': HTMLAttributes.name,\n 'src': HTMLAttributes.fallbackImage,\n 'alt': HTMLAttributes.alt,\n 'style': 'width: 1.25em; height: 1.25em; vertical-align: text-top',\n 'draggable': 'false',\n 'loading': 'lazy',\n }];\n },\n});\n\n// Blok node (component placeholder for vanilla usage)\n// Configure `renderComponent` option to render blok components in framework SDKs.\n// Similar to PHP Tiptap extension's `renderer` callback:\n// https://github.com/storyblok/php-tiptap-extension/blob/main/src/Node/Blok.php\nexport const ComponentBlok = Node.create<{ renderComponent: ((blok: Record<string, unknown>, id?: string) => unknown) | null }>({\n name: 'blok',\n group: 'block',\n atom: true,\n addOptions() {\n return {\n renderComponent: null,\n };\n },\n addAttributes() {\n return {\n id: { default: null },\n body: { default: [] },\n };\n },\n parseHTML() {\n return [{ tag: 'div[data-blok]' }];\n },\n renderHTML({ HTMLAttributes }) {\n console.warn('[StoryblokRichText] - BLOK resolver is not available for vanilla usage. Configure `renderComponent` option on the blok tiptapExtension.');\n return ['span', cleanObject({\n 'data-blok': JSON.stringify(HTMLAttributes?.body?.[0] ?? null),\n 'data-blok-id': HTMLAttributes?.id,\n 'style': 'display: none',\n })];\n },\n});\n","import type { SBRichTextSegment, StoryblokSegmentType } from './richtext-segment';\n\nexport interface RendererAdapter<T = unknown> {\n createElement: (\n tag: string,\n attrs?: Record<string, unknown>,\n children?: T[]\n ) => T;\n\n createText: (text: string) => T;\n\n createComponent?: (\n type: StoryblokSegmentType,\n props: Record<string, unknown>\n ) => T;\n}\n\nfunction renderSegment<T>(\n segment: SBRichTextSegment,\n adapter: RendererAdapter<T>,\n customComponents: StoryblokSegmentType[],\n key?: number,\n): T {\n if (segment.kind === 'text') {\n return adapter.createText(segment.text);\n }\n // Treat as component if it's a real component or a custom mapped type\n if (segment.kind === 'component') {\n if (!adapter.createComponent) {\n throw new Error('Component renderer not provided');\n }\n return adapter.createComponent(segment.type as StoryblokSegmentType, { key, ...segment.props });\n }\n // Node or Mark segment overrides\n if (customComponents.includes(segment.type as StoryblokSegmentType)) {\n if (!adapter.createComponent) {\n throw new Error('Component renderer not provided');\n }\n\n // Convert NodeSegment or MarkSegment to props\n const props = {\n ...('attrs' in segment ? segment.attrs : {}),\n key,\n children: segment.content?.map((child, i) => renderSegment(child, adapter, customComponents, i)),\n };\n\n return adapter.createComponent(segment.type as StoryblokSegmentType, props);\n }\n // node or mark\n const children = segment.content?.map((child, i) =>\n renderSegment(child, adapter, customComponents, i),\n ) ?? [];\n\n if (!segment.tag) {\n throw new Error(`Missing tag for ${segment.type}`);\n }\n\n return adapter.createElement(segment.tag, { ...segment.attrs, key }, children);\n}\nexport function renderSegments<T>(\n segments: SBRichTextSegment[],\n adapter: RendererAdapter<T>,\n customComponents: StoryblokSegmentType[],\n): T[] {\n return segments.map((segment, index) => renderSegment(segment, adapter, customComponents, index));\n}\n","import { Mark } from '@tiptap/core';\nimport Bold from '@tiptap/extension-bold';\nimport Code from '@tiptap/extension-code';\nimport Highlight from '@tiptap/extension-highlight';\nimport Italic from '@tiptap/extension-italic';\nimport LinkOriginal from '@tiptap/extension-link';\nimport Strike from '@tiptap/extension-strike';\nimport Subscript from '@tiptap/extension-subscript';\nimport Superscript from '@tiptap/extension-superscript';\nimport { TextStyleKit } from '@tiptap/extension-text-style';\nimport Underline from '@tiptap/extension-underline';\nimport { attrsToStyle, cleanObject } from '../utils';\nimport { getAllowedStylesForElement, resolveStoryblokLink, supportedAttributesByTagName } from './utils';\n\n// Unmodified mark extensions\nexport { Bold, Code, Italic, Strike, Subscript, Superscript, TextStyleKit, Underline };\n\n// Highlight\nexport const StoryblokHighlight = Highlight.extend({\n addAttributes() {\n return {\n color: {\n default: null,\n },\n };\n },\n});\n\n// Link with Storyblok-specific attributes and renderHTML\nexport const StoryblokLink = LinkOriginal.extend({\n addAttributes() {\n return {\n href: {\n parseHTML: (element: HTMLElement) => element.getAttribute('href'),\n },\n uuid: {\n default: null,\n parseHTML: (element: HTMLElement) => element.getAttribute('data-uuid') || null,\n },\n anchor: {\n default: null,\n parseHTML: (element: HTMLElement) => element.getAttribute('data-anchor') || null,\n },\n target: {\n parseHTML: (element: HTMLElement) => element.getAttribute('target') || null,\n },\n linktype: {\n default: 'url',\n parseHTML: (element: HTMLElement) => element.getAttribute('data-linktype') || 'url',\n },\n };\n },\n renderHTML({ HTMLAttributes }) {\n const { href, rest } = resolveStoryblokLink(HTMLAttributes);\n return ['a', cleanObject({ ...(href ? { href } : {}), ...rest }), 0];\n },\n});\n\n// Link with custom attributes support\nexport const StoryblokLinkWithCustomAttributes = StoryblokLink.extend({\n addAttributes() {\n return {\n ...this.parent?.(),\n custom: {\n default: null,\n parseHTML: (element: HTMLElement) => {\n const defaultLinkAttributes = supportedAttributesByTagName.a;\n const customAttributeNames = element.getAttributeNames().filter(n => !defaultLinkAttributes.includes(n));\n const customAttributes: Record<string, string | null> = {};\n for (const attributeName of customAttributeNames) {\n customAttributes[attributeName] = element.getAttribute(attributeName);\n }\n return Object.keys(customAttributes).length ? customAttributes : null;\n },\n },\n };\n },\n});\n\n// Anchor mark (renders as span with id)\nexport const StoryblokAnchor = Mark.create({\n name: 'anchor',\n addAttributes() {\n return {\n id: { default: null },\n };\n },\n parseHTML() {\n return [{ tag: 'span[id]' }];\n },\n renderHTML({ HTMLAttributes }) {\n return ['span', { id: HTMLAttributes.id }, 0];\n },\n});\n\nexport interface StyledOptions {\n allowedStyles?: string[];\n}\n\n// Styled mark with whitelisted CSS classes\nexport const StoryblokStyled = Mark.create<StyledOptions>({\n name: 'styled',\n addAttributes() {\n return {\n class: {\n parseHTML: (element: HTMLElement) => {\n const styles = getAllowedStylesForElement(element, { allowedStyles: this.options.allowedStyles || [] });\n return styles.length ? styles.join(' ') : null;\n },\n },\n };\n },\n parseHTML() {\n return [\n {\n tag: 'span',\n consuming: false,\n getAttrs: (element: HTMLElement) => {\n const styles = getAllowedStylesForElement(element, { allowedStyles: this.options.allowedStyles || [] });\n return styles.length ? null : false;\n },\n },\n ];\n },\n renderHTML({ HTMLAttributes }) {\n const { class: className, ...rest } = HTMLAttributes;\n return ['span', cleanObject({ class: className, style: attrsToStyle(rest) || undefined }), 0];\n },\n});\n\n// TextStyle mark\nexport const StoryblokTextStyle = Mark.create({\n name: 'textStyle',\n addAttributes() {\n return {\n class: { default: null },\n id: { default: null },\n color: { default: null },\n };\n },\n parseHTML() {\n return [{\n tag: 'span',\n consuming: false,\n getAttrs: (element: HTMLElement) => {\n // Only match spans with inline style containing color\n const style = element.getAttribute('style');\n if (style && /color/i.test(style)) {\n return null;\n }\n return false;\n },\n }];\n },\n renderHTML({ HTMLAttributes }) {\n const { class: className, id: idName, ...styleAttrs } = HTMLAttributes;\n return ['span', cleanObject({\n class: className,\n id: idName,\n style: attrsToStyle(styleAttrs) || undefined,\n }), 0];\n },\n});\n\n// Reporter mark: parse-only diagnostic, no renderHTML needed\nexport const Reporter = Mark.create({\n name: 'reporter',\n priority: 0,\n addOptions() {\n return {\n allowCustomAttributes: false,\n };\n },\n parseHTML() {\n return [\n {\n tag: '*',\n consuming: false,\n getAttrs: (element: HTMLElement) => {\n const tagName = element.tagName.toLowerCase();\n if (tagName === 'a' && this.options.allowCustomAttributes) {\n return false;\n }\n\n const unsupportedAttributes = element.getAttributeNames().filter((attr) => {\n const supportedAttrs = tagName in supportedAttributesByTagName ? supportedAttributesByTagName[tagName] : [];\n return !supportedAttrs.includes(attr);\n });\n for (const attr of unsupportedAttributes) {\n console.warn(`[StoryblokRichText] - \\`${attr}\\` \"${element.getAttribute(attr)}\" on \\`<${tagName}>\\` can not be transformed to rich text.`);\n }\n\n return false;\n },\n },\n ];\n },\n});\n","import type { Extension, Mark, Node } from '@tiptap/core';\nimport type { StoryblokRichTextImageOptimizationOptions } from '../types';\nimport {\n ComponentBlok,\n Details,\n DetailsContent,\n DetailsSummary,\n Document,\n StoryblokBlockquote,\n StoryblokBulletList,\n StoryblokCodeBlock,\n StoryblokEmoji,\n StoryblokHardBreak,\n StoryblokHeading,\n StoryblokHorizontalRule,\n StoryblokImage,\n StoryblokListItem,\n StoryblokOrderedList,\n StoryblokParagraph,\n StoryblokTable,\n StoryblokTableCell,\n StoryblokTableHeader,\n StoryblokTableRow,\n StoryblokTextAlign,\n Text,\n} from './nodes';\nimport {\n Bold,\n Code,\n Italic,\n Reporter,\n StoryblokAnchor,\n StoryblokHighlight,\n StoryblokLink,\n StoryblokLinkWithCustomAttributes,\n StoryblokStyled,\n StoryblokTextStyle,\n Strike,\n Subscript,\n Superscript,\n Underline,\n} from './marks';\n\nexport interface StyleOption {\n name: string;\n value: string;\n}\n\nexport interface HTMLParserOptions {\n allowCustomAttributes?: boolean;\n preserveWhitespace?: boolean | 'full';\n tiptapExtensions?: Partial<typeof defaultExtensions & Record<string, Extension | Mark | Node>>;\n styleOptions?: StyleOption[];\n}\n\nexport interface StoryblokExtensionOptions {\n optimizeImages?: boolean | Partial<StoryblokRichTextImageOptimizationOptions>;\n allowCustomAttributes?: boolean;\n styleOptions?: StyleOption[];\n}\n\nconst defaultExtensions = {\n document: Document,\n text: Text,\n paragraph: StoryblokParagraph,\n blockquote: StoryblokBlockquote,\n heading: StoryblokHeading,\n bulletList: StoryblokBulletList,\n orderedList: StoryblokOrderedList,\n listItem: StoryblokListItem,\n codeBlock: StoryblokCodeBlock,\n hardBreak: StoryblokHardBreak,\n horizontalRule: StoryblokHorizontalRule,\n image: StoryblokImage,\n emoji: StoryblokEmoji,\n table: StoryblokTable,\n tableRow: StoryblokTableRow,\n tableCell: StoryblokTableCell,\n tableHeader: StoryblokTableHeader,\n blok: ComponentBlok,\n details: Details,\n detailsContent: DetailsContent,\n detailsSummary: DetailsSummary,\n bold: Bold,\n italic: Italic,\n strike: Strike,\n underline: Underline,\n code: Code,\n superscript: Superscript,\n subscript: Subscript,\n highlight: StoryblokHighlight,\n textStyle: StoryblokTextStyle,\n link: StoryblokLink as typeof StoryblokLink,\n anchor: StoryblokAnchor,\n styled: StoryblokStyled,\n reporter: Reporter,\n textAlign: StoryblokTextAlign,\n};\n\nexport { defaultExtensions };\n\nexport function getStoryblokExtensions(options: StoryblokExtensionOptions = {}) {\n const Link = options.allowCustomAttributes ? StoryblokLinkWithCustomAttributes : StoryblokLink;\n\n return {\n ...defaultExtensions,\n image: StoryblokImage.configure({ optimizeImages: options.optimizeImages || false }),\n link: Link,\n styled: StoryblokStyled.configure({ allowedStyles: options.styleOptions?.map(o => o.value) }),\n reporter: Reporter.configure({ allowCustomAttributes: options.allowCustomAttributes }),\n };\n}\n\nexport * from './marks';\nexport * from './nodes';\nexport { computeTableCellAttrs, processBlockAttrs, resolveStoryblokLink } from './utils';\n","import { getStoryblokExtensions } from './extensions';\nimport type { BlockAttributes, MarkNode, StoryblokRichTextDocumentNode, StoryblokRichTextNode, StoryblokRichTextOptions, TextNode } from './types';\nimport { attrsToString, collectMarkedTextGroup, escapeHtml, getUniqueMarks, SELF_CLOSING_TAGS } from './utils';\n\n/**\n * Default render function that creates an HTML string for a given tag, attributes, and children.\n */\nfunction defaultRenderFn<T = string | null>(tag: string, attrs: BlockAttributes = {}, children?: T): T {\n const attrsString = attrsToString(attrs);\n const tagString = attrsString ? `${tag} ${attrsString}` : tag;\n const content = Array.isArray(children) ? children.join('') : children || '';\n\n if (!tag) {\n return content as unknown as T;\n }\n else if (SELF_CLOSING_TAGS.includes(tag)) {\n return `<${tagString}>` as unknown as T;\n }\n return `<${tagString}>${content}</${tag}>` as unknown as T;\n}\n\n/**\n * Converts a ProseMirror DOMOutputSpec array to renderFn calls.\n */\nfunction specToRender<T>(\n spec: any[],\n renderFn: (tag: string, attrs?: BlockAttributes, children?: T) => T,\n children: T | undefined,\n): T {\n const [tag, ...rest] = spec;\n let attrs: BlockAttributes = {};\n let content = rest;\n\n // First non-array, non-number, non-null plain object = attributes\n if (\n content.length > 0\n && content[0] !== null\n && content[0] !== undefined\n && typeof content[0] === 'object'\n && !Array.isArray(content[0])\n && typeof content[0] !== 'number'\n ) {\n attrs = content[0] as BlockAttributes;\n content = content.slice(1);\n }\n\n // Filter nulls (Table extension can produce them)\n content = content.filter((c: any) => c !== null && c !== undefined);\n\n // No content → self-closing (img, hr, br)\n if (content.length === 0) {\n return renderFn(tag, attrs) as T;\n }\n\n // Content hole (0) → insert children\n if (content.length === 1 && content[0] === 0) {\n return renderFn(tag, attrs, children) as T;\n }\n\n // Nested specs → recursive\n const nested = content.map((item: any) => {\n if (item === 0) {\n return children;\n }\n if (Array.isArray(item)) {\n return specToRender(item, renderFn, children);\n }\n return item;\n });\n\n return renderFn(tag, attrs, nested.length === 1 ? nested[0] : nested) as T;\n}\n\n/**\n * Calls renderHTML on a tiptap extension.\n */\nfunction callExtensionRenderHTML(ext: any, type: 'node' | 'mark', attrs: Record<string, any>): any[] {\n const thisContext = { options: ext.options || {}, name: ext.name, type: ext.type };\n if (type === 'node') {\n return ext.config.renderHTML.call(thisContext, {\n node: { attrs },\n HTMLAttributes: attrs,\n });\n }\n return ext.config.renderHTML.call(thisContext, {\n mark: { attrs },\n HTMLAttributes: attrs,\n });\n}\n\n/**\n * Creates a rich text resolver with the given options.\n */\nexport function richTextResolver<T>(options: StoryblokRichTextOptions<T> = {}) {\n const keyCounters = new Map<string, number>();\n\n const {\n renderFn = defaultRenderFn,\n textFn = escapeHtml,\n optimizeImages = false,\n keyedResolvers = false,\n tiptapExtensions,\n } = options;\n const isExternalRenderFn = renderFn !== defaultRenderFn;\n\n // Get extensions configured with runtime options, merged with user overrides\n const baseExtensions = getStoryblokExtensions({ optimizeImages });\n const allExtensions = tiptapExtensions\n ? { ...baseExtensions, ...tiptapExtensions }\n : baseExtensions;\n const extensionValues = Object.values(allExtensions);\n\n // Build lookup maps: type name → extension config\n const nodeExtMap = new Map<string, any>();\n const markExtMap = new Map<string, any>();\n for (const ext of extensionValues) {\n if (ext.type === 'node') {\n nodeExtMap.set(ext.name, ext);\n }\n else if (ext.type === 'mark') {\n markExtMap.set(ext.name, ext);\n }\n }\n\n // Wrap renderFn with auto-keying\n const contextRenderFn = (tag: string, attrs: BlockAttributes = {}, children?: T): T => {\n if (keyedResolvers && tag) {\n const currentCount = keyCounters.get(tag) || 0;\n keyCounters.set(tag, currentCount + 1);\n attrs = { ...attrs, key: `${tag}-${currentCount}` };\n }\n // Wrap children in a function for component tags to avoid Vue slot warning:\n // \"Non-function value encountered for default slot.\"\n if (isExternalRenderFn && typeof tag !== 'string' && children !== undefined) {\n return renderFn(tag, attrs, (() => children) as unknown as T);\n }\n return renderFn(tag, attrs, children);\n };\n\n // --- Mark merging (ProseMirror-style adjacent text node grouping) ---\n\n /** Renders a group of text nodes with shared marks wrapped once around unique-mark content. */\n function renderMergedTextNodes(group: TextNode<T>[], shared: MarkNode<T>[]): T {\n const innerRendered = group.map((node) => {\n return renderText({ ...node, marks: getUniqueMarks(node.marks || [], shared) } as TextNode<T>);\n });\n\n let content: T = isExternalRenderFn\n ? innerRendered as unknown as T\n : innerRendered.join('') as unknown as T;\n\n // Forward order: matches renderText's marks.reduce() where marks[0] wraps first\n // (innermost) and marks[last] wraps last (outermost).\n for (const mark of shared) {\n const ext = markExtMap.get(mark.type);\n if (!ext?.config?.renderHTML) {\n continue;\n }\n const markAttrs = mark.attrs || {};\n const spec = callExtensionRenderHTML(ext, 'mark', markAttrs);\n content = specToRender(spec, contextRenderFn, content);\n }\n\n return content;\n }\n\n /** Groups adjacent text nodes with shared marks and renders them merged. */\n function groupAndRenderChildren(children: StoryblokRichTextNode<T>[]): T[] {\n const result: T[] = [];\n let i = 0;\n while (i < children.length) {\n const match = collectMarkedTextGroup(children, i);\n if (!match) {\n result.push(render(children[i]));\n i++;\n continue;\n }\n if (match.group.length === 1) {\n result.push(renderText(match.group[0]));\n }\n else {\n result.push(renderMergedTextNodes(match.group, match.shared));\n }\n i = match.endIndex;\n }\n return result;\n }\n\n function renderNode(node: StoryblokRichTextNode<T>): T {\n // Text nodes — apply marks via reduce\n if (node.type === 'text') {\n return renderText(node as TextNode<T>);\n }\n\n // Document node renders without wrapper\n if (node.type === 'doc') {\n return render(node);\n }\n\n // Find extension and call renderHTML\n const ext = nodeExtMap.get(node.type);\n if (!ext?.config?.renderHTML) {\n console.error('<Storyblok>', `No extension found for node type ${node.type}`);\n return '' as unknown as T;\n }\n\n // Check for renderComponent option (e.g., blok nodes configured via tiptapExtensions)\n if (ext.options?.renderComponent) {\n const body = node.attrs?.body;\n const id = node.attrs?.id;\n if (!Array.isArray(body) || body.length === 0) {\n return (isExternalRenderFn ? [] : '') as unknown as T;\n }\n const rendered = body.map((blok: Record<string, unknown>) => ext.options.renderComponent(blok, id));\n return (isExternalRenderFn ? rendered : rendered.filter((r: unknown) => r != null).join('')) as unknown as T;\n }\n\n // Table: group rows into thead/tbody based on cell types\n if (node.type === 'table' && node.content?.length) {\n const headerRows: StoryblokRichTextNode<T>[] = [];\n const bodyRows: StoryblokRichTextNode<T>[] = [];\n for (const row of node.content) {\n const isHeaderRow = bodyRows.length === 0\n && row.content?.every((cell: StoryblokRichTextNode<T>) => cell.type === 'tableHeader');\n if (isHeaderRow) {\n headerRows.push(row);\n }\n else {\n bodyRows.push(row);\n }\n }\n const nodeAttrs = node.attrs || {};\n const spec = callExtensionRenderHTML(ext, 'node', nodeAttrs);\n const parts: T[] = [];\n if (headerRows.length > 0) {\n parts.push(contextRenderFn('thead', {}, headerRows.map(render) as T));\n }\n if (bodyRows.length > 0) {\n parts.push(contextRenderFn('tbody', {}, bodyRows.map(render) as T));\n }\n return specToRender(spec, contextRenderFn, parts as T | undefined) as T;\n }\n\n const children = node.content ? groupAndRenderChildren(node.content) : undefined;\n\n const nodeAttrs = node.attrs || {};\n const spec = callExtensionRenderHTML(ext, 'node', nodeAttrs);\n return specToRender(spec, contextRenderFn, children as T | undefined) as T;\n }\n\n function renderText(node: TextNode<T>): T {\n const { marks, ...rest } = node;\n\n if (marks?.length) {\n // Base text\n const baseText = (() => {\n const attrs: BlockAttributes = {};\n if (keyedResolvers) {\n const currentCount = keyCounters.get('txt') || 0;\n keyCounters.set('txt', currentCount + 1);\n attrs.key = `txt-${currentCount}`;\n }\n return textFn(rest.text, attrs) as T;\n })();\n\n // Apply marks as reduce: text → mark1(text) → mark2(mark1(text))\n return marks.reduce((text: T, mark: MarkNode<T>) => {\n const ext = markExtMap.get(mark.type);\n if (!ext?.config?.renderHTML) {\n console.error('<Storyblok>', `No extension found for node type ${mark.type}`);\n return text;\n }\n\n const markAttrs = mark.attrs || {};\n const spec = callExtensionRenderHTML(ext, 'mark', markAttrs);\n return specToRender(spec, contextRenderFn, text) as T;\n }, baseText);\n }\n\n // Plain text, no marks\n const attrs: BlockAttributes = node.attrs || {};\n if (keyedResolvers) {\n const currentCount = keyCounters.get('txt') || 0;\n keyCounters.set('txt', currentCount + 1);\n attrs.key = `txt-${currentCount}`;\n }\n return textFn(rest.text, attrs) as T;\n }\n\n function render(node: StoryblokRichTextNode<T> | StoryblokRichTextDocumentNode): T {\n const n = node as StoryblokRichTextNode<T>;\n if (n.type === 'doc') {\n return isExternalRenderFn ? n.content.ma