Folo/packages/internal/utils/src/html.ts

331 lines
8.8 KiB
TypeScript

import type { SpotlightRule } from "@follow/shared/spotlight"
import { spotlightHighlightOpacityHex } from "@follow/shared/spotlight"
import type { Element, Parent, Root, 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 { Fragment, jsx, jsxs } from "react/jsx-runtime"
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 { visitParents } from "unist-util-visit-parents"
import { buildHighlightSegments, compileSpotlightRules } from "./spotlight"
type ParseHtmlOptions = {
renderInlineStyle?: boolean
noMedia?: boolean
components?: Components
scrollEnabled?: boolean
hastTransform?: (tree: Root) => void
}
const spotlightExcludedTagNames = new Set([
"code",
"pre",
"kbd",
"samp",
"style",
"script",
"title",
])
const svgTextContextTagNames = new Set(["text", "tspan"])
const toSpotlightStyle = (color: string) =>
`background-color:${color}${spotlightHighlightOpacityHex};border-radius:4px;padding-inline:1px;`
type TextNodeTransformer = (node: Text, context: { ancestors: Element[] }) => Array<Element | Text>
const transformHastTextNodes = (
tree: Parent,
transformer: TextNodeTransformer,
ancestors: Element[] = [],
) => {
if (!Array.isArray(tree.children) || tree.children.length === 0) return
const nextChildren: Parent["children"] = []
for (const child of tree.children) {
if (child.type === "text") {
nextChildren.push(...transformer(child, { ancestors }))
continue
}
nextChildren.push(child)
if (child.type === "element") {
transformHastTextNodes(child, transformer, [...ancestors, child])
continue
}
if ("children" in child && Array.isArray(child.children)) {
transformHastTextNodes(child as Parent, transformer, ancestors)
}
}
tree.children = nextChildren
}
export const applySpotlightToHast = (tree: Root, rules: SpotlightRule[]) => {
const compiledRules = compileSpotlightRules(rules)
if (compiledRules.length === 0) return
transformHastTextNodes(tree, (node, { ancestors }) => {
if (ancestors.some((ancestor) => spotlightExcludedTagNames.has(ancestor.tagName))) {
return [node]
}
const isSvgSubtree = ancestors.some((ancestor) => ancestor.tagName === "svg")
const isVisibleSvgTextContext = ancestors.some((ancestor) =>
svgTextContextTagNames.has(ancestor.tagName),
)
if (isSvgSubtree && !isVisibleSvgTextContext) {
return [node]
}
const segments = buildHighlightSegments(node.value, compiledRules)
if (segments.length === 1 && !segments[0]?.highlight) {
return [node]
}
return segments.map((segment) =>
segment.highlight
? {
type: "element",
tagName: isVisibleSvgTextContext ? "tspan" : "span",
properties: {
"data-spotlight-rule-id": segment.highlight.ruleId,
"data-spotlight-color": segment.highlight.color,
style: toSpotlightStyle(segment.highlight.color),
},
children: [{ type: "text", value: segment.text }],
}
: {
type: "text",
value: segment.text,
},
)
})
}
/**
* Remove the last <br> element in the tree
*/
function rehypeTrimEndBrElement() {
function trim(tree: Parent): void {
if (!Array.isArray(tree.children) || tree.children.length === 0) {
return
}
for (let i = tree.children.length - 1; i >= 0; i--) {
const item = tree.children[i]!
if (item.type === "element") {
if (item.tagName === "br") {
tree.children.pop()
continue
} else {
trim(item)
}
}
break
}
}
return trim
}
export const parseHtml = (content: string, options?: ParseHtmlOptions) => {
const { renderInlineStyle = false, noMedia = false, components, hastTransform } = options || {}
const rehypeSchema: Schema = { ...defaultSchema }
rehypeSchema.tagNames = [...rehypeSchema.tagNames!, "math"]
if (noMedia) {
rehypeSchema.tagNames = rehypeSchema.tagNames?.filter(
(tag) => tag !== "img" && tag !== "picture",
)
} else {
rehypeSchema.tagNames = [
...rehypeSchema.tagNames!,
"video",
"iframe",
"style",
"figure",
// SVG
"svg",
"g",
"ellipse",
"text",
"tspan",
"polygon",
"path",
"title",
"rect",
"line",
"circle",
"use",
]
rehypeSchema.attributes = {
...rehypeSchema.attributes,
"*": renderInlineStyle
? [...rehypeSchema.attributes!["*"]!, "style", "class"]
: rehypeSchema.attributes!["*"]!,
video: ["src", "poster"],
iframe: [
"src",
"width",
"height",
"frameborder",
"allowfullscreen",
"sandbox",
"loading",
"title",
"id",
"class",
],
source: ["src", "type"],
svg: [
"width",
"height",
"viewBox",
"xmlns",
"version",
"preserveAspectRatio",
"xmlns:xlink",
"xml:space",
"fill",
"stroke",
"stroke-width",
],
g: ["transform", "fill", "stroke", "stroke-width"],
path: ["d", "fill", "stroke", "stroke-width", "transform"],
polygon: ["points", "fill", "stroke", "stroke-width", "transform"],
circle: ["cx", "cy", "r", "fill", "stroke", "stroke-width", "transform"],
ellipse: ["cx", "cy", "rx", "ry", "fill", "stroke", "stroke-width", "transform"],
rect: [
"x",
"y",
"width",
"height",
"fill",
"stroke",
"stroke-width",
"transform",
"rx",
"ry",
],
line: ["x1", "y1", "x2", "y2", "stroke", "stroke-width", "transform"],
text: ["x", "y", "fill", "font-size", "font-family", "text-anchor", "transform"],
tspan: ["x", "y", "dx", "dy", "fill", "font-size", "font-family", "text-anchor", "transform"],
use: ["href", "xlink:href", "x", "y", "width", "height", "transform"],
}
}
const pipeline = unified()
.use(rehypeParse, { fragment: true })
.use(rehypeSanitize, rehypeSchema)
.use(rehypeTrimEndBrElement)
.use(rehypeInferDescriptionMeta)
.use(rehypeStringify)
const tree = pipeline.parse(content)
rehypeUrlToAnchor(tree)
// console.log("tree", tree)
const hastTree = pipeline.runSync(tree, content)
hastTransform?.(hastTree as Root)
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,
}),
toText: () => toText(hastTree),
}
}
function rehypeUrlToAnchor(tree: Node) {
const tagsShouldNotBeWrapped = new Set(["a", "pre", "code"])
// https://chatgpt.com/share/37e0ceec-5c9e-4086-b9d6-5afc1af13bb0
visitParents(tree as any, "text", (node: Text, ancestors: Node[]) => {
if (
ancestors.some(
(ancestor) =>
"tagName" in ancestor && tagsShouldNotBeWrapped.has((ancestor as Element).tagName),
)
) {
return
}
const parent = ancestors.at(-1)
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),
})
}
const index = (parent.children as (Text | Element)[]).indexOf(node)
;(parent.children as (Text | Element)[]).splice(index, 1, ...newNodes)
})
}