diff --git a/src/UmbracoRichText.tsx b/src/UmbracoRichText.tsx index 778870b..cbba3d1 100644 --- a/src/UmbracoRichText.tsx +++ b/src/UmbracoRichText.tsx @@ -1,9 +1,13 @@ import { decode } from "html-entities"; import React from "react"; -import type { - RichTextElementModel, - RouteAttributes, - UmbracoBlockContext, +import { + type RichTextElementModel, + type RouteAttributes, + type UmbracoBlockContext, + isHtmlElement, + isRootElement, + isTextElement, + isUmbracoBlock, } from "./types/RichTextTypes"; import { parseStyle } from "./utils/parse-style"; @@ -86,34 +90,16 @@ function RichTextElement({ } & Pick) { if (!element || element.tag === "#comment" || element.tag === "#root") return null; - if (element.tag === "#text") { + + if (isTextElement(element)) { // Decode HTML entities in text nodes return decode(element.text); } - let children = element.elements?.map((node, index) => ( - - )); - if (!children?.length) children = undefined; - // If the tag is a block, skip the normal rendering and render the block - if ( - element.tag === "umb-rte-block" || - element.tag === "umb-rte-block-inline" - ) { + if (isUmbracoBlock(element)) { const block = blocks?.find( - (block) => block.content?.id === element.attributes["content-id"], + (block) => block.content?.id === element.attributes?.["content-id"], ); if (renderBlock && block) { return renderBlock(block); @@ -127,52 +113,76 @@ function RichTextElement({ return null; } - const { route, style, class: className, ...attributes } = element.attributes; - const defaultAttributes = htmlAttributes[element.tag]; - if (element.tag === "a" && route?.path) { - attributes.href = route?.path; - } + if (isHtmlElement(element)) { + const children: React.ReactNode = + element.elements.map((node, index) => ( + + )) || null; - if (className) { - if (defaultAttributes?.className) { - // Merge the default class with the class attribute - attributes.className = `${defaultAttributes.className} ${className}`; - } else { - attributes.className = className; + const { + route, + style, + class: className, + ...attributes + } = element.attributes; + const defaultAttributes = htmlAttributes[element.tag]; + if (element.tag === "a" && route?.path) { + attributes.href = route?.path; } - } - if (typeof style === "string") { - attributes.style = parseStyle(style); - } + if (className) { + if (defaultAttributes?.className) { + // Merge the default class with the class attribute + attributes.className = `${defaultAttributes.className} ${className}`; + } else { + attributes.className = className; + } + } - if (renderNode) { - const output = renderNode({ - // biome-ignore lint/suspicious/noExplicitAny: Avoid complicated TypeScript logic by using any. The type will be corrected in the implementation. - tag: element.tag as any, - attributes: { - ...defaultAttributes, - ...attributes, - } as Record, - children, - route, - meta: meta || {}, - }); + if (typeof style === "string") { + attributes.style = parseStyle(style); + } - if (output !== undefined) { - // If we got a valid output from the renderElement function, we return it - // `null` we will render nothing, but `undefined` fallback to the default element - return output; + if (renderNode) { + const output = renderNode({ + // biome-ignore lint/suspicious/noExplicitAny: Avoid complicated TypeScript logic by using any. The type will be corrected in the implementation. + tag: element.tag as any, + attributes: { + ...defaultAttributes, + ...attributes, + } as Record, + children, + route, + meta: meta || {}, + }); + + if (output !== undefined) { + // If we got a valid output from the renderElement function, we return it + // `null` we will render nothing, but `undefined` fallback to the default element + return output; + } } - } - return React.createElement( - element.tag, - htmlAttributes[element.tag] - ? { ...defaultAttributes, ...attributes } - : attributes, - children, - ); + return React.createElement( + element.tag, + htmlAttributes[element.tag] + ? { ...defaultAttributes, ...attributes } + : attributes, + children, + ); + } + return undefined; } /** @@ -180,7 +190,7 @@ function RichTextElement({ */ export function UmbracoRichText(props: RichTextProps) { const rootElement = props.data; - if (rootElement?.tag === "#root" && rootElement.elements) { + if (isRootElement(rootElement)) { return ( <> {rootElement.elements.map((element, index) => ( diff --git a/src/types/RichTextTypes.ts b/src/types/RichTextTypes.ts index 9256d5b..830b74f 100644 --- a/src/types/RichTextTypes.ts +++ b/src/types/RichTextTypes.ts @@ -1,5 +1,44 @@ import type { Overwrite } from "../utils/helper-types"; +export function isRootElement(data: RichTextElementModel | undefined): data is { + tag: "#root"; + attributes?: Record; + elements: RichTextElementModel[]; + blocks?: Array; +} { + return !!data && data.tag === "#root"; +} + +export function isTextElement( + data: RichTextElementModel, +): data is { tag: "#text"; text: string } { + return data.tag === "#text"; +} + +export function isCommentElement( + data: RichTextElementModel, +): data is { tag: "#comment"; text: string } { + return data.tag === "#comment"; +} + +export function isUmbracoBlock(data: RichTextElementModel): data is { + tag: string; + attributes: { + "content-id": string; + }; + elements: RichTextElementModel[]; +} { + return data.tag === "umb-rte-block" || data.tag === "umb-rte-block-inline"; +} + +export function isHtmlElement(data: RichTextElementModel): data is { + tag: keyof HTMLElementTagNameMap; + attributes: Record & { route?: RouteAttributes }; + elements: RichTextElementModel[]; +} { + return "elements" in data; +} + interface BaseBlockItemModel { content?: { id: string; @@ -67,14 +106,7 @@ export type RichTextElementModel = text: string; } | { - tag: "umb-rte-block"; - attributes: { - "content-id": string; - }; - elements: RichTextElementModel[]; - } - | { - tag: "umb-rte-block-inline"; + tag: "umb-rte-block" | "umb-rte-block-inline"; attributes: { "content-id": string; }; @@ -84,4 +116,10 @@ export type RichTextElementModel = tag: keyof HTMLElementTagNameMap; attributes: Record & { route?: RouteAttributes }; elements?: RichTextElementModel[]; + } + | { + tag: string; + attributes?: Record & { route?: RouteAttributes }; + elements?: RichTextElementModel[]; + blocks?: Array; }; diff --git a/src/utils/rich-text-converter.ts b/src/utils/rich-text-converter.ts index 1bd1419..935c674 100644 --- a/src/utils/rich-text-converter.ts +++ b/src/utils/rich-text-converter.ts @@ -1,5 +1,10 @@ import { decode } from "html-entities"; -import type { RichTextElementModel } from "../types/RichTextTypes"; +import { + type RichTextElementModel, + isCommentElement, + isHtmlElement, + isTextElement, +} from "../types/RichTextTypes"; const arrayContentLength = (arr: string[]) => arr.join("").length; @@ -12,7 +17,7 @@ function iterateRichText( options: Options, ) { // Iterate over the elements in the rich text, and find all `#text` elements - if (data.tag === "#text") { + if (isTextElement(data)) { // Decode the text and remove any extra whitespace/line breaks const decodedText = decode(data.text).trim().replace(/\s+/g, " "); // If the text is the first element, or the first character is a special character, don't add a space @@ -24,11 +29,11 @@ function iterateRichText( return acc; } - if (data.tag === "#comment" || options.ignoreTags?.includes(data.tag)) { + if (isCommentElement(data) || options.ignoreTags?.includes(data.tag)) { return acc; } - if (data.elements) { + if (isHtmlElement(data)) { for (let i = 0; i < data.elements.length; i++) { iterateRichText(data.elements[i], acc, options); if (options.maxLength && arrayContentLength(acc) >= options.maxLength) { @@ -55,7 +60,7 @@ function findElement( return undefined; } - if (data.elements) { + if (isHtmlElement(data)) { for (let i = 0; i < data.elements.length; i++) { const result = findElement(data.elements[i], tag); if (result) {