import { cx } from "class-variance-authority" import { Divider } from "../Divider" import { MaterialIcon } from "../Icons/MaterialIcon" import Image from "../Image" import ImageContainer from "../ImageContainer" import Table from "../Table" import { Typography } from "../Typography" import { extractAvailableListClassNames, hasAvailableParagraphFormat, } from "./utils" import styles from "./jsontohtml.module.css" import { DeprecatedImageVaultAssetResponse, ImageVaultAssetResponse, mapImageVaultAssetResponseToImageVaultAsset, mapInsertResponseToImageVaultAsset, } from "@scandic-hotels/common/utils/imageVault" import { Alert } from "../Alert" import { TextLink } from "../TextLink" import type { EmbedByUid } from "./JsonToHtml" import type { Attributes } from "./types/rte/attrs" import { AvailableParagraphFormatEnum, RTEItemTypeEnum, RTETypeEnum, } from "./types/rte/enums" import { type RTEDefaultNode, type RTEImageNode, RTEMarkType, type RTENext, type RTENode, type RTERegularNode, type RTETextNode, } from "./types/rte/node" import type { RenderOptions } from "./types/rte/option" function noNestedLinksOrReferences(node: RTENode) { if ("type" in node) { if (node.type === RTETypeEnum.reference) { return node.children } else if (node.type === RTETypeEnum.a) { return node.children } } return node } function extractPossibleAttributes(attrs: Attributes | undefined) { if (!attrs) return {} const props: Record & { className?: string style?: React.CSSProperties | undefined } = {} if (attrs.id) { props.id = attrs.id } if (attrs.class && typeof attrs.class === "string") { props.className = attrs.class } else if (attrs["class-name"]) { props.className = attrs["class-name"] } else if (attrs.classname && typeof attrs.classname === "string") { props.className = attrs.classname } if (attrs.style?.["text-align"]) { props.style = { textAlign: attrs.style["text-align"] as React.CSSProperties["textAlign"], } } return props } export const renderOptions: RenderOptions = { [RTETypeEnum.a]: ( node: RTERegularNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { if (!node.attrs.url) { return } const { className, ...props } = extractPossibleAttributes(node.attrs) return ( {next( // Sometimes editors happen to nest a reference inside a link and vice versa. // In that case use the outermost link, i.e. ignore nested links. node.children.flatMap(noNestedLinksOrReferences), embeds, fullRenderOptions )} ) }, [RTETypeEnum.blockquote]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (
{next(node.children, embeds, fullRenderOptions)}
) }, [RTETypeEnum.code]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.embed]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) if (node.attrs.src) { props.src = node.attrs.src } if (node.attrs.url) { props.src = node.attrs.url } if (!props.src) { return null } return ( ) }, [RTETypeEnum.h1]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (

{next(node.children, embeds, fullRenderOptions)}

) }, [RTETypeEnum.h2]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (

{next(node.children, embeds, fullRenderOptions)}

) }, [RTETypeEnum.h3]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (

{next(node.children, embeds, fullRenderOptions)}

) }, [RTETypeEnum.h4]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (

{next(node.children, embeds, fullRenderOptions)}

) }, [RTETypeEnum.h5]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (
{next(node.children, embeds, fullRenderOptions)}
) }, [RTETypeEnum.hr]: () => { return }, [RTETypeEnum.li]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) const compatibleClassNames = extractAvailableListClassNames(className) return (
  • {next(node.children, embeds, fullRenderOptions)}
  • ) }, [RTETypeEnum.ol]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) const compatibleClassNames = extractAvailableListClassNames(className) return (
      {next(node.children, embeds, fullRenderOptions)}
    ) }, [RTETypeEnum.p]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) const hasFormat = node.children.some((item) => hasAvailableParagraphFormat((item as RTETextNode)?.classname) ) // If a child node has an available format as className, we wrap it in a // span and render the children with the correct component if (hasFormat) { return next(node.children, embeds, fullRenderOptions) } // Determine whether to use a div or p based on children. // Some children use div or sections as wrappers and these are // not allowed inside p tags. const Elem = node.children.some((child) => "children" in child) ? "div" : "p" return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.span]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) let propsClassName = className if (className) { const availableClassNames = extractAvailableListClassNames(className) if (availableClassNames.length) { propsClassName = cx(availableClassNames) } } return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.div]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) let propsClassName = className if (className) { const availableClassNames = extractAvailableListClassNames(className) if (availableClassNames.length) { propsClassName = cx(availableClassNames) } } return (
    {next(node.children, embeds, fullRenderOptions)}
    ) }, [RTETypeEnum.reference]: ( node: RTENode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { if (!("attrs" in node)) { return null } const type = node.attrs.type if (type === RTEItemTypeEnum.asset) { const imageTypeRegex = /^image\// const isImage = imageTypeRegex.test(node.attrs["asset-type"] as string) if (isImage) { const image = embeds?.[node?.attrs?.["asset-uid"] as string] if (image?.node.__typename === "SysAsset") { if (image.node.url) { const alt = image?.node?.title ?? node.attrs.alt const { className, ...props } = extractPossibleAttributes( node.attrs ) return (
    {alt
    ) } } } else if (node.attrs["display-type"] === "link") { const asset = embeds?.[node?.attrs?.["asset-uid"] as string] // Decision note 2026-01-07: // Content team sometimes updates assets without updating the RTE reference. // The `permanent_url` should be used on the asset in Contentstack. // If this is not provided, we should not render the link. const rawUrl = asset?.node && "permanent_url" in asset.node ? asset.node.permanent_url : null if (!rawUrl) { return null } const { className, ...props } = extractPossibleAttributes(node.attrs) const cleanUrl = rawUrl.split("?")[0] return ( {next( // Sometimes editors happen to nest a reference inside a link and vice versa. // In that case use the outermost link, i.e. ignore nested links. node.children.flatMap(noNestedLinksOrReferences), embeds, fullRenderOptions )} ) } } if (type === RTEItemTypeEnum.entry) { const entry = embeds?.[node?.attrs?.["entry-uid"] as string] if (entry?.node.__typename === "ImageContainer") { if (entry.node.image_left && entry.node.image_right) { return ( ) } return null } else if (entry?.node.__typename === "Alert") { return } else if ( entry?.node.__typename === "AccountPage" || entry?.node.__typename === "CampaignOverviewPage" || entry?.node.__typename === "CampaignPage" || entry?.node.__typename === "CollectionPage" || entry?.node.__typename === "ContentPage" || entry?.node.__typename === "DestinationCityPage" || entry?.node.__typename === "DestinationCountryPage" || entry?.node.__typename === "DestinationOverviewPage" || entry?.node.__typename === "HotelPage" || entry?.node.__typename === "LoyaltyPage" || entry?.node.__typename === "StartPage" ) { // If entry is not an ImageContainer, it is a page and we return it as a link const { className, ...props } = extractPossibleAttributes(node.attrs) const entryHref = entry.node.url const nodeHref = node.attrs?.locale ? `/${node.attrs.locale}${node.attrs.href ?? node.attrs.url}` : ((node.attrs.href ?? node.attrs.url) as string) if (!entryHref && !nodeHref) { return null } return ( {next( // Sometimes editors happen to nest a reference inside a link and vice versa. // In that case use the outermost link, i.e. ignore nested links. node.children.flatMap(noNestedLinksOrReferences), embeds, fullRenderOptions )} ) } } }, [RTETypeEnum.ImageVault]: (node: RTEImageNode) => { const type = node.type if (!("attrs" in node) || type !== RTETypeEnum.ImageVault) { return null } let image = undefined if ("imageVaultId" in node.attrs && "fileName" in node.attrs) { image = mapImageVaultAssetResponseToImageVaultAsset( node.attrs as unknown as ImageVaultAssetResponse ) } if ("Name" in node.attrs && "Id" in node.attrs) { image = mapInsertResponseToImageVaultAsset( node.attrs as unknown as DeprecatedImageVaultAssetResponse ) } if (!image) { return null } const alt = image.meta.alt ?? image.title const caption = image.meta.caption const { className, ...props } = extractPossibleAttributes(node.attrs) return (
    {alt}
    {caption ? (

    {image.meta.caption}

    ) : null}
    ) }, [RTETypeEnum.table]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) return (
    {next(node.children, embeds, fullRenderOptions)}
    ) }, [RTETypeEnum.thead]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { // Override the styling of p tags inside the thead tag const theadChildPRenderOptions = { ...fullRenderOptions, [RTETypeEnum.p]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => ( {next(node.children, embeds, fullRenderOptions)} ), } const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, theadChildPRenderOptions)} ) }, [RTETypeEnum.tbody]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.tfoot]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.fragment]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { return <>{next(node.children, embeds, fullRenderOptions)} }, [RTETypeEnum.tr]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.th]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.td]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const props = extractPossibleAttributes(node.attrs) return ( {next(node.children, embeds, fullRenderOptions)} ) }, [RTETypeEnum.ul]: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { const { className, ...props } = extractPossibleAttributes(node.attrs) const compatibleClassNames = extractAvailableListClassNames(className) return (
      {next(node.children, embeds, fullRenderOptions)}
    ) }, /** TextNode wrappers */ [RTEMarkType.bold]: (children: React.ReactNode) => { return ( {children} ) }, [RTEMarkType.italic]: (children: React.ReactNode) => { return {children} }, [RTEMarkType.underline]: (children: React.ReactNode) => { return ( {children} ) }, [RTEMarkType.strikethrough]: (children: React.ReactNode) => { return {children} }, [RTEMarkType.inlineCode]: (children: React.ReactNode) => { return {children} }, [RTEMarkType.subscript]: (children: React.ReactNode) => { return {children} }, [RTEMarkType.superscript]: (children: React.ReactNode) => { return {children} }, [RTEMarkType.break]: (children: React.ReactNode) => { return ( <>
    {children} ) }, [RTEMarkType.classnameOrId]: ( children: React.ReactNode, className?: string, id?: string ) => { const props = { id } let propsClassName = className if (!id) { delete props.id } if (className) { const availableClassNames = extractAvailableListClassNames(className) if (availableClassNames.length) { propsClassName = cx(availableClassNames) } } if (className === AvailableParagraphFormatEnum.footnote) { return (

    {children}

    ) } if (className === AvailableParagraphFormatEnum.caption) { return (

    {children}

    ) } if (className === AvailableParagraphFormatEnum["script-1"]) { return (

    {children}

    ) } if (className === AvailableParagraphFormatEnum["script-2"]) { return (

    {children}

    ) } if (className === AvailableParagraphFormatEnum["subtitle-1"]) { return (

    {children}

    ) } if (className === AvailableParagraphFormatEnum["subtitle-2"]) { return (

    {children}

    ) } return ( {children} ) }, /** * Contentstack can return something called `default` as seen here in their * own SDK (https://github.com/contentstack/contentstack-utils-javascript/blob/master/src/options/default-node-options.ts#L89) */ default: ( node: RTEDefaultNode, embeds: EmbedByUid, next: RTENext, fullRenderOptions: RenderOptions ) => { return next(node.children, embeds, fullRenderOptions) }, }