UNPKG

@storyblok/richtext

Version:
803 lines (794 loc) 24.1 kB
//#region src/static/dynamic-resolvers.ts const resolveHeadingTag = (attrs) => { return `h${typeof attrs?.level === "number" ? Math.min(6, Math.max(1, attrs.level)) : 1}`; }; //#endregion //#region src/static/render-map.generated.ts /** * Render config for Tiptap nodes */ const NODE_RENDER_MAP = { paragraph: { "tag": "p", "content": true }, doc: null, text: null, blockquote: { "tag": "blockquote", "content": true }, heading: { resolve: resolveHeadingTag }, bullet_list: { "tag": "ul", "content": true }, ordered_list: { "tag": "ol", "attrs": { "order": 1 }, "content": true }, list_item: { "tag": "li", "content": true }, code_block: { "tag": "pre", "children": [{ "tag": "code", "content": true }] }, hard_break: { "tag": "br" }, horizontal_rule: { "tag": "hr" }, image: { "tag": "img" }, emoji: { "tag": "img", "attrs": { "style": "width: 1.25em; height: 1.25em; vertical-align: text-top;", "draggable": "false", "loading": "lazy" } }, table: { "tag": "table", "content": true }, tableRow: { "tag": "tr", "content": true }, tableCell: { "tag": "td", "content": true }, tableHeader: { "tag": "th", "content": true }, blok: { "tag": "span", "attrs": { "style": "display: none;" } }, details: { "tag": "details", "content": true }, detailsContent: { "tag": "div", "attrs": { "data-type": "detailsContent" }, "content": true }, detailsSummary: { "tag": "summary", "content": true } }; /** * Render config for Tiptap marks */ const MARK_RENDER_MAP = { link: { "tag": "a", "content": true }, bold: { "tag": "strong", "content": true }, italic: { "tag": "em", "content": true }, strike: { "tag": "s", "content": true }, underline: { "tag": "u", "content": true }, code: { "tag": "code", "content": true }, superscript: { "tag": "sup", "content": true }, subscript: { "tag": "sub", "content": true }, highlight: { "tag": "mark", "content": true }, textStyle: { "tag": "span", "content": true }, anchor: { "tag": "span", "content": true }, styled: { "tag": "span", "content": true }, reporter: null }; //#endregion //#region src/static/style.ts /** * Converts a style object to a CSS string. * @param style - The style object to convert. * @returns A CSS string representation of the style object. * @example * const styleObj = { color: 'red', fontSize: '16px' }; * const cssString = styleToString(styleObj); * console.log(cssString); // Output: "color: red; font-size: 16px" */ function styleToString(style) { return Object.entries(style).filter(([, value]) => isValidStyleValue(value)).map(([key, value]) => `${camelToKebab(key)}: ${value};`).join(" "); } /** * Converts a CSS string to a style object. * @param style - The CSS string to convert. * @returns A style object representation of the CSS string. * @example * const cssString = "color: red; font-size: 16px"; * const styleObj = stringToStyle(cssString); * console.log(styleObj); // Output: { color: 'red', fontSize: '16px' } */ function stringToStyle(style) { return style.split(";").map((rule) => rule.trim()).filter(Boolean).reduce((acc, rule) => { const colonIdx = rule.indexOf(":"); if (colonIdx === -1) return acc; const key = rule.slice(0, colonIdx).trim(); const value = rule.slice(colonIdx + 1).trim(); if (!key || !value) return acc; acc[kebabToCamel(key)] = value; return acc; }, {}); } function kebabToCamel(str) { return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function camelToKebab(str) { return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); } function isValidStyleValue(value) { return value !== null && value !== void 0 && value !== ""; } //#endregion //#region src/static/attribute.ts /** * Maps Tiptap attribute names to CSS property names for specific element types. */ const STYLE_MAP = { highlight: { color: "backgroundColor" }, textStyle: { color: "color" }, paragraph: { textAlign: "textAlign" }, heading: { textAlign: "textAlign" }, tableCell: { backgroundColor: "backgroundColor", colwidth: "width" }, tableHeader: { colwidth: "width" } }; /** * Maps Tiptap attribute names to HTML attribute names. */ const DEFAULT_ATTR_MAP = { fallbackImage: "src", body: "data-body", colspan: "colSpan", rowspan: "rowSpan", name: "data-name", emoji: "data-emoji" }; /** * Attributes that should be excluded from the output. */ const EXCLUDED_ATTRS = new Set([ "level", "linktype", "uuid", "anchor", "meta_data", "copyright", "source" ]); /** * Resolves the href for Storyblok link types (story, email). * @returns The resolved href, or undefined if no special handling is needed. */ function resolveStoryblokLinkHref(attrs) { const { linktype, href, anchor } = attrs; if (linktype === "story") return `${typeof href === "string" ? href : ""}${typeof anchor === "string" && anchor ? `#${anchor}` : ""}`; if (linktype === "email" && typeof href === "string") return `mailto:${href.replace(/^mailto:/, "")}`; } /** * Extracts static attributes and styles from the render map for a given element type. */ function getStaticAttrsFromRenderMap(type) { const staticStyle = {}; let staticAttrs = {}; if (!(type in NODE_RENDER_MAP)) return { staticAttrs, staticStyle }; const renderMap = NODE_RENDER_MAP[type]; if (!renderMap || !("attrs" in renderMap)) return { staticAttrs, staticStyle }; const renderAttrs = renderMap.attrs || {}; const rawStyle = "style" in renderAttrs && typeof renderAttrs.style === "string" ? renderAttrs.style : ""; const { style: _style, ...rest } = renderAttrs; staticAttrs = rest; if (rawStyle) Object.assign(staticStyle, stringToStyle(rawStyle)); return { staticAttrs, staticStyle }; } /** * Converts an attribute value to a CSS value based on the style map. * Handles arrays (e.g., colwidth) and primitive values. */ function convertToStyleValue(value) { if (Array.isArray(value)) return value[0] != null ? `${value[0]}px` : void 0; if (typeof value === "number" || typeof value === "string") return value; return String(value); } /** * Processes a single attribute and adds it to either the style or rest object. */ function processAttribute(key, value, type, styleMap, attrMap, style, rest) { if (!isValidStyleValue(value) || EXCLUDED_ATTRS.has(key)) return; if (key in styleMap) { const cssProp = styleMap[key]; const cssValue = convertToStyleValue(value); if (cssValue !== void 0 && isValidStyleValue(cssValue)) style[cssProp] = cssValue; return; } const attrName = attrMap[key] ?? key; if (attrName === "custom" && type === "link" && typeof value === "object" && value !== null) { for (const [customKey, customValue] of Object.entries(value)) rest[customKey] = String(customValue); return; } if (typeof value === "object" && value !== null) { rest[attrName] = JSON.stringify(value); return; } rest[attrName] = value; } /** * Process Tiptap attributes into HTML attributes and inline styles. * Applies internal style mappings and allows extending or overriding * default attribute mappings via `extendAttrMap`. * * @param type - {@link SbRichTextElement} * @param attrs - Attributes from the node/mark * @param extendAttrMap - {@link AttrMap} Additional attribute mappings (overrides defaults) * @returns Processed attributes with optional `style` object */ function processAttrs(type, attrs = {}, extendAttrMap = {}) { const { staticAttrs, staticStyle } = getStaticAttrsFromRenderMap(type); const style = { ...staticStyle }; const rest = {}; const styleMap = STYLE_MAP[type] || {}; const attrMap = { ...DEFAULT_ATTR_MAP, ...extendAttrMap }; const mergedAttrs = { ...attrs, ...staticAttrs }; for (const [key, value] of Object.entries(mergedAttrs)) processAttribute(key, value, type, styleMap, attrMap, style, rest); if (type === "link") { const linkHref = resolveStoryblokLinkHref(attrs); if (linkHref !== void 0) rest.href = linkHref; } return { ...rest, ...Object.keys(style).length > 0 && { style } }; } /** * Escapes special HTML characters in attribute values. */ const escapeAttr = (value) => String(value).replace(/[&"'<>]/g, (char) => { switch (char) { case "&": return "&amp;"; case "\"": return "&quot;"; case "'": return "&#39;"; case "<": return "&lt;"; case ">": return "&gt;"; default: return char; } }); //#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])); } const SELF_CLOSING_TAGS = [ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" ]; /** * 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;"); } //#endregion //#region src/static/node-helpers.ts /** * Gets the link mark from a text node, or null if not present. * @param node - The node to check * @returns The link mark if found, null otherwise */ function getTextNodeLinkMark(node) { if (node.type !== "text" || !node.marks) return null; for (const mark of node.marks) if (mark.type === "link") return mark; return null; } /** * Checks if two link marks have identical attributes. * Used for merging adjacent text nodes with the same link. * @param markA - First link mark * @param markB - Second link mark * @returns True if the marks have identical attributes */ function areLinkMarksEqual(markA, markB) { if (!markA || !markB) return false; return deepEqual(markA.attrs ?? {}, markB.attrs ?? {}); } /** * Gets non-link marks from a text node. * Used when rendering text inside a merged link group. * @param node - The text node * @returns Array of marks excluding the link mark */ function getInnerMarks(node) { if (node.type !== "text" || !node.marks) return []; return node.marks.filter((m) => m.type !== "link"); } /** * Identifies groups of adjacent text nodes that share the same link mark. * Returns an array of groups where each group is either: * - A single non-text node or text node without link * - Multiple consecutive text nodes with identical link marks * * @param children - Array of child nodes to group * @returns Array of node groups for rendering */ function groupLinkNodes(children) { const groups = []; let i = 0; const len = children.length; while (i < len) { const node = children[i]; const linkMark = getTextNodeLinkMark(node); if (linkMark) { const groupNodes = [node]; let end = i + 1; while (end < len && areLinkMarksEqual(linkMark, getTextNodeLinkMark(children[end]))) { groupNodes.push(children[end]); end++; } groups.push({ nodes: groupNodes, linkMark }); i = end; } else { groups.push({ nodes: [node], linkMark: null }); i++; } } return groups; } /** * Checks if a table row contains only tableHeader cells. * Used to determine which rows belong in thead vs tbody. * @param row - The table row node to check * @returns True if all cells are tableHeader type */ function isTableHeaderRow(row) { const cells = row.content; if (!cells?.length) return false; for (const cell of cells) if (cell.type !== "tableHeader") return false; return true; } /** * Splits table rows into header rows and body rows. * Header rows are contiguous tableHeader rows at the start. * @param rows - Array of table row nodes * @returns Object with headerRows and bodyRows arrays */ function splitTableRows(rows) { if (!rows?.length) return { headerRows: [], bodyRows: [] }; let headerEnd = 0; while (headerEnd < rows.length && isTableHeaderRow(rows[headerEnd])) headerEnd++; return { headerRows: rows.slice(0, headerEnd), bodyRows: rows.slice(headerEnd) }; } //#endregion //#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/static/util.ts /** * Resolves a component from the provided components map based on the type. * @param type - The type of the component to resolve. * @param components - The components map to search in. * @returns The resolved component or undefined if not found. * @example * const components = { * 'heading': MyCustomHeading, * }; * const resolvedComponent = resolveComponent('heading', components); * console.log(resolvedComponent); // Output: MyCustomHeading */ function resolveComponent(type, components) { return components?.[type]; } /** * Resolves the HTML tag for a given Richtext node or mark. * @param node - The Richtext node or mark to resolve the tag for. * @returns The resolved HTML tag as a string, or null if no tag could be resolved. * @example * const node = { type: 'paragraph', attrs: {} }; * const tag = resolveTag(node); * console.log(tag); // Output: "p" */ function resolveTag(node) { const type = node.type; const entry = NODE_RENDER_MAP[type] ?? MARK_RENDER_MAP[type]; if (!entry) return null; if ("resolve" in entry && typeof entry.resolve === "function") return entry.resolve(node.attrs); if ("tag" in entry && typeof entry.tag === "string") return entry.tag; return null; } /** * Checks if a given HTML tag is self-closing. * @param tag - The HTML tag to check. * @returns True if the tag is self-closing, false otherwise. * @example * console.log(isSelfClosing('img')); // Output: true * console.log(isSelfClosing('div')); // Output: false * */ function isSelfClosing(tag) { return SELF_CLOSING_TAGS.includes(tag); } /** * Returns static child definitions for a given RichText node. * * @param node - The RichText node * @returns Static child render specs, or null if none exist * * @example * const children = getStaticChildren({ type: 'table', attrs: {} }); * // [{ tag: 'tbody', content: true }] */ function getStaticChildren(node) { const renderMap = NODE_RENDER_MAP[node.type]; return renderMap && "children" in renderMap ? renderMap.children : null; } //#endregion //#region src/static/render-richtext.ts /** * Renders a Storyblok RichText JSON document to an HTML string. * * @param document - RichText JSON document, array of nodes, or nullish value * @param options - Renderer configuration with custom node/mark renderers * @returns Rendered HTML string * * @example * ```ts * const html = renderRichText({ * type: 'doc', * content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }] * }); * // => '<p>Hello</p>' * ``` */ function renderRichText(document, options) { if (!document) return ""; if (Array.isArray(document)) return renderChildren(document, options); const nodes = document.type === "doc" ? document.content : [document]; return nodes?.length ? renderChildren(nodes, options) : ""; } /** Renders a single node to HTML. */ function renderNode(node, options) { if (node.type === "text") return renderTextNode(node, node.marks, options); const customRenderer = options?.renderers?.[node.type]; if (customRenderer) return customRenderer(node); if (node.type === "blok") { console.warn("Rendering of \"blok\" nodes is not supported in richTextRenderer."); return ""; } const tag = resolveTag(node); if (!tag) return node.content ? renderChildren(node.content, options) : ""; if (node.type === "image" && options?.optimizeImages) return renderOptimizedImage(node, options); const htmlAttrs = buildHtmlAttrs(node.type, node.attrs); if (isSelfClosing(tag)) return `<${tag}${htmlAttrs}>`; if (node.type === "table") return `<${tag}${htmlAttrs}>${renderTableRows(node.content, options)}</${tag}>`; const content = node.content ? renderChildren(node.content, options) : ""; const staticChildren = getStaticChildren(node); if (staticChildren) return `<${tag}>${renderStaticStructure(node.type, staticChildren, node.attrs, content)}</${tag}>`; return `<${tag}${htmlAttrs}>${content}</${tag}>`; } /** Renders an image node with optimization applied. */ function renderOptimizedImage(node, options) { const attrs = node.attrs; const src = attrs?.src; let finalAttrs = attrs; if (src) { const { src: optimizedSrc, attrs: extraAttrs } = optimizeImage(src, options.optimizeImages); finalAttrs = { ...attrs, src: optimizedSrc, ...extraAttrs }; } const htmlAttrs = buildHtmlAttrs("image", finalAttrs); return htmlAttrs ? `<img${htmlAttrs}>` : "<img>"; } /** * Renders child nodes, merging adjacent text nodes that share the same link mark. * This produces cleaner HTML: `<a href="...">text <b>bold</b> more</a>` * instead of: `<a>text</a><a><b>bold</b></a><a>more</a>` */ function renderChildren(children, options) { let result = ""; let i = 0; const len = children.length; while (i < len) { const node = children[i]; const linkMark = getTextNodeLinkMark(node); if (linkMark) { let end = i + 1; while (end < len && areLinkMarksEqual(linkMark, getTextNodeLinkMark(children[end]))) end++; result += renderLinkGroup(children, i, end, linkMark, options); i = end; } else { result += renderNode(node, options); i++; } } return result; } /** Renders a text node with its marks. */ function renderTextNode(node, marks, options) { let html = escapeHtml(node.text); if (!marks?.length) return html; for (const mark of marks) html = wrapWithMark(html, mark, options); return html; } /** Wraps content with a single mark tag. */ function wrapWithMark(content, mark, options) { const customRenderer = options?.renderers?.[mark.type]; if (customRenderer) return customRenderer({ ...mark, children: content }); const tag = resolveTag(mark); if (!tag) return content; return `<${tag}${buildHtmlAttrs(mark.type, mark.attrs)}>${content}</${tag}>`; } /** Link Mark Merging */ /** Renders consecutive text nodes (from start to end) under a single link tag. */ function renderLinkGroup(children, start, end, linkMark, options) { let inner = ""; for (let i = start; i < end; i++) { const node = children[i]; const innerMarks = node.marks?.filter((m) => m.type !== "link"); inner += renderTextNode(node, innerMarks, options); } const tag = resolveTag(linkMark); if (!tag) return inner; return `<${tag}${buildHtmlAttrs(linkMark.type, linkMark.attrs)}>${inner}</${tag}>`; } /** Table Rendering */ /** Renders table rows with thead/tbody grouping based on cell types. */ function renderTableRows(rows, options) { if (!rows?.length) return ""; let headerEnd = 0; while (headerEnd < rows.length && isTableHeaderRow(rows[headerEnd])) headerEnd++; let result = ""; if (headerEnd > 0) { result += "<thead>"; for (let i = 0; i < headerEnd; i++) result += renderNode(rows[i], options); result += "</thead>"; } if (headerEnd < rows.length) { result += "<tbody>"; for (let i = headerEnd; i < rows.length; i++) result += renderNode(rows[i], options); result += "</tbody>"; } return result; } /** Renders nested static structure defined in render map. */ function renderStaticStructure(type, specs, parentAttrs, content) { let result = ""; for (const spec of specs) { const { tag, children, attrs: specAttrs } = spec; const htmlAttrs = buildHtmlAttrs(type, { ...specAttrs, ...parentAttrs }); if (isSelfClosing(tag)) result += `<${tag}${htmlAttrs}>`; else { const inner = children ? renderStaticStructure(type, children, parentAttrs, content) : content; result += `<${tag}${htmlAttrs}>${inner}</${tag}>`; } } return result; } /** Builds HTML attribute string from node/mark type and attrs. */ function buildHtmlAttrs(type, attrs) { const processed = processAttrs(type, attrs, { colspan: "colspan", rowspan: "rowspan" }); const styleObj = processed.style; const finalAttrs = { ...processed }; if (styleObj) finalAttrs.style = styleToString(styleObj); return attrsToHtmlString(finalAttrs); } /** Converts attribute record to HTML string: ` key="value" key2="value2"` */ function attrsToHtmlString(attrs) { let result = ""; for (const key in attrs) { const value = attrs[key]; if (value != null) result += ` ${key}="${escapeAttr(value)}"`; } return result; } //#endregion export { areLinkMarksEqual, getInnerMarks, getStaticChildren, getTextNodeLinkMark, groupLinkNodes, isSelfClosing, isTableHeaderRow, processAttrs, renderRichText, resolveComponent, resolveTag, splitTableRows, stringToStyle, styleToString }; //# sourceMappingURL=static.mjs.map