UNPKG

@storyblok/richtext

Version:
1,390 lines (1,376 loc) 43.8 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) { __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } } } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let _tiptap_core = require("@tiptap/core"); let _tiptap_extension_list = require("@tiptap/extension-list"); let _tiptap_extension_details = require("@tiptap/extension-details"); let _tiptap_extension_table = require("@tiptap/extension-table"); let _tiptap_extension_blockquote = require("@tiptap/extension-blockquote"); _tiptap_extension_blockquote = __toESM(_tiptap_extension_blockquote); let _tiptap_extension_code_block = require("@tiptap/extension-code-block"); _tiptap_extension_code_block = __toESM(_tiptap_extension_code_block); let _tiptap_extension_document = require("@tiptap/extension-document"); _tiptap_extension_document = __toESM(_tiptap_extension_document); let _tiptap_extension_emoji = require("@tiptap/extension-emoji"); _tiptap_extension_emoji = __toESM(_tiptap_extension_emoji); let _tiptap_extension_hard_break = require("@tiptap/extension-hard-break"); _tiptap_extension_hard_break = __toESM(_tiptap_extension_hard_break); let _tiptap_extension_heading = require("@tiptap/extension-heading"); _tiptap_extension_heading = __toESM(_tiptap_extension_heading); let _tiptap_extension_horizontal_rule = require("@tiptap/extension-horizontal-rule"); _tiptap_extension_horizontal_rule = __toESM(_tiptap_extension_horizontal_rule); let _tiptap_extension_image = require("@tiptap/extension-image"); _tiptap_extension_image = __toESM(_tiptap_extension_image); let _tiptap_extension_paragraph = require("@tiptap/extension-paragraph"); _tiptap_extension_paragraph = __toESM(_tiptap_extension_paragraph); let _tiptap_extension_text = require("@tiptap/extension-text"); _tiptap_extension_text = __toESM(_tiptap_extension_text); let _tiptap_extension_text_align = require("@tiptap/extension-text-align"); _tiptap_extension_text_align = __toESM(_tiptap_extension_text_align); let _tiptap_extension_bold = require("@tiptap/extension-bold"); _tiptap_extension_bold = __toESM(_tiptap_extension_bold); let _tiptap_extension_code = require("@tiptap/extension-code"); _tiptap_extension_code = __toESM(_tiptap_extension_code); let _tiptap_extension_highlight = require("@tiptap/extension-highlight"); _tiptap_extension_highlight = __toESM(_tiptap_extension_highlight); let _tiptap_extension_italic = require("@tiptap/extension-italic"); _tiptap_extension_italic = __toESM(_tiptap_extension_italic); let _tiptap_extension_link = require("@tiptap/extension-link"); _tiptap_extension_link = __toESM(_tiptap_extension_link); let _tiptap_extension_strike = require("@tiptap/extension-strike"); _tiptap_extension_strike = __toESM(_tiptap_extension_strike); let _tiptap_extension_subscript = require("@tiptap/extension-subscript"); _tiptap_extension_subscript = __toESM(_tiptap_extension_subscript); let _tiptap_extension_superscript = require("@tiptap/extension-superscript"); _tiptap_extension_superscript = __toESM(_tiptap_extension_superscript); require("@tiptap/extension-text-style"); let _tiptap_extension_underline = require("@tiptap/extension-underline"); _tiptap_extension_underline = __toESM(_tiptap_extension_underline); //#region src/images-optimization.ts function optimizeImage(src, options) { if (!options) return { src, attrs: {} }; let w = 0; let h = 0; const attrs = {}; const filterParams = []; function validateAndPushFilterParam(value, min, max, filter, filterParams) { if (typeof value !== "number" || value <= min || value >= max) console.warn(`[StoryblokRichText] - ${filter.charAt(0).toUpperCase() + filter.slice(1)} value must be a number between ${min} and ${max} (inclusive)`); else filterParams.push(`${filter}(${value})`); } if (typeof options === "object") { if (options.width !== void 0) if (typeof options.width === "number" && options.width >= 0) { attrs.width = options.width; w = options.width; } else console.warn("[StoryblokRichText] - Width value must be a number greater than or equal to 0"); if (options.height !== void 0) if (typeof options.height === "number" && options.height >= 0) { attrs.height = options.height; h = options.height; } else console.warn("[StoryblokRichText] - Height value must be a number greater than or equal to 0"); if (options.height === 0 && options.width === 0) { delete attrs.width; delete attrs.height; console.warn("[StoryblokRichText] - Width and height values cannot both be 0"); } if (options.loading && ["lazy", "eager"].includes(options.loading)) attrs.loading = options.loading; if (options.class) attrs.class = options.class; if (options.filters) { const { filters } = options || {}; const { blur, brightness, fill, format, grayscale, quality, rotate } = filters || {}; if (blur) validateAndPushFilterParam(blur, 0, 100, "blur", filterParams); if (quality) validateAndPushFilterParam(quality, 0, 100, "quality", filterParams); if (brightness) validateAndPushFilterParam(brightness, 0, 100, "brightness", filterParams); if (fill) filterParams.push(`fill(${fill})`); if (grayscale) filterParams.push(`grayscale()`); if (rotate && [ 0, 90, 180, 270 ].includes(options.filters.rotate || 0)) filterParams.push(`rotate(${rotate})`); if (format && [ "webp", "png", "jpeg" ].includes(format)) filterParams.push(`format(${format})`); } if (options.srcset) attrs.srcset = options.srcset.map((entry) => { if (typeof entry === "number") return `${src}/m/${entry}x0/${filterParams.length > 0 ? `filters:${filterParams.join(":")}` : ""} ${entry}w`; if (Array.isArray(entry) && entry.length === 2) { const [entryWidth, entryHeight] = entry; return `${src}/m/${entryWidth}x${entryHeight}/${filterParams.length > 0 ? `filters:${filterParams.join(":")}` : ""} ${entryWidth}w`; } else { console.warn("[StoryblokRichText] - srcset entry must be a number or a tuple of two numbers"); return; } }).join(", "); if (options.sizes) attrs.sizes = options.sizes.join(", "); } let resultSrc = `${src}/m/`; if (w > 0 || h > 0) resultSrc = `${resultSrc}${w}x${h}/`; if (filterParams.length > 0) resultSrc = `${resultSrc}filters:${filterParams.join(":")}`; return { src: resultSrc, attrs }; } //#endregion //#region src/utils/index.ts /** * Deep equality comparison for plain objects, arrays, and primitives. */ function deepEqual(a, b) { if (a === b) return true; if (a === null || a === void 0 || b === null || b === void 0) return a === b; if (typeof a !== typeof b) return false; if (typeof a !== "object") return false; if (Array.isArray(a) !== Array.isArray(b)) return false; if (Array.isArray(a)) { if (a.length !== b.length) return false; return a.every((v, i) => deepEqual(v, b[i])); } const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k])); } /** Checks if two marks are equal by comparing their type and attrs. */ function markEquals(a, b) { return a.type === b.type && deepEqual(a.attrs, b.attrs); } /** Type guard: checks if a node is a text node with at least one mark. */ function isMarkedTextNode(node) { return node.type === "text" && !!node.marks?.length; } /** Returns marks unique to a node (not in the shared set), or undefined if all marks are shared. */ function getUniqueMarks(marks, shared) { const unique = marks.filter((m) => !shared.some((s) => markEquals(s, m))); return unique.length ? unique : void 0; } /** * Starting at `fromIndex`, collects adjacent marked text nodes that share at least one common mark. * Returns null if the node at `fromIndex` is not a marked text node. */ function collectMarkedTextGroup(children, fromIndex) { const child = children[fromIndex]; if (!isMarkedTextNode(child)) return null; const group = [child]; let shared = child.marks; let j = fromIndex + 1; while (j < children.length) { const next = children[j]; if (!isMarkedTextNode(next)) break; const nextShared = shared.filter((m) => next.marks.some((n) => markEquals(m, n))); if (nextShared.length === 0) break; shared = nextShared; group.push(next); j++; } return { group, shared, endIndex: j }; } const SELF_CLOSING_TAGS = [ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" ]; /** * Converts an object of attributes to a string. * * @param {Record<string, string>} [attrs] * * @returns {string} The string representation of the attributes. * * @example * * ```typescript * const attrs = { * class: 'text-red', * style: 'color: red', * } * * const attrsString = attrsToString(attrs) * * console.log(attrsString) // 'class="text-red" style="color: red"' * * ``` * */ const attrsToString = (attrs = {}) => { const { custom, ...attrsWithoutCustom } = attrs; const normalizedAttrs = { ...attrsWithoutCustom, ...custom }; return Object.keys(normalizedAttrs).filter((key) => normalizedAttrs[key] != null).map((key) => `${key}="${String(normalizedAttrs[key]).replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`).join(" "); }; /** * Converts an object of attributes to a CSS style string. * * @param {Record<string, string>} [attrs] * * @returns {string} The string representation of the CSS styles. * * @example * * ```typescript * const attrs = { * color: 'red', * fontSize: '16px', * } * * const styleString = attrsToStyle(attrs) * * console.log(styleString) // 'color: red; font-size: 16px' * ``` */ const attrsToStyle = (attrs = {}) => Object.keys(attrs).map((key) => `${key}: ${attrs[key]}`).join("; "); /** * Escapes HTML entities in a string. * * @param {string} unsafeText * @return {*} {string} * * @example * * ```typescript * const unsafeText = '<script>alert("Hello")<\/script>' * * const safeText = escapeHtml(unsafeText) * * console.log(safeText) // '&lt;script&gt;alert("Hello")&lt;/script&gt;' * ``` */ function escapeHtml(unsafeText) { return unsafeText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;"); } /** * Removes undefined values from an object. * * @param {Record<string, any>} obj * @return {*} {Record<string, any>} * * @example * * ```typescript * const obj = { * name: 'John', * age: undefined, * } * * const cleanedObj = cleanObject(obj) * * console.log(cleanedObj) // { name: 'John' } * ``` * */ const cleanObject = (obj) => { return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null)); }; //#endregion //#region src/types/index.ts let BlockTypes = /* @__PURE__ */ function(BlockTypes) { BlockTypes["DOCUMENT"] = "doc"; BlockTypes["HEADING"] = "heading"; BlockTypes["PARAGRAPH"] = "paragraph"; BlockTypes["QUOTE"] = "blockquote"; BlockTypes["OL_LIST"] = "ordered_list"; BlockTypes["UL_LIST"] = "bullet_list"; BlockTypes["LIST_ITEM"] = "list_item"; BlockTypes["CODE_BLOCK"] = "code_block"; BlockTypes["HR"] = "horizontal_rule"; BlockTypes["BR"] = "hard_break"; BlockTypes["IMAGE"] = "image"; BlockTypes["EMOJI"] = "emoji"; BlockTypes["COMPONENT"] = "blok"; BlockTypes["TABLE"] = "table"; BlockTypes["TABLE_ROW"] = "tableRow"; BlockTypes["TABLE_CELL"] = "tableCell"; BlockTypes["TABLE_HEADER"] = "tableHeader"; return BlockTypes; }({}); let MarkTypes = /* @__PURE__ */ function(MarkTypes) { MarkTypes["BOLD"] = "bold"; MarkTypes["STRONG"] = "strong"; MarkTypes["STRIKE"] = "strike"; MarkTypes["UNDERLINE"] = "underline"; MarkTypes["ITALIC"] = "italic"; MarkTypes["CODE"] = "code"; MarkTypes["LINK"] = "link"; MarkTypes["ANCHOR"] = "anchor"; MarkTypes["STYLED"] = "styled"; MarkTypes["SUPERSCRIPT"] = "superscript"; MarkTypes["SUBSCRIPT"] = "subscript"; MarkTypes["TEXT_STYLE"] = "textStyle"; MarkTypes["HIGHLIGHT"] = "highlight"; return MarkTypes; }({}); let TextTypes = /* @__PURE__ */ function(TextTypes) { TextTypes["TEXT"] = "text"; return TextTypes; }({}); let LinkTargets = /* @__PURE__ */ function(LinkTargets) { LinkTargets["SELF"] = "_self"; LinkTargets["BLANK"] = "_blank"; return LinkTargets; }({}); let LinkTypes = /* @__PURE__ */ function(LinkTypes) { LinkTypes["URL"] = "url"; LinkTypes["STORY"] = "story"; LinkTypes["ASSET"] = "asset"; LinkTypes["EMAIL"] = "email"; return LinkTypes; }({}); //#endregion //#region src/extensions/utils.ts /** * Processes block-level attributes, converting textAlign to inline style * and preserving class/id/existing style. */ function processBlockAttrs(attrs = {}) { const { textAlign, class: className, id: idName, style: existingStyle, ...rest } = attrs; const styles = []; if (existingStyle) styles.push(existingStyle.endsWith(";") ? existingStyle : `${existingStyle};`); if (textAlign) styles.push(`text-align: ${textAlign};`); return cleanObject({ ...rest, class: className, id: idName, ...styles.length > 0 ? { style: styles.join(" ") } : {} }); } /** * Resolves a Storyblok link's attributes into a final href and remaining attrs. */ function resolveStoryblokLink(attrs = {}) { const { linktype, href, anchor, uuid, custom, ...rest } = attrs; let finalHref = href ?? ""; switch (linktype) { case LinkTypes.ASSET: case LinkTypes.URL: break; case LinkTypes.EMAIL: if (finalHref && !finalHref.startsWith("mailto:")) finalHref = `mailto:${finalHref}`; break; case LinkTypes.STORY: if (anchor) finalHref = `${finalHref}#${anchor}`; break; default: break; } return { href: finalHref, rest: { ...rest, ...custom || {} } }; } /** * Computes table cell attributes, converting colwidth/backgroundColor/textAlign to CSS styles. */ function computeTableCellAttrs(attrs = {}) { const { colspan, rowspan, colwidth, backgroundColor, textAlign, ...rest } = attrs; const styles = []; if (colwidth) styles.push(`width: ${colwidth}px;`); if (backgroundColor) styles.push(`background-color: ${backgroundColor};`); if (textAlign) styles.push(`text-align: ${textAlign};`); return cleanObject({ ...rest, ...colspan > 1 ? { colspan } : {}, ...rowspan > 1 ? { rowspan } : {}, ...styles.length > 0 ? { style: styles.join(" ") } : {} }); } /** * List of supported HTML attributes by tag name, used by the Reporter mark. */ const supportedAttributesByTagName = { a: [ "href", "target", "data-uuid", "data-anchor", "data-linktype" ], img: [ "alt", "src", "title" ], span: ["class"] }; /** * Gets allowed style classes for an element, warning on invalid ones. */ function getAllowedStylesForElement(element, { allowedStyles }) { const classes = (element.getAttribute("class") || "").split(" ").filter(Boolean); if (!classes.length) return []; const invalidStyles = classes.filter((x) => !allowedStyles.includes(x)); for (const invalidStyle of invalidStyles) console.warn(`[StoryblokRichText] - \`class\` "${invalidStyle}" on \`<${element.tagName.toLowerCase()}>\` can not be transformed to rich text.`); return allowedStyles.filter((x) => classes.includes(x)); } //#endregion //#region src/extensions/nodes.ts const StoryblokTextAlign = _tiptap_extension_text_align.default.configure({ types: ["heading", "paragraph"] }); const StoryblokBlockquote = _tiptap_extension_blockquote.default.extend({ renderHTML({ HTMLAttributes }) { return [ "blockquote", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokParagraph = _tiptap_extension_paragraph.default.extend({ renderHTML({ HTMLAttributes }) { return [ "p", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokHeading = _tiptap_extension_heading.default.extend({ renderHTML({ node, HTMLAttributes }) { const { level, ...rest } = HTMLAttributes; return [ `h${node.attrs.level}`, processBlockAttrs(rest), 0 ]; } }); const StoryblokTableRow = _tiptap_extension_table.TableRow.extend({ renderHTML({ HTMLAttributes }) { return [ "tr", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokBulletList = _tiptap_extension_list.BulletList.extend({ name: "bullet_list", addOptions() { return { ...this.parent(), itemTypeName: "list_item" }; }, renderHTML({ HTMLAttributes }) { return [ "ul", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokOrderedList = _tiptap_extension_list.OrderedList.extend({ name: "ordered_list", addAttributes() { return { order: { default: 1 } }; }, addOptions() { return { ...this.parent(), itemTypeName: "list_item" }; }, renderHTML({ HTMLAttributes }) { return [ "ol", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokListItem = _tiptap_extension_list.ListItem.extend({ name: "list_item", addOptions() { return { ...this.parent(), bulletListTypeName: "bullet_list", orderedListTypeName: "ordered_list" }; }, renderHTML({ HTMLAttributes }) { return [ "li", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokCodeBlock = _tiptap_extension_code_block.default.extend({ name: "code_block", addAttributes() { return { class: { default: null } }; }, renderHTML({ node, HTMLAttributes }) { const { language: _, ...rest } = HTMLAttributes; const attrs = processBlockAttrs(rest); const lang = node.attrs.language; return [ "pre", attrs, [ "code", lang ? { class: `language-${lang}` } : {}, 0 ] ]; } }); const StoryblokHardBreak = _tiptap_extension_hard_break.default.extend({ name: "hard_break" }); const StoryblokHorizontalRule = _tiptap_extension_horizontal_rule.default.extend({ name: "horizontal_rule" }); const StoryblokTable = _tiptap_extension_table.Table.extend({ renderHTML({ HTMLAttributes }) { return [ "table", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokTableCell = _tiptap_extension_table.TableCell.extend({ addAttributes() { return { ...this.parent?.(), colspan: { default: 1 }, rowspan: { default: 1 }, colwidth: { default: null, parseHTML: (element) => { const colwidth = element.getAttribute("colwidth"); return colwidth ? colwidth.split(",").map(Number) : null; } }, backgroundColor: { default: null } }; }, renderHTML({ HTMLAttributes }) { return [ "td", computeTableCellAttrs(HTMLAttributes), 0 ]; } }); const StoryblokTableHeader = _tiptap_extension_table.TableHeader.extend({ renderHTML({ HTMLAttributes }) { return [ "th", computeTableCellAttrs(HTMLAttributes), 0 ]; } }); const StoryblokImage = _tiptap_extension_image.default.extend({ addOptions() { return { ...this.parent?.(), optimizeImages: false }; }, renderHTML({ HTMLAttributes }) { const { src, alt, title, srcset, sizes } = HTMLAttributes; let finalSrc = src; let extraAttrs = {}; if (this.options.optimizeImages) { const result = optimizeImage(src, this.options.optimizeImages); finalSrc = result.src; extraAttrs = result.attrs; } return ["img", cleanObject({ src: finalSrc, alt, title, srcset, sizes, ...extraAttrs })]; } }); const StoryblokEmoji = _tiptap_extension_emoji.default.extend({ renderHTML({ HTMLAttributes }) { return ["img", { "data-emoji": HTMLAttributes.emoji, "data-name": HTMLAttributes.name, "src": HTMLAttributes.fallbackImage, "alt": HTMLAttributes.alt, "style": "width: 1.25em; height: 1.25em; vertical-align: text-top", "draggable": "false", "loading": "lazy" }]; } }); const ComponentBlok = _tiptap_core.Node.create({ name: "blok", group: "block", atom: true, addOptions() { return { renderComponent: null }; }, addAttributes() { return { id: { default: null }, body: { default: [] } }; }, parseHTML() { return [{ tag: "div[data-blok]" }]; }, renderHTML({ HTMLAttributes }) { console.warn("[StoryblokRichText] - BLOK resolver is not available for vanilla usage. Configure `renderComponent` option on the blok tiptapExtension."); return ["span", cleanObject({ "data-blok": JSON.stringify(HTMLAttributes?.body?.[0] ?? null), "data-blok-id": HTMLAttributes?.id, "style": "display: none" })]; } }); //#endregion //#region src/render-segments.ts function renderSegment(segment, adapter, customComponents, key) { if (segment.kind === "text") return adapter.createText(segment.text); if (segment.kind === "component") { if (!adapter.createComponent) throw new Error("Component renderer not provided"); return adapter.createComponent(segment.type, { key, ...segment.props }); } if (customComponents.includes(segment.type)) { if (!adapter.createComponent) throw new Error("Component renderer not provided"); const props = { ..."attrs" in segment ? segment.attrs : {}, key, children: segment.content?.map((child, i) => renderSegment(child, adapter, customComponents, i)) }; return adapter.createComponent(segment.type, props); } const children = segment.content?.map((child, i) => renderSegment(child, adapter, customComponents, i)) ?? []; if (!segment.tag) throw new Error(`Missing tag for ${segment.type}`); return adapter.createElement(segment.tag, { ...segment.attrs, key }, children); } function renderSegments(segments, adapter, customComponents) { return segments.map((segment, index) => renderSegment(segment, adapter, customComponents, index)); } //#endregion //#region src/extensions/marks.ts const StoryblokHighlight = _tiptap_extension_highlight.default.extend({ addAttributes() { return { color: { default: null } }; } }); const StoryblokLink = _tiptap_extension_link.default.extend({ addAttributes() { return { href: { parseHTML: (element) => element.getAttribute("href") }, uuid: { default: null, parseHTML: (element) => element.getAttribute("data-uuid") || null }, anchor: { default: null, parseHTML: (element) => element.getAttribute("data-anchor") || null }, target: { parseHTML: (element) => element.getAttribute("target") || null }, linktype: { default: "url", parseHTML: (element) => element.getAttribute("data-linktype") || "url" } }; }, renderHTML({ HTMLAttributes }) { const { href, rest } = resolveStoryblokLink(HTMLAttributes); return [ "a", cleanObject({ ...href ? { href } : {}, ...rest }), 0 ]; } }); const StoryblokLinkWithCustomAttributes = StoryblokLink.extend({ addAttributes() { return { ...this.parent?.(), custom: { default: null, parseHTML: (element) => { const defaultLinkAttributes = supportedAttributesByTagName.a; const customAttributeNames = element.getAttributeNames().filter((n) => !defaultLinkAttributes.includes(n)); const customAttributes = {}; for (const attributeName of customAttributeNames) customAttributes[attributeName] = element.getAttribute(attributeName); return Object.keys(customAttributes).length ? customAttributes : null; } } }; } }); const StoryblokAnchor = _tiptap_core.Mark.create({ name: "anchor", addAttributes() { return { id: { default: null } }; }, parseHTML() { return [{ tag: "span[id]" }]; }, renderHTML({ HTMLAttributes }) { return [ "span", { id: HTMLAttributes.id }, 0 ]; } }); const StoryblokStyled = _tiptap_core.Mark.create({ name: "styled", addAttributes() { return { class: { parseHTML: (element) => { const styles = getAllowedStylesForElement(element, { allowedStyles: this.options.allowedStyles || [] }); return styles.length ? styles.join(" ") : null; } } }; }, parseHTML() { return [{ tag: "span", consuming: false, getAttrs: (element) => { return getAllowedStylesForElement(element, { allowedStyles: this.options.allowedStyles || [] }).length ? null : false; } }]; }, renderHTML({ HTMLAttributes }) { const { class: className, ...rest } = HTMLAttributes; return [ "span", cleanObject({ class: className, style: attrsToStyle(rest) || void 0 }), 0 ]; } }); const StoryblokTextStyle = _tiptap_core.Mark.create({ name: "textStyle", addAttributes() { return { class: { default: null }, id: { default: null }, color: { default: null } }; }, parseHTML() { return [{ tag: "span", consuming: false, getAttrs: (element) => { const style = element.getAttribute("style"); if (style && /color/i.test(style)) return null; return false; } }]; }, renderHTML({ HTMLAttributes }) { const { class: className, id: idName, ...styleAttrs } = HTMLAttributes; return [ "span", cleanObject({ class: className, id: idName, style: attrsToStyle(styleAttrs) || void 0 }), 0 ]; } }); const Reporter = _tiptap_core.Mark.create({ name: "reporter", priority: 0, addOptions() { return { allowCustomAttributes: false }; }, parseHTML() { return [{ tag: "*", consuming: false, getAttrs: (element) => { const tagName = element.tagName.toLowerCase(); if (tagName === "a" && this.options.allowCustomAttributes) return false; const unsupportedAttributes = element.getAttributeNames().filter((attr) => { return !(tagName in supportedAttributesByTagName ? supportedAttributesByTagName[tagName] : []).includes(attr); }); for (const attr of unsupportedAttributes) console.warn(`[StoryblokRichText] - \`${attr}\` "${element.getAttribute(attr)}" on \`<${tagName}>\` can not be transformed to rich text.`); return false; } }]; } }); //#endregion //#region src/extensions/index.ts const defaultExtensions = { document: _tiptap_extension_document.default, text: _tiptap_extension_text.default, paragraph: StoryblokParagraph, blockquote: StoryblokBlockquote, heading: StoryblokHeading, bulletList: StoryblokBulletList, orderedList: StoryblokOrderedList, listItem: StoryblokListItem, codeBlock: StoryblokCodeBlock, hardBreak: StoryblokHardBreak, horizontalRule: StoryblokHorizontalRule, image: StoryblokImage, emoji: StoryblokEmoji, table: StoryblokTable, tableRow: StoryblokTableRow, tableCell: StoryblokTableCell, tableHeader: StoryblokTableHeader, blok: ComponentBlok, details: _tiptap_extension_details.Details, detailsContent: _tiptap_extension_details.DetailsContent, detailsSummary: _tiptap_extension_details.DetailsSummary, bold: _tiptap_extension_bold.default, italic: _tiptap_extension_italic.default, strike: _tiptap_extension_strike.default, underline: _tiptap_extension_underline.default, code: _tiptap_extension_code.default, superscript: _tiptap_extension_superscript.default, subscript: _tiptap_extension_subscript.default, highlight: StoryblokHighlight, textStyle: StoryblokTextStyle, link: StoryblokLink, anchor: StoryblokAnchor, styled: StoryblokStyled, reporter: Reporter, textAlign: StoryblokTextAlign }; function getStoryblokExtensions(options = {}) { const Link = options.allowCustomAttributes ? StoryblokLinkWithCustomAttributes : StoryblokLink; return { ...defaultExtensions, image: StoryblokImage.configure({ optimizeImages: options.optimizeImages || false }), link: Link, styled: StoryblokStyled.configure({ allowedStyles: options.styleOptions?.map((o) => o.value) }), reporter: Reporter.configure({ allowCustomAttributes: options.allowCustomAttributes }) }; } //#endregion //#region src/richtext.ts /** * Default render function that creates an HTML string for a given tag, attributes, and children. */ function defaultRenderFn(tag, attrs = {}, children) { const attrsString = attrsToString(attrs); const tagString = attrsString ? `${tag} ${attrsString}` : tag; const content = Array.isArray(children) ? children.join("") : children || ""; if (!tag) return content; else if (SELF_CLOSING_TAGS.includes(tag)) return `<${tagString}>`; return `<${tagString}>${content}</${tag}>`; } /** * Converts a ProseMirror DOMOutputSpec array to renderFn calls. */ function specToRender(spec, renderFn, children) { const [tag, ...rest] = spec; let attrs = {}; let content = rest; if (content.length > 0 && content[0] !== null && content[0] !== void 0 && typeof content[0] === "object" && !Array.isArray(content[0]) && typeof content[0] !== "number") { attrs = content[0]; content = content.slice(1); } content = content.filter((c) => c !== null && c !== void 0); if (content.length === 0) return renderFn(tag, attrs); if (content.length === 1 && content[0] === 0) return renderFn(tag, attrs, children); const nested = content.map((item) => { if (item === 0) return children; if (Array.isArray(item)) return specToRender(item, renderFn, children); return item; }); return renderFn(tag, attrs, nested.length === 1 ? nested[0] : nested); } /** * Calls renderHTML on a tiptap extension. */ function callExtensionRenderHTML$1(ext, type, attrs) { const thisContext = { options: ext.options || {}, name: ext.name, type: ext.type }; if (type === "node") return ext.config.renderHTML.call(thisContext, { node: { attrs }, HTMLAttributes: attrs }); return ext.config.renderHTML.call(thisContext, { mark: { attrs }, HTMLAttributes: attrs }); } /** * Creates a rich text resolver with the given options. */ function richTextResolver(options = {}) { const keyCounters = /* @__PURE__ */ new Map(); const { renderFn = defaultRenderFn, textFn = escapeHtml, optimizeImages = false, keyedResolvers = false, tiptapExtensions } = options; const isExternalRenderFn = renderFn !== defaultRenderFn; const baseExtensions = getStoryblokExtensions({ optimizeImages }); const allExtensions = tiptapExtensions ? { ...baseExtensions, ...tiptapExtensions } : baseExtensions; const extensionValues = Object.values(allExtensions); const nodeExtMap = /* @__PURE__ */ new Map(); const markExtMap = /* @__PURE__ */ new Map(); for (const ext of extensionValues) if (ext.type === "node") nodeExtMap.set(ext.name, ext); else if (ext.type === "mark") markExtMap.set(ext.name, ext); const contextRenderFn = (tag, attrs = {}, children) => { if (keyedResolvers && tag) { const currentCount = keyCounters.get(tag) || 0; keyCounters.set(tag, currentCount + 1); attrs = { ...attrs, key: `${tag}-${currentCount}` }; } if (isExternalRenderFn && typeof tag !== "string" && children !== void 0) return renderFn(tag, attrs, (() => children)); return renderFn(tag, attrs, children); }; /** Renders a group of text nodes with shared marks wrapped once around unique-mark content. */ function renderMergedTextNodes(group, shared) { const innerRendered = group.map((node) => { return renderText({ ...node, marks: getUniqueMarks(node.marks || [], shared) }); }); let content = isExternalRenderFn ? innerRendered : innerRendered.join(""); for (const mark of shared) { const ext = markExtMap.get(mark.type); if (!ext?.config?.renderHTML) continue; content = specToRender(callExtensionRenderHTML$1(ext, "mark", mark.attrs || {}), contextRenderFn, content); } return content; } /** Groups adjacent text nodes with shared marks and renders them merged. */ function groupAndRenderChildren(children) { const result = []; let i = 0; while (i < children.length) { const match = collectMarkedTextGroup(children, i); if (!match) { result.push(render(children[i])); i++; continue; } if (match.group.length === 1) result.push(renderText(match.group[0])); else result.push(renderMergedTextNodes(match.group, match.shared)); i = match.endIndex; } return result; } function renderNode(node) { if (node.type === "text") return renderText(node); if (node.type === "doc") return render(node); const ext = nodeExtMap.get(node.type); if (!ext?.config?.renderHTML) { console.error("<Storyblok>", `No extension found for node type ${node.type}`); return ""; } if (ext.options?.renderComponent) { const body = node.attrs?.body; const id = node.attrs?.id; if (!Array.isArray(body) || body.length === 0) return isExternalRenderFn ? [] : ""; const rendered = body.map((blok) => ext.options.renderComponent(blok, id)); return isExternalRenderFn ? rendered : rendered.filter((r) => r != null).join(""); } if (node.type === "table" && node.content?.length) { const headerRows = []; const bodyRows = []; for (const row of node.content) if (bodyRows.length === 0 && row.content?.every((cell) => cell.type === "tableHeader")) headerRows.push(row); else bodyRows.push(row); const spec = callExtensionRenderHTML$1(ext, "node", node.attrs || {}); const parts = []; if (headerRows.length > 0) parts.push(contextRenderFn("thead", {}, headerRows.map(render))); if (bodyRows.length > 0) parts.push(contextRenderFn("tbody", {}, bodyRows.map(render))); return specToRender(spec, contextRenderFn, parts); } const children = node.content ? groupAndRenderChildren(node.content) : void 0; return specToRender(callExtensionRenderHTML$1(ext, "node", node.attrs || {}), contextRenderFn, children); } function renderText(node) { const { marks, ...rest } = node; if (marks?.length) { const baseText = (() => { const attrs = {}; if (keyedResolvers) { const currentCount = keyCounters.get("txt") || 0; keyCounters.set("txt", currentCount + 1); attrs.key = `txt-${currentCount}`; } return textFn(rest.text, attrs); })(); return marks.reduce((text, mark) => { const ext = markExtMap.get(mark.type); if (!ext?.config?.renderHTML) { console.error("<Storyblok>", `No extension found for node type ${mark.type}`); return text; } return specToRender(callExtensionRenderHTML$1(ext, "mark", mark.attrs || {}), contextRenderFn, text); }, baseText); } const attrs = node.attrs || {}; if (keyedResolvers) { const currentCount = keyCounters.get("txt") || 0; keyCounters.set("txt", currentCount + 1); attrs.key = `txt-${currentCount}`; } return textFn(rest.text, attrs); } function render(node) { const n = node; if (n.type === "doc") return isExternalRenderFn ? n.content.map(renderNode) : n.content.map(renderNode).join(""); return Array.isArray(n) ? n.map(renderNode) : renderNode(n); } return { render }; } //#endregion //#region src/richtext-segment.ts function getRichTextSegments(richText, options = {}) { const blokExtension = { blok: ComponentBlok.configure({ renderComponent: (blok) => blok }) }; const extensions = Object.values({ ...getStoryblokExtensions({ optimizeImages: options.optimizeImages }), ...blokExtension }); const nodeExtMap = /* @__PURE__ */ new Map(); const markExtMap = /* @__PURE__ */ new Map(); for (const ext of extensions) { if (ext.type === "node") nodeExtMap.set(ext.name, ext); if (ext.type === "mark") markExtMap.set(ext.name, ext); } /** Renders a group of text nodes with shared marks wrapped once around unique-mark content. */ function renderMergedTextNodes(group, shared) { let segments = group.flatMap((node) => { return renderText({ ...node, marks: getUniqueMarks(node.marks || [], shared) }); }); for (let i = shared.length - 1; i >= 0; i--) { const mark = shared[i]; const ext = markExtMap.get(mark.type); if (!ext?.config?.renderHTML) continue; const attrs = mark.attrs ?? {}; const tag = getTagFromSpec(callExtensionRenderHTML(ext, attrs)); segments = [{ kind: "mark", type: mark.type, tag, attrs, content: segments }]; } return segments; } /** Groups adjacent text nodes with shared marks and renders them merged. */ function groupAndFlatMapChildren(children) { const result = []; let i = 0; while (i < children.length) { const match = collectMarkedTextGroup(children, i); if (!match) { result.push(...renderNode(children[i])); i++; continue; } if (match.group.length === 1) result.push(...renderText(match.group[0])); else result.push(...renderMergedTextNodes(match.group, match.shared)); i = match.endIndex; } return result; } function renderNode(node) { if (node.type === "text") return renderText(node); if (node.type === "doc") return node.content?.flatMap(renderNode) ?? []; const children = node.content ? groupAndFlatMapChildren(node.content) : []; const ext = nodeExtMap.get(node.type); if (!ext?.config?.renderHTML) return options.onUnknownNode?.(node) ?? children; const component = tryRenderComponent(ext, node.attrs || {}, node.type); if (component) return [component]; return [parseDOMSpec(callExtensionRenderHTML(ext, node.attrs || {}), node.type, children)]; } /** * Render text nodes + marks */ function renderText(node) { let segments = [{ kind: "text", text: node.text }]; if (!node.marks?.length) return segments; /** * reverse to maintain correct nesting order */ for (const mark of [...node.marks].reverse()) { const ext = markExtMap.get(mark.type); if (!ext?.config?.renderHTML) { segments = options.onUnknownMark?.(mark) ?? segments; continue; } const attrs = mark.attrs ?? {}; const tag = getTagFromSpec(callExtensionRenderHTML(ext, attrs)); segments = segments.map((seg) => ({ kind: "mark", type: mark.type, tag, attrs, content: [seg] })); } return segments; } return renderNode(richText); } /** * Call renderHTML safely */ function callExtensionRenderHTML(ext, attrs) { const render = ext.config.renderHTML; if (!render) throw new Error(`Extension "${ext.name}" does not define renderHTML`); const ctx = { name: ext.name, options: ext.options ?? {}, parent: null }; if (ext.type === "node") return render.call(ctx, { node: { attrs }, HTMLAttributes: attrs }); return render.call(ctx, { mark: { attrs }, HTMLAttributes: attrs }); } /** * Extract tag from DOMOutputSpec */ function getTagFromSpec(spec) { const first = spec?.[0]; return typeof first === "string" ? first : null; } function isVoidElement(tag) { return SELF_CLOSING_TAGS.includes(tag); } function tryRenderComponent(ext, attrs, type) { const renderComponent = ext.options?.renderComponent; if (typeof renderComponent !== "function") return null; return { kind: "component", type, props: { ...renderComponent(attrs) ?? {} } }; } function parseDOMSpec(spec, nodeType, fallbackChildren) { const [tag, maybeAttrs, ...rest] = spec; let attrs = {}; let childrenSpec = []; if (isAttributes(maybeAttrs)) { attrs = maybeAttrs; childrenSpec = rest; } else childrenSpec = [maybeAttrs, ...rest]; let content = []; for (const child of childrenSpec) { if (child === 0) { content.push(...fallbackChildren); continue; } if (Array.isArray(child)) { content.push(parseDOMSpec(child, nodeType, fallbackChildren)); continue; } if (typeof child === "string") content.push({ kind: "text", text: child }); } if (tag === "table") { const headerRows = []; const bodyRows = []; for (const row of content) if (row.tag === "tr" && row.content.every((cell) => cell.tag === "th")) headerRows.push(row); else bodyRows.push(row); content = []; if (headerRows.length) content.push({ kind: "node", type: "thead", tag: "thead", attrs: {}, content: headerRows }); if (bodyRows.length) content.push({ kind: "node", type: "tbody", tag: "tbody", attrs: {}, content: bodyRows }); } return { kind: "node", type: nodeType, tag: typeof tag === "string" ? tag : null, attrs, content }; } function isAttributes(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Parses an inline CSS style string into an object. * * Example: * parseStyleString("width: 1.25em; height: 1.25em; vertical-align: text-top") * -> { width: "1.25em", height: "1.25em", "vertical-align": "text-top" } */ function parseStyleString(style) { const result = {}; if (!style) return { ...result }; style.split(";").forEach((rule) => { const [prop, value] = rule.split(":"); if (!prop || !value) return; result[prop.trim()] = value.trim(); }); return result; } //#endregion //#region src/utils/segment-richtext.ts const BLOK_MARKER_PREFIX = "SB_BLOK_GROUP_"; const BLOK_MARKER_REGEX = /<!--SB_BLOK_GROUP_(\d+)-->/; /** * Converts a Storyblok Rich Text document into a linear list of segments. * * The returned segments preserve the original content order and consist of: * - HTML segments for regular rich text content * - Blok segments for embedded Storyblok components * * This allows consumers to render HTML normally while handling Storyblok * components separately using framework-specific logic. * * @param doc - The Storyblok Rich Text document to process * @param options - Optional rich text resolver options * @returns An ordered array of rich text segments (HTML and bloks) * * @example * ```ts * const segments = segmentStoryblokRichText(richTextDoc); * * for (const segment of segments) { * if (segment.type === 'html') { * renderHtml(segment.content); * } * * if (segment.type === 'blok') { * renderBlokComponent(segment.blok); * } * } * ``` */ function segmentStoryblokRichText(doc, options = {}) { const segments = []; const blokGroups = []; const parts = richTextResolver({ ...options, tiptapExtensions: { blok: ComponentBlok.configure({ renderComponent: (blok) => { const body = [blok]; return `<!--${BLOK_MARKER_PREFIX}${blokGroups.push(body) - 1}-->`; } }), ...options.tiptapExtensions } }).render(doc).split(BLOK_MARKER_REGEX); for (let i = 0; i < parts.length; i++) if (i % 2 === 0) { const htmlPart = parts[i]; if (htmlPart && htmlPart.trim()) segments.push({ type: "html", content: htmlPart }); } else { const bloks = blokGroups[Number(parts[i])]; if (bloks) for (const blok of bloks) segments.push({ type: "blok", blok }); } return segments; } //#endregion //#region src/index.ts /** * Wraps a framework component (React, Vue, etc.) for use as a tag * in Tiptap's `renderHTML` DOMOutputSpec. * * Tiptap's `DOMOutputSpec` type only accepts strings at position 0, * but the Storyblok richtext resolver also handles component references. * Use this helper to satisfy TypeScript without a manual `as unknown as string` type assertion. * * @example * ```typescript * import { Mark } from '@tiptap/core'; * import { asTag } from '@storyblok/vue'; // or @storyblok/react * import { RouterLink } from 'vue-router'; * * const CustomLink = Mark.create({ * name: 'link', * renderHTML({ HTMLAttributes }) { * return [asTag(RouterLink), { to: HTMLAttributes.href }, 0]; * }, * }); * ``` */ function asTag(component) { return component; } //#endregion exports.BlockTypes = BlockTypes; exports.ComponentBlok = ComponentBlok; exports.LinkTargets = LinkTargets; exports.LinkTypes = LinkTypes; exports.MarkTypes = MarkTypes; exports.TextTypes = TextTypes; exports.asTag = asTag; exports.getRichTextSegments = getRichTextSegments; exports.isVoidElement = isVoidElement; exports.parseStyleString = parseStyleString; exports.renderSegments = renderSegments; exports.richTextResolver = richTextResolver; exports.segmentStoryblokRichText = segmentStoryblokRichText; //# sourceMappingURL=index.cjs.map