UNPKG

@storyblok/richtext

Version:
667 lines (658 loc) 18.9 kB
import { generateJSON } from "@tiptap/html"; import { Mark, Node } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details"; import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"; import Blockquote from "@tiptap/extension-blockquote"; import CodeBlock from "@tiptap/extension-code-block"; import Document from "@tiptap/extension-document"; import Emoji from "@tiptap/extension-emoji"; import HardBreak from "@tiptap/extension-hard-break"; import Heading from "@tiptap/extension-heading"; import HorizontalRule from "@tiptap/extension-horizontal-rule"; import Image from "@tiptap/extension-image"; import Paragraph from "@tiptap/extension-paragraph"; import Text from "@tiptap/extension-text"; import TextAlign from "@tiptap/extension-text-align"; import Bold from "@tiptap/extension-bold"; import Code from "@tiptap/extension-code"; import Highlight from "@tiptap/extension-highlight"; import Italic from "@tiptap/extension-italic"; import LinkOriginal from "@tiptap/extension-link"; import Strike from "@tiptap/extension-strike"; import Subscript from "@tiptap/extension-subscript"; import Superscript from "@tiptap/extension-superscript"; import "@tiptap/extension-text-style"; import Underline from "@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 /** * 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("; "); /** * 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 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 = TextAlign.configure({ types: ["heading", "paragraph"] }); const StoryblokBlockquote = Blockquote.extend({ renderHTML({ HTMLAttributes }) { return [ "blockquote", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokParagraph = Paragraph.extend({ renderHTML({ HTMLAttributes }) { return [ "p", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokHeading = Heading.extend({ renderHTML({ node, HTMLAttributes }) { const { level, ...rest } = HTMLAttributes; return [ `h${node.attrs.level}`, processBlockAttrs(rest), 0 ]; } }); const StoryblokTableRow = TableRow.extend({ renderHTML({ HTMLAttributes }) { return [ "tr", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokBulletList = BulletList.extend({ name: "bullet_list", addOptions() { return { ...this.parent(), itemTypeName: "list_item" }; }, renderHTML({ HTMLAttributes }) { return [ "ul", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokOrderedList = 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 = ListItem.extend({ name: "list_item", addOptions() { return { ...this.parent(), bulletListTypeName: "bullet_list", orderedListTypeName: "ordered_list" }; }, renderHTML({ HTMLAttributes }) { return [ "li", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokCodeBlock = CodeBlock.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 = HardBreak.extend({ name: "hard_break" }); const StoryblokHorizontalRule = HorizontalRule.extend({ name: "horizontal_rule" }); const StoryblokTable = Table.extend({ renderHTML({ HTMLAttributes }) { return [ "table", processBlockAttrs(HTMLAttributes), 0 ]; } }); const StoryblokTableCell = 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 = TableHeader.extend({ renderHTML({ HTMLAttributes }) { return [ "th", computeTableCellAttrs(HTMLAttributes), 0 ]; } }); const StoryblokImage = Image.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 = Emoji.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 = 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/extensions/marks.ts const StoryblokHighlight = Highlight.extend({ addAttributes() { return { color: { default: null } }; } }); const StoryblokLink = LinkOriginal.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 = Mark.create({ name: "anchor", addAttributes() { return { id: { default: null } }; }, parseHTML() { return [{ tag: "span[id]" }]; }, renderHTML({ HTMLAttributes }) { return [ "span", { id: HTMLAttributes.id }, 0 ]; } }); const StoryblokStyled = 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 = 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 = 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: Document, text: Text, 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: Details, detailsContent: DetailsContent, detailsSummary: DetailsSummary, bold: Bold, italic: Italic, strike: Strike, underline: Underline, code: Code, superscript: Superscript, subscript: Subscript, 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/html-parser.ts function htmlToStoryblokRichtext(html, options = {}) { const { preserveWhitespace, tiptapExtensions, ...extensionOptions } = options; const allExtensions = getStoryblokExtensions(extensionOptions); const finalExtensions = tiptapExtensions ? { ...allExtensions, ...tiptapExtensions } : allExtensions; return generateJSON(html, Object.values(finalExtensions), { preserveWhitespace: preserveWhitespace || false }); } //#endregion export { htmlToStoryblokRichtext }; //# sourceMappingURL=html-parser.mjs.map