@storyblok/richtext
Version:
Storyblok RichText Resolver
1,330 lines (1,317 loc) • 39.9 kB
JavaScript
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
/**
* 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, "&").replace(/"/g, """)}"`).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 != 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 = 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/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 = 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/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
export { BlockTypes, ComponentBlok, LinkTargets, LinkTypes, MarkTypes, TextTypes, asTag, getRichTextSegments, isVoidElement, parseStyleString, renderSegments, richTextResolver, segmentStoryblokRichText };
//# sourceMappingURL=index.mjs.map