@storyblok/richtext
Version:
Storyblok RichText Resolver
764 lines (758 loc) • 22.2 kB
JavaScript
import MarkdownIt from "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 MarkdownIt({
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) // '<script>alert("Hello")</script>'
* ```
*/
function escapeHtml(unsafeText) {
return unsafeText.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
/**
* 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
export { BlockTypes, LinkTargets, LinkTypes, MarkTypes, MarkdownTokenTypes, TextTypes, markdownToStoryblokRichtext, richTextResolver };
//# sourceMappingURL=index.js.map