From 2e7ac53c205773af993199a878af90f5ae972e9f Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Sun, 1 Oct 2023 17:27:00 +0200 Subject: [PATCH] [mastodon-client] Use modified mfm-to-html renderer --- .../server/api/mastodon/converters/note.ts | 4 +- .../server/api/mastodon/converters/user.ts | 6 +- .../src/server/api/mastodon/helpers/mfm.ts | 184 ++++++++++++++++++ .../src/server/api/mastodon/helpers/note.ts | 4 +- 4 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/helpers/mfm.ts diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts index ac6244a69..05cbe2f3a 100644 --- a/packages/backend/src/server/api/mastodon/converters/note.ts +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -3,7 +3,6 @@ import {getNote, getUser} from "@/server/api/common/getters.js"; import { Note } from "@/models/entities/note.js"; import config from "@/config/index.js"; import mfm from "mfm-js"; -import { toHtml } from "@/mfm/to-html.js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js"; import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; @@ -18,6 +17,7 @@ import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { awaitAll } from "@/prelude/await-all.js"; import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { IsNull } from "typeorm"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; export class NoteConverter { public static async encode(note: Note, user: ILocalUser | null, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { @@ -99,7 +99,7 @@ export class NoteConverter { in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, reblog: Promise.resolve(renote).then(renote => renote && note.text === null ? this.encode(renote, user, cache) : null), - content: text.then(text => text !== null ? toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""), + content: text.then(text => text !== null ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""), text: text, created_at: note.createdAt.toISOString(), // Remove reaction emojis with names containing @ from the emojis list. diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index afc80bf30..f06fa8377 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -3,11 +3,11 @@ import config from "@/config/index.js"; import { DriveFiles, UserProfiles, Users } from "@/models/index.js"; import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; import { populateEmojis } from "@/misc/populate-emojis.js"; -import { toHtml } from "@/mfm/to-html.js"; import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; import mfm from "mfm-js"; import { awaitAll } from "@/prelude/await-all.js"; import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; type Field = { name: string; @@ -28,7 +28,7 @@ export class UserConverter { acctUrl = `https://${u.host}/@${u.username}`; } const profile = UserProfiles.findOneBy({userId: u.id}); - const bio = profile.then(profile => toHtml(mfm.parse(profile?.description ?? "")) ?? escapeMFM(profile?.description ?? "")); + const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? "")) ?? escapeMFM(profile?.description ?? "")); const avatar = u.avatarId ? (DriveFiles.findOneBy({ id: u.avatarId })) .then(p => p?.url ?? Users.getIdenticonUrl(u.id)) @@ -74,7 +74,7 @@ export class UserConverter { private static encodeField(f: Field): MastodonEntity.Field { return { name: f.name, - value: toHtml(mfm.parse(f.value)) ?? escapeMFM(f.value), + value: MfmHelpers.toHtml(mfm.parse(f.value)) ?? escapeMFM(f.value), verified_at: f.verified ? (new Date()).toISOString() : null, } } diff --git a/packages/backend/src/server/api/mastodon/helpers/mfm.ts b/packages/backend/src/server/api/mastodon/helpers/mfm.ts new file mode 100644 index 000000000..c18a7f1ae --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/mfm.ts @@ -0,0 +1,184 @@ +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { JSDOM } from "jsdom"; +import config from "@/config/index.js"; +import { intersperse } from "@/prelude/array.js"; +import mfm from "mfm-js"; + +export class MfmHelpers { + public static toHtml( + nodes: mfm.MfmNode[] | null, + mentionedRemoteUsers: IMentionedRemoteUsers = [] + ) { + if (nodes == null) { + return null; + } + + const {window} = new JSDOM(""); + + const doc = window.document; + + function appendChildren(children: mfm.MfmNode[], targetElement: any): void { + if (children) { + for (const child of children.map((x) => (handlers as any)[x.type](x))) + targetElement.appendChild(child); + } + } + + const handlers: { + [K in mfm.MfmNode["type"]]: (node: mfm.NodeType) => any; + } = { + bold(node) { + const el = doc.createElement("span"); + el.textContent = '**'; + appendChildren(node.children, el); + el.textContent += '**'; + return el; + }, + + small(node) { + const el = doc.createElement("small"); + appendChildren(node.children, el); + return el; + }, + + strike(node) { + const el = doc.createElement("span"); + el.textContent = '~~'; + appendChildren(node.children, el); + el.textContent += '~~'; + return el; + }, + + italic(node) { + const el = doc.createElement("span"); + el.textContent = '*'; + appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, + + fn(node) { + const el = doc.createElement("span"); + el.textContent = '*'; + appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, + + blockCode(node) { + const pre = doc.createElement("pre"); + const inner = doc.createElement("code"); + inner.textContent = node.props.code; + pre.appendChild(inner); + return pre; + }, + + center(node) { + const el = doc.createElement("div"); + appendChildren(node.children, el); + return el; + }, + + emojiCode(node) { + return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + }, + + unicodeEmoji(node) { + return doc.createTextNode(node.props.emoji); + }, + + hashtag(node) { + const a = doc.createElement("a"); + a.href = `${config.url}/tags/${node.props.hashtag}`; + a.textContent = `#${node.props.hashtag}`; + a.setAttribute("rel", "tag"); + return a; + }, + + inlineCode(node) { + const el = doc.createElement("code"); + el.textContent = node.props.code; + return el; + }, + + mathInline(node) { + const el = doc.createElement("code"); + el.textContent = node.props.formula; + return el; + }, + + mathBlock(node) { + const el = doc.createElement("code"); + el.textContent = node.props.formula; + return el; + }, + + link(node) { + const a = doc.createElement("a"); + a.href = node.props.url; + appendChildren(node.children, a); + return a; + }, + + mention(node) { + const a = doc.createElement("a"); + const {username, host, acct} = node.props; + const remoteUserInfo = mentionedRemoteUsers.find( + (remoteUser) => + remoteUser.username === username && remoteUser.host === host, + ); + a.href = remoteUserInfo + ? remoteUserInfo.url + ? remoteUserInfo.url + : remoteUserInfo.uri + : `${config.url}/${acct}`; + a.className = "u-url mention"; + a.textContent = acct; + return a; + }, + + quote(node) { + const el = doc.createElement("blockquote"); + appendChildren(node.children, el); + return el; + }, + + text(node) { + const el = doc.createElement("span"); + const nodes = node.props.text + .split(/\r\n|\r|\n/) + .map((x) => doc.createTextNode(x)); + + for (const x of intersperse("br", nodes)) { + el.appendChild(x === "br" ? doc.createElement("br") : x); + } + + return el; + }, + + url(node) { + const a = doc.createElement("a"); + a.href = node.props.url; + a.textContent = node.props.url; + return a; + }, + + search(node) { + const a = doc.createElement("a"); + a.href = `${config.searchEngine}${node.props.query}`; + a.textContent = node.props.content; + return a; + }, + + plain(node) { + const el = doc.createElement("span"); + appendChildren(node.children, el); + return el; + }, + }; + + appendChildren(nodes, doc.body); + + return `

${doc.body.innerHTML}

`; + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts index 4b17e8d13..7cbd403ec 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -29,7 +29,7 @@ import { awaitAll } from "@/prelude/await-all.js"; import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; import mfm from "mfm-js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js"; -import { toHtml } from "@/mfm/to-html.js"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; export class NoteHelpers { public static async getDefaultReaction(): Promise { @@ -186,7 +186,7 @@ export class NoteHelpers { const files = DriveFiles.packMany(edit.fileIds); const item = { account: account, - content: toHtml(mfm.parse(edit.text ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', + content: MfmHelpers.toHtml(mfm.parse(edit.text ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', created_at: lastDate.toISOString(), emojis: [], sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false),