diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-09-18 03:27:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-09-18 03:27:08 +0900 |
| commit | b75184ec8e3436200bacdcd832e3324702553d20 (patch) | |
| tree | 8b7e316f29e95df921db57289c8b8da476d18f07 /packages/backend/src/core/MfmService.ts | |
| parent | Update ROADMAP.md (diff) | |
| download | sharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.gz sharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.bz2 sharkey-b75184ec8e3436200bacdcd832e3324702553d20.zip | |
なんかもうめっちゃ変えた
Diffstat (limited to 'packages/backend/src/core/MfmService.ts')
| -rw-r--r-- | packages/backend/src/core/MfmService.ts | 384 |
1 files changed, 384 insertions, 0 deletions
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts new file mode 100644 index 0000000000..236be4bbf8 --- /dev/null +++ b/packages/backend/src/core/MfmService.ts @@ -0,0 +1,384 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import * as parse5 from 'parse5'; +import { JSDOM } from 'jsdom'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { intersperse } from '@/misc/prelude/array.js'; +import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; +import type * as mfm from 'mfm-js'; + +const treeAdapter = TreeAdapter.defaultTreeAdapter; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; + +@Injectable() +export class MfmService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + 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 dom = parse5.parseFragment(html); + + let text = ''; + + for (const n of dom.childNodes) { + analyze(n); + } + + return text.trim(); + + function getText(node: TreeAdapter.Node): string { + if (treeAdapter.isTextNode(node)) return node.value; + if (!treeAdapter.isElementNode(node)) return ''; + if (node.nodeName === 'br') return '\n'; + + if (node.childNodes) { + return node.childNodes.map(n => getText(n)).join(''); + } + + return ''; + } + + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { + if (childNodes) { + for (const n of childNodes) { + analyze(n); + } + } + } + + function analyze(node: TreeAdapter.Node) { + if (treeAdapter.isTextNode(node)) { + text += node.value; + return; + } + + // Skip comment or document type node + if (!treeAdapter.isElementNode(node)) return; + + switch (node.nodeName) { + case 'br': { + text += '\n'; + break; + } + + case 'a': + { + const txt = getText(node); + const rel = node.attrs.find(x => x.name === 'rel'); + const href = node.attrs.find(x => x.name === 'href'); + + // ハッシュタグ + if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + text += txt; + // メンション + } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + const part = txt.split('@'); + + if (part.length === 2 && href) { + //#region ホスト名部分が省略されているので復元する + const acct = `${txt}@${(new URL(href.value)).hostname}`; + text += acct; + //#endregion + } else if (part.length === 3) { + text += txt; + } + // その他 + } 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})`; + } + }; + + text += generateLink(); + } + break; + } + + case 'h1': + { + text += '【'; + appendChildren(node.childNodes); + text += '】\n'; + break; + } + + case 'b': + case 'strong': + { + text += '**'; + appendChildren(node.childNodes); + text += '**'; + break; + } + + case 'small': + { + text += '<small>'; + appendChildren(node.childNodes); + text += '</small>'; + break; + } + + case 's': + case 'del': + { + text += '~~'; + appendChildren(node.childNodes); + text += '~~'; + break; + } + + case 'i': + case 'em': + { + text += '<i>'; + appendChildren(node.childNodes); + text += '</i>'; + break; + } + + // 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; + } + + // inline code (<code>) + case 'code': { + text += '`'; + appendChildren(node.childNodes); + text += '`'; + break; + } + + case 'blockquote': { + const t = getText(node); + if (t) { + text += '\n> '; + text += t.split('\n').join('\n> '); + } + break; + } + + case 'p': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + { + text += '\n\n'; + appendChildren(node.childNodes); + break; + } + + // other block elements + case 'div': + case 'header': + case 'footer': + case 'article': + case 'li': + case 'dt': + case 'dd': + { + text += '\n'; + appendChildren(node.childNodes); + break; + } + + default: // includes inline elements + { + appendChildren(node.childNodes); + break; + } + } + } + } + + public 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<K>) => any } = { + bold: (node) => { + const el = doc.createElement('b'); + appendChildren(node.children, el); + return el; + }, + + small: (node) => { + const el = doc.createElement('small'); + appendChildren(node.children, el); + return el; + }, + + strike: (node) => { + const el = doc.createElement('del'); + appendChildren(node.children, el); + return el; + }, + + italic: (node) => { + const el = doc.createElement('i'); + appendChildren(node.children, el); + return el; + }, + + fn: (node) => { + const el = doc.createElement('i'); + appendChildren(node.children, el); + 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 = `${this.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) : `${this.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<FIXME | 'br'>('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 = `https://www.google.com/search?q=${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 `<p>${doc.body.innerHTML}</p>`; + } +} |