336 lines
9.8 KiB
TypeScript
336 lines
9.8 KiB
TypeScript
import type { Element, Text } from "hast"
|
|
import type { Schema } from "hast-util-sanitize"
|
|
import type { Components } from "hast-util-to-jsx-runtime"
|
|
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
|
|
import { toText } from "hast-util-to-text"
|
|
import { createElement } from "react"
|
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
|
|
import { renderToString } from "react-dom/server"
|
|
import rehypeInferDescriptionMeta from "rehype-infer-description-meta"
|
|
import rehypeParse from "rehype-parse"
|
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize"
|
|
import rehypeStringify from "rehype-stringify"
|
|
import { unified } from "unified"
|
|
import type { Node } from "unist"
|
|
import { visit } from "unist-util-visit"
|
|
import { VFile } from "vfile"
|
|
|
|
import { ShadowDOM } from "~/components/common/ShadowDOM"
|
|
import { Checkbox } from "~/components/ui/checkbox"
|
|
import { ShikiHighLighter } from "~/components/ui/code-highlighter"
|
|
import { MarkdownBlockImage, MarkdownLink, MarkdownP } from "~/components/ui/markdown/renderers"
|
|
import { BlockError } from "~/components/ui/markdown/renderers/BlockErrorBoundary"
|
|
import { createHeadingRenderer } from "~/components/ui/markdown/renderers/Heading"
|
|
import { Media } from "~/components/ui/media"
|
|
|
|
function markInlineImage(node?: Element) {
|
|
for (const item of node?.children ?? []) {
|
|
if (item.type === "element" && item.tagName === "img") {
|
|
;(item.properties as any).inline = true
|
|
}
|
|
}
|
|
}
|
|
|
|
export type MediaInfo = Record<string, { width?: number; height?: number }>
|
|
|
|
export const parseHtml = (
|
|
content: string,
|
|
options?: Partial<{
|
|
renderInlineStyle: boolean
|
|
noMedia?: boolean
|
|
mediaInfo?: MediaInfo
|
|
}>,
|
|
) => {
|
|
const file = new VFile(content)
|
|
const { renderInlineStyle = false, noMedia = false } = options || {}
|
|
|
|
const rehypeSchema: Schema = { ...defaultSchema }
|
|
|
|
if (noMedia) {
|
|
rehypeSchema.tagNames = rehypeSchema.tagNames?.filter(
|
|
(tag) => tag !== "img" && tag !== "picture",
|
|
)
|
|
} else {
|
|
rehypeSchema.tagNames = [...rehypeSchema.tagNames!, "video", "style"]
|
|
rehypeSchema.attributes = {
|
|
...rehypeSchema.attributes,
|
|
"*": renderInlineStyle
|
|
? [...rehypeSchema.attributes!["*"], "style", "class"]
|
|
: rehypeSchema.attributes!["*"],
|
|
video: ["src", "poster"],
|
|
}
|
|
}
|
|
|
|
const pipeline = unified()
|
|
.use(rehypeParse, { fragment: true })
|
|
.use(rehypeSanitize, rehypeSchema)
|
|
|
|
.use(rehypeInferDescriptionMeta)
|
|
.use(rehypeStringify)
|
|
|
|
const tree = pipeline.parse(content)
|
|
|
|
rehypeUrlToAnchor(tree)
|
|
|
|
// console.log("tree", tree)
|
|
|
|
const hastTree = pipeline.runSync(tree, file)
|
|
|
|
const images = [] as string[]
|
|
|
|
visit(tree, "element", (node) => {
|
|
if (node.tagName === "img" && node.properties.src) {
|
|
images.push(node.properties.src as string)
|
|
}
|
|
})
|
|
|
|
return {
|
|
hastTree,
|
|
images,
|
|
toContent: () =>
|
|
toJsxRuntime(hastTree, {
|
|
Fragment,
|
|
ignoreInvalidStyle: true,
|
|
jsx: (type, props, key) => jsx(type as any, props, key),
|
|
jsxs: (type, props, key) => jsxs(type as any, props, key),
|
|
passNode: true,
|
|
components: {
|
|
a: ({ node, ...props }) => {
|
|
markInlineImage(node)
|
|
return createElement(MarkdownLink, { ...props } as any)
|
|
},
|
|
img: ({ ...props }) => {
|
|
return createElement(Img, {
|
|
...props,
|
|
width: props.src ? options?.mediaInfo?.[props.src]?.width : props.width,
|
|
height: props.src ? options?.mediaInfo?.[props.src]?.height : props.height,
|
|
})
|
|
},
|
|
|
|
h1: createHeadingRenderer(1),
|
|
h2: createHeadingRenderer(2),
|
|
h3: createHeadingRenderer(3),
|
|
h4: createHeadingRenderer(4),
|
|
h5: createHeadingRenderer(5),
|
|
h6: createHeadingRenderer(6),
|
|
style: Style,
|
|
|
|
video: ({ node, ...props }) =>
|
|
createElement(Media, { ...props, popper: true, type: "video" }),
|
|
p: ({ node, ...props }) => {
|
|
if (node?.children && node.children.length !== 1) {
|
|
for (const item of node.children) {
|
|
item.type === "element" &&
|
|
item.tagName === "img" &&
|
|
((item.properties as any).inline = true)
|
|
}
|
|
}
|
|
return createElement(MarkdownP, props, props.children)
|
|
},
|
|
span: ({ node, ...props }) => {
|
|
markInlineImage(node)
|
|
return createElement("span", props, props.children)
|
|
},
|
|
hr: ({ node, ...props }) =>
|
|
createElement("hr", {
|
|
...props,
|
|
className: tw`scale-x-50`,
|
|
}),
|
|
input: ({ node, ...props }) => {
|
|
if (props.type === "checkbox") {
|
|
return createElement(Checkbox, {
|
|
...props,
|
|
disabled: false,
|
|
className: tw`pointer-events-none mr-2`,
|
|
})
|
|
}
|
|
return createElement("input", props)
|
|
},
|
|
pre: ({ node, ...props }) => {
|
|
if (!props.children) return null
|
|
|
|
let language = ""
|
|
|
|
let codeString = null as string | null
|
|
if (props.className?.includes("language-")) {
|
|
language = props.className.replace("language-", "")
|
|
}
|
|
|
|
if (typeof props.children !== "object") {
|
|
codeString = props.children.toString()
|
|
} else {
|
|
const propsChildren = props.children
|
|
const children = Array.isArray(propsChildren)
|
|
? propsChildren.find((i) => i.type === "code")
|
|
: propsChildren
|
|
|
|
if (
|
|
"type" in children &&
|
|
children.type === "code" &&
|
|
children.props.className?.includes("language-")
|
|
) {
|
|
language = children.props.className.replace("language-", "")
|
|
}
|
|
const code = "props" in children && children.props.children
|
|
if (!code) return null
|
|
|
|
try {
|
|
codeString = extractCodeFromHtml(renderToString(code))
|
|
} catch (error) {
|
|
return createElement(BlockError, {
|
|
error,
|
|
message: "Code Block Render Error",
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!codeString) return null
|
|
|
|
return createElement(ShikiHighLighter, {
|
|
code: codeString.trimEnd(),
|
|
language: language.toLowerCase(),
|
|
})
|
|
},
|
|
table: ({ node, ...props }) =>
|
|
createElement(
|
|
"div",
|
|
{
|
|
className: "w-full overflow-x-auto",
|
|
},
|
|
|
|
createElement("table", {
|
|
...props,
|
|
className: tw`w-full my-0`,
|
|
}),
|
|
),
|
|
},
|
|
}),
|
|
toText: () => toText(hastTree),
|
|
}
|
|
}
|
|
|
|
const Img: Components["img"] = ({ node, ...props }) => {
|
|
const nextProps = {
|
|
...props,
|
|
proxy: { height: 0, width: 700 },
|
|
}
|
|
if (node?.properties.inline) {
|
|
return createElement(Media, {
|
|
type: "photo",
|
|
...nextProps,
|
|
|
|
mediaContainerClassName: tw`max-w-full inline rounded-md`,
|
|
popper: true,
|
|
className: tw`inline`,
|
|
showFallback: true,
|
|
})
|
|
}
|
|
|
|
return createElement(MarkdownBlockImage, nextProps)
|
|
}
|
|
|
|
function extractCodeFromHtml(htmlString: string) {
|
|
const tempDiv = document.createElement("div")
|
|
tempDiv.innerHTML = htmlString
|
|
|
|
// 1. line break via <div />
|
|
const divElements = tempDiv.querySelectorAll("div")
|
|
|
|
let code = ""
|
|
|
|
if (divElements.length > 0) {
|
|
divElements.forEach((div) => {
|
|
code += `${div.textContent}\n`
|
|
})
|
|
return code
|
|
}
|
|
|
|
// 2. line wrapper like <span><span>...</span></span>
|
|
const spanElements = tempDiv.querySelectorAll("span > span")
|
|
|
|
// 2.1 outside <span /> as a line break?
|
|
|
|
let spanAsLineBreak = false
|
|
|
|
if (tempDiv.children.length > 2) {
|
|
for (const node of tempDiv.children) {
|
|
const span = node as HTMLSpanElement
|
|
// 2.2 If the span has only one child and it's a line break, then span can be as a line break
|
|
spanAsLineBreak = span.children.length === 1 && span.childNodes.item(0).textContent === "\n"
|
|
if (spanAsLineBreak) break
|
|
}
|
|
}
|
|
if (spanElements.length > 0) {
|
|
for (const node of tempDiv.children) {
|
|
if (spanAsLineBreak) {
|
|
code += `${node.textContent}`
|
|
} else {
|
|
code += `${node.textContent}\n`
|
|
}
|
|
}
|
|
|
|
return code
|
|
}
|
|
|
|
return tempDiv.textContent
|
|
}
|
|
|
|
const Style: Components["style"] = ({ node, ...props }) => {
|
|
const isShadowDOM = ShadowDOM.useIsShadowDOM()
|
|
|
|
if (isShadowDOM) {
|
|
return createElement("style", {
|
|
...props,
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
|
|
function rehypeUrlToAnchor(tree: Node) {
|
|
// https://chatgpt.com/share/37e0ceec-5c9e-4086-b9d6-5afc1af13bb0
|
|
visit(tree, "text", (node: Text, index, parent: Node) => {
|
|
const urlRegex = /https?:\/\/\S+/g
|
|
const text = node.value
|
|
const matches = [...text.matchAll(urlRegex)]
|
|
|
|
if (matches.length === 0 || !parent || !("children" in parent)) return
|
|
|
|
if ((parent as Element).tagName === "a") {
|
|
return
|
|
}
|
|
|
|
const newNodes: (Text | Element)[] = []
|
|
let lastIndex = 0
|
|
|
|
matches.forEach((match) => {
|
|
const [url] = match
|
|
const urlIndex = match.index || 0
|
|
|
|
if (urlIndex > lastIndex) {
|
|
newNodes.push({
|
|
type: "text",
|
|
value: text.slice(lastIndex, urlIndex),
|
|
})
|
|
}
|
|
|
|
newNodes.push({
|
|
type: "element",
|
|
tagName: "a",
|
|
properties: { href: url },
|
|
children: [{ type: "text", value: url }],
|
|
})
|
|
|
|
lastIndex = urlIndex + url.length
|
|
})
|
|
|
|
if (lastIndex < text.length) {
|
|
newNodes.push({
|
|
type: "text",
|
|
value: text.slice(lastIndex),
|
|
})
|
|
}
|
|
|
|
;(parent.children as (Text | Element)[]).splice(index, 1, ...newNodes)
|
|
})
|
|
}
|