refactor: migrate to nuxt compatibilityVersion: 4 (#3298)
This commit is contained in:
parent
46e4433e1c
commit
a3fbc056a9
342 changed files with 1200 additions and 2932 deletions
683
app/composables/content-parse.ts
Normal file
683
app/composables/content-parse.ts
Normal file
|
@ -0,0 +1,683 @@
|
|||
// @unimport-disable
|
||||
import type { mastodon } from 'masto'
|
||||
import type { Node } from 'ultrahtml'
|
||||
import { findAndReplaceEmojisInText } from '@iconify/utils'
|
||||
import { decode } from 'tiny-decode'
|
||||
import { DOCUMENT_NODE, ELEMENT_NODE, h, parse, render, TEXT_NODE } from 'ultrahtml'
|
||||
import { emojiRegEx, getEmojiAttributes } from '~~/config/emojis'
|
||||
|
||||
export interface ContentParseOptions {
|
||||
emojis?: Record<string, mastodon.v1.CustomEmoji>
|
||||
hideEmojis?: boolean
|
||||
mentions?: mastodon.v1.StatusMention[]
|
||||
markdown?: boolean
|
||||
replaceUnicodeEmoji?: boolean
|
||||
astTransforms?: Transform[]
|
||||
convertMentionLink?: boolean
|
||||
collapseMentionLink?: boolean
|
||||
status?: mastodon.v1.Status
|
||||
inReplyToStatus?: mastodon.v1.Status
|
||||
}
|
||||
|
||||
const sanitizerBasicClasses = filterClasses(/^h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible$/u)
|
||||
const sanitizer = sanitize({
|
||||
// Allow basic elements as seen in https://github.com/mastodon/mastodon/blob/17f79082b098e05b68d6f0d38fabb3ac121879a9/lib/sanitize_ext/sanitize_config.rb
|
||||
br: {},
|
||||
p: {},
|
||||
a: {
|
||||
href: filterHref(),
|
||||
class: sanitizerBasicClasses,
|
||||
rel: set('nofollow noopener noreferrer'),
|
||||
target: set('_blank'),
|
||||
},
|
||||
span: {
|
||||
class: sanitizerBasicClasses,
|
||||
},
|
||||
// Allow elements potentially created for Markdown code blocks above
|
||||
pre: {},
|
||||
code: {
|
||||
class: filterClasses(/^language-\w+$/),
|
||||
},
|
||||
// Other elements supported in glitch, as seen in
|
||||
// https://github.com/glitch-soc/mastodon/blob/13227e1dafd308dfe1a3effc3379b766274809b3/lib/sanitize_ext/sanitize_config.rb#L75
|
||||
abbr: {
|
||||
title: keep,
|
||||
},
|
||||
del: {},
|
||||
blockquote: {
|
||||
cite: filterHref(),
|
||||
},
|
||||
b: {},
|
||||
strong: {},
|
||||
u: {},
|
||||
sub: {},
|
||||
sup: {},
|
||||
i: {},
|
||||
em: {},
|
||||
h1: {},
|
||||
h2: {},
|
||||
h3: {},
|
||||
h4: {},
|
||||
h5: {},
|
||||
ul: {},
|
||||
ol: {
|
||||
start: keep,
|
||||
reversed: keep,
|
||||
},
|
||||
li: {
|
||||
value: keep,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse raw HTML form Mastodon server to AST,
|
||||
* with interop of custom emojis and inline Markdown syntax
|
||||
* @param html The content to parse
|
||||
* @param options The parsing options
|
||||
*/
|
||||
export function parseMastodonHTML(
|
||||
html: string,
|
||||
options: ContentParseOptions = {},
|
||||
) {
|
||||
const {
|
||||
markdown = true,
|
||||
replaceUnicodeEmoji = true,
|
||||
convertMentionLink = false,
|
||||
collapseMentionLink = false,
|
||||
hideEmojis = false,
|
||||
mentions,
|
||||
status,
|
||||
inReplyToStatus,
|
||||
} = options
|
||||
|
||||
// remove newline before Tags
|
||||
html = html.replace(/\n(<[^>]+>)/g, (_1, raw) => {
|
||||
return raw
|
||||
})
|
||||
|
||||
if (markdown) {
|
||||
// Handle code blocks
|
||||
html = html
|
||||
/* eslint-disable regexp/no-super-linear-backtracking, regexp/no-misleading-capturing-group */
|
||||
.replace(/>(```|~~~)(\w*)([\s\S]+?)\1/g, (_1, _2, lang: string, raw: string) => {
|
||||
const code = htmlToText(raw)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`')
|
||||
const classes = lang ? ` class="language-${lang}"` : ''
|
||||
return `><pre><code${classes}>${code}</code></pre>`
|
||||
})
|
||||
.replace(/`([^`\n]*)`/g, (_1, raw) => {
|
||||
return raw ? `<code>${htmlToText(raw).replace(/</g, '<').replace(/>/g, '>')}</code>` : ''
|
||||
})
|
||||
}
|
||||
|
||||
// Always sanitize the raw HTML data *after* it has been modified
|
||||
const transforms: Transform[] = [
|
||||
sanitizer,
|
||||
...options.astTransforms || [],
|
||||
]
|
||||
|
||||
if (hideEmojis) {
|
||||
transforms.push(removeUnicodeEmoji)
|
||||
transforms.push(removeCustomEmoji(options.emojis ?? {}))
|
||||
}
|
||||
else {
|
||||
if (replaceUnicodeEmoji)
|
||||
transforms.push(transformUnicodeEmoji)
|
||||
|
||||
transforms.push(replaceCustomEmoji(options.emojis ?? {}))
|
||||
}
|
||||
|
||||
if (markdown)
|
||||
transforms.push(transformMarkdown)
|
||||
|
||||
if (mentions?.length)
|
||||
transforms.push(createTransformNamedMentions(mentions))
|
||||
|
||||
if (convertMentionLink)
|
||||
transforms.push(transformMentionLink)
|
||||
|
||||
transforms.push(transformParagraphs)
|
||||
|
||||
if (collapseMentionLink)
|
||||
transforms.push(transformCollapseMentions(status, inReplyToStatus))
|
||||
|
||||
return transformSync(parse(html), transforms)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts raw HTML form Mastodon server to HTML for Tiptap editor
|
||||
* @param html The content to parse
|
||||
* @param customEmojis The custom emojis to use
|
||||
*/
|
||||
export function convertMastodonHTML(html: string, customEmojis: Record<string, mastodon.v1.CustomEmoji> = {}) {
|
||||
const tree = parseMastodonHTML(html, {
|
||||
emojis: customEmojis,
|
||||
markdown: true,
|
||||
convertMentionLink: true,
|
||||
})
|
||||
return render(tree)
|
||||
}
|
||||
|
||||
export function sanitizeEmbeddedIframe(html: string): Node {
|
||||
const transforms: Transform[] = [
|
||||
sanitize({
|
||||
iframe: {
|
||||
src: (src) => {
|
||||
if (typeof src !== 'string')
|
||||
return undefined
|
||||
|
||||
const url = new URL(src)
|
||||
return url.protocol === 'https:' ? src : undefined
|
||||
},
|
||||
allowfullscreen: set('true'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
return transformSync(parse(html), transforms)
|
||||
}
|
||||
|
||||
export function htmlToText(html: string) {
|
||||
try {
|
||||
const tree = parse(html)
|
||||
return (tree.children as Node[]).map(n => treeToText(n)).join('').trim()
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function recursiveTreeToText(input: Node): string {
|
||||
if (input && input.children && input.children.length > 0)
|
||||
return input.children.map((n: Node) => recursiveTreeToText(n)).join('')
|
||||
else
|
||||
return treeToText(input)
|
||||
}
|
||||
|
||||
const emojiIdNeedsWrappingRE = /^([\w\-])+$/
|
||||
|
||||
export function treeToText(input: Node): string {
|
||||
let pre = ''
|
||||
let body = ''
|
||||
let post = ''
|
||||
|
||||
if (input.type === TEXT_NODE)
|
||||
return decode(input.value)
|
||||
|
||||
if (input.name === 'br')
|
||||
return '\n'
|
||||
|
||||
if (['p', 'pre'].includes(input.name))
|
||||
pre = '\n'
|
||||
|
||||
if (input.attributes?.['data-type'] === 'mention') {
|
||||
const acct = input.attributes['data-id']
|
||||
if (acct)
|
||||
return acct.startsWith('@') ? acct : `@${acct}`
|
||||
}
|
||||
|
||||
if (input.name === 'code') {
|
||||
if (input.parent?.name === 'pre') {
|
||||
const lang = input.attributes.class?.replace('language-', '')
|
||||
|
||||
pre = `\`\`\`${lang || ''}\n`
|
||||
post = '\n```'
|
||||
}
|
||||
else {
|
||||
pre = '`'
|
||||
post = '`'
|
||||
}
|
||||
}
|
||||
else if (input.name === 'b' || input.name === 'strong') {
|
||||
pre = '**'
|
||||
post = '**'
|
||||
}
|
||||
else if (input.name === 'i' || input.name === 'em') {
|
||||
pre = '*'
|
||||
post = '*'
|
||||
}
|
||||
else if (input.name === 'del') {
|
||||
pre = '~~'
|
||||
post = '~~'
|
||||
}
|
||||
|
||||
if ('children' in input)
|
||||
body = (input.children as Node[]).map(n => treeToText(n)).join('')
|
||||
|
||||
if (input.name === 'img' || input.name === 'picture') {
|
||||
if (input.attributes.class?.includes('custom-emoji')) {
|
||||
const id = input.attributes['data-emoji-id'] ?? input.attributes.alt ?? input.attributes.title ?? 'unknown'
|
||||
return id.match(emojiIdNeedsWrappingRE) ? `:${id}:` : id
|
||||
}
|
||||
if (input.attributes.class?.includes('iconify-emoji'))
|
||||
return input.attributes.alt
|
||||
}
|
||||
|
||||
return pre + body + post
|
||||
}
|
||||
|
||||
// A tree transform function takes an ultrahtml Node object and returns
|
||||
// new content that will replace the given node in the tree.
|
||||
// Returning a null removes the node from the tree.
|
||||
// Strings get converted to text nodes.
|
||||
// The input node's children have been transformed before the node itself
|
||||
// gets transformed.
|
||||
type Transform = (node: Node, root: Node) => (Node | string)[] | Node | string | null
|
||||
|
||||
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
|
||||
// by running the given chain of transform functions one-by-one.
|
||||
function transformSync(doc: Node, transforms: Transform[]) {
|
||||
function visit(node: Node, transform: Transform, root: Node) {
|
||||
if (Array.isArray(node.children)) {
|
||||
const children = [] as (Node | string)[]
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const result = visit(node.children[i], transform, root)
|
||||
if (Array.isArray(result))
|
||||
children.push(...result)
|
||||
|
||||
else if (result)
|
||||
children.push(result)
|
||||
}
|
||||
|
||||
node.children = children.map((value) => {
|
||||
if (typeof value === 'string')
|
||||
return { type: TEXT_NODE, value, parent: node }
|
||||
value.parent = node
|
||||
return value
|
||||
})
|
||||
}
|
||||
return transform(node, root)
|
||||
}
|
||||
|
||||
for (const transform of transforms)
|
||||
doc = visit(doc, transform, doc) as Node
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
// A tree transform for sanitizing elements & their attributes.
|
||||
type AttrSanitizers = Record<string, (value: string | undefined) => string | undefined>
|
||||
function sanitize(allowedElements: Record<string, AttrSanitizers>): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== ELEMENT_NODE)
|
||||
return node
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(allowedElements, node.name))
|
||||
return null
|
||||
|
||||
const attrSanitizers = allowedElements[node.name]
|
||||
const attrs = {} as Record<string, string>
|
||||
for (const [name, func] of Object.entries(attrSanitizers)) {
|
||||
const value = func(node.attributes[name])
|
||||
if (value !== undefined)
|
||||
attrs[name] = value
|
||||
}
|
||||
node.attributes = attrs
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
function filterClasses(allowed: RegExp) {
|
||||
return (c: string | undefined) => {
|
||||
if (!c)
|
||||
return undefined
|
||||
|
||||
return c.split(/\s/g).filter(cls => allowed.test(cls)).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
function keep(value: string | undefined) {
|
||||
return value
|
||||
}
|
||||
|
||||
function set(value: string) {
|
||||
return () => value
|
||||
}
|
||||
|
||||
function filterHref() {
|
||||
const LINK_PROTOCOLS = new Set([
|
||||
'http:',
|
||||
'https:',
|
||||
'dat:',
|
||||
'dweb:',
|
||||
'ipfs:',
|
||||
'ipns:',
|
||||
'ssb:',
|
||||
'gopher:',
|
||||
'xmpp:',
|
||||
'magnet:',
|
||||
'gemini:',
|
||||
])
|
||||
|
||||
return (href: string | undefined) => {
|
||||
if (href === undefined)
|
||||
return undefined
|
||||
|
||||
// Allow relative links
|
||||
if (href.startsWith('/') || href.startsWith('.'))
|
||||
return href
|
||||
|
||||
href = href.replace(/&/g, '&')
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(href)
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof TypeError)
|
||||
return undefined
|
||||
throw err
|
||||
}
|
||||
|
||||
if (LINK_PROTOCOLS.has(url.protocol))
|
||||
return url.toString()
|
||||
return '#'
|
||||
}
|
||||
}
|
||||
|
||||
function removeUnicodeEmoji(node: Node) {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
let start = 0
|
||||
|
||||
const matches = [] as (string | Node)[]
|
||||
findAndReplaceEmojisInText(emojiRegEx, node.value, (match, result) => {
|
||||
matches.push(result.slice(start).trimEnd())
|
||||
start = result.length + match.match.length
|
||||
return undefined
|
||||
})
|
||||
if (matches.length === 0)
|
||||
return node
|
||||
|
||||
matches.push(node.value.slice(start))
|
||||
return matches.filter(Boolean)
|
||||
}
|
||||
|
||||
function transformUnicodeEmoji(node: Node) {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
let start = 0
|
||||
|
||||
const matches = [] as (string | Node)[]
|
||||
findAndReplaceEmojisInText(emojiRegEx, node.value, (match, result) => {
|
||||
const attrs = getEmojiAttributes(match)
|
||||
matches.push(result.slice(start))
|
||||
matches.push(h('img', { src: attrs.src, alt: attrs.alt, class: attrs.class }))
|
||||
start = result.length + match.match.length
|
||||
return undefined
|
||||
})
|
||||
if (matches.length === 0)
|
||||
return node
|
||||
|
||||
matches.push(node.value.slice(start))
|
||||
return matches.filter(Boolean)
|
||||
}
|
||||
|
||||
function removeCustomEmoji(customEmojis: Record<string, mastodon.v1.CustomEmoji>): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
const split = node.value.split(/\s?:([\w-]+):/g)
|
||||
if (split.length === 1)
|
||||
return node
|
||||
|
||||
return split.map((name, i) => {
|
||||
if (i % 2 === 0)
|
||||
return name
|
||||
|
||||
const emoji = customEmojis[name] as mastodon.v1.CustomEmoji
|
||||
if (!emoji)
|
||||
return `:${name}:`
|
||||
|
||||
return ''
|
||||
}).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCustomEmoji(customEmojis: Record<string, mastodon.v1.CustomEmoji>): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
const split = node.value.split(/:([\w-]+):/g)
|
||||
if (split.length === 1)
|
||||
return node
|
||||
|
||||
return split.map((name, i) => {
|
||||
if (i % 2 === 0)
|
||||
return name
|
||||
|
||||
const emoji = customEmojis[name] as mastodon.v1.CustomEmoji
|
||||
if (!emoji)
|
||||
return `:${name}:`
|
||||
|
||||
return h(
|
||||
'picture',
|
||||
{
|
||||
'alt': `:${name}:`,
|
||||
'class': 'custom-emoji',
|
||||
'data-emoji-id': name,
|
||||
},
|
||||
[
|
||||
h(
|
||||
'source',
|
||||
{
|
||||
srcset: emoji.staticUrl,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
},
|
||||
),
|
||||
h(
|
||||
'img',
|
||||
{
|
||||
src: emoji.url,
|
||||
alt: `:${name}:`,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
}).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
const _markdownReplacements: [RegExp, (c: (string | Node)[]) => Node][] = [
|
||||
[/\*\*\*(.*?)\*\*\*/g, ([c]) => h('b', null, [h('em', null, c)])],
|
||||
[/\*\*(.*?)\*\*/g, c => h('b', null, c)],
|
||||
[/\*(.*?)\*/g, c => h('em', null, c)],
|
||||
[/~~(.*?)~~/g, c => h('del', null, c)],
|
||||
[/`([^`]+)`/g, c => h('code', null, c)],
|
||||
// transform @username@twitter.com as links
|
||||
[/\B@(\w+)@twitter\.com\b/gi, c => h('a', { href: `https://twitter.com/${c}`, target: '_blank', rel: 'nofollow noopener noreferrer', class: 'mention external' }, `@${c}@twitter.com`)],
|
||||
]
|
||||
|
||||
function _markdownProcess(value: string) {
|
||||
const results = [] as (string | Node)[]
|
||||
|
||||
let start = 0
|
||||
while (true) {
|
||||
let found: {
|
||||
match: RegExpMatchArray
|
||||
replacer: (c: (string | Node)[]) => Node
|
||||
} | undefined
|
||||
|
||||
for (const [re, replacer] of _markdownReplacements) {
|
||||
re.lastIndex = start
|
||||
|
||||
const match = re.exec(value)
|
||||
if (match) {
|
||||
if (!found || match.index < found.match.index!)
|
||||
found = { match, replacer }
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
break
|
||||
|
||||
results.push(value.slice(start, found.match.index))
|
||||
results.push(found.replacer(_markdownProcess(found.match[1])))
|
||||
start = found.match.index! + found.match[0].length
|
||||
}
|
||||
|
||||
results.push(value.slice(start))
|
||||
return results.filter(Boolean)
|
||||
}
|
||||
|
||||
function transformMarkdown(node: Node) {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
return _markdownProcess(node.value)
|
||||
}
|
||||
|
||||
function addBdiParagraphs(node: Node) {
|
||||
if (node.name === 'p' && !('dir' in node.attributes) && node.children?.length && node.children.length > 1)
|
||||
node.attributes.dir = 'auto'
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function transformParagraphs(node: Node): Node | Node[] {
|
||||
// Add bdi to paragraphs
|
||||
addBdiParagraphs(node)
|
||||
|
||||
// For top level paragraphs, inject an empty <p> to preserve status paragraphs in our editor (except for the last one)
|
||||
if (node.parent?.type === DOCUMENT_NODE && node.name === 'p' && node.parent.children.at(-1) !== node)
|
||||
return [node, h('p')]
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function isMention(node: Node) {
|
||||
const child = node.children?.length === 1 ? node.children[0] : null
|
||||
return Boolean(child?.name === 'a' && child.attributes.class?.includes('mention'))
|
||||
}
|
||||
|
||||
function isSpacing(node: Node) {
|
||||
return node.type === TEXT_NODE && !node.value.trim()
|
||||
}
|
||||
|
||||
// Extract the username from a known mention node
|
||||
function getMentionHandle(node: Node): string | undefined {
|
||||
return hrefToHandle(node.children?.[0].attributes.href) ?? node.children?.[0]?.children?.[0]?.attributes?.['data-id']
|
||||
}
|
||||
|
||||
function transformCollapseMentions(status?: mastodon.v1.Status, inReplyToStatus?: mastodon.v1.Status): Transform {
|
||||
let processed = false
|
||||
|
||||
return (node: Node, root: Node): Node | Node[] => {
|
||||
if (processed || node.parent !== root || !node.children)
|
||||
return node
|
||||
const mentions: (Node | undefined)[] = []
|
||||
const children = node.children as Node[]
|
||||
let trimContentStart: (() => void) | undefined
|
||||
for (const child of children) {
|
||||
// mention
|
||||
if (isMention(child)) {
|
||||
mentions.push(child)
|
||||
}
|
||||
// spaces in between
|
||||
else if (isSpacing(child)) {
|
||||
mentions.push(child)
|
||||
}
|
||||
// other content, stop collapsing
|
||||
else {
|
||||
if (child.type === TEXT_NODE) {
|
||||
trimContentStart = () => {
|
||||
child.value = child.value.trimStart()
|
||||
}
|
||||
}
|
||||
// remove <br> after mention
|
||||
if (child.name === 'br')
|
||||
mentions.push(undefined)
|
||||
break
|
||||
}
|
||||
}
|
||||
processed = true
|
||||
if (mentions.length === 0)
|
||||
return node
|
||||
|
||||
let mentionsCount = 0
|
||||
let contextualMentionsCount = 0
|
||||
let removeNextSpacing = false
|
||||
|
||||
const contextualMentions = mentions.filter((mention) => {
|
||||
if (!mention)
|
||||
return false
|
||||
|
||||
if (removeNextSpacing && isSpacing(mention)) {
|
||||
removeNextSpacing = false
|
||||
return false
|
||||
}
|
||||
|
||||
if (isMention(mention)) {
|
||||
mentionsCount++
|
||||
if (inReplyToStatus) {
|
||||
const mentionHandle = getMentionHandle(mention)
|
||||
if (inReplyToStatus.account.acct === mentionHandle || inReplyToStatus.mentions.some(m => m.acct === mentionHandle)) {
|
||||
removeNextSpacing = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
contextualMentionsCount++
|
||||
}
|
||||
return true
|
||||
}) as Node[]
|
||||
|
||||
// We have a special case for single mentions that are part of a reply.
|
||||
// We already have the replying to badge in this case or the status is connected to the previous one.
|
||||
// This is needed because the status doesn't include the in Reply to handle, only the account id.
|
||||
// But this covers the majority of cases.
|
||||
const showMentions = !(contextualMentionsCount === 0 || (mentionsCount === 1 && status?.inReplyToAccountId))
|
||||
const grouped = contextualMentionsCount > 2
|
||||
if (!showMentions || grouped)
|
||||
trimContentStart?.()
|
||||
|
||||
const contextualChildren = children.slice(mentions.length)
|
||||
const mentionNodes = showMentions ? (grouped ? [h('mention-group', null, ...contextualMentions)] : contextualMentions) : []
|
||||
return {
|
||||
...node,
|
||||
children: [...mentionNodes, ...contextualChildren],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hrefToHandle(href: string): string | undefined {
|
||||
const matchUser = href.match(UserLinkRE)
|
||||
if (matchUser) {
|
||||
const [, server, username] = matchUser
|
||||
return `${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
|
||||
}
|
||||
}
|
||||
|
||||
function transformMentionLink(node: Node): string | Node | (string | Node)[] | null {
|
||||
if (node.name === 'a' && node.attributes.class?.includes('mention')) {
|
||||
const href = node.attributes.href
|
||||
if (href) {
|
||||
const handle = hrefToHandle(href)
|
||||
if (handle) {
|
||||
// convert to Tiptap mention node
|
||||
return h('span', { 'data-type': 'mention', 'data-id': handle }, handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function createTransformNamedMentions(mentions: mastodon.v1.StatusMention[]) {
|
||||
return (node: Node): string | Node | (string | Node)[] | null => {
|
||||
if (node.name === 'a' && node.attributes.class?.includes('mention')) {
|
||||
const href = node.attributes.href
|
||||
const mention = href && mentions.find(m => m.url === href)
|
||||
if (mention) {
|
||||
node.attributes.href = `/${currentServer.value}/@${mention.acct}`
|
||||
node.children = [h('span', { 'data-type': 'mention', 'data-id': mention.acct }, `@${mention.username}`)]
|
||||
return node
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue