Improve MFM parser (#3337)
* wip * wip * Refactor * Refactor * wip * wip * wip * wip * Refactor * Refactor * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Clean up * Update misskey-flavored-markdown.ts * wip * wip * wip * wip * Update parser.ts * wip * Add new test * wip * Add new test * Add new test * wip * Refactor * Update parse.ts * Refactor * Update parser.ts * wip
This commit is contained in:
parent
6e347e4221
commit
79ffbf95db
44 changed files with 1097 additions and 916 deletions
|
@ -65,6 +65,7 @@
|
||||||
"@types/ms": "0.7.30",
|
"@types/ms": "0.7.30",
|
||||||
"@types/node": "10.12.2",
|
"@types/node": "10.12.2",
|
||||||
"@types/oauth": "0.9.1",
|
"@types/oauth": "0.9.1",
|
||||||
|
"@types/parsimmon": "1.10.0",
|
||||||
"@types/portscanner": "2.1.0",
|
"@types/portscanner": "2.1.0",
|
||||||
"@types/pug": "2.0.4",
|
"@types/pug": "2.0.4",
|
||||||
"@types/qrcode": "1.3.0",
|
"@types/qrcode": "1.3.0",
|
||||||
|
@ -170,6 +171,7 @@
|
||||||
"on-build-webpack": "0.1.0",
|
"on-build-webpack": "0.1.0",
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
"parse5": "5.1.0",
|
"parse5": "5.1.0",
|
||||||
|
"parsimmon": "1.12.0",
|
||||||
"portscanner": "2.2.0",
|
"portscanner": "2.2.0",
|
||||||
"postcss-loader": "3.0.0",
|
"postcss-loader": "3.0.0",
|
||||||
"progress-bar-webpack-plugin": "1.11.0",
|
"progress-bar-webpack-plugin": "1.11.0",
|
||||||
|
|
|
@ -41,6 +41,7 @@
|
||||||
if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev';
|
if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev';
|
||||||
if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth';
|
if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth';
|
||||||
if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin';
|
if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin';
|
||||||
|
if (`${url.pathname}/`.startsWith('/test/')) app = 'test';
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// Script version
|
// Script version
|
||||||
|
|
|
@ -17,7 +17,7 @@ import forkit from './forkit.vue';
|
||||||
import acct from './acct.vue';
|
import acct from './acct.vue';
|
||||||
import avatar from './avatar.vue';
|
import avatar from './avatar.vue';
|
||||||
import nav from './nav.vue';
|
import nav from './nav.vue';
|
||||||
import misskeyFlavoredMarkdown from './misskey-flavored-markdown';
|
import misskeyFlavoredMarkdown from './misskey-flavored-markdown.vue';
|
||||||
import poll from './poll.vue';
|
import poll from './poll.vue';
|
||||||
import pollEditor from './poll-editor.vue';
|
import pollEditor from './poll-editor.vue';
|
||||||
import reactionIcon from './reaction-icon.vue';
|
import reactionIcon from './reaction-icon.vue';
|
||||||
|
|
|
@ -1,11 +1,39 @@
|
||||||
import Vue, { VNode } from 'vue';
|
import Vue, { VNode } from 'vue';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
import { Node } from '../../../../../mfm/parser';
|
||||||
import parse from '../../../../../mfm/parse';
|
import parse from '../../../../../mfm/parse';
|
||||||
import getAcct from '../../../../../misc/acct/render';
|
|
||||||
import MkUrl from './url.vue';
|
import MkUrl from './url.vue';
|
||||||
import { concat } from '../../../../../prelude/array';
|
import { concat } from '../../../../../prelude/array';
|
||||||
import MkFormula from './formula.vue';
|
import MkFormula from './formula.vue';
|
||||||
import MkGoogle from './google.vue';
|
import MkGoogle from './google.vue';
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
import syntaxHighlight from '../../../../../mfm/syntax-highlight';
|
||||||
|
|
||||||
|
function getText(tokens: Node[]): string {
|
||||||
|
let text = '';
|
||||||
|
const extract = (tokens: Node[]) => {
|
||||||
|
tokens.filter(x => x.name === 'text').forEach(x => {
|
||||||
|
text += x.props.text;
|
||||||
|
});
|
||||||
|
tokens.filter(x => x.children).forEach(x => {
|
||||||
|
extract(x.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
extract(tokens);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChildrenCount(tokens: Node[]): number {
|
||||||
|
let count = 0;
|
||||||
|
const extract = (tokens: Node[]) => {
|
||||||
|
tokens.filter(x => x.children).forEach(x => {
|
||||||
|
count++;
|
||||||
|
extract(x.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
extract(tokens);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export default Vue.component('misskey-flavored-markdown', {
|
export default Vue.component('misskey-flavored-markdown', {
|
||||||
props: {
|
props: {
|
||||||
|
@ -21,6 +49,10 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
i: {
|
i: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
|
@ -31,23 +63,24 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
},
|
},
|
||||||
|
|
||||||
render(createElement) {
|
render(createElement) {
|
||||||
let ast: any[];
|
if (this.text == null || this.text == '') return;
|
||||||
|
|
||||||
|
let ast: Node[];
|
||||||
|
|
||||||
if (this.ast == null) {
|
if (this.ast == null) {
|
||||||
// Parse text to ast
|
// Parse text to ast
|
||||||
ast = parse(this.text);
|
ast = parse(this.text);
|
||||||
} else {
|
} else {
|
||||||
ast = this.ast as any[];
|
ast = this.ast as Node[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let bigCount = 0;
|
let bigCount = 0;
|
||||||
let motionCount = 0;
|
let motionCount = 0;
|
||||||
|
|
||||||
// Parse ast to DOM
|
const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => {
|
||||||
const els = concat(ast.map((token): VNode[] => {
|
switch (token.name) {
|
||||||
switch (token.type) {
|
|
||||||
case 'text': {
|
case 'text': {
|
||||||
const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
|
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||||
|
|
||||||
if (this.shouldBreak) {
|
if (this.shouldBreak) {
|
||||||
const x = text.split('\n')
|
const x = text.split('\n')
|
||||||
|
@ -60,12 +93,12 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'bold': {
|
case 'bold': {
|
||||||
return [createElement('b', token.bold)];
|
return [createElement('b', genEl(token.children))];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'big': {
|
case 'big': {
|
||||||
bigCount++;
|
bigCount++;
|
||||||
const isLong = length(token.big) > 10;
|
const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5;
|
||||||
const isMany = bigCount > 3;
|
const isMany = bigCount > 3;
|
||||||
return (createElement as any)('strong', {
|
return (createElement as any)('strong', {
|
||||||
attrs: {
|
attrs: {
|
||||||
|
@ -75,12 +108,12 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
name: 'animate-css',
|
name: 'animate-css',
|
||||||
value: { classes: 'tada', iteration: 'infinite' }
|
value: { classes: 'tada', iteration: 'infinite' }
|
||||||
}]
|
}]
|
||||||
}, token.big);
|
}, genEl(token.children));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'motion': {
|
case 'motion': {
|
||||||
motionCount++;
|
motionCount++;
|
||||||
const isLong = length(token.motion) > 10;
|
const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5;
|
||||||
const isMany = motionCount > 3;
|
const isMany = motionCount > 3;
|
||||||
return (createElement as any)('span', {
|
return (createElement as any)('span', {
|
||||||
attrs: {
|
attrs: {
|
||||||
|
@ -90,13 +123,14 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
name: 'animate-css',
|
name: 'animate-css',
|
||||||
value: { classes: 'rubberBand', iteration: 'infinite' }
|
value: { classes: 'rubberBand', iteration: 'infinite' }
|
||||||
}]
|
}]
|
||||||
}, token.motion);
|
}, genEl(token.children));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'url': {
|
case 'url': {
|
||||||
return [createElement(MkUrl, {
|
return [createElement(MkUrl, {
|
||||||
|
key: Math.random(),
|
||||||
props: {
|
props: {
|
||||||
url: token.content,
|
url: token.props.url,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
style: 'color:var(--mfmLink);'
|
style: 'color:var(--mfmLink);'
|
||||||
}
|
}
|
||||||
|
@ -107,75 +141,75 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
return [createElement('a', {
|
return [createElement('a', {
|
||||||
attrs: {
|
attrs: {
|
||||||
class: 'link',
|
class: 'link',
|
||||||
href: token.url,
|
href: token.props.url,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
title: token.url,
|
title: token.props.url,
|
||||||
style: 'color:var(--mfmLink);'
|
style: 'color:var(--mfmLink);'
|
||||||
}
|
}
|
||||||
}, token.title)];
|
}, genEl(token.children))];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'mention': {
|
case 'mention': {
|
||||||
|
const host = token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host;
|
||||||
|
const canonical = host != null ? `@${token.props.username}@${toUnicode(host)}` : `@${token.props.username}`;
|
||||||
return (createElement as any)('router-link', {
|
return (createElement as any)('router-link', {
|
||||||
|
key: Math.random(),
|
||||||
attrs: {
|
attrs: {
|
||||||
to: `/${token.canonical}`,
|
to: `/${canonical}`,
|
||||||
dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token),
|
// TODO
|
||||||
|
//dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token),
|
||||||
style: 'color:var(--mfmMention);'
|
style: 'color:var(--mfmMention);'
|
||||||
},
|
},
|
||||||
directives: [{
|
directives: [{
|
||||||
name: 'user-preview',
|
name: 'user-preview',
|
||||||
value: token.canonical
|
value: canonical
|
||||||
}]
|
}]
|
||||||
}, token.canonical);
|
}, canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'hashtag': {
|
case 'hashtag': {
|
||||||
return [createElement('router-link', {
|
return [createElement('router-link', {
|
||||||
|
key: Math.random(),
|
||||||
attrs: {
|
attrs: {
|
||||||
to: `/tags/${encodeURIComponent(token.hashtag)}`,
|
to: `/tags/${encodeURIComponent(token.props.hashtag)}`,
|
||||||
style: 'color:var(--mfmHashtag);'
|
style: 'color:var(--mfmHashtag);'
|
||||||
}
|
}
|
||||||
}, token.content)];
|
}, `#${token.props.hashtag}`)];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'code': {
|
case 'blockCode': {
|
||||||
return [createElement('pre', {
|
return [createElement('pre', {
|
||||||
class: 'code'
|
class: 'code'
|
||||||
}, [
|
}, [
|
||||||
createElement('code', {
|
createElement('code', {
|
||||||
domProps: {
|
domProps: {
|
||||||
innerHTML: token.html
|
innerHTML: syntaxHighlight(token.props.code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
])];
|
])];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'inline-code': {
|
case 'inlineCode': {
|
||||||
return [createElement('code', {
|
return [createElement('code', {
|
||||||
domProps: {
|
domProps: {
|
||||||
innerHTML: token.html
|
innerHTML: syntaxHighlight(token.props.code)
|
||||||
}
|
}
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'quote': {
|
case 'quote': {
|
||||||
const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
|
|
||||||
|
|
||||||
if (this.shouldBreak) {
|
if (this.shouldBreak) {
|
||||||
const x = text2.split('\n')
|
|
||||||
.map(t => [createElement('span', t), createElement('br')]);
|
|
||||||
x[x.length - 1].pop();
|
|
||||||
return [createElement('div', {
|
return [createElement('div', {
|
||||||
attrs: {
|
attrs: {
|
||||||
class: 'quote'
|
class: 'quote'
|
||||||
}
|
}
|
||||||
}, x)];
|
}, genEl(token.children))];
|
||||||
} else {
|
} else {
|
||||||
return [createElement('span', {
|
return [createElement('span', {
|
||||||
attrs: {
|
attrs: {
|
||||||
class: 'quote'
|
class: 'quote'
|
||||||
}
|
}
|
||||||
}, text2.replace(/\n/g, ' '))];
|
}, genEl(token.children))];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,15 +218,16 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
attrs: {
|
attrs: {
|
||||||
class: 'title'
|
class: 'title'
|
||||||
}
|
}
|
||||||
}, token.title)];
|
}, genEl(token.children))];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'emoji': {
|
case 'emoji': {
|
||||||
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
|
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
|
||||||
return [createElement('mk-emoji', {
|
return [createElement('mk-emoji', {
|
||||||
|
key: Math.random(),
|
||||||
attrs: {
|
attrs: {
|
||||||
emoji: token.emoji,
|
emoji: token.props.emoji,
|
||||||
name: token.name
|
name: token.props.name
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
customEmojis: this.customEmojis || customEmojis
|
customEmojis: this.customEmojis || customEmojis
|
||||||
|
@ -203,8 +238,9 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
case 'math': {
|
case 'math': {
|
||||||
//const MkFormula = () => import('./formula.vue').then(m => m.default);
|
//const MkFormula = () => import('./formula.vue').then(m => m.default);
|
||||||
return [createElement(MkFormula, {
|
return [createElement(MkFormula, {
|
||||||
|
key: Math.random(),
|
||||||
props: {
|
props: {
|
||||||
formula: token.formula
|
formula: token.props.formula
|
||||||
}
|
}
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
@ -212,22 +248,22 @@ export default Vue.component('misskey-flavored-markdown', {
|
||||||
case 'search': {
|
case 'search': {
|
||||||
//const MkGoogle = () => import('./google.vue').then(m => m.default);
|
//const MkGoogle = () => import('./google.vue').then(m => m.default);
|
||||||
return [createElement(MkGoogle, {
|
return [createElement(MkGoogle, {
|
||||||
|
key: Math.random(),
|
||||||
props: {
|
props: {
|
||||||
q: token.query
|
q: token.props.query
|
||||||
}
|
}
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
console.log('unknown ast type:', token.type);
|
console.log('unknown ast type:', token.name);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない
|
// Parse ast to DOM
|
||||||
const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag)));
|
return createElement('span', genEl(ast));
|
||||||
return createElement('span', _els);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<mfm v-bind="$attrs" class="havbbuyv"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue';
|
||||||
|
import Mfm from './mfm';
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
Mfm
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus" scoped>
|
||||||
|
.havbbuyv
|
||||||
|
>>> .title
|
||||||
|
display block
|
||||||
|
margin-bottom 4px
|
||||||
|
padding 4px
|
||||||
|
font-size 90%
|
||||||
|
text-align center
|
||||||
|
background var(--mfmTitleBg)
|
||||||
|
border-radius 4px
|
||||||
|
|
||||||
|
>>> .code
|
||||||
|
margin 8px 0
|
||||||
|
|
||||||
|
>>> .quote
|
||||||
|
margin 8px
|
||||||
|
padding 6px 12px
|
||||||
|
color var(--mfmQuote)
|
||||||
|
border-left solid 3px var(--mfmQuoteLine)
|
||||||
|
|
||||||
|
>>> code
|
||||||
|
padding 4px 8px
|
||||||
|
margin 0 0.5em
|
||||||
|
font-size 80%
|
||||||
|
color #525252
|
||||||
|
background #f8f8f8
|
||||||
|
border-radius 2px
|
||||||
|
|
||||||
|
>>> pre > code
|
||||||
|
padding 16px
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
>>> [data-is-me]:after
|
||||||
|
content "you"
|
||||||
|
padding 0 4px
|
||||||
|
margin-left 4px
|
||||||
|
font-size 80%
|
||||||
|
color var(--primaryForeground)
|
||||||
|
background var(--primary)
|
||||||
|
border-radius 4px
|
||||||
|
|
||||||
|
</style>
|
|
@ -14,7 +14,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<misskey-flavored-markdown v-if="note.text" :text="note.text" :customEmojis="note.emojis"/>
|
<misskey-flavored-markdown v-if="note.text" :text="note.text" :author="note.user" :custom-emojis="note.emojis"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
|
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
|
||||||
<span class="username">@{{ user | acct }}</span>
|
<span class="username">@{{ user | acct }}</span>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
|
<misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
||||||
<span v-if="appearNote.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span>
|
<span v-if="appearNote.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span>
|
||||||
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :customEmojis="appearNote.emojis" />
|
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" />
|
||||||
</div>
|
</div>
|
||||||
<div class="files" v-if="appearNote.files.length > 0">
|
<div class="files" v-if="appearNote.files.length > 0">
|
||||||
<mk-media-list :media-list="appearNote.files" :raw="true"/>
|
<mk-media-list :media-list="appearNote.files" :raw="true"/>
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
||||||
<a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a>
|
<a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a>
|
||||||
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :customEmojis="appearNote.emojis"/>
|
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
|
||||||
<a class="rp" v-if="appearNote.renote">RN:</a>
|
<a class="rp" v-if="appearNote.renote">RN:</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="files" v-if="appearNote.files.length > 0">
|
<div class="files" v-if="appearNote.files.length > 0">
|
||||||
|
@ -223,24 +223,6 @@ export default Vue.extend({
|
||||||
overflow-wrap break-word
|
overflow-wrap break-word
|
||||||
color var(--noteText)
|
color var(--noteText)
|
||||||
|
|
||||||
>>> .title
|
|
||||||
display block
|
|
||||||
margin-bottom 4px
|
|
||||||
padding 4px
|
|
||||||
font-size 90%
|
|
||||||
text-align center
|
|
||||||
background var(--mfmTitleBg)
|
|
||||||
border-radius 4px
|
|
||||||
|
|
||||||
>>> .code
|
|
||||||
margin 8px 0
|
|
||||||
|
|
||||||
>>> .quote
|
|
||||||
margin 8px
|
|
||||||
padding 6px 12px
|
|
||||||
color var(--mfmQuote)
|
|
||||||
border-left solid 3px var(--mfmQuoteLine)
|
|
||||||
|
|
||||||
> .reply
|
> .reply
|
||||||
margin-right 8px
|
margin-right 8px
|
||||||
color var(--text)
|
color var(--text)
|
||||||
|
@ -322,28 +304,3 @@ export default Vue.extend({
|
||||||
opacity 0.7
|
opacity 0.7
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="stylus" module>
|
|
||||||
.text
|
|
||||||
|
|
||||||
code
|
|
||||||
padding 4px 8px
|
|
||||||
margin 0 0.5em
|
|
||||||
font-size 80%
|
|
||||||
color #525252
|
|
||||||
background #f8f8f8
|
|
||||||
border-radius 2px
|
|
||||||
|
|
||||||
pre > code
|
|
||||||
padding 16px
|
|
||||||
margin 0
|
|
||||||
|
|
||||||
[data-is-me]:after
|
|
||||||
content "you"
|
|
||||||
padding 0 4px
|
|
||||||
margin-left 4px
|
|
||||||
font-size 80%
|
|
||||||
color var(--primaryForeground)
|
|
||||||
background var(--primary)
|
|
||||||
border-radius 4px
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<span v-if="note.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
<span v-if="note.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
|
||||||
<span v-if="note.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span>
|
<span v-if="note.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span>
|
||||||
<a class="reply" v-if="note.replyId"><fa icon="reply"/></a>
|
<a class="reply" v-if="note.replyId"><fa icon="reply"/></a>
|
||||||
<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :custom-emojis="note.emojis"/>
|
<misskey-flavored-markdown v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/>
|
||||||
<a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a>
|
<a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a>
|
||||||
</div>
|
</div>
|
||||||
<details v-if="note.files.length > 0">
|
<details v-if="note.files.length > 0">
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
|
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
|
||||||
<span class="username">@{{ user | acct }}</span>
|
<span class="username">@{{ user | acct }}</span>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
|
<misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
</header>
|
</header>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
|
<misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="counts">
|
<div class="counts">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
|
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
|
<misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span>
|
<span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span>
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
||||||
<span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
|
<span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
|
||||||
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :customEmojis="appearNote.emojis"/>
|
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="files" v-if="appearNote.files.length > 0">
|
<div class="files" v-if="appearNote.files.length > 0">
|
||||||
<mk-media-list :media-list="appearNote.files" :raw="true"/>
|
<mk-media-list :media-list="appearNote.files" :raw="true"/>
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
||||||
<a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a>
|
<a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a>
|
||||||
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :custom-emojis="appearNote.emojis"/>
|
<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
|
||||||
<a class="rp" v-if="appearNote.renote != null">RN:</a>
|
<a class="rp" v-if="appearNote.renote != null">RN:</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="files" v-if="appearNote.files.length > 0">
|
<div class="files" v-if="appearNote.files.length > 0">
|
||||||
|
@ -188,24 +188,6 @@ export default Vue.extend({
|
||||||
overflow-wrap break-word
|
overflow-wrap break-word
|
||||||
color var(--noteText)
|
color var(--noteText)
|
||||||
|
|
||||||
>>> .title
|
|
||||||
display block
|
|
||||||
margin-bottom 4px
|
|
||||||
padding 4px
|
|
||||||
font-size 90%
|
|
||||||
text-align center
|
|
||||||
background var(--mfmTitleBg)
|
|
||||||
border-radius 4px
|
|
||||||
|
|
||||||
>>> .code
|
|
||||||
margin 8px 0
|
|
||||||
|
|
||||||
>>> .quote
|
|
||||||
margin 8px
|
|
||||||
padding 6px 12px
|
|
||||||
color var(--mfmQuote)
|
|
||||||
border-left solid 3px var(--mfmQuoteLine)
|
|
||||||
|
|
||||||
> .reply
|
> .reply
|
||||||
margin-right 8px
|
margin-right 8px
|
||||||
color var(--noteText)
|
color var(--noteText)
|
||||||
|
@ -215,15 +197,6 @@ export default Vue.extend({
|
||||||
font-style oblique
|
font-style oblique
|
||||||
color var(--renoteText)
|
color var(--renoteText)
|
||||||
|
|
||||||
[data-is-me]:after
|
|
||||||
content "you"
|
|
||||||
padding 0 4px
|
|
||||||
margin-left 4px
|
|
||||||
font-size 80%
|
|
||||||
color var(--primaryForeground)
|
|
||||||
background var(--primary)
|
|
||||||
border-radius 4px
|
|
||||||
|
|
||||||
.mk-url-preview
|
.mk-url-preview
|
||||||
margin-top 8px
|
margin-top 8px
|
||||||
|
|
||||||
|
@ -289,18 +262,3 @@ export default Vue.extend({
|
||||||
opacity 0.7
|
opacity 0.7
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="stylus" module>
|
|
||||||
.text
|
|
||||||
code
|
|
||||||
padding 4px 8px
|
|
||||||
margin 0 0.5em
|
|
||||||
font-size 80%
|
|
||||||
color #525252
|
|
||||||
background #f8f8f8
|
|
||||||
border-radius 2px
|
|
||||||
|
|
||||||
pre > code
|
|
||||||
padding 16px
|
|
||||||
margin 0
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
<span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
|
||||||
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
|
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
|
||||||
<a class="reply" v-if="note.replyId"><fa icon="reply"/></a>
|
<a class="reply" v-if="note.replyId"><fa icon="reply"/></a>
|
||||||
<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :custom-emojis="note.emojis"/>
|
<misskey-flavored-markdown v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/>
|
||||||
<a class="rp" v-if="note.renoteId">RN: ...</a>
|
<a class="rp" v-if="note.renoteId">RN: ...</a>
|
||||||
</div>
|
</div>
|
||||||
<details v-if="note.files.length > 0">
|
<details v-if="note.files.length > 0">
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
|
<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
|
<misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<p class="location" v-if="user.host === null && user.profile.location">
|
<p class="location" v-if="user.host === null && user.profile.location">
|
||||||
|
|
23
src/client/app/test/script.ts
Normal file
23
src/client/app/test/script.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import VueRouter from 'vue-router';
|
||||||
|
|
||||||
|
// Style
|
||||||
|
import './style.styl';
|
||||||
|
|
||||||
|
import init from '../init';
|
||||||
|
import Index from './views/index.vue';
|
||||||
|
|
||||||
|
init(launch => {
|
||||||
|
document.title = 'Misskey';
|
||||||
|
|
||||||
|
// Init router
|
||||||
|
const router = new VueRouter({
|
||||||
|
mode: 'history',
|
||||||
|
base: '/test/',
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: Index },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Launch the app
|
||||||
|
launch(router);
|
||||||
|
});
|
6
src/client/app/test/style.styl
Normal file
6
src/client/app/test/style.styl
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
@import "../app"
|
||||||
|
@import "../reset"
|
||||||
|
|
||||||
|
html
|
||||||
|
height 100%
|
||||||
|
background var(--bg)
|
34
src/client/app/test/views/index.vue
Normal file
34
src/client/app/test/views/index.vue
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<ui-card>
|
||||||
|
<div slot="title">MFM Playground</div>
|
||||||
|
<section class="fit-top">
|
||||||
|
<ui-textarea v-model="mfm">
|
||||||
|
<span>MFM</span>
|
||||||
|
</ui-textarea>
|
||||||
|
<div>
|
||||||
|
<misskey-flavored-markdown :text="mfm" :i="$store.state.i"/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ui-card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue';
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mfm: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus" scoped>
|
||||||
|
main
|
||||||
|
max-width 700px
|
||||||
|
margin 0 auto
|
||||||
|
|
||||||
|
</style>
|
232
src/mfm/html.ts
232
src/mfm/html.ts
|
@ -1,127 +1,135 @@
|
||||||
const { lib: emojilib } = require('emojilib');
|
|
||||||
const jsdom = require('jsdom');
|
const jsdom = require('jsdom');
|
||||||
const { JSDOM } = jsdom;
|
const { JSDOM } = jsdom;
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { INote } from '../models/note';
|
import { INote } from '../models/note';
|
||||||
import { TextElement } from './parse';
|
import { Node } from './parser';
|
||||||
import { intersperse } from '../prelude/array';
|
import { intersperse } from '../prelude/array';
|
||||||
|
|
||||||
const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = {
|
export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => {
|
||||||
bold({ document }, { bold }) {
|
|
||||||
const b = document.createElement('b');
|
|
||||||
b.textContent = bold;
|
|
||||||
document.body.appendChild(b);
|
|
||||||
},
|
|
||||||
|
|
||||||
big({ document }, { big }) {
|
|
||||||
const b = document.createElement('strong');
|
|
||||||
b.textContent = big;
|
|
||||||
document.body.appendChild(b);
|
|
||||||
},
|
|
||||||
|
|
||||||
motion({ document }, { big }) {
|
|
||||||
const b = document.createElement('strong');
|
|
||||||
b.textContent = big;
|
|
||||||
document.body.appendChild(b);
|
|
||||||
},
|
|
||||||
|
|
||||||
code({ document }, { code }) {
|
|
||||||
const pre = document.createElement('pre');
|
|
||||||
const inner = document.createElement('code');
|
|
||||||
inner.innerHTML = code;
|
|
||||||
pre.appendChild(inner);
|
|
||||||
document.body.appendChild(pre);
|
|
||||||
},
|
|
||||||
|
|
||||||
emoji({ document }, { content, emoji }) {
|
|
||||||
const found = emojilib[emoji];
|
|
||||||
const node = document.createTextNode(found ? found.char : content);
|
|
||||||
document.body.appendChild(node);
|
|
||||||
},
|
|
||||||
|
|
||||||
hashtag({ document }, { hashtag }) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = `${config.url}/tags/${hashtag}`;
|
|
||||||
a.textContent = `#${hashtag}`;
|
|
||||||
a.setAttribute('rel', 'tag');
|
|
||||||
document.body.appendChild(a);
|
|
||||||
},
|
|
||||||
|
|
||||||
'inline-code'({ document }, { code }) {
|
|
||||||
const element = document.createElement('code');
|
|
||||||
element.textContent = code;
|
|
||||||
document.body.appendChild(element);
|
|
||||||
},
|
|
||||||
|
|
||||||
math({ document }, { formula }) {
|
|
||||||
const element = document.createElement('code');
|
|
||||||
element.textContent = formula;
|
|
||||||
document.body.appendChild(element);
|
|
||||||
},
|
|
||||||
|
|
||||||
link({ document }, { url, title }) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.textContent = title;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
},
|
|
||||||
|
|
||||||
mention({ document }, { content, username, host }, mentionedRemoteUsers) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
|
||||||
a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${content}`;
|
|
||||||
a.textContent = content;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
},
|
|
||||||
|
|
||||||
quote({ document }, { quote }) {
|
|
||||||
const blockquote = document.createElement('blockquote');
|
|
||||||
blockquote.textContent = quote;
|
|
||||||
document.body.appendChild(blockquote);
|
|
||||||
},
|
|
||||||
|
|
||||||
title({ document }, { content }) {
|
|
||||||
const h1 = document.createElement('h1');
|
|
||||||
h1.textContent = content;
|
|
||||||
document.body.appendChild(h1);
|
|
||||||
},
|
|
||||||
|
|
||||||
text({ document }, { content }) {
|
|
||||||
const nodes = (content as string).split('\n').map(x => document.createTextNode(x));
|
|
||||||
for (const x of intersperse('br', nodes)) {
|
|
||||||
if (x === 'br') {
|
|
||||||
document.body.appendChild(document.createElement('br'));
|
|
||||||
} else {
|
|
||||||
document.body.appendChild(x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
url({ document }, { url }) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.textContent = url;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
},
|
|
||||||
|
|
||||||
search({ document }, { content, query }) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = `https://www.google.com/?#q=${query}`;
|
|
||||||
a.textContent = content;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (tokens: TextElement[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => {
|
|
||||||
if (tokens == null) {
|
if (tokens == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { window } = new JSDOM('');
|
const { window } = new JSDOM('');
|
||||||
|
|
||||||
for (const token of tokens) {
|
const doc = window.document;
|
||||||
handlers[token.type](window, token, mentionedRemoteUsers);
|
|
||||||
|
function dive(nodes: Node[]): any[] {
|
||||||
|
return nodes.map(n => handlers[n.name](n));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<p>${window.document.body.innerHTML}</p>`;
|
const handlers: { [key: string]: (token: Node) => any } = {
|
||||||
|
bold(token) {
|
||||||
|
const el = doc.createElement('b');
|
||||||
|
dive(token.children).forEach(child => el.appendChild(child));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
big(token) {
|
||||||
|
const el = doc.createElement('strong');
|
||||||
|
dive(token.children).forEach(child => el.appendChild(child));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
motion(token) {
|
||||||
|
const el = doc.createElement('i');
|
||||||
|
dive(token.children).forEach(child => el.appendChild(child));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
blockCode(token) {
|
||||||
|
const pre = doc.createElement('pre');
|
||||||
|
const inner = doc.createElement('code');
|
||||||
|
inner.innerHTML = token.props.code;
|
||||||
|
pre.appendChild(inner);
|
||||||
|
return pre;
|
||||||
|
},
|
||||||
|
|
||||||
|
emoji(token) {
|
||||||
|
return doc.createTextNode(token.props.emoji ? token.props.emoji : `:${token.props.name}:`);
|
||||||
|
},
|
||||||
|
|
||||||
|
hashtag(token) {
|
||||||
|
const a = doc.createElement('a');
|
||||||
|
a.href = `${config.url}/tags/${token.props.hashtag}`;
|
||||||
|
a.textContent = `#${token.props.hashtag}`;
|
||||||
|
a.setAttribute('rel', 'tag');
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
|
||||||
|
inlineCode(token) {
|
||||||
|
const el = doc.createElement('code');
|
||||||
|
el.textContent = token.props.code;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
math(token) {
|
||||||
|
const el = doc.createElement('code');
|
||||||
|
el.textContent = token.props.formula;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
link(token) {
|
||||||
|
const a = doc.createElement('a');
|
||||||
|
a.href = token.props.url;
|
||||||
|
dive(token.children).forEach(child => a.appendChild(child));
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
|
||||||
|
mention(token) {
|
||||||
|
const a = doc.createElement('a');
|
||||||
|
const { username, host, acct } = token.props;
|
||||||
|
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||||
|
a.href = remoteUserInfo ? remoteUserInfo.uri : `${config.url}/${acct}`;
|
||||||
|
a.textContent = acct;
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
|
||||||
|
quote(token) {
|
||||||
|
const el = doc.createElement('blockquote');
|
||||||
|
dive(token.children).forEach(child => el.appendChild(child));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
title(token) {
|
||||||
|
const el = doc.createElement('h1');
|
||||||
|
dive(token.children).forEach(child => el.appendChild(child));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
text(token) {
|
||||||
|
const el = doc.createElement('span');
|
||||||
|
const nodes = (token.props.text as string).split('\n').map(x => doc.createTextNode(x));
|
||||||
|
|
||||||
|
for (const x of intersperse('br', nodes)) {
|
||||||
|
if (x === 'br') {
|
||||||
|
el.appendChild(doc.createElement('br'));
|
||||||
|
} else {
|
||||||
|
el.appendChild(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
url(token) {
|
||||||
|
const a = doc.createElement('a');
|
||||||
|
a.href = token.props.url;
|
||||||
|
a.textContent = token.props.url;
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
|
||||||
|
search(token) {
|
||||||
|
const a = doc.createElement('a');
|
||||||
|
a.href = `https://www.google.com/?#q=${token.props.query}`;
|
||||||
|
a.textContent = token.props.content;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dive(tokens).forEach(x => {
|
||||||
|
doc.body.appendChild(x);
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<p>${doc.body.innerHTML}</p>`;
|
||||||
};
|
};
|
||||||
|
|
81
src/mfm/parse.ts
Normal file
81
src/mfm/parse.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import parser, { Node } from './parser';
|
||||||
|
import * as A from '../prelude/array';
|
||||||
|
import * as S from '../prelude/string';
|
||||||
|
|
||||||
|
export default (source: string): Node[] => {
|
||||||
|
if (source == null || source == '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes: Node[] = parser.root.tryParse(source);
|
||||||
|
|
||||||
|
const combineText = (es: Node[]): Node =>
|
||||||
|
({ name: 'text', props: { text: S.concat(es.map(e => e.props.text)) } });
|
||||||
|
|
||||||
|
const concatText = (nodes: Node[]): Node[] =>
|
||||||
|
A.concat(A.groupOn(x => x.name, nodes).map(es =>
|
||||||
|
es[0].name === 'text' ? [combineText(es)] : es
|
||||||
|
));
|
||||||
|
|
||||||
|
const concatTextRecursive = (es: Node[]): void =>
|
||||||
|
es.filter(x => x.children).forEach(x => {
|
||||||
|
x.children = concatText(x.children);
|
||||||
|
concatTextRecursive(x.children);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes = concatText(nodes);
|
||||||
|
concatTextRecursive(nodes);
|
||||||
|
|
||||||
|
function getBeforeTextNode(node: Node): Node {
|
||||||
|
if (node == null) return null;
|
||||||
|
if (node.name == 'text') return node;
|
||||||
|
if (node.children) return getBeforeTextNode(node.children[node.children.length - 1]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAfterTextNode(node: Node): Node {
|
||||||
|
if (node == null) return null;
|
||||||
|
if (node.name == 'text') return node;
|
||||||
|
if (node.children) return getBeforeTextNode(node.children[0]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockNode(node: Node): boolean {
|
||||||
|
return ['blockCode', 'quote', 'title'].includes(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ブロック要素の前後にある改行を削除します(ブロック要素自体が改行の役割も果たすため、余計に改行されてしまうため)
|
||||||
|
* @param nodes
|
||||||
|
*/
|
||||||
|
const removeNeedlessLineBreaks = (nodes: Node[]) => {
|
||||||
|
nodes.forEach((node, i) => {
|
||||||
|
if (node.children) removeNeedlessLineBreaks(node.children);
|
||||||
|
if (isBlockNode(node)) {
|
||||||
|
const before = getBeforeTextNode(nodes[i - 1]);
|
||||||
|
const after = getAfterTextNode(nodes[i + 1]);
|
||||||
|
if (before && before.props.text.endsWith('\n')) {
|
||||||
|
before.props.text = before.props.text.substring(0, before.props.text.length - 1);
|
||||||
|
}
|
||||||
|
if (after && after.props.text.startsWith('\n')) {
|
||||||
|
after.props.text = after.props.text.substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEmptyTextNodes = (nodes: Node[]) => {
|
||||||
|
nodes.forEach(n => {
|
||||||
|
if (n.children) {
|
||||||
|
n.children = removeEmptyTextNodes(n.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nodes.filter(n => !(n.name == 'text' && n.props.text == ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
removeNeedlessLineBreaks(nodes);
|
||||||
|
|
||||||
|
nodes = removeEmptyTextNodes(nodes);
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
};
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Big
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementBig = {
|
|
||||||
type: 'big';
|
|
||||||
content: string;
|
|
||||||
big: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^\*\*\*(.+?)\*\*\*/);
|
|
||||||
if (!match) return null;
|
|
||||||
const big = match[0];
|
|
||||||
return {
|
|
||||||
type: 'big',
|
|
||||||
content: big,
|
|
||||||
big: match[1]
|
|
||||||
} as TextElementBig;
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Bold
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementBold = {
|
|
||||||
type: 'bold';
|
|
||||||
content: string;
|
|
||||||
bold: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^\*\*(.+?)\*\*/);
|
|
||||||
if (!match) return null;
|
|
||||||
const bold = match[0];
|
|
||||||
return {
|
|
||||||
type: 'bold',
|
|
||||||
content: bold,
|
|
||||||
bold: match[1]
|
|
||||||
} as TextElementBold;
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* Code (block)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import genHtml from '../core/syntax-highlighter';
|
|
||||||
|
|
||||||
export type TextElementCode = {
|
|
||||||
type: 'code';
|
|
||||||
content: string;
|
|
||||||
code: string;
|
|
||||||
html: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^```([\s\S]+?)```/);
|
|
||||||
if (!match) return null;
|
|
||||||
const code = match[0];
|
|
||||||
return {
|
|
||||||
type: 'code',
|
|
||||||
content: code,
|
|
||||||
code: match[1],
|
|
||||||
html: genHtml(match[1].trim())
|
|
||||||
} as TextElementCode;
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
|
@ -1,33 +0,0 @@
|
||||||
/**
|
|
||||||
* Emoji
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { emojiRegex } from "./emoji.regex";
|
|
||||||
|
|
||||||
export type TextElementEmoji = {
|
|
||||||
type: 'emoji';
|
|
||||||
content: string;
|
|
||||||
emoji?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const name = text.match(/^:([a-zA-Z0-9+_-]+):/);
|
|
||||||
if (name) {
|
|
||||||
return {
|
|
||||||
type: 'emoji',
|
|
||||||
content: name[0],
|
|
||||||
name: name[1]
|
|
||||||
} as TextElementEmoji;
|
|
||||||
}
|
|
||||||
const unicode = text.match(emojiRegex);
|
|
||||||
if (unicode) {
|
|
||||||
const [content] = unicode;
|
|
||||||
return {
|
|
||||||
type: 'emoji',
|
|
||||||
content,
|
|
||||||
emoji: content
|
|
||||||
} as TextElementEmoji;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Hashtag
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementHashtag = {
|
|
||||||
type: 'hashtag';
|
|
||||||
content: string;
|
|
||||||
hashtag: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string, before: string) {
|
|
||||||
const isBegin = before == '';
|
|
||||||
|
|
||||||
if (!(/^\s#[^\s\.,!\?#]+/.test(text) || (isBegin && /^#[^\s\.,!\?#]+/.test(text)))) return null;
|
|
||||||
const isHead = text.startsWith('#');
|
|
||||||
const hashtag = text.match(/^\s?#[^\s\.,!\?#]+/)[0];
|
|
||||||
const res: any[] = !isHead ? [{
|
|
||||||
type: 'text',
|
|
||||||
content: text[0]
|
|
||||||
}] : [];
|
|
||||||
res.push({
|
|
||||||
type: 'hashtag',
|
|
||||||
content: isHead ? hashtag : hashtag.substr(1),
|
|
||||||
hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2)
|
|
||||||
});
|
|
||||||
return res as TextElementHashtag[];
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* Code (inline)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import genHtml from '../core/syntax-highlighter';
|
|
||||||
|
|
||||||
export type TextElementInlineCode = {
|
|
||||||
type: 'inline-code';
|
|
||||||
content: string;
|
|
||||||
code: string;
|
|
||||||
html: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^`(.+?)`/);
|
|
||||||
if (!match) return null;
|
|
||||||
if (match[1].includes('´')) return null;
|
|
||||||
const code = match[0];
|
|
||||||
return {
|
|
||||||
type: 'inline-code',
|
|
||||||
content: code,
|
|
||||||
code: match[1],
|
|
||||||
html: genHtml(match[1])
|
|
||||||
} as TextElementInlineCode;
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Link
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementLink = {
|
|
||||||
type: 'link';
|
|
||||||
content: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
silent: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/);
|
|
||||||
if (!match) return null;
|
|
||||||
const silent = text.startsWith('?');
|
|
||||||
const link = match[0];
|
|
||||||
const title = match[1];
|
|
||||||
const url = match[2];
|
|
||||||
return {
|
|
||||||
type: 'link',
|
|
||||||
content: link,
|
|
||||||
title: title,
|
|
||||||
url: url,
|
|
||||||
silent: silent
|
|
||||||
} as TextElementLink;
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Math
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementMath = {
|
|
||||||
type: 'math';
|
|
||||||
content: string;
|
|
||||||
formula: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^\\\((.+?)\\\)/);
|
|
||||||
if (!match) return null;
|
|
||||||
const math = match[0];
|
|
||||||
return {
|
|
||||||
type: 'math',
|
|
||||||
content: math,
|
|
||||||
formula: match[1]
|
|
||||||
} as TextElementMath;
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
/**
|
|
||||||
* Mention
|
|
||||||
*/
|
|
||||||
import parseAcct from '../../../misc/acct/parse';
|
|
||||||
import { toUnicode } from 'punycode';
|
|
||||||
|
|
||||||
export type TextElementMention = {
|
|
||||||
type: 'mention';
|
|
||||||
content: string;
|
|
||||||
canonical: string;
|
|
||||||
username: string;
|
|
||||||
host: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string, before: string) {
|
|
||||||
const match = text.match(/^@[a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9])?/i);
|
|
||||||
if (!match) return null;
|
|
||||||
if (/[a-zA-Z0-9]$/.test(before)) return null;
|
|
||||||
const mention = match[0];
|
|
||||||
const { username, host } = parseAcct(mention.substr(1));
|
|
||||||
const canonical = host != null ? `@${username}@${toUnicode(host)}` : mention;
|
|
||||||
return {
|
|
||||||
type: 'mention',
|
|
||||||
content: mention,
|
|
||||||
canonical,
|
|
||||||
username,
|
|
||||||
host
|
|
||||||
} as TextElementMention;
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Motion
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementMotion = {
|
|
||||||
type: 'motion';
|
|
||||||
content: string;
|
|
||||||
motion: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^\(\(\((.+?)\)\)\)/) || text.match(/^<motion>(.+?)<\/motion>/);
|
|
||||||
if (!match) return null;
|
|
||||||
const motion = match[0];
|
|
||||||
return {
|
|
||||||
type: 'motion',
|
|
||||||
content: motion,
|
|
||||||
motion: match[1]
|
|
||||||
} as TextElementMotion;
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Quoted text
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementQuote = {
|
|
||||||
type: 'quote';
|
|
||||||
content: string;
|
|
||||||
quote: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string, before: string) {
|
|
||||||
const isBegin = before == '';
|
|
||||||
|
|
||||||
const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) ||
|
|
||||||
(isBegin ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null);
|
|
||||||
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const quote = match[1]
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.replace(/^>+/g, '').trim())
|
|
||||||
.join('\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'quote',
|
|
||||||
content: match[0],
|
|
||||||
quote: quote,
|
|
||||||
} as TextElementQuote;
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
/**
|
|
||||||
* Search
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementSearch = {
|
|
||||||
type: 'search';
|
|
||||||
content: string;
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string) {
|
|
||||||
const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i);
|
|
||||||
if (!match) return null;
|
|
||||||
return {
|
|
||||||
type: 'search',
|
|
||||||
content: match[0],
|
|
||||||
query: match[1]
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
/**
|
|
||||||
* Title
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementTitle = {
|
|
||||||
type: 'title';
|
|
||||||
content: string;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string, before: string) {
|
|
||||||
const isBegin = before == '';
|
|
||||||
|
|
||||||
const match = isBegin ? text.match(/^(【|\[)(.+?)(】|])\n/) : text.match(/^\n(【|\[)(.+?)(】|])\n/);
|
|
||||||
if (!match) return null;
|
|
||||||
return {
|
|
||||||
type: 'title',
|
|
||||||
content: match[0],
|
|
||||||
title: match[2]
|
|
||||||
} as TextElementTitle;
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
/**
|
|
||||||
* URL
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TextElementUrl = {
|
|
||||||
type: 'url';
|
|
||||||
content: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function(text: string, before: string) {
|
|
||||||
const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/);
|
|
||||||
if (!match) return null;
|
|
||||||
let url = match[0];
|
|
||||||
if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.'));
|
|
||||||
if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(','));
|
|
||||||
if (url.endsWith(')') && before.endsWith('(')) url = url.substr(0, url.lastIndexOf(')'));
|
|
||||||
return {
|
|
||||||
type: 'url',
|
|
||||||
content: url,
|
|
||||||
url: url
|
|
||||||
} as TextElementUrl;
|
|
||||||
}
|
|
|
@ -1,100 +0,0 @@
|
||||||
/**
|
|
||||||
* Misskey Text Analyzer
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { TextElementBold } from './elements/bold';
|
|
||||||
import { TextElementBig } from './elements/big';
|
|
||||||
import { TextElementCode } from './elements/code';
|
|
||||||
import { TextElementEmoji } from './elements/emoji';
|
|
||||||
import { TextElementHashtag } from './elements/hashtag';
|
|
||||||
import { TextElementInlineCode } from './elements/inline-code';
|
|
||||||
import { TextElementMath } from './elements/math';
|
|
||||||
import { TextElementLink } from './elements/link';
|
|
||||||
import { TextElementMention } from './elements/mention';
|
|
||||||
import { TextElementQuote } from './elements/quote';
|
|
||||||
import { TextElementSearch } from './elements/search';
|
|
||||||
import { TextElementTitle } from './elements/title';
|
|
||||||
import { TextElementUrl } from './elements/url';
|
|
||||||
import { TextElementMotion } from './elements/motion';
|
|
||||||
import { groupOn } from '../../prelude/array';
|
|
||||||
import * as A from '../../prelude/array';
|
|
||||||
import * as S from '../../prelude/string';
|
|
||||||
|
|
||||||
const elements = [
|
|
||||||
require('./elements/big'),
|
|
||||||
require('./elements/bold'),
|
|
||||||
require('./elements/title'),
|
|
||||||
require('./elements/url'),
|
|
||||||
require('./elements/link'),
|
|
||||||
require('./elements/mention'),
|
|
||||||
require('./elements/hashtag'),
|
|
||||||
require('./elements/code'),
|
|
||||||
require('./elements/inline-code'),
|
|
||||||
require('./elements/math'),
|
|
||||||
require('./elements/quote'),
|
|
||||||
require('./elements/emoji'),
|
|
||||||
require('./elements/search'),
|
|
||||||
require('./elements/motion')
|
|
||||||
].map(element => element.default as TextElementProcessor);
|
|
||||||
|
|
||||||
export type TextElement = { type: 'text', content: string }
|
|
||||||
| TextElementBold
|
|
||||||
| TextElementBig
|
|
||||||
| TextElementCode
|
|
||||||
| TextElementEmoji
|
|
||||||
| TextElementHashtag
|
|
||||||
| TextElementInlineCode
|
|
||||||
| TextElementMath
|
|
||||||
| TextElementLink
|
|
||||||
| TextElementMention
|
|
||||||
| TextElementQuote
|
|
||||||
| TextElementSearch
|
|
||||||
| TextElementTitle
|
|
||||||
| TextElementUrl
|
|
||||||
| TextElementMotion;
|
|
||||||
export type TextElementProcessor = (text: string, before: string) => TextElement | TextElement[];
|
|
||||||
|
|
||||||
export default (source: string): TextElement[] => {
|
|
||||||
if (source == null || source == '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens: TextElement[] = [];
|
|
||||||
|
|
||||||
function push(token: TextElement) {
|
|
||||||
if (token != null) {
|
|
||||||
tokens.push(token);
|
|
||||||
source = source.substr(token.content.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// パース
|
|
||||||
while (source != '') {
|
|
||||||
const parsed = elements.some(el => {
|
|
||||||
let _tokens = el(source, tokens.map(token => token.content).join(''));
|
|
||||||
if (_tokens) {
|
|
||||||
if (!Array.isArray(_tokens)) {
|
|
||||||
_tokens = [_tokens];
|
|
||||||
}
|
|
||||||
_tokens.forEach(push);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
push({
|
|
||||||
type: 'text',
|
|
||||||
content: source[0]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const combineText = (es: TextElement[]): TextElement =>
|
|
||||||
({ type: 'text', content: S.concat(es.map(e => e.content)) });
|
|
||||||
|
|
||||||
return A.concat(groupOn(x => x.type, tokens).map(es =>
|
|
||||||
es[0].type === 'text' ? [combineText(es)] : es
|
|
||||||
));
|
|
||||||
};
|
|
256
src/mfm/parser.ts
Normal file
256
src/mfm/parser.ts
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,4 +1,4 @@
|
||||||
import { capitalize, toUpperCase } from "../../../prelude/string";
|
import { capitalize, toUpperCase } from "../prelude/string";
|
||||||
|
|
||||||
function escape(text: string) {
|
function escape(text: string) {
|
||||||
return text
|
return text
|
||||||
|
@ -308,7 +308,7 @@ const elements: Element[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
// specify lang is todo
|
// specify lang is todo
|
||||||
export default (source: string, lang?: string) => {
|
export default (source: string, lang?: string): string => {
|
||||||
let code = source;
|
let code = source;
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
|
@ -7,7 +7,6 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file';
|
||||||
import Note, { INote } from '../../../models/note';
|
import Note, { INote } from '../../../models/note';
|
||||||
import User from '../../../models/user';
|
import User from '../../../models/user';
|
||||||
import toHtml from '../misc/get-note-html';
|
import toHtml from '../misc/get-note-html';
|
||||||
import parseMfm from '../../../mfm/parse';
|
|
||||||
import Emoji, { IEmoji } from '../../../models/emoji';
|
import Emoji, { IEmoji } from '../../../models/emoji';
|
||||||
|
|
||||||
export default async function renderNote(note: INote, dive = true): Promise<any> {
|
export default async function renderNote(note: INote, dive = true): Promise<any> {
|
||||||
|
@ -95,17 +94,6 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
|
||||||
text += `\n\nRE: ${url}`;
|
text += `\n\nRE: ${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 省略されたメンションのホストを復元する
|
|
||||||
if (text != null && text != '') {
|
|
||||||
text = parseMfm(text).map(x => {
|
|
||||||
if (x.type == 'mention' && x.host == null) {
|
|
||||||
return `${x.content}@${config.host}`;
|
|
||||||
} else {
|
|
||||||
return x.content;
|
|
||||||
}
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = toHtml(Object.assign({}, note, { text }));
|
const content = toHtml(Object.assign({}, note, { text }));
|
||||||
|
|
||||||
const emojis = await getEmojis(note.emojis);
|
const emojis = await getEmojis(note.emojis);
|
||||||
|
|
|
@ -21,8 +21,6 @@ import Meta from '../../models/meta';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import registerHashtag from '../register-hashtag';
|
import registerHashtag from '../register-hashtag';
|
||||||
import isQuote from '../../misc/is-quote';
|
import isQuote from '../../misc/is-quote';
|
||||||
import { TextElementMention } from '../../mfm/parse/elements/mention';
|
|
||||||
import { TextElementHashtag } from '../../mfm/parse/elements/hashtag';
|
|
||||||
import notesChart from '../../chart/notes';
|
import notesChart from '../../chart/notes';
|
||||||
import perUserNotesChart from '../../chart/per-user-notes';
|
import perUserNotesChart from '../../chart/per-user-notes';
|
||||||
|
|
||||||
|
@ -30,7 +28,7 @@ import { erase, unique } from '../../prelude/array';
|
||||||
import insertNoteUnread from './unread';
|
import insertNoteUnread from './unread';
|
||||||
import registerInstance from '../register-instance';
|
import registerInstance from '../register-instance';
|
||||||
import Instance from '../../models/instance';
|
import Instance from '../../models/instance';
|
||||||
import { TextElementEmoji } from '../../mfm/parse/elements/emoji';
|
import { Node } from '../../mfm/parser';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
@ -162,7 +160,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
|
||||||
|
|
||||||
const emojis = extractEmojis(tokens);
|
const emojis = extractEmojis(tokens);
|
||||||
|
|
||||||
const mentionedUsers = data.apMentions || await extractMentionedUsers(tokens);
|
const mentionedUsers = data.apMentions || await extractMentionedUsers(user, tokens);
|
||||||
|
|
||||||
if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) {
|
if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) {
|
||||||
mentionedUsers.push(await User.findOne({ _id: data.reply.userId }));
|
mentionedUsers.push(await User.findOne({ _id: data.reply.userId }));
|
||||||
|
@ -460,21 +458,41 @@ async function insertNote(user: IUser, data: Option, tags: string[], emojis: str
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractHashtags(tokens: ReturnType<typeof parse>): string[] {
|
function extractHashtags(tokens: ReturnType<typeof parse>): string[] {
|
||||||
|
const hashtags: string[] = [];
|
||||||
|
|
||||||
|
const extract = (tokens: Node[]) => {
|
||||||
|
tokens.filter(x => x.name === 'hashtag').forEach(x => {
|
||||||
|
if (x.props.hashtag.length <= 100) {
|
||||||
|
hashtags.push(x.props.hashtag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokens.filter(x => x.children).forEach(x => {
|
||||||
|
extract(x.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Extract hashtags
|
// Extract hashtags
|
||||||
const hashtags = tokens
|
extract(tokens);
|
||||||
.filter(t => t.type == 'hashtag')
|
|
||||||
.map(t => (t as TextElementHashtag).hashtag)
|
|
||||||
.filter(tag => tag.length <= 100);
|
|
||||||
|
|
||||||
return unique(hashtags);
|
return unique(hashtags);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractEmojis(tokens: ReturnType<typeof parse>): string[] {
|
function extractEmojis(tokens: ReturnType<typeof parse>): string[] {
|
||||||
|
const emojis: string[] = [];
|
||||||
|
|
||||||
|
const extract = (tokens: Node[]) => {
|
||||||
|
tokens.filter(x => x.name === 'emoji').forEach(x => {
|
||||||
|
if (x.props.name && x.props.name.length <= 100) {
|
||||||
|
emojis.push(x.props.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokens.filter(x => x.children).forEach(x => {
|
||||||
|
extract(x.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Extract emojis
|
// Extract emojis
|
||||||
const emojis = tokens
|
extract(tokens);
|
||||||
.filter(t => t.type == 'emoji' && t.name)
|
|
||||||
.map(t => (t as TextElementEmoji).name)
|
|
||||||
.filter(emoji => emoji.length <= 100);
|
|
||||||
|
|
||||||
return unique(emojis);
|
return unique(emojis);
|
||||||
}
|
}
|
||||||
|
@ -638,16 +656,27 @@ function incNotesCount(user: IUser) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractMentionedUsers(tokens: ReturnType<typeof parse>): Promise<IUser[]> {
|
async function extractMentionedUsers(user: IUser, tokens: ReturnType<typeof parse>): Promise<IUser[]> {
|
||||||
if (tokens == null) return [];
|
if (tokens == null) return [];
|
||||||
|
|
||||||
const mentionTokens = tokens
|
const mentions: any[] = [];
|
||||||
.filter(t => t.type == 'mention') as TextElementMention[];
|
|
||||||
|
const extract = (tokens: Node[]) => {
|
||||||
|
tokens.filter(x => x.name === 'mention').forEach(x => {
|
||||||
|
mentions.push(x.props);
|
||||||
|
});
|
||||||
|
tokens.filter(x => x.children).forEach(x => {
|
||||||
|
extract(x.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract hashtags
|
||||||
|
extract(tokens);
|
||||||
|
|
||||||
let mentionedUsers =
|
let mentionedUsers =
|
||||||
erase(null, await Promise.all(mentionTokens.map(async m => {
|
erase(null, await Promise.all(mentions.map(async m => {
|
||||||
try {
|
try {
|
||||||
return await resolveUser(m.username, m.host);
|
return await resolveUser(m.username, m.host ? m.host : user.host);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
570
test/mfm.ts
570
test/mfm.ts
|
@ -6,102 +6,158 @@ import * as assert from 'assert';
|
||||||
|
|
||||||
import analyze from '../src/mfm/parse';
|
import analyze from '../src/mfm/parse';
|
||||||
import toHtml from '../src/mfm/html';
|
import toHtml from '../src/mfm/html';
|
||||||
import syntaxhighlighter from '../src/mfm/parse/core/syntax-highlighter';
|
|
||||||
|
function _node(name: string, children: any[], props: any) {
|
||||||
|
return children ? { name, children, props } : { name, props };
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(name: string, props?: any) {
|
||||||
|
return _node(name, null, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeWithChildren(name: string, children: any[], props?: any) {
|
||||||
|
return _node(name, children, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(text: string) {
|
||||||
|
return node('text', { text });
|
||||||
|
}
|
||||||
|
|
||||||
describe('Text', () => {
|
describe('Text', () => {
|
||||||
it('can be analyzed', () => {
|
it('can be analyzed', () => {
|
||||||
const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr');
|
const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null },
|
node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
|
||||||
{ type: 'text', content: ' ' },
|
text(' '),
|
||||||
{ type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
|
node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
|
||||||
{ type: 'text', content: ' お腹ペコい ' },
|
text(' お腹ペコい '),
|
||||||
{ type: 'emoji', content: ':cat:', name: 'cat' },
|
node('emoji', { name: 'cat' }),
|
||||||
{ type: 'text', content: ' ' },
|
text(' '),
|
||||||
{ type: 'hashtag', content: '#yryr', hashtag: 'yryr' }
|
node('hashtag', { hashtag: 'yryr' }),
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can be inverted', () => {
|
|
||||||
const text = '@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr';
|
|
||||||
assert.equal(analyze(text).map(x => x.content).join(''), text);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('elements', () => {
|
describe('elements', () => {
|
||||||
it('bold', () => {
|
describe('bold', () => {
|
||||||
const tokens = analyze('**Strawberry** Pasta');
|
it('simple', () => {
|
||||||
assert.deepEqual([
|
const tokens = analyze('**foo**');
|
||||||
{ type: 'bold', content: '**Strawberry**', bold: 'Strawberry' },
|
assert.deepEqual([
|
||||||
{ type: 'text', content: ' Pasta' }
|
nodeWithChildren('bold', [
|
||||||
], tokens);
|
text('foo')
|
||||||
|
]),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with other texts', () => {
|
||||||
|
const tokens = analyze('bar**foo**bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('bar'),
|
||||||
|
nodeWithChildren('bold', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('bar'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('big', () => {
|
it('big', () => {
|
||||||
const tokens = analyze('***Strawberry*** Pasta');
|
const tokens = analyze('***Strawberry*** Pasta');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'big', content: '***Strawberry***', big: 'Strawberry' },
|
nodeWithChildren('big', [
|
||||||
{ type: 'text', content: ' Pasta' }
|
text('Strawberry')
|
||||||
|
]),
|
||||||
|
text(' Pasta'),
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('motion', () => {
|
describe('motion', () => {
|
||||||
const tokens1 = analyze('(((Strawberry))) Pasta');
|
it('by triple brackets', () => {
|
||||||
assert.deepEqual([
|
const tokens = analyze('(((foo)))');
|
||||||
{ type: 'motion', content: '(((Strawberry)))', motion: 'Strawberry' },
|
assert.deepEqual([
|
||||||
{ type: 'text', content: ' Pasta' }
|
nodeWithChildren('motion', [
|
||||||
], tokens1);
|
text('foo')
|
||||||
|
]),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
const tokens2 = analyze('<motion>Strawberry</motion> Pasta');
|
it('by triple brackets (with other texts)', () => {
|
||||||
assert.deepEqual([
|
const tokens = analyze('bar(((foo)))bar');
|
||||||
{ type: 'motion', content: '<motion>Strawberry</motion>', motion: 'Strawberry' },
|
assert.deepEqual([
|
||||||
{ type: 'text', content: ' Pasta' }
|
text('bar'),
|
||||||
], tokens2);
|
nodeWithChildren('motion', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('bar'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by <motion> tag', () => {
|
||||||
|
const tokens = analyze('<motion>foo</motion>');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('motion', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by <motion> tag (with other texts)', () => {
|
||||||
|
const tokens = analyze('bar<motion>foo</motion>bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('bar'),
|
||||||
|
nodeWithChildren('motion', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('bar'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mention', () => {
|
describe('mention', () => {
|
||||||
it('local', () => {
|
it('local', () => {
|
||||||
const tokens = analyze('@himawari お腹ペコい');
|
const tokens = analyze('@himawari foo');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null },
|
node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
|
||||||
{ type: 'text', content: ' お腹ペコい' }
|
text(' foo')
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remote', () => {
|
it('remote', () => {
|
||||||
const tokens = analyze('@hima_sub@namori.net お腹ペコい');
|
const tokens = analyze('@hima_sub@namori.net foo');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
|
node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
|
||||||
{ type: 'text', content: ' お腹ペコい' }
|
text(' foo')
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remote punycode', () => {
|
it('remote punycode', () => {
|
||||||
const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah お腹ペコい');
|
const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah foo');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'mention', content: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' },
|
node('mention', { acct: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }),
|
||||||
{ type: 'text', content: ' お腹ペコい' }
|
text(' foo')
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignore', () => {
|
it('ignore', () => {
|
||||||
const tokens = analyze('idolm@ster');
|
const tokens = analyze('idolm@ster');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'text', content: 'idolm@ster' }
|
text('idolm@ster')
|
||||||
], tokens);
|
], tokens);
|
||||||
|
|
||||||
const tokens2 = analyze('@a\n@b\n@c');
|
const tokens2 = analyze('@a\n@b\n@c');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null },
|
node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }),
|
||||||
{ type: 'text', content: '\n' },
|
text('\n'),
|
||||||
{ type: 'mention', content: '@b', canonical: '@b', username: 'b', host: null },
|
node('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }),
|
||||||
{ type: 'text', content: '\n' },
|
text('\n'),
|
||||||
{ type: 'mention', content: '@c', canonical: '@c', username: 'c', host: null }
|
node('mention', { acct: '@c', canonical: '@c', username: 'c', host: null })
|
||||||
], tokens2);
|
], tokens2);
|
||||||
|
|
||||||
const tokens3 = analyze('**x**@a');
|
const tokens3 = analyze('**x**@a');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'bold', content: '**x**', bold: 'x' },
|
nodeWithChildren('bold', [
|
||||||
{ type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null }
|
text('x')
|
||||||
|
]),
|
||||||
|
node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null })
|
||||||
], tokens3);
|
], tokens3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -109,172 +165,294 @@ describe('Text', () => {
|
||||||
it('hashtag', () => {
|
it('hashtag', () => {
|
||||||
const tokens1 = analyze('Strawberry Pasta #alice');
|
const tokens1 = analyze('Strawberry Pasta #alice');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'text', content: 'Strawberry Pasta ' },
|
text('Strawberry Pasta '),
|
||||||
{ type: 'hashtag', content: '#alice', hashtag: 'alice' }
|
node('hashtag', { hashtag: 'alice' })
|
||||||
], tokens1);
|
], tokens1);
|
||||||
|
|
||||||
const tokens2 = analyze('Foo #bar, baz #piyo.');
|
const tokens2 = analyze('Foo #bar, baz #piyo.');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'text', content: 'Foo ' },
|
text('Foo '),
|
||||||
{ type: 'hashtag', content: '#bar', hashtag: 'bar' },
|
node('hashtag', { hashtag: 'bar' }),
|
||||||
{ type: 'text', content: ', baz ' },
|
text(', baz '),
|
||||||
{ type: 'hashtag', content: '#piyo', hashtag: 'piyo' },
|
node('hashtag', { hashtag: 'piyo' }),
|
||||||
{ type: 'text', content: '.' }
|
text('.'),
|
||||||
], tokens2);
|
], tokens2);
|
||||||
|
|
||||||
const tokens3 = analyze('#Foo!');
|
const tokens3 = analyze('#Foo!');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'hashtag', content: '#Foo', hashtag: 'Foo' },
|
node('hashtag', { hashtag: 'Foo' }),
|
||||||
{ type: 'text', content: '!' },
|
text('!'),
|
||||||
], tokens3);
|
], tokens3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quote', () => {
|
describe('quote', () => {
|
||||||
const tokens1 = analyze('> foo\nbar\nbaz');
|
it('basic', () => {
|
||||||
assert.deepEqual([
|
const tokens1 = analyze('> foo');
|
||||||
{ type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' }
|
assert.deepEqual([
|
||||||
], tokens1);
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
])
|
||||||
|
], tokens1);
|
||||||
|
|
||||||
const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter');
|
const tokens2 = analyze('>foo');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'text', content: 'before' },
|
nodeWithChildren('quote', [
|
||||||
{ type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' },
|
text('foo')
|
||||||
{ type: 'text', content: 'after' }
|
])
|
||||||
], tokens2);
|
], tokens2);
|
||||||
|
});
|
||||||
|
|
||||||
const tokens3 = analyze('piyo> foo\nbar\nbaz');
|
it('series', () => {
|
||||||
assert.deepEqual([
|
const tokens = analyze('> foo\n\n> bar');
|
||||||
{ type: 'text', content: 'piyo> foo\nbar\nbaz' }
|
assert.deepEqual([
|
||||||
], tokens3);
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('bar')
|
||||||
|
]),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
const tokens4 = analyze('> foo\n> bar\n> baz');
|
it('trailing line break', () => {
|
||||||
assert.deepEqual([
|
const tokens1 = analyze('> foo\n');
|
||||||
{ type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' }
|
assert.deepEqual([
|
||||||
], tokens4);
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
], tokens1);
|
||||||
|
|
||||||
const tokens5 = analyze('"\nfoo\nbar\nbaz\n"');
|
const tokens2 = analyze('> foo\n\n');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'quote', content: '"\nfoo\nbar\nbaz\n"', quote: 'foo\nbar\nbaz' }
|
nodeWithChildren('quote', [
|
||||||
], tokens5);
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('\n')
|
||||||
|
], tokens2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiline', () => {
|
||||||
|
const tokens1 = analyze('>foo\n>bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo\nbar')
|
||||||
|
])
|
||||||
|
], tokens1);
|
||||||
|
|
||||||
|
const tokens2 = analyze('> foo\n> bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo\nbar')
|
||||||
|
])
|
||||||
|
], tokens2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiline with trailing line break', () => {
|
||||||
|
const tokens1 = analyze('> foo\n> bar\n');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo\nbar')
|
||||||
|
]),
|
||||||
|
], tokens1);
|
||||||
|
|
||||||
|
const tokens2 = analyze('> foo\n> bar\n\n');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo\nbar')
|
||||||
|
]),
|
||||||
|
text('\n')
|
||||||
|
], tokens2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with before and after texts', () => {
|
||||||
|
const tokens = analyze('before\n> foo\nafter');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('before'),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('after'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('require line break before ">"', () => {
|
||||||
|
const tokens = analyze('foo>bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('foo>bar'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nested', () => {
|
||||||
|
const tokens = analyze('>> foo\n> bar');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
text('bar')
|
||||||
|
])
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trim line breaks', () => {
|
||||||
|
const tokens = analyze('foo\n\n>a\n>>b\n>>\n>>>\n>>>c\n>>>\n>d\n\n');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('foo\n'),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('a'),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('b\n'),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('\nc\n')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
text('d')
|
||||||
|
]),
|
||||||
|
text('\n'),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('url', () => {
|
describe('url', () => {
|
||||||
it('simple', () => {
|
it('simple', () => {
|
||||||
const tokens = analyze('https://example.com');
|
const tokens = analyze('https://example.com');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'url',
|
node('url', { url: 'https://example.com' })
|
||||||
content: 'https://example.com',
|
], tokens);
|
||||||
url: 'https://example.com'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignore trailing period', () => {
|
it('ignore trailing period', () => {
|
||||||
const tokens = analyze('https://example.com.');
|
const tokens = analyze('https://example.com.');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'url',
|
node('url', { url: 'https://example.com' }),
|
||||||
content: 'https://example.com',
|
text('.')
|
||||||
url: 'https://example.com'
|
], tokens);
|
||||||
}, {
|
|
||||||
type: 'text', content: '.'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with comma', () => {
|
it('with comma', () => {
|
||||||
const tokens = analyze('https://example.com/foo?bar=a,b');
|
const tokens = analyze('https://example.com/foo?bar=a,b');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'url',
|
node('url', { url: 'https://example.com/foo?bar=a,b' })
|
||||||
content: 'https://example.com/foo?bar=a,b',
|
], tokens);
|
||||||
url: 'https://example.com/foo?bar=a,b'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignore trailing comma', () => {
|
it('ignore trailing comma', () => {
|
||||||
const tokens = analyze('https://example.com/foo, bar');
|
const tokens = analyze('https://example.com/foo, bar');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'url',
|
node('url', { url: 'https://example.com/foo' }),
|
||||||
content: 'https://example.com/foo',
|
text(', bar')
|
||||||
url: 'https://example.com/foo'
|
], tokens);
|
||||||
}, {
|
|
||||||
type: 'text', content: ', bar'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with brackets', () => {
|
it('with brackets', () => {
|
||||||
const tokens = analyze('https://example.com/foo(bar)');
|
const tokens = analyze('https://example.com/foo(bar)');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'url',
|
node('url', { url: 'https://example.com/foo(bar)' })
|
||||||
content: 'https://example.com/foo(bar)',
|
], tokens);
|
||||||
url: 'https://example.com/foo(bar)'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignore parent brackets', () => {
|
it('ignore parent brackets', () => {
|
||||||
const tokens = analyze('(https://example.com/foo)');
|
const tokens = analyze('(https://example.com/foo)');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'text', content: '('
|
text('('),
|
||||||
}, {
|
node('url', { url: 'https://example.com/foo' }),
|
||||||
type: 'url',
|
text(')')
|
||||||
content: 'https://example.com/foo',
|
], tokens);
|
||||||
url: 'https://example.com/foo'
|
|
||||||
}, {
|
|
||||||
type: 'text', content: ')'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignore parent brackets with internal brackets', () => {
|
it('ignore parent brackets with internal brackets', () => {
|
||||||
const tokens = analyze('(https://example.com/foo(bar))');
|
const tokens = analyze('(https://example.com/foo(bar))');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'text', content: '('
|
text('('),
|
||||||
}, {
|
node('url', { url: 'https://example.com/foo(bar)' }),
|
||||||
type: 'url',
|
text(')')
|
||||||
content: 'https://example.com/foo(bar)',
|
], tokens);
|
||||||
url: 'https://example.com/foo(bar)'
|
|
||||||
}, {
|
|
||||||
type: 'text', content: ')'
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('link', () => {
|
it('link', () => {
|
||||||
const tokens = analyze('[ひまさく](https://himasaku.net)');
|
const tokens = analyze('[foo](https://example.com)');
|
||||||
assert.deepEqual([{
|
assert.deepEqual([
|
||||||
type: 'link',
|
nodeWithChildren('link', [
|
||||||
content: '[ひまさく](https://himasaku.net)',
|
text('foo')
|
||||||
title: 'ひまさく',
|
], { url: 'https://example.com', silent: false })
|
||||||
url: 'https://himasaku.net',
|
], tokens);
|
||||||
silent: false
|
|
||||||
}], tokens);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emoji', () => {
|
it('emoji', () => {
|
||||||
const tokens1 = analyze(':cat:');
|
const tokens1 = analyze(':cat:');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'emoji', content: ':cat:', name: 'cat' }
|
node('emoji', { name: 'cat' })
|
||||||
], tokens1);
|
], tokens1);
|
||||||
|
|
||||||
const tokens2 = analyze(':cat::cat::cat:');
|
const tokens2 = analyze(':cat::cat::cat:');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'emoji', content: ':cat:', name: 'cat' },
|
node('emoji', { name: 'cat' }),
|
||||||
{ type: 'emoji', content: ':cat:', name: 'cat' },
|
node('emoji', { name: 'cat' }),
|
||||||
{ type: 'emoji', content: ':cat:', name: 'cat' }
|
node('emoji', { name: 'cat' })
|
||||||
], tokens2);
|
], tokens2);
|
||||||
|
|
||||||
const tokens3 = analyze('🍎');
|
const tokens3 = analyze('🍎');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'emoji', content: '🍎', emoji: '🍎' }
|
node('emoji', { emoji: '🍎' })
|
||||||
], tokens3);
|
], tokens3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('block code', () => {
|
describe('block code', () => {
|
||||||
const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```');
|
it('simple', () => {
|
||||||
assert.equal(tokens[0].type, 'code');
|
const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```');
|
||||||
assert.equal(tokens[0].content, '```\nvar x = "Strawberry Pasta";\n```');
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: 'var x = "Strawberry Pasta";', lang: null })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can specify language', () => {
|
||||||
|
const tokens = analyze('``` json\n{ "x": 42 }\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: '{ "x": 42 }', lang: 'json' })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('require line break before "```"', () => {
|
||||||
|
const tokens = analyze('before```\nfoo\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
text('before'),
|
||||||
|
node('inlineCode', { code: '`' }),
|
||||||
|
text('\nfoo\n'),
|
||||||
|
node('inlineCode', { code: '`' })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('series', () => {
|
||||||
|
const tokens = analyze('```\nfoo\n```\n```\nbar\n```\n```\nbaz\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: 'foo', lang: null }),
|
||||||
|
node('blockCode', { code: 'bar', lang: null }),
|
||||||
|
node('blockCode', { code: 'baz', lang: null }),
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore internal marker', () => {
|
||||||
|
const tokens = analyze('```\naaa```bbb\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: 'aaa```bbb', lang: null })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trim after line break', () => {
|
||||||
|
const tokens = analyze('```\nfoo\n```\nbar');
|
||||||
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: 'foo', lang: null }),
|
||||||
|
text('bar')
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inline code', () => {
|
it('inline code', () => {
|
||||||
const tokens = analyze('`var x = "Strawberry Pasta";`');
|
const tokens = analyze('`var x = "Strawberry Pasta";`');
|
||||||
assert.equal(tokens[0].type, 'inline-code');
|
assert.deepEqual([
|
||||||
assert.equal(tokens[0].content, '`var x = "Strawberry Pasta";`');
|
node('inlineCode', { code: 'var x = "Strawberry Pasta";' })
|
||||||
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('math', () => {
|
it('math', () => {
|
||||||
|
@ -282,82 +460,88 @@ describe('Text', () => {
|
||||||
const text = `\\(${fomula}\\)`;
|
const text = `\\(${fomula}\\)`;
|
||||||
const tokens = analyze(text);
|
const tokens = analyze(text);
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'math', content: text, formula: fomula }
|
node('math', { formula: fomula })
|
||||||
], tokens);
|
], tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('search', () => {
|
it('search', () => {
|
||||||
const tokens1 = analyze('a b c 検索');
|
const tokens1 = analyze('a b c 検索');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'search', content: 'a b c 検索', query: 'a b c' }
|
node('search', { content: 'a b c 検索', query: 'a b c' })
|
||||||
], tokens1);
|
], tokens1);
|
||||||
|
|
||||||
const tokens2 = analyze('a b c Search');
|
const tokens2 = analyze('a b c Search');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'search', content: 'a b c Search', query: 'a b c' }
|
node('search', { content: 'a b c Search', query: 'a b c' })
|
||||||
], tokens2);
|
], tokens2);
|
||||||
|
|
||||||
const tokens3 = analyze('a b c search');
|
const tokens3 = analyze('a b c search');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'search', content: 'a b c search', query: 'a b c' }
|
node('search', { content: 'a b c search', query: 'a b c' })
|
||||||
], tokens3);
|
], tokens3);
|
||||||
|
|
||||||
const tokens4 = analyze('a b c SEARCH');
|
const tokens4 = analyze('a b c SEARCH');
|
||||||
assert.deepEqual([
|
assert.deepEqual([
|
||||||
{ type: 'search', content: 'a b c SEARCH', query: 'a b c' }
|
node('search', { content: 'a b c SEARCH', query: 'a b c' })
|
||||||
], tokens4);
|
], tokens4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('title', () => {
|
describe('title', () => {
|
||||||
const tokens1 = analyze('【yee】\nhaw');
|
it('simple', () => {
|
||||||
assert.deepEqual(
|
const tokens = analyze('【foo】');
|
||||||
{ type: 'title', content: '【yee】\n', title: 'yee' }
|
assert.deepEqual([
|
||||||
, tokens1[0]);
|
nodeWithChildren('title', [
|
||||||
|
text('foo')
|
||||||
|
])
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
const tokens2 = analyze('[yee]\nhaw');
|
it('require line break', () => {
|
||||||
assert.deepEqual(
|
const tokens = analyze('a【foo】');
|
||||||
{ type: 'title', content: '[yee]\n', title: 'yee' }
|
assert.deepEqual([
|
||||||
, tokens2[0]);
|
text('a【foo】')
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
const tokens3 = analyze('a [a]\nb [b]\nc [c]');
|
it('with before and after texts', () => {
|
||||||
assert.deepEqual(
|
const tokens = analyze('before\n【foo】\nafter');
|
||||||
{ type: 'text', content: 'a [a]\nb [b]\nc [c]' }
|
assert.deepEqual([
|
||||||
, tokens3[0]);
|
text('before'),
|
||||||
|
nodeWithChildren('title', [
|
||||||
const tokens4 = analyze('foo\n【bar】\nbuzz');
|
text('foo')
|
||||||
assert.deepEqual([
|
]),
|
||||||
{ type: 'text', content: 'foo' },
|
text('after')
|
||||||
{ type: 'title', content: '\n【bar】\n', title: 'bar' },
|
], tokens);
|
||||||
{ type: 'text', content: 'buzz' },
|
});
|
||||||
], tokens4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('syntax highlighting', () => {
|
|
||||||
it('comment', () => {
|
|
||||||
const html1 = syntaxhighlighter('// Strawberry pasta');
|
|
||||||
assert.equal(html1, '<span class="comment">// Strawberry pasta</span>');
|
|
||||||
|
|
||||||
const html2 = syntaxhighlighter('x // x\ny // y');
|
|
||||||
assert.equal(html2, 'x <span class="comment">// x\n</span>y <span class="comment">// y</span>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('regexp', () => {
|
|
||||||
const html = syntaxhighlighter('/.*/');
|
|
||||||
assert.equal(html, '<span class="regexp">/.*/</span>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('slash', () => {
|
|
||||||
const html = syntaxhighlighter('/');
|
|
||||||
assert.equal(html, '<span class="symbol">/</span>');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toHtml', () => {
|
describe('toHtml', () => {
|
||||||
it('br', () => {
|
it('br', () => {
|
||||||
const input = 'foo\nbar\nbaz';
|
const input = 'foo\nbar\nbaz';
|
||||||
const output = '<p>foo<br>bar<br>baz</p>';
|
const output = '<p><span>foo<br>bar<br>baz</span></p>';
|
||||||
assert.equal(toHtml(analyze(input)), output);
|
assert.equal(toHtml(analyze(input)), output);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('code block with quote', () => {
|
||||||
|
const tokens = analyze('> foo\n```\nbar\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
node('blockCode', { code: 'bar', lang: null })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quote between two code blocks', () => {
|
||||||
|
const tokens = analyze('```\nbefore\n```\n> foo\n```\nafter\n```');
|
||||||
|
assert.deepEqual([
|
||||||
|
node('blockCode', { code: 'before', lang: null }),
|
||||||
|
nodeWithChildren('quote', [
|
||||||
|
text('foo')
|
||||||
|
]),
|
||||||
|
node('blockCode', { code: 'after', lang: null })
|
||||||
|
], tokens);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -38,6 +38,7 @@ module.exports = {
|
||||||
dev: './src/client/app/dev/script.ts',
|
dev: './src/client/app/dev/script.ts',
|
||||||
auth: './src/client/app/auth/script.ts',
|
auth: './src/client/app/auth/script.ts',
|
||||||
admin: './src/client/app/admin/script.ts',
|
admin: './src/client/app/admin/script.ts',
|
||||||
|
test: './src/client/app/test/script.ts',
|
||||||
sw: './src/client/app/sw.js'
|
sw: './src/client/app/sw.js'
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
|
|
Loading…
Reference in a new issue