mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-23 13:23:09 +02:00
Use mfm-js for MFM parsing (#7415)
* wip * Update mfm.ts * wip * update mfmjs * refactor * nanka * Update mfm.ts * Update to-html.ts * Update to-html.ts * wip * fix test * fix test
This commit is contained in:
parent
b378066ebf
commit
1f4ae2f63a
31 changed files with 262 additions and 1771 deletions
|
@ -180,6 +180,7 @@
|
||||||
"markdown-it": "12.0.4",
|
"markdown-it": "12.0.4",
|
||||||
"markdown-it-anchor": "7.1.0",
|
"markdown-it-anchor": "7.1.0",
|
||||||
"matter-js": "0.16.1",
|
"matter-js": "0.16.1",
|
||||||
|
"mfm-js": "0.12.0",
|
||||||
"mocha": "8.3.2",
|
"mocha": "8.3.2",
|
||||||
"moji": "0.5.1",
|
"moji": "0.5.1",
|
||||||
"ms": "2.1.3",
|
"ms": "2.1.3",
|
||||||
|
@ -190,7 +191,6 @@
|
||||||
"object-assign-deep": "0.4.0",
|
"object-assign-deep": "0.4.0",
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
"parse5": "6.0.1",
|
"parse5": "6.0.1",
|
||||||
"parsimmon": "1.16.0",
|
|
||||||
"pg": "8.5.1",
|
"pg": "8.5.1",
|
||||||
"portscanner": "2.2.0",
|
"portscanner": "2.2.0",
|
||||||
"postcss": "8.2.8",
|
"postcss": "8.2.8",
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { VNode, defineComponent, h } from 'vue';
|
import { VNode, defineComponent, h } from 'vue';
|
||||||
import { MfmForest } from '@client/../mfm/prelude';
|
import * as mfm from 'mfm-js';
|
||||||
import { parse, parsePlain } from '@client/../mfm/parse';
|
|
||||||
import MkUrl from '@client/components/global/url.vue';
|
import MkUrl from '@client/components/global/url.vue';
|
||||||
import MkLink from '@client/components/link.vue';
|
import MkLink from '@client/components/link.vue';
|
||||||
import MkMention from '@client/components/mention.vue';
|
import MkMention from '@client/components/mention.vue';
|
||||||
|
@ -46,17 +45,17 @@ export default defineComponent({
|
||||||
render() {
|
render() {
|
||||||
if (this.text == null || this.text == '') return;
|
if (this.text == null || this.text == '') return;
|
||||||
|
|
||||||
const ast = (this.plain ? parsePlain : parse)(this.text);
|
const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text);
|
||||||
|
|
||||||
const validTime = (t: string | null | undefined) => {
|
const validTime = (t: string | null | undefined) => {
|
||||||
if (t == null) return null;
|
if (t == null) return null;
|
||||||
return t.match(/^[0-9.]+s$/) ? t : null;
|
return t.match(/^[0-9.]+s$/) ? t : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => {
|
const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => {
|
||||||
switch (token.node.type) {
|
switch (token.type) {
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||||
|
|
||||||
if (!this.plain) {
|
if (!this.plain) {
|
||||||
const x = text.split('\n')
|
const x = text.split('\n')
|
||||||
|
@ -83,38 +82,38 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'fn': {
|
case 'fn': {
|
||||||
// TODO: CSSを文字列で組み立てていくと token.node.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
||||||
let style;
|
let style;
|
||||||
switch (token.node.props.name) {
|
switch (token.props.name) {
|
||||||
case 'tada': {
|
case 'tada': {
|
||||||
style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : '');
|
style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'jelly': {
|
case 'jelly': {
|
||||||
const speed = validTime(token.node.props.args.speed) || '1s';
|
const speed = validTime(token.props.args.speed) || '1s';
|
||||||
style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
|
style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'twitch': {
|
case 'twitch': {
|
||||||
const speed = validTime(token.node.props.args.speed) || '0.5s';
|
const speed = validTime(token.props.args.speed) || '0.5s';
|
||||||
style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
|
style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'shake': {
|
case 'shake': {
|
||||||
const speed = validTime(token.node.props.args.speed) || '0.5s';
|
const speed = validTime(token.props.args.speed) || '0.5s';
|
||||||
style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
|
style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'spin': {
|
case 'spin': {
|
||||||
const direction =
|
const direction =
|
||||||
token.node.props.args.left ? 'reverse' :
|
token.props.args.left ? 'reverse' :
|
||||||
token.node.props.args.alternate ? 'alternate' :
|
token.props.args.alternate ? 'alternate' :
|
||||||
'normal';
|
'normal';
|
||||||
const anime =
|
const anime =
|
||||||
token.node.props.args.x ? 'mfm-spinX' :
|
token.props.args.x ? 'mfm-spinX' :
|
||||||
token.node.props.args.y ? 'mfm-spinY' :
|
token.props.args.y ? 'mfm-spinY' :
|
||||||
'mfm-spin';
|
'mfm-spin';
|
||||||
const speed = validTime(token.node.props.args.speed) || '1.5s';
|
const speed = validTime(token.props.args.speed) || '1.5s';
|
||||||
style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
|
style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -128,8 +127,8 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
case 'flip': {
|
case 'flip': {
|
||||||
const transform =
|
const transform =
|
||||||
(token.node.props.args.h && token.node.props.args.v) ? 'scale(-1, -1)' :
|
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
|
||||||
token.node.props.args.v ? 'scaleY(-1)' :
|
token.props.args.v ? 'scaleY(-1)' :
|
||||||
'scaleX(-1)';
|
'scaleX(-1)';
|
||||||
style = `transform: ${transform};`;
|
style = `transform: ${transform};`;
|
||||||
break;
|
break;
|
||||||
|
@ -148,12 +147,12 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
case 'font': {
|
case 'font': {
|
||||||
const family =
|
const family =
|
||||||
token.node.props.args.serif ? 'serif' :
|
token.props.args.serif ? 'serif' :
|
||||||
token.node.props.args.monospace ? 'monospace' :
|
token.props.args.monospace ? 'monospace' :
|
||||||
token.node.props.args.cursive ? 'cursive' :
|
token.props.args.cursive ? 'cursive' :
|
||||||
token.node.props.args.fantasy ? 'fantasy' :
|
token.props.args.fantasy ? 'fantasy' :
|
||||||
token.node.props.args.emoji ? 'emoji' :
|
token.props.args.emoji ? 'emoji' :
|
||||||
token.node.props.args.math ? 'math' :
|
token.props.args.math ? 'math' :
|
||||||
null;
|
null;
|
||||||
if (family) style = `font-family: ${family};`;
|
if (family) style = `font-family: ${family};`;
|
||||||
break;
|
break;
|
||||||
|
@ -165,7 +164,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (style == null) {
|
if (style == null) {
|
||||||
return h('span', {}, ['[', token.node.props.name, ...genEl(token.children), ']']);
|
return h('span', {}, ['[', token.props.name, ...genEl(token.children), ']']);
|
||||||
} else {
|
} else {
|
||||||
return h('span', {
|
return h('span', {
|
||||||
style: 'display: inline-block;' + style,
|
style: 'display: inline-block;' + style,
|
||||||
|
@ -188,7 +187,7 @@ export default defineComponent({
|
||||||
case 'url': {
|
case 'url': {
|
||||||
return [h(MkUrl, {
|
return [h(MkUrl, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
url: token.node.props.url,
|
url: token.props.url,
|
||||||
rel: 'nofollow noopener',
|
rel: 'nofollow noopener',
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
@ -196,7 +195,7 @@ export default defineComponent({
|
||||||
case 'link': {
|
case 'link': {
|
||||||
return [h(MkLink, {
|
return [h(MkLink, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
url: token.node.props.url,
|
url: token.props.url,
|
||||||
rel: 'nofollow noopener',
|
rel: 'nofollow noopener',
|
||||||
}, genEl(token.children))];
|
}, genEl(token.children))];
|
||||||
}
|
}
|
||||||
|
@ -204,32 +203,31 @@ export default defineComponent({
|
||||||
case 'mention': {
|
case 'mention': {
|
||||||
return [h(MkMention, {
|
return [h(MkMention, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host,
|
host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
|
||||||
username: token.node.props.username
|
username: token.props.username
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'hashtag': {
|
case 'hashtag': {
|
||||||
return [h(MkA, {
|
return [h(MkA, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`,
|
to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
|
||||||
style: 'color:var(--hashtag);'
|
style: 'color:var(--hashtag);'
|
||||||
}, `#${token.node.props.hashtag}`)];
|
}, `#${token.props.hashtag}`)];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'blockCode': {
|
case 'blockCode': {
|
||||||
return [h(MkCode, {
|
return [h(MkCode, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
code: token.node.props.code,
|
code: token.props.code,
|
||||||
lang: token.node.props.lang,
|
lang: token.props.lang,
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'inlineCode': {
|
case 'inlineCode': {
|
||||||
return [h(MkCode, {
|
return [h(MkCode, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
code: token.node.props.code,
|
code: token.props.code,
|
||||||
lang: token.node.props.lang,
|
|
||||||
inline: true
|
inline: true
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
@ -246,10 +244,19 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'emoji': {
|
case 'emojiCode': {
|
||||||
return [h(MkEmoji, {
|
return [h(MkEmoji, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
emoji: token.node.props.name ? `:${token.node.props.name}:` : token.node.props.emoji,
|
emoji: `:${token.props.name}:`,
|
||||||
|
customEmojis: this.customEmojis,
|
||||||
|
normal: this.plain
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unicodeEmoji': {
|
||||||
|
return [h(MkEmoji, {
|
||||||
|
key: Math.random(),
|
||||||
|
emoji: token.props.emoji,
|
||||||
customEmojis: this.customEmojis,
|
customEmojis: this.customEmojis,
|
||||||
normal: this.plain
|
normal: this.plain
|
||||||
})];
|
})];
|
||||||
|
@ -258,7 +265,7 @@ export default defineComponent({
|
||||||
case 'mathInline': {
|
case 'mathInline': {
|
||||||
return [h(MkFormula, {
|
return [h(MkFormula, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
formula: token.node.props.formula,
|
formula: token.props.formula,
|
||||||
block: false
|
block: false
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
@ -266,7 +273,7 @@ export default defineComponent({
|
||||||
case 'mathBlock': {
|
case 'mathBlock': {
|
||||||
return [h(MkFormula, {
|
return [h(MkFormula, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
formula: token.node.props.formula,
|
formula: token.props.formula,
|
||||||
block: true
|
block: true
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
@ -274,12 +281,12 @@ export default defineComponent({
|
||||||
case 'search': {
|
case 'search': {
|
||||||
return [h(MkGoogle, {
|
return [h(MkGoogle, {
|
||||||
key: Math.random(),
|
key: Math.random(),
|
||||||
q: token.node.props.query
|
q: token.props.query
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
console.error('unrecognized ast type:', token.node.type);
|
console.error('unrecognized ast type:', token.type);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,11 +120,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue';
|
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
||||||
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { parse } from '../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { sum, unique } from '../../prelude/array';
|
import { sum } from '../../prelude/array';
|
||||||
import XSub from './note.sub.vue';
|
import XSub from './note.sub.vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XNotePreview from './note-preview.vue';
|
import XNotePreview from './note-preview.vue';
|
||||||
|
@ -141,6 +141,7 @@ import { userPage } from '@client/filters/user';
|
||||||
import * as os from '@client/os';
|
import * as os from '@client/os';
|
||||||
import { noteActions, noteViewInterruptors } from '@client/store';
|
import { noteActions, noteViewInterruptors } from '@client/store';
|
||||||
import { reactionPicker } from '@client/scripts/reaction-picker';
|
import { reactionPicker } from '@client/scripts/reaction-picker';
|
||||||
|
import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
|
||||||
|
|
||||||
function markRawAll(...xs) {
|
function markRawAll(...xs) {
|
||||||
for (const x of xs) {
|
for (const x of xs) {
|
||||||
|
@ -252,21 +253,7 @@ export default defineComponent({
|
||||||
|
|
||||||
urls(): string[] {
|
urls(): string[] {
|
||||||
if (this.appearNote.text) {
|
if (this.appearNote.text) {
|
||||||
const ast = parse(this.appearNote.text);
|
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
|
||||||
// TODO: 再帰的にURL要素がないか調べる
|
|
||||||
const urls = unique(ast
|
|
||||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
|
||||||
.map(t => t.node.props.url));
|
|
||||||
|
|
||||||
// unique without hash
|
|
||||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
|
||||||
const removeHash = x => x.replace(/#[^#]*$/, '');
|
|
||||||
|
|
||||||
return urls.reduce((array, url) => {
|
|
||||||
const removed = removeHash(url);
|
|
||||||
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
|
|
||||||
return array;
|
|
||||||
}, []);
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,11 +102,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue';
|
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
||||||
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { parse } from '../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { sum, unique } from '../../prelude/array';
|
import { sum } from '../../prelude/array';
|
||||||
import XSub from './note.sub.vue';
|
import XSub from './note.sub.vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XNotePreview from './note-preview.vue';
|
import XNotePreview from './note-preview.vue';
|
||||||
|
@ -123,6 +123,7 @@ import { userPage } from '@client/filters/user';
|
||||||
import * as os from '@client/os';
|
import * as os from '@client/os';
|
||||||
import { noteActions, noteViewInterruptors } from '@client/store';
|
import { noteActions, noteViewInterruptors } from '@client/store';
|
||||||
import { reactionPicker } from '@client/scripts/reaction-picker';
|
import { reactionPicker } from '@client/scripts/reaction-picker';
|
||||||
|
import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
|
||||||
|
|
||||||
function markRawAll(...xs) {
|
function markRawAll(...xs) {
|
||||||
for (const x of xs) {
|
for (const x of xs) {
|
||||||
|
@ -238,21 +239,7 @@ export default defineComponent({
|
||||||
|
|
||||||
urls(): string[] {
|
urls(): string[] {
|
||||||
if (this.appearNote.text) {
|
if (this.appearNote.text) {
|
||||||
const ast = parse(this.appearNote.text);
|
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
|
||||||
// TODO: 再帰的にURL要素がないか調べる
|
|
||||||
const urls = unique(ast
|
|
||||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
|
||||||
.map(t => t.node.props.url));
|
|
||||||
|
|
||||||
// unique without hash
|
|
||||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
|
||||||
const removeHash = x => x.replace(/#[^#]*$/, '');
|
|
||||||
|
|
||||||
return urls.reduce((array, url) => {
|
|
||||||
const removed = removeHash(url);
|
|
||||||
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
|
|
||||||
return array;
|
|
||||||
}, []);
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
import { TextBlock } from '@client/scripts/hpml/block';
|
import { TextBlock } from '@client/scripts/hpml/block';
|
||||||
import { Hpml } from '@client/scripts/hpml/evaluator';
|
import { Hpml } from '@client/scripts/hpml/evaluator';
|
||||||
import { defineAsyncComponent, defineComponent, PropType } from 'vue';
|
import { defineAsyncComponent, defineComponent, PropType } from 'vue';
|
||||||
import { parse } from '../../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { unique } from '../../../prelude/array';
|
import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
@ -34,11 +34,7 @@ export default defineComponent({
|
||||||
computed: {
|
computed: {
|
||||||
urls(): string[] {
|
urls(): string[] {
|
||||||
if (this.text) {
|
if (this.text) {
|
||||||
const ast = parse(this.text);
|
return extractUrlFromMfm(mfm.parse(this.text));
|
||||||
// TODO: 再帰的にURL要素がないか調べる
|
|
||||||
return unique(ast
|
|
||||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
|
||||||
.map(t => t.node.props.url));
|
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ import insertTextAtCursor from 'insert-text-at-cursor';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
import { toASCII } from 'punycode';
|
import { toASCII } from 'punycode';
|
||||||
import XNotePreview from './note-preview.vue';
|
import XNotePreview from './note-preview.vue';
|
||||||
import { parse } from '../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { host, url } from '@client/config';
|
import { host, url } from '@client/config';
|
||||||
import { erase, unique } from '../../prelude/array';
|
import { erase, unique } from '../../prelude/array';
|
||||||
import extractMentions from '@/misc/extract-mentions';
|
import extractMentions from '@/misc/extract-mentions';
|
||||||
|
@ -229,7 +229,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.reply && this.reply.text != null) {
|
if (this.reply && this.reply.text != null) {
|
||||||
const ast = parse(this.reply.text);
|
const ast = mfm.parse(this.reply.text);
|
||||||
|
|
||||||
for (const x of extractMentions(ast)) {
|
for (const x of extractMentions(ast)) {
|
||||||
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
|
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
|
||||||
|
@ -580,7 +580,7 @@ export default defineComponent({
|
||||||
this.deleteDraft();
|
this.deleteDraft();
|
||||||
this.$emit('posted');
|
this.$emit('posted');
|
||||||
if (this.text && this.text != '') {
|
if (this.text && this.text != '') {
|
||||||
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
|
const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
|
||||||
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
|
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
|
||||||
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
|
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
|
<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
|
||||||
<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
|
<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
|
||||||
<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
|
<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
|
||||||
|
<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
|
||||||
<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template>
|
<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
|
|
|
@ -37,8 +37,8 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { parse } from '../../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { unique } from '../../../prelude/array';
|
import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
|
||||||
import MkUrlPreview from '@client/components/url-preview.vue';
|
import MkUrlPreview from '@client/components/url-preview.vue';
|
||||||
import * as os from '@client/os';
|
import * as os from '@client/os';
|
||||||
|
|
||||||
|
@ -60,10 +60,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
urls(): string[] {
|
urls(): string[] {
|
||||||
if (this.message.text) {
|
if (this.message.text) {
|
||||||
const ast = parse(this.message.text);
|
return extractUrlFromMfm(mfm.parse(this.message.text));
|
||||||
return unique(ast
|
|
||||||
.filter(t => ((t.node.type === 'url' || t.node.type === 'link') && t.node.props.url && !t.node.props.silent))
|
|
||||||
.map(t => t.node.props.url));
|
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,11 +101,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue';
|
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
||||||
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { parse } from '../../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { sum, unique } from '../../../prelude/array';
|
import { sum } from '../../../prelude/array';
|
||||||
import XSub from './note.sub.vue';
|
import XSub from './note.sub.vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XNotePreview from './note-preview.vue';
|
import XNotePreview from './note-preview.vue';
|
||||||
|
@ -122,6 +122,7 @@ import { userPage } from '@client/filters/user';
|
||||||
import * as os from '@client/os';
|
import * as os from '@client/os';
|
||||||
import { noteActions, noteViewInterruptors } from '@client/store';
|
import { noteActions, noteViewInterruptors } from '@client/store';
|
||||||
import { reactionPicker } from '@client/scripts/reaction-picker';
|
import { reactionPicker } from '@client/scripts/reaction-picker';
|
||||||
|
import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
|
||||||
|
|
||||||
function markRawAll(...xs) {
|
function markRawAll(...xs) {
|
||||||
for (const x of xs) {
|
for (const x of xs) {
|
||||||
|
@ -238,21 +239,7 @@ export default defineComponent({
|
||||||
|
|
||||||
urls(): string[] {
|
urls(): string[] {
|
||||||
if (this.appearNote.text) {
|
if (this.appearNote.text) {
|
||||||
const ast = parse(this.appearNote.text);
|
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
|
||||||
// TODO: 再帰的にURL要素がないか調べる
|
|
||||||
const urls = unique(ast
|
|
||||||
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
|
|
||||||
.map(t => t.node.props.url));
|
|
||||||
|
|
||||||
// unique without hash
|
|
||||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
|
||||||
const removeHash = x => x.replace(/#[^#]*$/, '');
|
|
||||||
|
|
||||||
return urls.reduce((array, url) => {
|
|
||||||
const removed = removeHash(url);
|
|
||||||
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
|
|
||||||
return array;
|
|
||||||
}, []);
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
|
||||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
import { toASCII } from 'punycode';
|
import { toASCII } from 'punycode';
|
||||||
import { parse } from '../../../mfm/parse';
|
import * as mfm from 'mfm-js';
|
||||||
import { host, url } from '@client/config';
|
import { host, url } from '@client/config';
|
||||||
import { erase, unique } from '../../../prelude/array';
|
import { erase, unique } from '../../../prelude/array';
|
||||||
import extractMentions from '@/misc/extract-mentions';
|
import extractMentions from '@/misc/extract-mentions';
|
||||||
|
@ -216,7 +216,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.reply && this.reply.text != null) {
|
if (this.reply && this.reply.text != null) {
|
||||||
const ast = parse(this.reply.text);
|
const ast = mfm.parse(this.reply.text);
|
||||||
|
|
||||||
for (const x of extractMentions(ast)) {
|
for (const x of extractMentions(ast)) {
|
||||||
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
|
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
|
||||||
|
@ -567,7 +567,7 @@ export default defineComponent({
|
||||||
this.deleteDraft();
|
this.deleteDraft();
|
||||||
this.$emit('posted');
|
this.$emit('posted');
|
||||||
if (this.text && this.text != '') {
|
if (this.text && this.text != '') {
|
||||||
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
|
const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
|
||||||
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
|
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
|
||||||
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
|
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import * as parse5 from 'parse5';
|
import * as parse5 from 'parse5';
|
||||||
import treeAdapter = require('parse5/lib/tree-adapters/default');
|
import treeAdapter = require('parse5/lib/tree-adapters/default');
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { urlRegex, urlRegexFull } from './prelude';
|
|
||||||
|
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||||
|
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||||
|
|
||||||
export function fromHtml(html: string, hashtagNames?: string[]): string {
|
export function fromHtml(html: string, hashtagNames?: string[]): string {
|
||||||
const dom = parse5.parseFragment(html);
|
const dom = parse5.parseFragment(html);
|
||||||
|
|
|
@ -1,191 +0,0 @@
|
||||||
import * as P from 'parsimmon';
|
|
||||||
import { createLeaf, createTree, urlRegex } from './prelude';
|
|
||||||
import { takeWhile, cumulativeSum } from '../prelude/array';
|
|
||||||
import parseAcct from '@/misc/acct/parse';
|
|
||||||
import { toUnicode } from 'punycode';
|
|
||||||
import { emojiRegex } from '@/misc/emoji-regex';
|
|
||||||
|
|
||||||
export function removeOrphanedBrackets(s: string): string {
|
|
||||||
const openBrackets = ['(', '「', '['];
|
|
||||||
const closeBrackets = [')', '」', ']'];
|
|
||||||
const xs = cumulativeSum(s.split('').map(c => {
|
|
||||||
if (openBrackets.includes(c)) return 1;
|
|
||||||
if (closeBrackets.includes(c)) return -1;
|
|
||||||
return 0;
|
|
||||||
}));
|
|
||||||
const firstOrphanedCloseBracket = xs.findIndex(x => x < 0);
|
|
||||||
if (firstOrphanedCloseBracket !== -1) return s.substr(0, firstOrphanedCloseBracket);
|
|
||||||
const lastMatched = xs.lastIndexOf(0);
|
|
||||||
return s.substr(0, lastMatched + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mfmLanguage = P.createLanguage({
|
|
||||||
root: r => P.alt(r.block, r.inline).atLeast(1),
|
|
||||||
plain: r => P.alt(r.emoji, r.text).atLeast(1),
|
|
||||||
block: r => P.alt(
|
|
||||||
r.quote,
|
|
||||||
r.search,
|
|
||||||
r.blockCode,
|
|
||||||
r.mathBlock,
|
|
||||||
r.center,
|
|
||||||
),
|
|
||||||
startOfLine: () => P((input, i) => {
|
|
||||||
if (i === 0 || input[i] === '\n' || input[i - 1] === '\n') {
|
|
||||||
return P.makeSuccess(i, null);
|
|
||||||
} else {
|
|
||||||
return P.makeFailure(i, 'not newline');
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
quote: r => r.startOfLine.then(P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
if (!text.match(/^>[\s\S]+?/)) return P.makeFailure(i, 'not a quote');
|
|
||||||
const quote = takeWhile(line => line.startsWith('>'), text.split('\n'));
|
|
||||||
const qInner = quote.join('\n').replace(/^>/gm, '').replace(/^ /gm, '');
|
|
||||||
if (qInner === '') return P.makeFailure(i, 'not a quote');
|
|
||||||
const contents = r.root.tryParse(qInner);
|
|
||||||
return P.makeSuccess(i + quote.join('\n').length + 1, createTree('quote', contents, {}));
|
|
||||||
})),
|
|
||||||
search: r => r.startOfLine.then(P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i);
|
|
||||||
if (!match) return P.makeFailure(i, 'not a search');
|
|
||||||
return P.makeSuccess(i + match[0].length, createLeaf('search', { query: match[1], content: match[0].trim() }));
|
|
||||||
})),
|
|
||||||
blockCode: r => r.startOfLine.then(P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(/^```(.+?)?\n([\s\S]+?)\n```(\n|$)/i);
|
|
||||||
if (!match) return P.makeFailure(i, 'not a blockCode');
|
|
||||||
return P.makeSuccess(i + match[0].length, createLeaf('blockCode', { code: match[2], lang: match[1] ? match[1].trim() : null }));
|
|
||||||
})),
|
|
||||||
inline: r => P.alt(
|
|
||||||
r.big,
|
|
||||||
r.bold,
|
|
||||||
r.small,
|
|
||||||
r.italic,
|
|
||||||
r.strike,
|
|
||||||
r.inlineCode,
|
|
||||||
r.mathInline,
|
|
||||||
r.mention,
|
|
||||||
r.hashtag,
|
|
||||||
r.url,
|
|
||||||
r.link,
|
|
||||||
r.emoji,
|
|
||||||
r.fn,
|
|
||||||
r.text
|
|
||||||
),
|
|
||||||
// TODO: そのうち消す
|
|
||||||
big: r => P.regexp(/^\*\*\*([\s\S]+?)\*\*\*/, 1).map(x => createTree('fn', r.inline.atLeast(1).tryParse(x), {
|
|
||||||
name: 'tada',
|
|
||||||
args: {}
|
|
||||||
})),
|
|
||||||
bold: r => {
|
|
||||||
const asterisk = P.regexp(/\*\*([\s\S]+?)\*\*/, 1);
|
|
||||||
const underscore = P.regexp(/__([a-zA-Z0-9\s]+?)__/, 1);
|
|
||||||
return P.alt(asterisk, underscore).map(x => createTree('bold', r.inline.atLeast(1).tryParse(x), {}));
|
|
||||||
},
|
|
||||||
small: r => P.regexp(/<small>([\s\S]+?)<\/small>/, 1).map(x => createTree('small', r.inline.atLeast(1).tryParse(x), {})),
|
|
||||||
italic: r => {
|
|
||||||
const xml = P.regexp(/<i>([\s\S]+?)<\/i>/, 1);
|
|
||||||
const underscore = P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(/^(\*|_)([a-zA-Z0-9]+?[\s\S]*?)\1/);
|
|
||||||
if (!match) return P.makeFailure(i, 'not a italic');
|
|
||||||
if (input[i - 1] != null && input[i - 1] != ' ' && input[i - 1] != '\n') return P.makeFailure(i, 'not a italic');
|
|
||||||
return P.makeSuccess(i + match[0].length, match[2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return P.alt(xml, underscore).map(x => createTree('italic', r.inline.atLeast(1).tryParse(x), {}));
|
|
||||||
},
|
|
||||||
strike: r => P.regexp(/~~([^\n~]+?)~~/, 1).map(x => createTree('strike', r.inline.atLeast(1).tryParse(x), {})),
|
|
||||||
center: r => r.startOfLine.then(P.regexp(/<center>([\s\S]+?)<\/center>/, 1).map(x => createTree('center', r.inline.atLeast(1).tryParse(x), {}))),
|
|
||||||
inlineCode: () => P.regexp(/`([^´\n]+?)`/, 1).map(x => createLeaf('inlineCode', { code: x })),
|
|
||||||
mathBlock: r => r.startOfLine.then(P.regexp(/\\\[([\s\S]+?)\\\]/, 1).map(x => createLeaf('mathBlock', { formula: x.trim() }))),
|
|
||||||
mathInline: () => P.regexp(/\\\((.+?)\\\)/, 1).map(x => createLeaf('mathInline', { formula: x })),
|
|
||||||
mention: () => {
|
|
||||||
return P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(/^@\w([\w-]*\w)?(?:@[\w.\-]+\w)?/);
|
|
||||||
if (!match) return P.makeFailure(i, 'not a mention');
|
|
||||||
if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a mention');
|
|
||||||
return P.makeSuccess(i + match[0].length, match[0]);
|
|
||||||
}).map(x => {
|
|
||||||
const { username, host } = parseAcct(x.substr(1));
|
|
||||||
const canonical = host != null ? `@${username}@${toUnicode(host)}` : x;
|
|
||||||
return createLeaf('mention', { canonical, username, host, acct: x });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
hashtag: () => P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(/^#([^\s.,!?'"#:\/\[\]【】]+)/i);
|
|
||||||
if (!match) return P.makeFailure(i, 'not a hashtag');
|
|
||||||
let hashtag = match[1];
|
|
||||||
hashtag = removeOrphanedBrackets(hashtag);
|
|
||||||
if (hashtag.match(/^(\u20e3|\ufe0f)/)) return P.makeFailure(i, 'not a hashtag');
|
|
||||||
if (hashtag.match(/^[0-9]+$/)) return P.makeFailure(i, 'not a hashtag');
|
|
||||||
if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a hashtag');
|
|
||||||
if (Array.from(hashtag || '').length > 128) return P.makeFailure(i, 'not a hashtag');
|
|
||||||
return P.makeSuccess(i + ('#' + hashtag).length, createLeaf('hashtag', { hashtag: hashtag }));
|
|
||||||
}),
|
|
||||||
url: () => {
|
|
||||||
return P((input, i) => {
|
|
||||||
const text = input.substr(i);
|
|
||||||
const match = text.match(urlRegex);
|
|
||||||
let url: string;
|
|
||||||
if (!match) {
|
|
||||||
const match = text.match(/^<(https?:\/\/.*?)>/);
|
|
||||||
if (!match) {
|
|
||||||
return P.makeFailure(i, 'not a url');
|
|
||||||
}
|
|
||||||
url = match[1];
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
url = match[0];
|
|
||||||
}
|
|
||||||
url = removeOrphanedBrackets(url);
|
|
||||||
url = url.replace(/[.,]*$/, '');
|
|
||||||
return P.makeSuccess(i + url.length, url);
|
|
||||||
}).map(x => createLeaf('url', { url: x }));
|
|
||||||
},
|
|
||||||
link: r => {
|
|
||||||
return P.seqObj(
|
|
||||||
['silent', P.string('?').fallback(null).map(x => x != null)] as any,
|
|
||||||
P.string('['), ['text', P.regexp(/[^\n\[\]]+/)] as any, P.string(']'),
|
|
||||||
P.string('('), ['url', r.url] as any, P.string(')'),
|
|
||||||
).map((x: any) => {
|
|
||||||
return createTree('link', r.inline.atLeast(1).tryParse(x.text), {
|
|
||||||
silent: x.silent,
|
|
||||||
url: x.url.node.props.url
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
emoji: () => {
|
|
||||||
const name = P.regexp(/:([a-z0-9_+-]+):/i, 1).map(x => createLeaf('emoji', { name: x }));
|
|
||||||
const code = P.regexp(emojiRegex).map(x => createLeaf('emoji', { emoji: x }));
|
|
||||||
return P.alt(name, code);
|
|
||||||
},
|
|
||||||
fn: r => {
|
|
||||||
return P.seqObj(
|
|
||||||
P.string('['), ['fn', P.regexp(/[^\s\n\[\]]+/)] as any, P.string(' '), P.optWhitespace, ['text', P.regexp(/[^\n\[\]]+/)] as any, P.string(']'),
|
|
||||||
).map((x: any) => {
|
|
||||||
let name = x.fn;
|
|
||||||
const args = {};
|
|
||||||
const separator = x.fn.indexOf('.');
|
|
||||||
if (separator > -1) {
|
|
||||||
name = x.fn.substr(0, separator);
|
|
||||||
for (const arg of x.fn.substr(separator + 1).split(',')) {
|
|
||||||
const kv = arg.split('=');
|
|
||||||
if (kv.length === 1) {
|
|
||||||
args[kv[0]] = true;
|
|
||||||
} else {
|
|
||||||
args[kv[0]] = kv[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createTree('fn', r.inline.atLeast(1).tryParse(x.text), {
|
|
||||||
name,
|
|
||||||
args
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
text: () => P.any.map(x => createLeaf('text', { text: x }))
|
|
||||||
});
|
|
|
@ -1,31 +0,0 @@
|
||||||
import * as A from '../prelude/array';
|
|
||||||
import * as S from '../prelude/string';
|
|
||||||
import { MfmForest, MfmTree } from './prelude';
|
|
||||||
import { createTree, createLeaf } from '../prelude/tree';
|
|
||||||
|
|
||||||
function isEmptyTextTree(t: MfmTree): boolean {
|
|
||||||
return t.node.type === 'text' && t.node.props.text === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function concatTextTrees(ts: MfmForest): MfmTree {
|
|
||||||
return createLeaf({ type: 'text', props: { text: S.concat(ts.map(x => x.node.props.text)) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function concatIfTextTrees(ts: MfmForest): MfmForest {
|
|
||||||
return ts[0].node.type === 'text' ? [concatTextTrees(ts)] : ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function concatConsecutiveTextTrees(ts: MfmForest): MfmForest {
|
|
||||||
const us = A.concat(A.groupOn(t => t.node.type, ts).map(concatIfTextTrees));
|
|
||||||
return us.map(t => createTree(t.node, concatConsecutiveTextTrees(t.children)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEmptyTextNodes(ts: MfmForest): MfmForest {
|
|
||||||
return ts
|
|
||||||
.filter(t => !isEmptyTextTree(t))
|
|
||||||
.map(t => createTree(t.node, removeEmptyTextNodes(t.children)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalize(ts: MfmForest): MfmForest {
|
|
||||||
return removeEmptyTextNodes(concatConsecutiveTextTrees(ts));
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { mfmLanguage } from './language';
|
|
||||||
import { MfmForest } from './prelude';
|
|
||||||
import { normalize } from './normalize';
|
|
||||||
|
|
||||||
export function parse(source: string | null): MfmForest | null {
|
|
||||||
if (source == null || source === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalize(mfmLanguage.root.tryParse(source));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parsePlain(source: string | null): MfmForest | null {
|
|
||||||
if (source == null || source === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalize(mfmLanguage.plain.tryParse(source));
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
import { Tree } from '../prelude/tree';
|
|
||||||
import * as T from '../prelude/tree';
|
|
||||||
|
|
||||||
type Node<T, P> = { type: T, props: P };
|
|
||||||
|
|
||||||
export type MentionNode = Node<'mention', {
|
|
||||||
canonical: string,
|
|
||||||
username: string,
|
|
||||||
host: string,
|
|
||||||
acct: string
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type HashtagNode = Node<'hashtag', {
|
|
||||||
hashtag: string
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type EmojiNode = Node<'emoji', {
|
|
||||||
name: string
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type MfmNode =
|
|
||||||
MentionNode |
|
|
||||||
HashtagNode |
|
|
||||||
EmojiNode |
|
|
||||||
Node<string, any>;
|
|
||||||
|
|
||||||
export type MfmTree = Tree<MfmNode>;
|
|
||||||
|
|
||||||
export type MfmForest = MfmTree[];
|
|
||||||
|
|
||||||
export function createLeaf(type: string, props: any): MfmTree {
|
|
||||||
return T.createLeaf({ type, props });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTree(type: string, children: MfmForest, props: any): MfmTree {
|
|
||||||
return T.createTree({ type, props }, children);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
|
||||||
export const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { intersperse } from '../prelude/array';
|
import { intersperse } from '../prelude/array';
|
||||||
import { MfmForest, MfmTree } from './prelude';
|
|
||||||
import { IMentionedRemoteUsers } from '../models/entities/note';
|
import { IMentionedRemoteUsers } from '../models/entities/note';
|
||||||
import { wellKnownServices } from '../well-known-services';
|
import { wellKnownServices } from '../well-known-services';
|
||||||
|
|
||||||
export function toHtml(tokens: MfmForest | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||||
if (tokens == null) {
|
if (nodes == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,95 +14,101 @@ export function toHtml(tokens: MfmForest | null, mentionedRemoteUsers: IMentione
|
||||||
|
|
||||||
const doc = window.document;
|
const doc = window.document;
|
||||||
|
|
||||||
function appendChildren(children: MfmForest, targetElement: any): void {
|
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
|
||||||
for (const child of children.map(t => handlers[t.node.type](t))) targetElement.appendChild(child);
|
if (children) {
|
||||||
|
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers: { [key: string]: (token: MfmTree) => any } = {
|
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||||
bold(token) {
|
bold(node) {
|
||||||
const el = doc.createElement('b');
|
const el = doc.createElement('b');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
small(token) {
|
small(node) {
|
||||||
const el = doc.createElement('small');
|
const el = doc.createElement('small');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
strike(token) {
|
strike(node) {
|
||||||
const el = doc.createElement('del');
|
const el = doc.createElement('del');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
italic(token) {
|
italic(node) {
|
||||||
const el = doc.createElement('i');
|
const el = doc.createElement('i');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
fn(token) {
|
fn(node) {
|
||||||
const el = doc.createElement('i');
|
const el = doc.createElement('i');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
blockCode(token) {
|
blockCode(node) {
|
||||||
const pre = doc.createElement('pre');
|
const pre = doc.createElement('pre');
|
||||||
const inner = doc.createElement('code');
|
const inner = doc.createElement('code');
|
||||||
inner.textContent = token.node.props.code;
|
inner.textContent = node.props.code;
|
||||||
pre.appendChild(inner);
|
pre.appendChild(inner);
|
||||||
return pre;
|
return pre;
|
||||||
},
|
},
|
||||||
|
|
||||||
center(token) {
|
center(node) {
|
||||||
const el = doc.createElement('div');
|
const el = doc.createElement('div');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
emoji(token) {
|
emojiCode(node) {
|
||||||
return doc.createTextNode(token.node.props.emoji ? token.node.props.emoji : `\u200B:${token.node.props.name}:\u200B`);
|
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||||
},
|
},
|
||||||
|
|
||||||
hashtag(token) {
|
unicodeEmoji(node) {
|
||||||
|
return doc.createTextNode(node.props.emoji);
|
||||||
|
},
|
||||||
|
|
||||||
|
hashtag(node) {
|
||||||
const a = doc.createElement('a');
|
const a = doc.createElement('a');
|
||||||
a.href = `${config.url}/tags/${token.node.props.hashtag}`;
|
a.href = `${config.url}/tags/${node.props.hashtag}`;
|
||||||
a.textContent = `#${token.node.props.hashtag}`;
|
a.textContent = `#${node.props.hashtag}`;
|
||||||
a.setAttribute('rel', 'tag');
|
a.setAttribute('rel', 'tag');
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
inlineCode(token) {
|
inlineCode(node) {
|
||||||
const el = doc.createElement('code');
|
const el = doc.createElement('code');
|
||||||
el.textContent = token.node.props.code;
|
el.textContent = node.props.code;
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathInline(token) {
|
mathInline(node) {
|
||||||
const el = doc.createElement('code');
|
const el = doc.createElement('code');
|
||||||
el.textContent = token.node.props.formula;
|
el.textContent = node.props.formula;
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathBlock(token) {
|
mathBlock(node) {
|
||||||
const el = doc.createElement('code');
|
const el = doc.createElement('code');
|
||||||
el.textContent = token.node.props.formula;
|
el.textContent = node.props.formula;
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
link(token) {
|
link(node) {
|
||||||
const a = doc.createElement('a');
|
const a = doc.createElement('a');
|
||||||
a.href = token.node.props.url;
|
a.href = node.props.url;
|
||||||
appendChildren(token.children, a);
|
appendChildren(node.children, a);
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
mention(token) {
|
mention(node) {
|
||||||
const a = doc.createElement('a');
|
const a = doc.createElement('a');
|
||||||
const { username, host, acct } = token.node.props;
|
const { username, host, acct } = node.props;
|
||||||
const wellKnown = wellKnownServices.find(x => x[0] === host);
|
const wellKnown = wellKnownServices.find(x => x[0] === host);
|
||||||
if (wellKnown) {
|
if (wellKnown) {
|
||||||
a.href = wellKnown[1](username);
|
a.href = wellKnown[1](username);
|
||||||
|
@ -115,39 +121,39 @@ export function toHtml(tokens: MfmForest | null, mentionedRemoteUsers: IMentione
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
quote(token) {
|
quote(node) {
|
||||||
const el = doc.createElement('blockquote');
|
const el = doc.createElement('blockquote');
|
||||||
appendChildren(token.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
text(token) {
|
text(node) {
|
||||||
const el = doc.createElement('span');
|
const el = doc.createElement('span');
|
||||||
const nodes = (token.node.props.text as string).split(/\r\n|\r|\n/).map(x => doc.createTextNode(x) as Node);
|
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||||
|
|
||||||
for (const x of intersperse<Node | 'br'>('br', nodes)) {
|
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
url(token) {
|
url(node) {
|
||||||
const a = doc.createElement('a');
|
const a = doc.createElement('a');
|
||||||
a.href = token.node.props.url;
|
a.href = node.props.url;
|
||||||
a.textContent = token.node.props.url;
|
a.textContent = node.props.url;
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
search(token) {
|
search(node) {
|
||||||
const a = doc.createElement('a');
|
const a = doc.createElement('a');
|
||||||
a.href = `https://www.google.com/search?q=${token.node.props.query}`;
|
a.href = `https://www.google.com/search?q=${node.props.query}`;
|
||||||
a.textContent = token.node.props.content;
|
a.textContent = node.props.content;
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
appendChildren(tokens, doc.body);
|
appendChildren(nodes, doc.body);
|
||||||
|
|
||||||
return `<p>${doc.body.innerHTML}</p>`;
|
return `<p>${doc.body.innerHTML}</p>`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
import { MfmForest, MfmTree } from './prelude';
|
|
||||||
import { nyaize } from '@/misc/nyaize';
|
|
||||||
|
|
||||||
export type RestoreOptions = {
|
|
||||||
doNyaize?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toString(tokens: MfmForest | null, opts?: RestoreOptions): string {
|
|
||||||
|
|
||||||
if (tokens === null) return '';
|
|
||||||
|
|
||||||
function appendChildren(children: MfmForest, opts?: RestoreOptions): string {
|
|
||||||
return children.map(t => handlers[t.node.type](t, opts)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers: { [key: string]: (token: MfmTree, opts?: RestoreOptions) => string } = {
|
|
||||||
bold(token, opts) {
|
|
||||||
return `**${appendChildren(token.children, opts)}**`;
|
|
||||||
},
|
|
||||||
|
|
||||||
small(token, opts) {
|
|
||||||
return `<small>${appendChildren(token.children, opts)}</small>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
strike(token, opts) {
|
|
||||||
return `~~${appendChildren(token.children, opts)}~~`;
|
|
||||||
},
|
|
||||||
|
|
||||||
italic(token, opts) {
|
|
||||||
return `<i>${appendChildren(token.children, opts)}</i>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
fn(token, opts) {
|
|
||||||
const name = token.node.props?.name;
|
|
||||||
const args = token.node.props?.args || {};
|
|
||||||
const argsStr = Object.entries(args).map(([k, v]) => v === true ? k : `${k}=${v}`).join(',');
|
|
||||||
return `[${name}${argsStr !== '' ? '.' + argsStr : ''} ${appendChildren(token.children, opts)}]`;
|
|
||||||
},
|
|
||||||
|
|
||||||
blockCode(token) {
|
|
||||||
return `\`\`\`${token.node.props.lang || ''}\n${token.node.props.code}\n\`\`\`\n`;
|
|
||||||
},
|
|
||||||
|
|
||||||
center(token, opts) {
|
|
||||||
return `<center>${appendChildren(token.children, opts)}</center>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
emoji(token) {
|
|
||||||
return (token.node.props.emoji ? token.node.props.emoji : `:${token.node.props.name}:`);
|
|
||||||
},
|
|
||||||
|
|
||||||
hashtag(token) {
|
|
||||||
return `#${token.node.props.hashtag}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
inlineCode(token) {
|
|
||||||
return `\`${token.node.props.code}\``;
|
|
||||||
},
|
|
||||||
|
|
||||||
mathInline(token) {
|
|
||||||
return `\\(${token.node.props.formula}\\)`;
|
|
||||||
},
|
|
||||||
|
|
||||||
mathBlock(token) {
|
|
||||||
return `\\[${token.node.props.formula}\\]`;
|
|
||||||
},
|
|
||||||
|
|
||||||
link(token, opts) {
|
|
||||||
if (token.node.props.silent) {
|
|
||||||
return `?[${appendChildren(token.children, opts)}](${token.node.props.url})`;
|
|
||||||
} else {
|
|
||||||
return `[${appendChildren(token.children, opts)}](${token.node.props.url})`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mention(token) {
|
|
||||||
return token.node.props.canonical;
|
|
||||||
},
|
|
||||||
|
|
||||||
quote(token) {
|
|
||||||
return `${appendChildren(token.children, {doNyaize: false}).replace(/^/gm,'>').trim()}\n`;
|
|
||||||
},
|
|
||||||
|
|
||||||
text(token, opts) {
|
|
||||||
return (opts && opts.doNyaize) ? nyaize(token.node.props.text) : token.node.props.text;
|
|
||||||
},
|
|
||||||
|
|
||||||
url(token) {
|
|
||||||
return `<${token.node.props.url}>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
search(token, opts) {
|
|
||||||
const query = token.node.props.query;
|
|
||||||
return `${(opts && opts.doNyaize ? nyaize(query) : query)} [search]\n`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return appendChildren(tokens, { doNyaize: (opts && opts.doNyaize) || false }).trim();
|
|
||||||
}
|
|
18
src/misc/extract-custom-emojis-from-mfm.ts
Normal file
18
src/misc/extract-custom-emojis-from-mfm.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
|
import { unique } from '@/prelude/array';
|
||||||
|
|
||||||
|
export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] {
|
||||||
|
const emojiNodes = [] as mfm.MfmEmojiCode[];
|
||||||
|
|
||||||
|
function scan(nodes: mfm.MfmNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'emojiCode') emojiNodes.push(node);
|
||||||
|
else if (node.children) scan(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(nodes);
|
||||||
|
|
||||||
|
const emojis = emojiNodes.filter(x => x.props.name.length <= 100).map(x => x.props.name!);
|
||||||
|
return unique(emojis);
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
import { EmojiNode, MfmForest } from '../mfm/prelude';
|
|
||||||
import { preorderF } from '../prelude/tree';
|
|
||||||
import { unique } from '../prelude/array';
|
|
||||||
|
|
||||||
export default function(mfmForest: MfmForest): string[] {
|
|
||||||
const emojiNodes = preorderF(mfmForest).filter(x => x.type === 'emoji') as EmojiNode[];
|
|
||||||
const emojis = emojiNodes.filter(x => x.props.name && x.props.name.length <= 100).map(x => x.props.name);
|
|
||||||
return unique(emojis);
|
|
||||||
}
|
|
|
@ -1,9 +1,18 @@
|
||||||
import { HashtagNode, MfmForest } from '../mfm/prelude';
|
import * as mfm from 'mfm-js';
|
||||||
import { preorderF } from '../prelude/tree';
|
import { unique } from '@/prelude/array';
|
||||||
import { unique } from '../prelude/array';
|
|
||||||
|
export default function(nodes: mfm.MfmNode[]): string[] {
|
||||||
|
const hashtagNodes = [] as mfm.MfmHashtag[];
|
||||||
|
|
||||||
|
function scan(nodes: mfm.MfmNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'hashtag') hashtagNodes.push(node);
|
||||||
|
else if (node.children) scan(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(nodes);
|
||||||
|
|
||||||
export default function(mfmForest: MfmForest): string[] {
|
|
||||||
const hashtagNodes = preorderF(mfmForest).filter(x => x.type === 'hashtag') as HashtagNode[];
|
|
||||||
const hashtags = hashtagNodes.map(x => x.props.hashtag);
|
const hashtags = hashtagNodes.map(x => x.props.hashtag);
|
||||||
return unique(hashtags);
|
return unique(hashtags);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
// test is located in test/extract-mentions
|
// test is located in test/extract-mentions
|
||||||
|
|
||||||
import { MentionNode, MfmForest } from '../mfm/prelude';
|
import * as mfm from 'mfm-js';
|
||||||
import { preorderF } from '../prelude/tree';
|
|
||||||
|
|
||||||
export default function(mfmForest: MfmForest): MentionNode['props'][] {
|
export default function(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
|
||||||
// TODO: 重複を削除
|
// TODO: 重複を削除
|
||||||
const mentionNodes = preorderF(mfmForest).filter(x => x.type === 'mention') as MentionNode[];
|
const mentionNodes = [] as mfm.MfmMention[];
|
||||||
|
|
||||||
|
function scan(nodes: mfm.MfmNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'mention') mentionNodes.push(node);
|
||||||
|
else if (node.children) scan(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(nodes);
|
||||||
|
|
||||||
return mentionNodes.map(x => x.props);
|
return mentionNodes.map(x => x.props);
|
||||||
}
|
}
|
||||||
|
|
34
src/misc/extract-url-from-mfm.ts
Normal file
34
src/misc/extract-url-from-mfm.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
|
import { unique } from '@/prelude/array';
|
||||||
|
|
||||||
|
// unique without hash
|
||||||
|
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
||||||
|
const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
|
||||||
|
|
||||||
|
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
|
||||||
|
const urlNodes = [] as (mfm.MfmUrl | mfm.MfmLink)[];
|
||||||
|
|
||||||
|
function scan(nodes: mfm.MfmNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'url') {
|
||||||
|
urlNodes.push(node);
|
||||||
|
} else if (node.type === 'link') {
|
||||||
|
if (!respectSilentFlag || !node.props.silent) {
|
||||||
|
urlNodes.push(node);
|
||||||
|
}
|
||||||
|
} else if (node.children) {
|
||||||
|
scan(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(nodes);
|
||||||
|
|
||||||
|
const urls = unique(urlNodes.map(x => x.props.url));
|
||||||
|
|
||||||
|
return urls.reduce((array, url) => {
|
||||||
|
const removed = removeHash(url);
|
||||||
|
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
|
||||||
|
return array;
|
||||||
|
}, [] as string[]);
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
import { EntityRepository, Repository, In } from 'typeorm';
|
import { EntityRepository, Repository, In } from 'typeorm';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import { Note } from '../entities/note';
|
import { Note } from '../entities/note';
|
||||||
import { User } from '../entities/user';
|
import { User } from '../entities/user';
|
||||||
import { Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
|
import { Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
|
||||||
import { SchemaType } from '@/misc/schema';
|
import { SchemaType } from '@/misc/schema';
|
||||||
|
import { nyaize } from '@/misc/nyaize';
|
||||||
import { awaitAll } from '../../prelude/await-all';
|
import { awaitAll } from '../../prelude/await-all';
|
||||||
import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '@/misc/reaction-lib';
|
import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '@/misc/reaction-lib';
|
||||||
import { toString } from '../../mfm/to-string';
|
|
||||||
import { parse } from '../../mfm/parse';
|
|
||||||
import { NoteReaction } from '../entities/note-reaction';
|
import { NoteReaction } from '../entities/note-reaction';
|
||||||
import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from '@/misc/populate-emojis';
|
import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from '@/misc/populate-emojis';
|
||||||
|
|
||||||
|
@ -223,8 +223,13 @@ export class NoteRepository extends Repository<Note> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (packed.user.isCat && packed.text) {
|
if (packed.user.isCat && packed.text) {
|
||||||
const tokens = packed.text ? parse(packed.text) : [];
|
const tokens = packed.text ? mfm.parse(packed.text) : [];
|
||||||
packed.text = toString(tokens, { doNyaize: true });
|
mfm.inspect(tokens, node => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
node.props.text = nyaize(node.props.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
packed.text = mfm.toString(tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opts.skipHide) {
|
if (!opts.skipHide) {
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
import { concat, sum } from './array';
|
|
||||||
|
|
||||||
export type Tree<T> = {
|
|
||||||
node: T,
|
|
||||||
children: Forest<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Forest<T> = Tree<T>[];
|
|
||||||
|
|
||||||
export function createLeaf<T>(node: T): Tree<T> {
|
|
||||||
return { node, children: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTree<T>(node: T, children: Forest<T>): Tree<T> {
|
|
||||||
return { node, children };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasChildren<T>(t: Tree<T>): boolean {
|
|
||||||
return t.children.length !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preorder<T>(t: Tree<T>): T[] {
|
|
||||||
return [t.node, ...preorderF(t.children)];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preorderF<T>(ts: Forest<T>): T[] {
|
|
||||||
return concat(ts.map(preorder));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countNodes<T>(t: Tree<T>): number {
|
|
||||||
return preorder(t).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countNodesF<T>(ts: Forest<T>): number {
|
|
||||||
return sum(ts.map(countNodes));
|
|
||||||
}
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import { Note } from '../../../models/entities/note';
|
import { Note } from '../../../models/entities/note';
|
||||||
import { toHtml } from '../../../mfm/to-html';
|
import { toHtml } from '../../../mfm/to-html';
|
||||||
import { parse } from '../../../mfm/parse';
|
|
||||||
|
|
||||||
export default function(note: Note) {
|
export default function(note: Note) {
|
||||||
let html = toHtml(parse(note.text), JSON.parse(note.mentionedRemoteUsers));
|
let html = note.text ? toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)) : null;
|
||||||
if (html == null) html = '<p>.</p>';
|
if (html == null) html = '<p>.</p>';
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import renderImage from './image';
|
import renderImage from './image';
|
||||||
import renderKey from './key';
|
import renderKey from './key';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { ILocalUser } from '../../../models/entities/user';
|
import { ILocalUser } from '../../../models/entities/user';
|
||||||
import { toHtml } from '../../../mfm/to-html';
|
import { toHtml } from '../../../mfm/to-html';
|
||||||
import { parse } from '../../../mfm/parse';
|
|
||||||
import { getEmojis } from './note';
|
import { getEmojis } from './note';
|
||||||
import renderEmoji from './emoji';
|
import renderEmoji from './emoji';
|
||||||
import { IIdentifier } from '../models/identifier';
|
import { IIdentifier } from '../models/identifier';
|
||||||
|
@ -66,7 +66,7 @@ export async function renderPerson(user: ILocalUser) {
|
||||||
url: `${config.url}/@${user.username}`,
|
url: `${config.url}/@${user.username}`,
|
||||||
preferredUsername: user.username,
|
preferredUsername: user.username,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
summary: toHtml(parse(profile.description)),
|
summary: profile.description ? toHtml(mfm.parse(profile.description)) : null,
|
||||||
icon: avatar ? renderImage(avatar) : null,
|
icon: avatar ? renderImage(avatar) : null,
|
||||||
image: banner ? renderImage(banner) : null,
|
image: banner ? renderImage(banner) : null,
|
||||||
tag,
|
tag,
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import $ from 'cafy';
|
import $ from 'cafy';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import { ID } from '@/misc/cafy-id';
|
import { ID } from '@/misc/cafy-id';
|
||||||
import { publishMainStream, publishUserEvent } from '../../../../services/stream';
|
import { publishMainStream, publishUserEvent } from '../../../../services/stream';
|
||||||
import acceptAllFollowRequests from '../../../../services/following/requests/accept-all';
|
import acceptAllFollowRequests from '../../../../services/following/requests/accept-all';
|
||||||
import { publishToFollowers } from '../../../../services/i/update';
|
import { publishToFollowers } from '../../../../services/i/update';
|
||||||
import define from '../../define';
|
import define from '../../define';
|
||||||
import { parse, parsePlain } from '../../../../mfm/parse';
|
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm';
|
||||||
import extractEmojis from '@/misc/extract-emojis';
|
|
||||||
import extractHashtags from '@/misc/extract-hashtags';
|
import extractHashtags from '@/misc/extract-hashtags';
|
||||||
import * as langmap from 'langmap';
|
import * as langmap from 'langmap';
|
||||||
import { updateUsertags } from '../../../../services/update-hashtag';
|
import { updateUsertags } from '../../../../services/update-hashtag';
|
||||||
|
@ -291,13 +291,13 @@ export default define(meta, async (ps, _user, token) => {
|
||||||
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
|
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
|
||||||
|
|
||||||
if (newName != null) {
|
if (newName != null) {
|
||||||
const tokens = parsePlain(newName);
|
const tokens = mfm.parsePlain(newName);
|
||||||
emojis = emojis.concat(extractEmojis(tokens!));
|
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newDescription != null) {
|
if (newDescription != null) {
|
||||||
const tokens = parse(newDescription);
|
const tokens = mfm.parse(newDescription);
|
||||||
emojis = emojis.concat(extractEmojis(tokens!));
|
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
|
||||||
tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32);
|
tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
import es from '../../db/elasticsearch';
|
import es from '../../db/elasticsearch';
|
||||||
import { publishMainStream, publishNotesStream } from '../stream';
|
import { publishMainStream, publishNotesStream } from '../stream';
|
||||||
import DeliverManager from '../../remote/activitypub/deliver-manager';
|
import DeliverManager from '../../remote/activitypub/deliver-manager';
|
||||||
|
@ -5,7 +6,6 @@ import renderNote from '../../remote/activitypub/renderer/note';
|
||||||
import renderCreate from '../../remote/activitypub/renderer/create';
|
import renderCreate from '../../remote/activitypub/renderer/create';
|
||||||
import renderAnnounce from '../../remote/activitypub/renderer/announce';
|
import renderAnnounce from '../../remote/activitypub/renderer/announce';
|
||||||
import { renderActivity } from '../../remote/activitypub/renderer';
|
import { renderActivity } from '../../remote/activitypub/renderer';
|
||||||
import { parse } from '../../mfm/parse';
|
|
||||||
import { resolveUser } from '../../remote/resolve-user';
|
import { resolveUser } from '../../remote/resolve-user';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { updateHashtags } from '../update-hashtag';
|
import { updateHashtags } from '../update-hashtag';
|
||||||
|
@ -13,7 +13,7 @@ import { concat } from '../../prelude/array';
|
||||||
import insertNoteUnread from './unread';
|
import insertNoteUnread from './unread';
|
||||||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
|
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
|
||||||
import extractMentions from '@/misc/extract-mentions';
|
import extractMentions from '@/misc/extract-mentions';
|
||||||
import extractEmojis from '@/misc/extract-emojis';
|
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm';
|
||||||
import extractHashtags from '@/misc/extract-hashtags';
|
import extractHashtags from '@/misc/extract-hashtags';
|
||||||
import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
|
import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
|
||||||
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models';
|
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models';
|
||||||
|
@ -182,17 +182,17 @@ export default async (user: { id: User['id']; username: User['username']; host:
|
||||||
|
|
||||||
// Parse MFM if needed
|
// Parse MFM if needed
|
||||||
if (!tags || !emojis || !mentionedUsers) {
|
if (!tags || !emojis || !mentionedUsers) {
|
||||||
const tokens = data.text ? parse(data.text)! : [];
|
const tokens = data.text ? mfm.parse(data.text)! : [];
|
||||||
const cwTokens = data.cw ? parse(data.cw)! : [];
|
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
|
||||||
const choiceTokens = data.poll && data.poll.choices
|
const choiceTokens = data.poll && data.poll.choices
|
||||||
? concat(data.poll.choices.map(choice => parse(choice)!))
|
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||||
|
|
||||||
tags = data.apHashtags || extractHashtags(combinedTokens);
|
tags = data.apHashtags || extractHashtags(combinedTokens);
|
||||||
|
|
||||||
emojis = data.apEmojis || extractEmojis(combinedTokens);
|
emojis = data.apEmojis || extractCustomEmojisFromMfm(combinedTokens);
|
||||||
|
|
||||||
mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens);
|
mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens);
|
||||||
}
|
}
|
||||||
|
@ -604,7 +604,7 @@ function incNotesCountOfUser(user: { id: User['id']; }) {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractMentionedUsers(user: { host: User['host']; }, tokens: ReturnType<typeof parse>): Promise<User[]> {
|
async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
|
||||||
if (tokens == null) return [];
|
if (tokens == null) return [];
|
||||||
|
|
||||||
const mentions = extractMentions(tokens);
|
const mentions = extractMentions(tokens);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
|
|
||||||
import extractMentions from '../src/misc/extract-mentions';
|
import extractMentions from '../src/misc/extract-mentions';
|
||||||
import { parse } from '../src/mfm/parse';
|
import { parse } from 'mfm-js';
|
||||||
|
|
||||||
describe('Extract mentions', () => {
|
describe('Extract mentions', () => {
|
||||||
it('simple', () => {
|
it('simple', () => {
|
||||||
|
@ -10,17 +10,14 @@ describe('Extract mentions', () => {
|
||||||
assert.deepStrictEqual(mentions, [{
|
assert.deepStrictEqual(mentions, [{
|
||||||
username: 'foo',
|
username: 'foo',
|
||||||
acct: '@foo',
|
acct: '@foo',
|
||||||
canonical: '@foo',
|
|
||||||
host: null
|
host: null
|
||||||
}, {
|
}, {
|
||||||
username: 'bar',
|
username: 'bar',
|
||||||
acct: '@bar',
|
acct: '@bar',
|
||||||
canonical: '@bar',
|
|
||||||
host: null
|
host: null
|
||||||
}, {
|
}, {
|
||||||
username: 'baz',
|
username: 'baz',
|
||||||
acct: '@baz',
|
acct: '@baz',
|
||||||
canonical: '@baz',
|
|
||||||
host: null
|
host: null
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
@ -31,17 +28,14 @@ describe('Extract mentions', () => {
|
||||||
assert.deepStrictEqual(mentions, [{
|
assert.deepStrictEqual(mentions, [{
|
||||||
username: 'foo',
|
username: 'foo',
|
||||||
acct: '@foo',
|
acct: '@foo',
|
||||||
canonical: '@foo',
|
|
||||||
host: null
|
host: null
|
||||||
}, {
|
}, {
|
||||||
username: 'bar',
|
username: 'bar',
|
||||||
acct: '@bar',
|
acct: '@bar',
|
||||||
canonical: '@bar',
|
|
||||||
host: null
|
host: null
|
||||||
}, {
|
}, {
|
||||||
username: 'baz',
|
username: 'baz',
|
||||||
acct: '@baz',
|
acct: '@baz',
|
||||||
canonical: '@baz',
|
|
||||||
host: null
|
host: null
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
1138
test/mfm.ts
1138
test/mfm.ts
File diff suppressed because it is too large
Load diff
17
yarn.lock
17
yarn.lock
|
@ -6608,6 +6608,13 @@ methods@^1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
||||||
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
|
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
|
||||||
|
|
||||||
|
mfm-js@0.12.0:
|
||||||
|
version "0.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mfm-js/-/mfm-js-0.12.0.tgz#47be2fdb18869b9e55576fffcc159d0417c670db"
|
||||||
|
integrity sha512-u0IyIMwzsGsOGmctRXcOdWYsh9LWHKHqX+XCBfPjORX+1DCBdonaO6pryOawns6z16Xvus2yZk0KMMqWt2TotQ==
|
||||||
|
dependencies:
|
||||||
|
twemoji-parser "13.0.x"
|
||||||
|
|
||||||
micromatch@^3.0.4, micromatch@^3.1.4:
|
micromatch@^3.0.4, micromatch@^3.1.4:
|
||||||
version "3.1.10"
|
version "3.1.10"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
|
||||||
|
@ -7494,11 +7501,6 @@ parseurl@^1.3.2:
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||||
|
|
||||||
parsimmon@1.16.0:
|
|
||||||
version "1.16.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/parsimmon/-/parsimmon-1.16.0.tgz#2834e3db645b6a855ab2ea14fbaad10d82867e0f"
|
|
||||||
integrity sha512-tekGDz2Lny27SQ/5DzJdIK0lqsWwZ667SCLFIDCxaZM7VNgQjyKLbaL7FYPKpbjdxNAXFV/mSxkq5D2fnkW4pA==
|
|
||||||
|
|
||||||
pascalcase@^0.1.1:
|
pascalcase@^0.1.1:
|
||||||
version "0.1.1"
|
version "0.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
|
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
|
||||||
|
@ -10521,6 +10523,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
|
||||||
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
||||||
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
|
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
|
||||||
|
|
||||||
|
twemoji-parser@13.0.x:
|
||||||
|
version "13.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.0.0.tgz#bd9d1b98474f1651dc174696b45cabefdfa405af"
|
||||||
|
integrity sha512-zMaGdskpH8yKjT2RSE/HwE340R4Fm+fbie4AaqjDa4H/l07YUmAvxkSfNl6awVWNRRQ0zdzLQ8SAJZuY5MgstQ==
|
||||||
|
|
||||||
type-check@^0.4.0, type-check@~0.4.0:
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||||
|
|
Loading…
Reference in a new issue