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:
syuilo
2018-11-21 05:11:00 +09:00
committed by GitHub
parent 6e347e4221
commit 79ffbf95db
44 changed files with 1097 additions and 916 deletions

View File

@ -41,6 +41,7 @@
if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev';
if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth';
if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin';
if (`${url.pathname}/`.startsWith('/test/')) app = 'test';
//#endregion
// Script version

View File

@ -17,7 +17,7 @@ import forkit from './forkit.vue';
import acct from './acct.vue';
import avatar from './avatar.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 pollEditor from './poll-editor.vue';
import reactionIcon from './reaction-icon.vue';

View File

@ -1,11 +1,39 @@
import Vue, { VNode } from 'vue';
import { length } from 'stringz';
import { Node } from '../../../../../mfm/parser';
import parse from '../../../../../mfm/parse';
import getAcct from '../../../../../misc/acct/render';
import MkUrl from './url.vue';
import { concat } from '../../../../../prelude/array';
import MkFormula from './formula.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', {
props: {
@ -21,6 +49,10 @@ export default Vue.component('misskey-flavored-markdown', {
type: Boolean,
default: true
},
author: {
type: Object,
default: null
},
i: {
type: Object,
default: null
@ -31,23 +63,24 @@ export default Vue.component('misskey-flavored-markdown', {
},
render(createElement) {
let ast: any[];
if (this.text == null || this.text == '') return;
let ast: Node[];
if (this.ast == null) {
// Parse text to ast
ast = parse(this.text);
} else {
ast = this.ast as any[];
ast = this.ast as Node[];
}
let bigCount = 0;
let motionCount = 0;
// Parse ast to DOM
const els = concat(ast.map((token): VNode[] => {
switch (token.type) {
const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => {
switch (token.name) {
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) {
const x = text.split('\n')
@ -60,12 +93,12 @@ export default Vue.component('misskey-flavored-markdown', {
}
case 'bold': {
return [createElement('b', token.bold)];
return [createElement('b', genEl(token.children))];
}
case 'big': {
bigCount++;
const isLong = length(token.big) > 10;
const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5;
const isMany = bigCount > 3;
return (createElement as any)('strong', {
attrs: {
@ -75,12 +108,12 @@ export default Vue.component('misskey-flavored-markdown', {
name: 'animate-css',
value: { classes: 'tada', iteration: 'infinite' }
}]
}, token.big);
}, genEl(token.children));
}
case 'motion': {
motionCount++;
const isLong = length(token.motion) > 10;
const isLong = length(getText(token.children)) > 10 || getChildrenCount(token.children) > 5;
const isMany = motionCount > 3;
return (createElement as any)('span', {
attrs: {
@ -90,13 +123,14 @@ export default Vue.component('misskey-flavored-markdown', {
name: 'animate-css',
value: { classes: 'rubberBand', iteration: 'infinite' }
}]
}, token.motion);
}, genEl(token.children));
}
case 'url': {
return [createElement(MkUrl, {
key: Math.random(),
props: {
url: token.content,
url: token.props.url,
target: '_blank',
style: 'color:var(--mfmLink);'
}
@ -107,75 +141,75 @@ export default Vue.component('misskey-flavored-markdown', {
return [createElement('a', {
attrs: {
class: 'link',
href: token.url,
href: token.props.url,
target: '_blank',
title: token.url,
title: token.props.url,
style: 'color:var(--mfmLink);'
}
}, token.title)];
}, genEl(token.children))];
}
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', {
key: Math.random(),
attrs: {
to: `/${token.canonical}`,
dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token),
to: `/${canonical}`,
// TODO
//dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token),
style: 'color:var(--mfmMention);'
},
directives: [{
name: 'user-preview',
value: token.canonical
value: canonical
}]
}, token.canonical);
}, canonical);
}
case 'hashtag': {
return [createElement('router-link', {
key: Math.random(),
attrs: {
to: `/tags/${encodeURIComponent(token.hashtag)}`,
to: `/tags/${encodeURIComponent(token.props.hashtag)}`,
style: 'color:var(--mfmHashtag);'
}
}, token.content)];
}, `#${token.props.hashtag}`)];
}
case 'code': {
case 'blockCode': {
return [createElement('pre', {
class: 'code'
}, [
createElement('code', {
domProps: {
innerHTML: token.html
innerHTML: syntaxHighlight(token.props.code)
}
})
])];
}
case 'inline-code': {
case 'inlineCode': {
return [createElement('code', {
domProps: {
innerHTML: token.html
innerHTML: syntaxHighlight(token.props.code)
}
})];
}
case 'quote': {
const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
if (this.shouldBreak) {
const x = text2.split('\n')
.map(t => [createElement('span', t), createElement('br')]);
x[x.length - 1].pop();
return [createElement('div', {
attrs: {
class: 'quote'
}
}, x)];
}, genEl(token.children))];
} else {
return [createElement('span', {
attrs: {
class: 'quote'
}
}, text2.replace(/\n/g, ' '))];
}, genEl(token.children))];
}
}
@ -184,15 +218,16 @@ export default Vue.component('misskey-flavored-markdown', {
attrs: {
class: 'title'
}
}, token.title)];
}, genEl(token.children))];
}
case 'emoji': {
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
return [createElement('mk-emoji', {
key: Math.random(),
attrs: {
emoji: token.emoji,
name: token.name
emoji: token.props.emoji,
name: token.props.name
},
props: {
customEmojis: this.customEmojis || customEmojis
@ -203,8 +238,9 @@ export default Vue.component('misskey-flavored-markdown', {
case 'math': {
//const MkFormula = () => import('./formula.vue').then(m => m.default);
return [createElement(MkFormula, {
key: Math.random(),
props: {
formula: token.formula
formula: token.props.formula
}
})];
}
@ -212,22 +248,22 @@ export default Vue.component('misskey-flavored-markdown', {
case 'search': {
//const MkGoogle = () => import('./google.vue').then(m => m.default);
return [createElement(MkGoogle, {
key: Math.random(),
props: {
q: token.query
q: token.props.query
}
})];
}
default: {
console.log('unknown ast type:', token.type);
console.log('unknown ast type:', token.name);
return [];
}
}
}));
// el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない
const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag)));
return createElement('span', _els);
// Parse ast to DOM
return createElement('span', genEl(ast));
}
});

