Sharkey/packages/backend/src/core/MfmService.ts

867 lines
22 KiB
TypeScript
Raw Normal View History

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
2022-09-18 03:27:08 +09:00
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
2025-04-24 14:23:45 -04:00
import { type Document, type HTMLParagraphElement, Window } from 'happy-dom';
2022-09-18 03:27:08 +09:00
import { DI } from '@/di-symbols.js';
2022-09-21 05:33:11 +09:00
import type { Config } from '@/config.js';
2022-09-18 03:27:08 +09:00
import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
2022-12-04 17:05:32 +09:00
import { bindThis } from '@/decorators.js';
import type { DefaultTreeAdapterMap } from 'parse5';
2024-02-03 15:01:09 +01:00
import type * as mfm from '@transfem-org/sfm-js';
2022-09-18 03:27:08 +09:00
const treeAdapter = parse5.defaultTreeAdapter;
type Node = DefaultTreeAdapterMap['node'];
type ChildNode = DefaultTreeAdapterMap['childNode'];
2022-09-18 03:27:08 +09:00
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
2022-09-18 03:27:08 +09:00
@Injectable()
export class MfmService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
@bindThis
2022-09-18 03:27:08 +09:00
public fromHtml(html: string, hashtagNames?: string[]): string {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
2022-09-18 03:27:08 +09:00
const dom = parse5.parseFragment(html);
2022-09-18 03:27:08 +09:00
let text = '';
2022-09-18 03:27:08 +09:00
for (const n of dom.childNodes) {
analyze(n);
}
2022-09-18 03:27:08 +09:00
return text.trim();
function getText(node: Node): string {
2022-09-18 03:27:08 +09:00
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n';
2022-09-18 03:27:08 +09:00
if (node.childNodes) {
return node.childNodes.map(n => getText(n)).join('');
}
2022-09-18 03:27:08 +09:00
return '';
}
function appendChildren(childNodes: ChildNode[]): void {
2022-09-18 03:27:08 +09:00
if (childNodes) {
for (const n of childNodes) {
analyze(n);
}
}
}
function analyze(node: Node) {
2022-09-18 03:27:08 +09:00
if (treeAdapter.isTextNode(node)) {
text += node.value;
return;
}
2022-09-18 03:27:08 +09:00
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) {
return;
}
2022-09-18 03:27:08 +09:00
switch (node.nodeName) {
case 'br': {
text += '\n';
break;
}
case 'a': {
2022-09-18 03:27:08 +09:00
const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
2022-09-18 03:27:08 +09:00
// ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
2022-09-18 03:27:08 +09:00
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
2022-09-18 03:27:08 +09:00
const part = txt.split('@');
2022-09-18 03:27:08 +09:00
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
text += txt;
}
// その他
2022-09-18 03:27:08 +09:00
} else {
const generateLink = () => {
if (!href && !txt) {
return '';
}
if (!href) {
return txt;
}
if (!txt || txt === href.value) { // #6383: Missing text node
if (href.value.match(urlRegexFull)) {
return href.value;
} else {
return `<${href.value}>`;
}
}
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846
} else {
return `[${txt}](${href.value})`;
}
};
2022-09-18 03:27:08 +09:00
text += generateLink();
}
break;
}
case 'h1': {
2024-06-08 18:00:29 +00:00
text += '**【';
2022-09-18 03:27:08 +09:00
appendChildren(node.childNodes);
2024-06-08 18:00:29 +00:00
text += '】**\n';
2022-09-18 03:27:08 +09:00
break;
}
2024-06-08 18:00:29 +00:00
case 'h2':
case 'h3': {
2024-06-08 18:53:42 +00:00
text += '**';
appendChildren(node.childNodes);
text += '**\n';
2022-09-18 03:27:08 +09:00
break;
}
2022-09-18 03:27:08 +09:00
case 'b':
case 'strong': {
2022-09-18 03:27:08 +09:00
text += '**';
appendChildren(node.childNodes);
text += '**';
break;
}
case 'small': {
2022-09-18 03:27:08 +09:00
text += '<small>';
appendChildren(node.childNodes);
text += '</small>';
break;
}
2022-09-18 03:27:08 +09:00
case 's':
case 'del': {
2022-09-18 03:27:08 +09:00
text += '~~';
appendChildren(node.childNodes);
text += '~~';
break;
}
2022-09-18 03:27:08 +09:00
case 'i':
case 'em': {
2022-09-18 03:27:08 +09:00
text += '<i>';
appendChildren(node.childNodes);
text += '</i>';
break;
}
// this is here only to catch upstream changes!
2025-02-07 18:20:36 +00:00
case 'ruby--': {
let ruby: [string, string][] = [];
for (const child of node.childNodes) {
if (child.nodeName === 'rp') {
continue;
}
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
ruby.push([child.value, '']);
continue;
}
if (child.nodeName === 'rt' && ruby.length > 0) {
const rt = getText(child);
if (/\s|\[|\]/.test(rt)) {
// If any space is included in rt, it is treated as a normal text
ruby = [];
appendChildren(node.childNodes);
break;
} else {
ruby.at(-1)![1] = rt;
continue;
}
}
// If any other element is included in ruby, it is treated as a normal text
ruby = [];
appendChildren(node.childNodes);
break;
}
for (const [base, rt] of ruby) {
text += `$[ruby ${base} ${rt}]`;
}
break;
}
2022-09-18 03:27:08 +09:00
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
} else {
appendChildren(node.childNodes);
}
break;
}
2022-09-18 03:27:08 +09:00
// inline code (<code>)
case 'code': {
text += '`';
appendChildren(node.childNodes);
text += '`';
break;
}
2022-09-18 03:27:08 +09:00
case 'blockquote': {
const t = getText(node);
if (t) {
text += '\n> ';
text += t.split('\n').join('\n> ');
}
break;
}
2022-09-18 03:27:08 +09:00
case 'p':
case 'h4':
case 'h5':
case 'h6': {
2022-09-18 03:27:08 +09:00
text += '\n\n';
appendChildren(node.childNodes);
break;
}
2022-09-18 03:27:08 +09:00
// other block elements
case 'div':
case 'header':
case 'footer':
case 'article':
case 'li':
case 'dt':
case 'dd': {
2022-09-18 03:27:08 +09:00
text += '\n';
appendChildren(node.childNodes);
break;
}
2025-01-18 14:54:17 +00:00
case 'rp': break;
case 'rt': {
appendChildren(node.childNodes);
break;
}
case 'ruby': {
if (node.childNodes) {
/*
we get:
```
<ruby>
some text <rp>(</rp> <rt>annotation</rt> <rp>)</rp>
more text <rt>more annotation<rt>
</ruby>
```
and we want to produce:
```
$[ruby $[group some text] annotation]
$[ruby $[group more text] more annotation]
```
that `group` is a hack, because when the `ruby` render
sees just text inside the `$[ruby]`, it splits on
whitespace, considers the first "word" to be the main
content, and the rest the annotation
with that `group`, we force it to consider the whole
group as the main content
(note that the `rp` are to be ignored, they only exist
for browsers who don't understand ruby)
*/
2025-01-18 14:54:17 +00:00
let nonRtNodes = [];
// scan children, ignore `rp`, split on `rt`
for (const child of node.childNodes) {
if (treeAdapter.isTextNode(child)) {
nonRtNodes.push(child);
continue;
}
if (!treeAdapter.isElementNode(child)) {
continue;
}
2025-01-18 14:54:17 +00:00
if (child.nodeName === 'rp') {
continue;
}
2025-01-18 14:54:17 +00:00
if (child.nodeName === 'rt') {
2025-02-07 18:20:36 +00:00
// the only case in which we don't need a `$[group ]`
// is when both sides of the ruby are simple words
const needsGroup = nonRtNodes.length > 1 ||
/\s|\[|\]/.test(getText(nonRtNodes[0])) ||
2025-02-07 18:33:24 +00:00
/\s|\[|\]/.test(getText(child));
2025-02-07 18:20:36 +00:00
text += '$[ruby ';
if (needsGroup) text += '$[group ';
appendChildren(nonRtNodes);
2025-02-07 18:20:36 +00:00
if (needsGroup) text += ']';
text += ' ';
analyze(child);
2025-02-07 18:20:36 +00:00
text += ']';
2025-01-18 14:54:17 +00:00
nonRtNodes = [];
continue;
}
nonRtNodes.push(child);
}
2025-02-07 18:20:36 +00:00
appendChildren(nonRtNodes);
}
break;
}
2022-09-18 03:27:08 +09:00
default: // includes inline elements
{
appendChildren(node.childNodes);
break;
}
}
}
}
@bindThis
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
2022-09-18 03:27:08 +09:00
if (nodes == null) {
return null;
}
const { happyDOM, window } = new Window();
2022-09-18 03:27:08 +09:00
const doc = window.document;
const body = doc.createElement('p');
2022-09-18 03:27:08 +09:00
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);
}
}
function fnDefault(node: mfm.MfmFn) {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
}
2022-09-18 03:27:08 +09:00
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
bold: (node) => {
const el = doc.createElement('b');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
small: (node) => {
const el = doc.createElement('small');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
strike: (node) => {
const el = doc.createElement('del');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
italic: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
fn: (node) => {
switch (node.props.name) {
case 'unixtime': {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
const el = doc.createElement('time');
el.setAttribute('datetime', date.toISOString());
el.textContent = date.toISOString();
return el;
} catch (err) {
return fnDefault(node);
}
}
case 'ruby': {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
return fnDefault(node);
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
}
}
2025-01-19 11:15:01 +00:00
// hack for ruby, should never be needed because we should
// never send this out to other instances
case 'group': {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
}
default: {
return fnDefault(node);
}
}
2022-09-18 03:27:08 +09:00
},
2022-09-18 03:27:08 +09:00
blockCode: (node) => {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
inner.textContent = node.props.code;
pre.appendChild(inner);
return pre;
},
2022-09-18 03:27:08 +09:00
center: (node) => {
const el = doc.createElement('div');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
2022-09-18 03:27:08 +09:00
unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji);
},
2022-09-18 03:27:08 +09:00
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
2022-09-18 03:27:08 +09:00
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;
},
2022-09-18 03:27:08 +09:00
inlineCode: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.code;
return el;
},
2022-09-18 03:27:08 +09:00
mathInline: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
2022-09-18 03:27:08 +09:00
mathBlock: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
2022-09-18 03:27:08 +09:00
link: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
2022-09-18 03:27:08 +09:00
appendChildren(node.children, a);
return a;
},
2022-09-18 03:27:08 +09:00
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
a.setAttribute('href', remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
2022-09-18 03:27:08 +09:00
a.className = 'u-url mention';
a.textContent = acct;
return a;
},
2022-09-18 03:27:08 +09:00
quote: (node) => {
const el = doc.createElement('blockquote');
appendChildren(node.children, el);
return el;
},
2022-09-18 03:27:08 +09:00
text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
return doc.createTextNode(node.props.text);
}
2022-09-18 03:27:08 +09:00
const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
2022-09-18 03:27:08 +09:00
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
}
2022-09-18 03:27:08 +09:00
return el;
},
2022-09-18 03:27:08 +09:00
url: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
2022-09-18 03:27:08 +09:00
a.textContent = node.props.url;
return a;
},
2022-09-18 03:27:08 +09:00
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
2022-09-18 03:27:08 +09:00
a.textContent = node.props.content;
return a;
},
2022-09-18 03:27:08 +09:00
plain: (node) => {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
},
};
appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
2025-04-02 09:47:49 -04:00
const serialized = body.outerHTML;
happyDOM.close().catch(err => {});
return serialized;
}
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
// additionally modified by hazelnoot to remove async
2023-11-01 00:55:53 +01:00
@bindThis
public toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
if (nodes == null) {
return null;
}
const { happyDOM, window } = new Window();
const doc = window.document;
const body = doc.createElement('p');
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: {
2024-06-08 16:57:17 +01:00
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
} = {
bold(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('span');
el.textContent = '**';
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
el.textContent += '**';
return el;
},
small(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('small');
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
return el;
},
strike(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('span');
el.textContent = '~~';
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
el.textContent += '~~';
return el;
},
italic(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('span');
el.textContent = '*';
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
el.textContent += '*';
return el;
},
fn(node) {
2025-01-19 11:15:01 +00:00
switch (node.props.name) {
case 'group': { // hack for ruby
const el = doc.createElement('span');
appendChildren(node.children, el);
2025-01-19 11:15:01 +00:00
return el;
}
case 'ruby': {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
const el = doc.createElement('span');
appendChildren(node.children, el);
2025-01-19 11:15:01 +00:00
return el;
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
2025-01-19 11:15:01 +00:00
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
}
}
default: {
const el = doc.createElement('span');
el.textContent = '*';
appendChildren(node.children, el);
2025-01-19 11:15:01 +00:00
el.textContent += '*';
return el;
}
}
2024-06-08 16:57:17 +01:00
},
blockCode(node) {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
const nodes = node.props.code
.split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
}
pre.appendChild(inner);
return pre;
},
center(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('div');
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
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.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
a.setAttribute('class', 'hashtag');
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) {
2024-06-08 16:57:17 +01:00
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
appendChildren(node.children, a);
2024-06-08 16:57:17 +01:00
return a;
},
mention(node) {
2024-06-08 16:57:17 +01:00
const { username, host, acct } = node.props;
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
const el = doc.createElement('span');
if (!resolved) {
el.textContent = acct;
} else {
el.setAttribute('class', 'h-card');
el.setAttribute('translate', 'no');
const a = doc.createElement('a');
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
a.className = 'u-url mention';
const span = doc.createElement('span');
span.textContent = resolved.username || username;
a.textContent = '@';
a.appendChild(span);
el.appendChild(a);
}
return el;
},
quote(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('blockquote');
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
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<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
}
return el;
},
url(node) {
const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, '');
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
},
plain(node) {
2024-06-08 16:57:17 +01:00
const el = doc.createElement('span');
appendChildren(node.children, el);
2024-06-08 16:57:17 +01:00
return el;
},
};
2024-03-02 16:36:49 +00:00
appendChildren(nodes, body);
if (quoteUri !== null) {
const a = doc.createElement('a');
a.setAttribute('href', quoteUri);
a.textContent = quoteUri.replace(/^https?:\/\//, '');
const quote = doc.createElement('span');
quote.setAttribute('class', 'quote-inline');
quote.appendChild(doc.createElement('br'));
quote.appendChild(doc.createElement('br'));
quote.innerHTML += 'RE: ';
quote.appendChild(a);
body.appendChild(quote);
}
2025-04-02 09:47:49 -04:00
let result = body.outerHTML;
if (inline) {
2024-08-06 19:23:03 +01:00
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
}
happyDOM.close().catch(() => {});
return result;
}
2022-09-18 03:27:08 +09:00
}