summaryrefslogtreecommitdiff
path: root/apps/web/src/components/ui/prose.tsx
blob: 74bb14fdcef215f34dbba05135347a9425c8eb60 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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<HTMLElement> & {
	as?: "article";
	html: string;
};

type HastTextNode = {
	type: "text";
	value: string;
};

type HastElementNode = {
	type: "element";
	tagName: string;
	properties?: Record<string, unknown>;
	children?: HastNode[];
};

type HastRootNode = {
	type: "root";
	children?: HastNode[];
};

type HastNode = HastTextNode | HastElementNode | HastRootNode;

function toReactProps({
	properties,
}: {
	properties: HastElementNode["properties"];
}): Record<string, unknown> {
	if (!properties) {
		return {};
	}

	const props: Record<string, unknown> = {};

	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 (
		<article
			className={cn(
				"prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2",
				className,
			)}
		>
			{html ? renderHtmlNodes({ html }) : children}
		</article>
	);
}

export default Prose;