View File

@ -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>

View File

@ -14,7 +14,7 @@
</div>
</header>
<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>

View File

@ -9,7 +9,7 @@
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
<span class="username">@{{ user | acct }}</span>
<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>
</main>

View File

@ -46,7 +46,7 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</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 class="files" v-if="appearNote.files.length > 0">
<mk-media-list :media-list="appearNote.files" :raw="true"/>

View File

@ -27,7 +27,7 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
<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>
</div>
<div class="files" v-if="appearNote.files.length > 0">
@ -223,24 +223,6 @@ export default Vue.extend({
overflow-wrap break-word
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
margin-right 8px
color var(--text)
@ -322,28 +304,3 @@ export default Vue.extend({
opacity 0.7
</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>

View File

@ -4,7 +4,7 @@
<span v-if="note.isHidden" style="opacity: 0.5">{{ $t('private') }}</span>
<span v-if="note.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span>
<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>
</div>
<details v-if="note.files.length > 0">

View File

@ -7,7 +7,7 @@
<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
<span class="username">@{{ user | acct }}</span>
<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>

View File

@ -22,7 +22,7 @@
</header>
<div class="info">
<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 class="counts">
<div>

View File

@ -14,7 +14,7 @@
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<div class="body">
<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 class="info">
<span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span>

View File

@ -33,7 +33,7 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</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 class="files" v-if="appearNote.files.length > 0">
<mk-media-list :media-list="appearNote.files" :raw="true"/>

View File

@ -23,7 +23,7 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
<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>
</div>
<div class="files" v-if="appearNote.files.length > 0">
@ -188,24 +188,6 @@ export default Vue.extend({
overflow-wrap break-word
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
margin-right 8px
color var(--noteText)
@ -215,15 +197,6 @@ export default Vue.extend({
font-style oblique
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
margin-top 8px
@ -289,18 +262,3 @@ export default Vue.extend({
opacity 0.7
</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>

View File

@ -4,7 +4,7 @@
<span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
<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>
</div>
<details v-if="note.files.length > 0">

View File

@ -20,7 +20,7 @@
<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
</div>
<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 class="info">
<p class="location" v-if="user.host === null && user.profile.location">

View 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);
});

View File

@ -0,0 +1,6 @@
@import "../app"
@import "../reset"
html
height 100%
background var(--bg)

View 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>