/* Copyright 2026 Marimo. All rights reserved. */ import parse, { type DOMNode, Element, type HTMLReactParserOptions, } from "html-react-parser"; import React, { isValidElement, type JSX, type ReactNode, useMemo, useRef, } from "react"; import { CopyClipboardIcon } from "@/components/icons/copy-icon"; import { QueryParamPreservingLink } from "@/components/ui/query-param-preserving-link"; import { Tooltip } from "@/components/ui/tooltip"; import { DocHoverTarget } from "@/core/documentation/DocHoverTarget"; import { hasTrustedNotebookContext } from "@/core/static/export-context"; import { Logger } from "@/utils/Logger"; import { sanitizeHtml, useSanitizeHtml } from "./sanitize"; type ReplacementFn = NonNullable; type TransformFn = NonNullable; interface Options { html: string; /** * Whether to sanitize the HTML. * @default true */ alwaysSanitizeHtml?: boolean; additionalReplacements?: ReplacementFn[]; } const replaceValidTags = (domNode: DOMNode) => { // Don't render invalid tags if (domNode instanceof Element && !/^[A-Za-z][\w-]*$/.test(domNode.name)) { return React.createElement(React.Fragment); } }; const removeWrappingBodyTags: TransformFn = ( reactNode: ReactNode, domNode: DOMNode, ) => { // Remove body tags and just render their children if (domNode instanceof Element && domNode.name === "body") { if (isValidElement(reactNode) && "props" in reactNode) { const props = reactNode.props as { children?: ReactNode }; const children = props.children; return <>{children}; // oxlint-disable-line react/jsx-no-useless-fragment } return; } }; const removeWrappingHtmlTags: TransformFn = ( reactNode: ReactNode, domNode: DOMNode, ) => { // Remove html tags and just render their children if (domNode instanceof Element && domNode.name === "html") { if (isValidElement(reactNode) && "props" in reactNode) { const props = reactNode.props as { children?: ReactNode }; const children = props.children; return <>{children}; // oxlint-disable-line react/jsx-no-useless-fragment } return; } }; const replaceValidIframes = (domNode: DOMNode) => { // For iframe, we just want to use dangerouslySetInnerHTML so: // 1) we can remount the iframe when the src changes // 2) keep event attributes (onload, etc.) since this library removes them if ( domNode instanceof Element && domNode.attribs && domNode.name === "iframe" ) { const element = document.createElement("iframe"); Object.entries(domNode.attribs).forEach(([key, value]) => { // If it is wrapped in quotes, remove them // html-react-parser will return quoted keys if they are // valueless attributes (e.g. "allowfullscreen") if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } element.setAttribute(key, value); }); return
; } }; const replaceSrcScripts = (domNode: DOMNode): JSX.Element | undefined => { if (domNode instanceof Element && domNode.name === "script") { // Missing src, we don't handle inline scripts const src = domNode.attribs.src; if (!src) { return; } // Only append notebook-authored scripts when the page is a trusted // context (the user has run a cell, the page is a trusted export, or // we're running in read/app mode). In untrusted edit mode before any // user interaction, drop the script and log a warning. Outer // sanitization will normally strip