UNPKG

@storyblok/richtext

Version:
800 lines (793 loc) 23.8 kB
(function(global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('markdown-it')) : typeof define === 'function' && define.amd ? define(['exports', 'markdown-it'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory((global.StoryblokRichtext = {}), global.markdown_it)); })(this, function(exports, markdown_it) { //#region rolldown:runtime 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 markdown_it = __toESM(markdown_it); //#region src/types/index.ts let BlockTypes = /* @__PURE__ */ function(BlockTypes$1) { BlockTypes$1["DOCUMENT"] = "doc"; BlockTypes$1["HEADING"] = "heading"; BlockTypes$1["PARAGRAPH"] = "paragraph"; BlockTypes$1["QUOTE"] = "blockquote"; BlockTypes$1["OL_LIST"] = "ordered_list"; BlockTypes$1["UL_LIST"] = "bullet_list"; BlockTypes$1["LIST_ITEM"] = "list_item"; BlockTypes$1["CODE_BLOCK"] = "code_block"; BlockTypes$1["HR"] = "horizontal_rule"; BlockTypes$1["BR"] = "hard_break"; BlockTypes$1["IMAGE"] = "image"; BlockTypes$1["EMOJI"] = "emoji"; BlockTypes$1["COMPONENT"] = "blok"; BlockTypes$1["TABLE"] = "table"; BlockTypes$1["TABLE_ROW"] = "tableRow"; BlockTypes$1["TABLE_CELL"] = "tableCell"; BlockTypes$1["TABLE_HEADER"] = "tableHeader"; return BlockTypes$1; }({}); let MarkTypes = /* @__PURE__ */ function(MarkTypes$1) { MarkTypes$1["BOLD"] = "bold"; MarkTypes$1["STRONG"] = "strong"; MarkTypes$1["STRIKE"] = "strike"; MarkTypes$1["UNDERLINE"] = "underline"; MarkTypes$1["ITALIC"] = "italic"; MarkTypes$1["CODE"] = "code"; MarkTypes$1["LINK"] = "link"; MarkTypes$1["ANCHOR"] = "anchor"; MarkTypes$1["STYLED"] = "styled"; MarkTypes$1["SUPERSCRIPT"] = "superscript"; MarkTypes$1["SUBSCRIPT"] = "subscript"; MarkTypes$1["TEXT_STYLE"] = "textStyle"; MarkTypes$1["HIGHLIGHT"] = "highlight"; return MarkTypes$1; }({}); let TextTypes = /* @__PURE__ */ function(TextTypes$1) { TextTypes$1["TEXT"] = "text"; return TextTypes$1; }({}); let LinkTargets = /* @__PURE__ */ function(LinkTargets$1) { LinkTargets$1["SELF"] = "_self"; LinkTargets$1["BLANK"] = "_blank"; return LinkTargets$1; }({}); let LinkTypes = /* @__PURE__ */ function(LinkTypes$1) { LinkTypes$1["URL"] = "url"; LinkTypes$1["STORY"] = "story"; LinkTypes$1["ASSET"] = "asset"; LinkTypes$1["EMAIL"] = "email"; return LinkTypes$1; }({}); //#endregion //#region src/markdown-parser.ts /** * Supported Markdown token types as constants for maintainability and type safety. * @see https://markdown-it.github.io/token-class.html */ const MarkdownTokenTypes = { HEADING: "heading_open", PARAGRAPH: "paragraph_open", TEXT: "text", STRONG: "strong_open", EMP: "em_open", ORDERED_LIST: "ordered_list_open", BULLET_LIST: "bullet_list_open", LIST_ITEM: "list_item_open", IMAGE: "image", BLOCKQUOTE: "blockquote_open", CODE_INLINE: "code_inline", CODE_BLOCK: "code_block", FENCE: "fence", LINK: "link_open", HR: "hr", DEL: "del_open", HARD_BREAK: "hardbreak", SOFT_BREAK: "softbreak", TABLE: "table_open", THEAD: "thead_open", TBODY: "tbody_open", TR: "tr_open", TH: "th_open", TD: "td_open", S: "s_open" }; /** * Default resolvers for supported Markdown token types. * These map markdown-it tokens to Storyblok RichText nodes. */ const defaultResolvers = { [MarkdownTokenTypes.HEADING]: (token, children) => { const level = Number(token.tag.replace("h", "")); return { type: BlockTypes.HEADING, attrs: { level }, content: children }; }, [MarkdownTokenTypes.PARAGRAPH]: (_token, children) => { return { type: BlockTypes.PARAGRAPH, content: children }; }, [MarkdownTokenTypes.TEXT]: (token) => { if (!token.content || token.content.trim() === "") return null; return { type: TextTypes.TEXT, text: token.content }; }, [MarkdownTokenTypes.STRONG]: (_token, children) => { const text = children?.map((c) => c.text).join("") ?? ""; return { type: TextTypes.TEXT, text, marks: [{ type: MarkTypes.BOLD }] }; }, [MarkdownTokenTypes.EMP]: (_token, children) => { const text = children?.map((c) => c.text).join("") ?? ""; return { type: TextTypes.TEXT, text, marks: [{ type: MarkTypes.ITALIC }] }; }, [MarkdownTokenTypes.ORDERED_LIST]: (_token, children) => { return { type: BlockTypes.OL_LIST, content: children }; }, [MarkdownTokenTypes.BULLET_LIST]: (_token, children) => { return { type: BlockTypes.UL_LIST, content: children }; }, [MarkdownTokenTypes.LIST_ITEM]: (_token, children) => { return { type: BlockTypes.LIST_ITEM, content: children }; }, [MarkdownTokenTypes.IMAGE]: (token) => { return { type: BlockTypes.IMAGE, attrs: { src: token.attrGet("src"), alt: token.content || token.attrGet("alt") || "", title: token.attrGet("title") || "" } }; }, [MarkdownTokenTypes.BLOCKQUOTE]: (_token, children) => { return { type: BlockTypes.QUOTE, content: children }; }, [MarkdownTokenTypes.CODE_INLINE]: (token) => { return { type: MarkTypes.CODE, text: token.content, marks: [{ type: MarkTypes.CODE }] }; }, [MarkdownTokenTypes.CODE_BLOCK]: (token) => { return { type: BlockTypes.CODE_BLOCK, attrs: { language: null }, content: [{ type: "text", text: token.content }] }; }, [MarkdownTokenTypes.FENCE]: (token) => { return { type: BlockTypes.CODE_BLOCK, attrs: { language: token.info || null }, content: [{ type: "text", text: token.content }] }; }, [MarkdownTokenTypes.LINK]: (token, children) => { return { type: MarkTypes.LINK, attrs: { href: token.attrGet("href"), title: token.attrGet("title") || null }, content: children }; }, [MarkdownTokenTypes.HR]: () => { return { type: BlockTypes.HR }; }, [MarkdownTokenTypes.DEL]: (_token, children) => { const text = children?.map((c) => c.text).join("") ?? ""; return { type: TextTypes.TEXT, text, marks: [{ type: MarkTypes.STRIKE }] }; }, [MarkdownTokenTypes.HARD_BREAK]: () => { return { type: BlockTypes.BR }; }, [MarkdownTokenTypes.SOFT_BREAK]: () => { return { type: TextTypes.TEXT, text: " " }; }, [MarkdownTokenTypes.TABLE]: (_token, children) => ({ type: BlockTypes.TABLE, content: children }), [MarkdownTokenTypes.THEAD]: () => null, [MarkdownTokenTypes.TBODY]: () => null, [MarkdownTokenTypes.TR]: (_token, children) => ({ type: BlockTypes.TABLE_ROW, content: children }), [MarkdownTokenTypes.TH]: (_token, children) => ({ type: BlockTypes.TABLE_CELL, attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [{ type: BlockTypes.PARAGRAPH, content: children || [] }] }), [MarkdownTokenTypes.TD]: (_token, children) => ({ type: BlockTypes.TABLE_CELL, attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [{ type: BlockTypes.PARAGRAPH, content: children || [] }] }), [MarkdownTokenTypes.S]: (_token, children) => ({ type: TextTypes.TEXT, text: children?.map((c) => c.text).join("") ?? "", marks: [{ type: MarkTypes.STRIKE }] }) }; /** * Converts Markdown string to Storyblok Richtext Document Node using resolvers. * @param markdown - The markdown string to convert * @param options - Optional custom resolvers * @returns StoryblokRichTextDocumentNode */ function markdownToStoryblokRichtext(markdown, options = {}) { const md = new markdown_it.default({ html: false, linkify: true, typographer: true, breaks: true }); const tokens = md.parse(markdown, {}); const resolvers = { ...defaultResolvers, ...options.resolvers }; function walkTokens(tokens$1, start = 0) { const nodes = []; let i = start; while (i < tokens$1.length) { const token = tokens$1[i]; if (token.type === "inline" && token.children) { const [inlineNodes] = walkTokens(token.children, 0); nodes.push(...inlineNodes); i++; continue; } if (token.nesting === 1) { const type = token.type; const children = []; i++; while (i < tokens$1.length && !(tokens$1[i].type === type.replace("_open", "_close") && tokens$1[i].nesting === -1)) { const [childNodes, consumed] = walkTokens(tokens$1, i); children.push(...childNodes); i += consumed; } const resolver = resolvers[type]; if (resolver) { const node = resolver(token, children.length ? children : void 0); if (node) nodes.push(node); else nodes.push(...children); } i++; } else if (token.nesting === 0) { const resolver = resolvers[token.type]; if (resolver) { const node = resolver(token, void 0); if (node) nodes.push(node); } i++; } else if (token.nesting === -1) break; else i++; } return [nodes, i - start]; } const [content] = walkTokens(tokens); return { type: "doc", content }; } //#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$1) { 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$1.push(`${filter}(${value})`); } if (typeof options === "object") { 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 0"); if (options.height && 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 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 void 0; } }).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 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 = {}) => Object.keys(attrs).map((key) => `${key}="${attrs[key]}"`).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 !== void 0)); }; //#endregion //#region src/richtext.ts /** * Default render function that creates an HTML string for a given tag, attributes, and children. * * @template T * @param {string} tag * @param {Record<string, any>} [attrs] * @param {T} children * @return {*} {T} */ 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}>`; } /** * Creates a rich text resolver with the given options. * * @export * @template T * @param {StoryblokRichTextOptions<T>} [options] * @return {*} */ function richTextResolver(options = {}) { const keyCounters = /* @__PURE__ */ new Map(); const { renderFn = defaultRenderFn, textFn = escapeHtml, resolvers = {}, optimizeImages = false, keyedResolvers = false } = options; const isExternalRenderFn = renderFn !== defaultRenderFn; const nodeResolver = (tag) => (node, context) => { const attributes = node.attrs || {}; return context.render(tag, attributes, node.children || null); }; const imageResolver = (node, context) => { const { src, alt, title, srcset, sizes } = node.attrs || {}; let finalSrc = src; let finalAttrs = {}; if (optimizeImages) { const { src: optimizedSrc, attrs: optimizedAttrs } = optimizeImage(src, optimizeImages); finalSrc = optimizedSrc; finalAttrs = optimizedAttrs; } const imgAttrs = { src: finalSrc, alt, title, srcset, sizes, ...finalAttrs }; return context.render("img", cleanObject(imgAttrs)); }; const headingResolver = (node, context) => { const { level,...rest } = node.attrs || {}; return context.render(`h${level}`, rest, node.children); }; const emojiResolver = (node, context) => { const internalImg = context.render("img", { src: node.attrs?.fallbackImage, alt: node.attrs?.alt, style: "width: 1.25em; height: 1.25em; vertical-align: text-top", draggable: "false", loading: "lazy" }); return context.render("span", { "data-type": "emoji", "data-name": node.attrs?.name, "data-emoji": node.attrs?.emoji }, internalImg); }; const codeBlockResolver = (node, context) => { return context.render("pre", node.attrs || {}, context.render("code", {}, node.children || "")); }; const markResolver = (tag, styled = false) => ({ text, attrs }, context) => { const { class: className, id: idName,...styleAttrs } = attrs || {}; const attributes = styled ? { class: className, id: idName, style: attrsToStyle(styleAttrs) || void 0 } : attrs || {}; return context.render(tag, cleanObject(attributes), text); }; const renderToT = (node) => { return render(node); }; const textResolver = (node) => { const { marks,...rest } = node; if ("text" in node) { if (marks) return marks.reduce((text, mark) => renderToT({ ...mark, text }), renderToT({ ...rest, children: rest.children })); const attributes = node.attrs || {}; if (keyedResolvers) { const currentCount = keyCounters.get("txt") || 0; keyCounters.set("txt", currentCount + 1); attributes.key = `txt-${currentCount}`; } return textFn(rest.text, attributes); } return ""; }; const linkResolver = (node, context) => { const { linktype, href, anchor,...rest } = node.attrs || {}; let finalHref = ""; switch (linktype) { case LinkTypes.ASSET: case LinkTypes.URL: finalHref = href; break; case LinkTypes.EMAIL: finalHref = `mailto:${href}`; break; case LinkTypes.STORY: finalHref = href; if (anchor) finalHref = `${finalHref}#${anchor}`; break; default: finalHref = href; break; } const attributes = { ...rest }; if (finalHref) attributes.href = finalHref; return context.render("a", attributes, node.text); }; const componentResolver = (node, context) => { console.warn("[StoryblokRichtText] - BLOK resolver is not available for vanilla usage"); return context.render("span", { blok: node?.attrs?.body[0], id: node.attrs?.id, style: "display: none" }); }; const tableResolver = (node, context) => { const attributes = {}; const tableBody = context.render("tbody", {}, node.children); return context.render("table", attributes, tableBody); }; const tableRowResolver = (node, context) => { const attributes = {}; return context.render("tr", attributes, node.children); }; const tableCellResolver = (node, context) => { const { colspan, rowspan, colwidth, backgroundColor,...rest } = node.attrs || {}; const attributes = { ...rest }; if (colspan > 1) attributes.colspan = colspan; if (rowspan > 1) attributes.rowspan = rowspan; const styles = []; if (colwidth) styles.push(`width: ${colwidth}px;`); if (backgroundColor) styles.push(`background-color: ${backgroundColor};`); if (styles.length > 0) attributes.style = styles.join(" "); return context.render("td", cleanObject(attributes), node.children); }; const tableHeaderResolver = (node, context) => { const { colspan, rowspan, colwidth, backgroundColor,...rest } = node.attrs || {}; const attributes = { ...rest }; if (colspan > 1) attributes.colspan = colspan; if (rowspan > 1) attributes.rowspan = rowspan; const styles = []; if (colwidth) styles.push(`width: ${colwidth}px;`); if (backgroundColor) styles.push(`background-color: ${backgroundColor};`); if (styles.length > 0) attributes.style = styles.join(" "); return context.render("th", cleanObject(attributes), node.children); }; const originalResolvers = new Map([ [BlockTypes.DOCUMENT, nodeResolver("")], [BlockTypes.HEADING, headingResolver], [BlockTypes.PARAGRAPH, nodeResolver("p")], [BlockTypes.UL_LIST, nodeResolver("ul")], [BlockTypes.OL_LIST, nodeResolver("ol")], [BlockTypes.LIST_ITEM, nodeResolver("li")], [BlockTypes.IMAGE, imageResolver], [BlockTypes.EMOJI, emojiResolver], [BlockTypes.CODE_BLOCK, codeBlockResolver], [BlockTypes.HR, nodeResolver("hr")], [BlockTypes.BR, nodeResolver("br")], [BlockTypes.QUOTE, nodeResolver("blockquote")], [BlockTypes.COMPONENT, componentResolver], [TextTypes.TEXT, textResolver], [MarkTypes.LINK, linkResolver], [MarkTypes.ANCHOR, linkResolver], [MarkTypes.STYLED, markResolver("span", true)], [MarkTypes.BOLD, markResolver("strong")], [MarkTypes.TEXT_STYLE, markResolver("span", true)], [MarkTypes.ITALIC, markResolver("em")], [MarkTypes.UNDERLINE, markResolver("u")], [MarkTypes.STRIKE, markResolver("s")], [MarkTypes.CODE, markResolver("code")], [MarkTypes.SUPERSCRIPT, markResolver("sup")], [MarkTypes.SUBSCRIPT, markResolver("sub")], [MarkTypes.HIGHLIGHT, markResolver("mark")], [BlockTypes.TABLE, tableResolver], [BlockTypes.TABLE_ROW, tableRowResolver], [BlockTypes.TABLE_CELL, tableCellResolver], [BlockTypes.TABLE_HEADER, tableHeaderResolver] ]); const mergedResolvers = new Map([...originalResolvers, ...Object.entries(resolvers).map(([type, resolver]) => [type, resolver])]); const createRenderContext = () => { const contextRenderFn = (tag, attrs = {}, children) => { if (keyedResolvers && tag) { const currentCount = keyCounters.get(tag) || 0; keyCounters.set(tag, currentCount + 1); attrs.key = `${tag}-${currentCount}`; } return renderFn(tag, attrs, children); }; const context = { render: contextRenderFn, originalResolvers, mergedResolvers }; return context; }; function renderNode(node) { const resolver = mergedResolvers.get(node.type); if (!resolver) { console.error("<Storyblok>", `No resolver found for node type ${node.type}`); return ""; } const context = createRenderContext(); if (node.type === "text") return resolver(node, context); const children = node.content ? node.content.map(render) : void 0; return resolver({ ...node, children }, context); } /** * Renders a rich text node coming from Storyblok. * * @param {StoryblokRichTextNode<T>} node * @return {*} {T} * * @example * * ```typescript * import StoryblokClient from 'storyblok-js-client' * import { richTextResolver } from '@storyblok/richtext' * * const storyblok = new StoryblokClient({ * accessToken: import.meta.env.VITE_STORYBLOK_TOKEN, * }) * * const story = await client.get('cdn/stories/home', { * version: 'draft', * }) * * const html = richTextResolver().render(story.data.story.content.richtext) * ``` * */ function render(node) { if (node.type === "doc") return isExternalRenderFn ? node.content.map(renderNode) : node.content.map(renderNode).join(""); return Array.isArray(node) ? node.map(renderNode) : renderNode(node); } return { render }; } //#endregion exports.BlockTypes = BlockTypes; exports.LinkTargets = LinkTargets; exports.LinkTypes = LinkTypes; exports.MarkTypes = MarkTypes; exports.MarkdownTokenTypes = MarkdownTokenTypes; exports.TextTypes = TextTypes; exports.markdownToStoryblokRichtext = markdownToStoryblokRichtext; exports.richTextResolver = richTextResolver; }); //# sourceMappingURL=index.umd.js.map