unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
1import { unified } from 'unified'
2import rehypeParse from 'rehype-parse'
3import rehypeStringify from 'rehype-stringify'
4import postcss from 'postcss'
5import safeParser from 'postcss-safe-parser'
6import Color from 'color'
7
8function parseInlineStyle(style: string) {
9 if (!style) return {}
10 // @ts-ignore
11 const root = postcss.parse(`*{${style}}`, { parser: safeParser })
12 const types: Record<string, string> = {}
13 root.walkDecls(d => { types[d.prop] = d.value })
14 return types
15}
16
17export async function htmlToMfm(input: string): Promise<string> {
18 const file = await unified()
19 .use(rehypeParse, { fragment: true })
20 .use(rehypeToMfm)
21 .use(rehypeStringify, { allowDangerousHtml: true })
22 .process(input)
23
24 return file.toString().replace('<', '<')
25}
26
27function cssToMfm(child: string, types: Record<string, string>): string {
28 let mfm = child
29 if (types['text-align'] === 'center') mfm = `<center>${mfm}</center>`
30 if (types['color']) {
31 try {
32 const c = Color(types['color'])
33 mfm = `$[fg.${c.hex().replace('#', '')} ${mfm}]`
34 } catch {
35 mfm = `$[fg.${types['color'].replace('#', '')} ${mfm}]`
36 }
37 }
38 if (types['background-color']) {
39 try {
40 const c = Color(types['background-color'])
41 mfm = `$[bg.${c.hex().replace('#', '')} ${mfm}]`
42 } catch {
43 mfm = `$[bg.${types['background-color'].replace('#', '')} ${mfm}]`
44 }
45 }
46 if (types['background']) {
47 try {
48 const c = Color(types['background'])
49 mfm = `$[bg.${c.hex().replace('#', '')} ${mfm}]`
50 } catch {
51 mfm = `$[bg.${types['background'].replace('#', '')} ${mfm}]`
52 }
53 }
54 if (types['font-weight'] === 'bold') mfm = `**${mfm}**`
55 if (types['font-style'] === 'italic') mfm = `*${mfm}*`
56 if (types['text-decoration']?.includes('line-through')) mfm = `~~${mfm}~~`
57 if (types['filter']?.includes('blur')) mfm = `$[blur ${mfm}]`
58
59 return mfm
60}
61
62function rehypeToMfm() {
63 return (tree: any) => {
64 function nodeToMfm(node: any): string {
65 if (node.type === 'text') return node.value
66 if (node.type === 'element') {
67 const child = (node.children || []).map(nodeToMfm).join('')
68 const style = node.properties?.style || ''
69 const href = node.properties?.href || ''
70 const types = parseInlineStyle(style)
71 const schild = cssToMfm(child, types)
72
73 switch (node.tagName.toLowerCase()) {
74 case 'strong':
75 case 'b': return `**${schild}**`
76 case 'em':
77 case 'i': return `*${schild}*`
78 case 'center': return `<center>${schild}</center>`
79 case 'del': return `~~${schild}~~`
80 case 'code': return (schild.includes('\n')) ? `\`\`\`\n${schild}\n\`\`\`` : `\`${schild}\``
81 case 'p': return `${schild}\n\n`
82 case 'br': return '\n'
83 case 'h1': return `$[x2 ${schild}]\n\n`
84 case 'h2': return `**${schild}**\n\n`
85 case 'h3': return `${schild}\n\n`
86 case 'blockquote': return `> ${schild}`
87 case 'ul':
88 case 'ol': return Array.from(node.children || []).map(nodeToMfm).join('')
89 case 'li': return `- ${schild}\n`
90 case 'a': {
91 if (schild.startsWith('@')) {
92 const mfmMention = new URL(href).hostname;
93 return `${schild}@${mfmMention}`
94 }
95 return `[${schild}](${href})`
96 }
97 default:
98 return schild
99 }
100 }
101 return node.value
102 }
103
104 const mfm = (tree.children || []).map(nodeToMfm).join('').trim()
105 tree.type = 'text'
106 tree.value = mfm
107 tree.child = []
108 }
109}