diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx new file mode 100644 index 00000000..88d017fe --- /dev/null +++ b/apps/web/src/components/ui/prose.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/utils/ui"; +import rehypeParse from "rehype-parse"; +import { unified } from "unified"; +import { createElement } from "react"; +import type React from "react"; + +type ProseProps = React.HTMLAttributes & { + as?: "article"; + html: string; +}; + +type HastTextNode = { + type: "text"; + value: string; +}; + +type HastElementNode = { + type: "element"; + tagName: string; + properties?: Record; + children?: HastNode[]; +}; + +type HastRootNode = { + type: "root"; + children?: HastNode[]; +}; + +type HastNode = HastTextNode | HastElementNode | HastRootNode; + +function toReactProps({ + properties, +}: { + properties: HastElementNode["properties"]; +}): Record { + if (!properties) { + return {}; + } + + const props: Record = {}; + + for (const [propertyName, propertyValue] of Object.entries(properties)) { + if (propertyValue === null || propertyValue === undefined) { + continue; + } + + if (propertyName.startsWith("on")) { + continue; + } + + if (propertyName === "class") { + props.className = Array.isArray(propertyValue) + ? propertyValue.join(" ") + : propertyValue; + continue; + } + + if (propertyName === "for") { + props.htmlFor = propertyValue; + continue; + } + + props[propertyName] = propertyValue; + } + + return props; +} + +function renderHastNode({ + node, + key, +}: { + node: HastNode; + key: string; +}): React.ReactNode { + if (node.type === "text") { + return node.value; + } + + if (node.type !== "element") { + return null; + } + + const children = (node.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `${key}-${index}` }), + ); + + return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children); +} + +function renderHtmlNodes({ html }: { html: string }): React.ReactNode { + const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode; + return (rootNode.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `prose-node-${index}` }), + ); +} + +function Prose({ children, html, className }: ProseProps) { + return ( +
+ {html ? renderHtmlNodes({ html }) : children} +
+ ); +} + +export default Prose;