From 79ffbf95db9d0cc019d06ab93b1bfa6ba0d4f9ae Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Wed, 21 Nov 2018 05:11:00 +0900
Subject: [PATCH] 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
---
 package.json                                  |   2 +
 src/client/app/boot.js                        |   1 +
 .../app/common/views/components/index.ts      |   2 +-
 .../{misskey-flavored-markdown.ts => mfm.ts}  | 120 ++--
 .../components/misskey-flavored-markdown.vue  |  57 ++
 .../views/components/welcome-timeline.vue     |   2 +-
 src/client/app/common/views/pages/follow.vue  |   2 +-
 .../desktop/views/components/note-detail.vue  |   2 +-
 .../app/desktop/views/components/note.vue     |  45 +-
 .../views/components/sub-note-content.vue     |   2 +-
 .../desktop/views/components/user-card.vue    |   2 +-
 .../views/pages/deck/deck.user-column.vue     |   2 +-
 .../desktop/views/pages/user/user.header.vue  |   2 +-
 .../mobile/views/components/note-detail.vue   |   2 +-
 .../app/mobile/views/components/note.vue      |  44 +-
 .../views/components/sub-note-content.vue     |   2 +-
 src/client/app/mobile/views/pages/user.vue    |   2 +-
 src/client/app/test/script.ts                 |  23 +
 src/client/app/test/style.styl                |   6 +
 src/client/app/test/views/index.vue           |  34 ++
 src/mfm/html.ts                               | 232 +++----
 src/mfm/parse.ts                              |  81 +++
 src/mfm/parse/elements/big.ts                 |  20 -
 src/mfm/parse/elements/bold.ts                |  20 -
 src/mfm/parse/elements/code.ts                |  24 -
 src/mfm/parse/elements/emoji.regex.ts         |   2 -
 src/mfm/parse/elements/emoji.ts               |  33 -
 src/mfm/parse/elements/hashtag.ts             |  27 -
 src/mfm/parse/elements/inline-code.ts         |  25 -
 src/mfm/parse/elements/link.ts                |  27 -
 src/mfm/parse/elements/math.ts                |  20 -
 src/mfm/parse/elements/mention.ts             |  29 -
 src/mfm/parse/elements/motion.ts              |  20 -
 src/mfm/parse/elements/quote.ts               |  30 -
 src/mfm/parse/elements/search.ts              |  19 -
 src/mfm/parse/elements/title.ts               |  21 -
 src/mfm/parse/elements/url.ts                 |  23 -
 src/mfm/parse/index.ts                        | 100 ---
 src/mfm/parser.ts                             | 256 ++++++++
 ...tax-highlighter.ts => syntax-highlight.ts} |   4 +-
 src/remote/activitypub/renderer/note.ts       |  12 -
 src/services/note/create.ts                   |  63 +-
 test/mfm.ts                                   | 570 ++++++++++++------
 webpack.config.ts                             |   1 +
 44 files changed, 1097 insertions(+), 916 deletions(-)
 rename src/client/app/common/views/components/{misskey-flavored-markdown.ts => mfm.ts} (59%)
 create mode 100644 src/client/app/common/views/components/misskey-flavored-markdown.vue
 create mode 100644 src/client/app/test/script.ts
 create mode 100644 src/client/app/test/style.styl
 create mode 100644 src/client/app/test/views/index.vue
 create mode 100644 src/mfm/parse.ts
 delete mode 100644 src/mfm/parse/elements/big.ts
 delete mode 100644 src/mfm/parse/elements/bold.ts
 delete mode 100644 src/mfm/parse/elements/code.ts
 delete mode 100644 src/mfm/parse/elements/emoji.regex.ts
 delete mode 100644 src/mfm/parse/elements/emoji.ts
 delete mode 100644 src/mfm/parse/elements/hashtag.ts
 delete mode 100644 src/mfm/parse/elements/inline-code.ts
 delete mode 100644 src/mfm/parse/elements/link.ts
 delete mode 100644 src/mfm/parse/elements/math.ts
 delete mode 100644 src/mfm/parse/elements/mention.ts
 delete mode 100644 src/mfm/parse/elements/motion.ts
 delete mode 100644 src/mfm/parse/elements/quote.ts
 delete mode 100644 src/mfm/parse/elements/search.ts
 delete mode 100644 src/mfm/parse/elements/title.ts
 delete mode 100644 src/mfm/parse/elements/url.ts
 delete mode 100644 src/mfm/parse/index.ts
 create mode 100644 src/mfm/parser.ts
 rename src/mfm/{parse/core/syntax-highlighter.ts => syntax-highlight.ts} (97%)

diff --git a/package.json b/package.json
index b1c1d01b77..a082898f7b 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
 		"@types/ms": "0.7.30",
 		"@types/node": "10.12.2",
 		"@types/oauth": "0.9.1",
+		"@types/parsimmon": "1.10.0",
 		"@types/portscanner": "2.1.0",
 		"@types/pug": "2.0.4",
 		"@types/qrcode": "1.3.0",
@@ -170,6 +171,7 @@
 		"on-build-webpack": "0.1.0",
 		"os-utils": "0.0.14",
 		"parse5": "5.1.0",
+		"parsimmon": "1.12.0",
 		"portscanner": "2.2.0",
 		"postcss-loader": "3.0.0",
 		"progress-bar-webpack-plugin": "1.11.0",
diff --git a/src/client/app/boot.js b/src/client/app/boot.js
index 76ea41c649..5e894a18d7 100644
--- a/src/client/app/boot.js
+++ b/src/client/app/boot.js
@@ -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
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index 8569e2cf10..b8fc7c4096 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -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';
diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/mfm.ts
similarity index 59%
rename from src/client/app/common/views/components/misskey-flavored-markdown.ts
rename to src/client/app/common/views/components/mfm.ts
index 1eb738813e..b7ff5bd487 100644
--- a/src/client/app/common/views/components/misskey-flavored-markdown.ts
+++ b/src/client/app/common/views/components/mfm.ts
@@ -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));
 	}
 });
diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue
new file mode 100644
index 0000000000..b54f376935
--- /dev/null
+++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue
@@ -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>
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index cad09a11a6..d075f06934 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -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>
diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue
index 9db53fdf8a..72b0b73e01 100644
--- a/src/client/app/common/views/pages/follow.vue
+++ b/src/client/app/common/views/pages/follow.vue
@@ -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>
diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue
index 88108d961f..37c4093355 100644
--- a/src/client/app/desktop/views/components/note-detail.vue
+++ b/src/client/app/desktop/views/components/note-detail.vue
@@ -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"/>
diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue
index 26615b16a6..025d489c09 100644
--- a/src/client/app/desktop/views/components/note.vue
+++ b/src/client/app/desktop/views/components/note.vue
@@ -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>
diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue
index 0007520e99..2a407bdcab 100644
--- a/src/client/app/desktop/views/components/sub-note-content.vue
+++ b/src/client/app/desktop/views/components/sub-note-content.vue
@@ -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">
diff --git a/src/client/app/desktop/views/components/user-card.vue b/src/client/app/desktop/views/components/user-card.vue
index 54fa15a190..c5d925fe6d 100644
--- a/src/client/app/desktop/views/components/user-card.vue
+++ b/src/client/app/desktop/views/components/user-card.vue
@@ -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>
diff --git a/src/client/app/desktop/views/pages/deck/deck.user-column.vue b/src/client/app/desktop/views/pages/deck/deck.user-column.vue
index 90f7e2aaaa..937166cec1 100644
--- a/src/client/app/desktop/views/pages/deck/deck.user-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.user-column.vue
@@ -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>
diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue
index 48b5a487f4..9eacbe3914 100644
--- a/src/client/app/desktop/views/pages/user/user.header.vue
+++ b/src/client/app/desktop/views/pages/user/user.header.vue
@@ -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>
diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue
index f24cc0916f..61968a64d1 100644
--- a/src/client/app/mobile/views/components/note-detail.vue
+++ b/src/client/app/mobile/views/components/note-detail.vue
@@ -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"/>
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue
index 42fb7118f8..5cfcdc0f3b 100644
--- a/src/client/app/mobile/views/components/note.vue
+++ b/src/client/app/mobile/views/components/note.vue
@@ -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>
diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue
index f4c86f19d2..715ddd6527 100644
--- a/src/client/app/mobile/views/components/sub-note-content.vue
+++ b/src/client/app/mobile/views/components/sub-note-content.vue
@@ -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">
diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue
index b7f0db6eb9..1f0551680e 100644
--- a/src/client/app/mobile/views/pages/user.vue
+++ b/src/client/app/mobile/views/pages/user.vue
@@ -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">
diff --git a/src/client/app/test/script.ts b/src/client/app/test/script.ts
new file mode 100644
index 0000000000..5818cf2913
--- /dev/null
+++ b/src/client/app/test/script.ts
@@ -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);
+});
diff --git a/src/client/app/test/style.styl b/src/client/app/test/style.styl
new file mode 100644
index 0000000000..ae1a28226a
--- /dev/null
+++ b/src/client/app/test/style.styl
@@ -0,0 +1,6 @@
+@import "../app"
+@import "../reset"
+
+html
+	height 100%
+	background var(--bg)
diff --git a/src/client/app/test/views/index.vue b/src/client/app/test/views/index.vue
new file mode 100644
index 0000000000..b1947ffa4a
--- /dev/null
+++ b/src/client/app/test/views/index.vue
@@ -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>
diff --git a/src/mfm/html.ts b/src/mfm/html.ts
index cb7c7e2855..d45cc13af4 100644
--- a/src/mfm/html.ts
+++ b/src/mfm/html.ts
@@ -1,127 +1,135 @@
-const { lib: emojilib } = require('emojilib');
 const jsdom = require('jsdom');
 const { JSDOM } = jsdom;
 import config from '../config';
 import { INote } from '../models/note';
-import { TextElement } from './parse';
+import { Node } from './parser';
 import { intersperse } from '../prelude/array';
 
-const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = {
-	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'] = []) => {
+export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => {
 	if (tokens == null) {
 		return null;
 	}
 
 	const { window } = new JSDOM('');
 
-	for (const token of tokens) {
-		handlers[token.type](window, token, mentionedRemoteUsers);
+	const doc = window.document;
+
+	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>`;
 };
diff --git a/src/mfm/parse.ts b/src/mfm/parse.ts
new file mode 100644
index 0000000000..e7ae3d012e
--- /dev/null
+++ b/src/mfm/parse.ts
@@ -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;
+};
diff --git a/src/mfm/parse/elements/big.ts b/src/mfm/parse/elements/big.ts
deleted file mode 100644
index 24e8bad50e..0000000000
--- a/src/mfm/parse/elements/big.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/bold.ts b/src/mfm/parse/elements/bold.ts
deleted file mode 100644
index 42c9cf0e1e..0000000000
--- a/src/mfm/parse/elements/bold.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/code.ts b/src/mfm/parse/elements/code.ts
deleted file mode 100644
index 63a535fc55..0000000000
--- a/src/mfm/parse/elements/code.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/emoji.regex.ts b/src/mfm/parse/elements/emoji.regex.ts
deleted file mode 100644
index a5c6b71825..0000000000
--- a/src/mfm/parse/elements/emoji.regex.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// https://github.com/twitter/twemoji/blob/fc458b467c1bd706acd8653028ee8ab3e6562ce3/2/scripts/regex
-export const emojiRegex = /^((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/;
diff --git a/src/mfm/parse/elements/emoji.ts b/src/mfm/parse/elements/emoji.ts
deleted file mode 100644
index 6c09ddf5c0..0000000000
--- a/src/mfm/parse/elements/emoji.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts
deleted file mode 100644
index df07de6645..0000000000
--- a/src/mfm/parse/elements/hashtag.ts
+++ /dev/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[];
-}
diff --git a/src/mfm/parse/elements/inline-code.ts b/src/mfm/parse/elements/inline-code.ts
deleted file mode 100644
index efacd734cb..0000000000
--- a/src/mfm/parse/elements/inline-code.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/link.ts b/src/mfm/parse/elements/link.ts
deleted file mode 100644
index 972fce3810..0000000000
--- a/src/mfm/parse/elements/link.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/math.ts b/src/mfm/parse/elements/math.ts
deleted file mode 100644
index f2b6c5f479..0000000000
--- a/src/mfm/parse/elements/math.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/mention.ts b/src/mfm/parse/elements/mention.ts
deleted file mode 100644
index 7a609e5d34..0000000000
--- a/src/mfm/parse/elements/mention.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/motion.ts b/src/mfm/parse/elements/motion.ts
deleted file mode 100644
index c6500e7be0..0000000000
--- a/src/mfm/parse/elements/motion.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts
deleted file mode 100644
index 969c1fb4a9..0000000000
--- a/src/mfm/parse/elements/quote.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/search.ts b/src/mfm/parse/elements/search.ts
deleted file mode 100644
index f51844b079..0000000000
--- a/src/mfm/parse/elements/search.ts
+++ /dev/null
@@ -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]
-	};
-}
diff --git a/src/mfm/parse/elements/title.ts b/src/mfm/parse/elements/title.ts
deleted file mode 100644
index a9922c8aca..0000000000
--- a/src/mfm/parse/elements/title.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/elements/url.ts b/src/mfm/parse/elements/url.ts
deleted file mode 100644
index a16f67f2c2..0000000000
--- a/src/mfm/parse/elements/url.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/mfm/parse/index.ts b/src/mfm/parse/index.ts
deleted file mode 100644
index 7697bb6e36..0000000000
--- a/src/mfm/parse/index.ts
+++ /dev/null
@@ -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
-	));
-};
diff --git a/src/mfm/parser.ts b/src/mfm/parser.ts
new file mode 100644
index 0000000000..5f89696c20
--- /dev/null
+++ b/src/mfm/parser.ts
@@ -0,0 +1,256 @@
+import * as P from 'parsimmon';
+import parseAcct from '../misc/acct/parse';
+import { toUnicode } from 'punycode';
+
+const emojiRegex = /((?:\ud83d[\udc68\udc69])(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddb0-\uddb3])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f)|[\u0023\u002a\u0030-\u0039]\ufe0f?\u20e3|(?:[\u00a9\u00ae\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\uddb5\uddb6\uddb8\uddb9\uddd1-\udddd]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a-\udc6d\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\udeeb\udeec\udef4-\udef9]|\ud83e[\udd10-\udd17\udd1d\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd40-\udd45\udd47-\udd70\udd73-\udd76\udd7a\udd7c-\udda2\uddb4\uddb7\uddc0-\uddc2\uddd0\uddde-\uddff]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f)/;
+
+export type Node = {
+	name: string,
+	children?: Node[],
+	props?: any;
+};
+
+function _makeNode(name: string, children?: Node[], props?: any): Node {
+	return children ? {
+		name,
+		children,
+		props
+	} : {
+		name,
+		props
+	};
+}
+
+function makeNode(name: string, props?: any): Node {
+	return _makeNode(name, null, props);
+}
+
+function makeNodeWithChildren(name: string, children: Node[], props?: any): Node {
+	return _makeNode(name, children, props);
+}
+
+const newline = P((input, i) => {
+	if (i == 0 || input[i] == '\n' || input[i - 1] == '\n') {
+		return P.makeSuccess(i, null);
+	} else {
+		return P.makeFailure(i, 'not newline');
+	}
+});
+
+const mfm = P.createLanguage({
+	root: r => P.alt(
+		r.big,
+		r.bold,
+		r.motion,
+		r.url,
+		r.link,
+		r.mention,
+		r.hashtag,
+		r.emoji,
+		r.blockCode,
+		r.inlineCode,
+		r.quote,
+		r.math,
+		r.search,
+		r.title,
+		r.text
+	).atLeast(1),
+
+	text: () => P.any.map(x => makeNode('text', { text: x })),
+
+	//#region Big
+	big: r =>
+		P.regexp(/^\*\*\*([\s\S]+?)\*\*\*/, 1)
+		.map(x => makeNodeWithChildren('big', P.alt(
+			r.mention,
+			r.emoji,
+			r.text
+		).atLeast(1).tryParse(x))),
+	//#endregion
+
+	//#region Block code
+	blockCode: r =>
+		newline.then(
+			P((input, i) => {
+				const text = input.substr(i);
+				const match = text.match(/^```(.+?)?\n([\s\S]+?)\n```(\n|$)/i);
+				if (!match) return P.makeFailure(i, 'not a blockCode');
+				return P.makeSuccess(i + match[0].length, makeNode('blockCode', { code: match[2], lang: match[1] ? match[1].trim() : null }));
+			})
+		),
+	//#endregion
+
+	//#region Bold
+	bold: r =>
+		P.regexp(/\*\*([\s\S]+?)\*\*/, 1)
+		.map(x => makeNodeWithChildren('bold', P.alt(
+			r.mention,
+			r.emoji,
+			r.text
+		).atLeast(1).tryParse(x))),
+	//#endregion
+
+	//#region Emoji
+	emoji: r =>
+		P.alt(
+			P.regexp(/:([a-z0-9_+-]+):/i, 1)
+			.map(x => makeNode('emoji', {
+				name: x
+			})),
+			P.regexp(emojiRegex)
+			.map(x => makeNode('emoji', {
+				emoji: x
+			})),
+		),
+	//#endregion
+
+	//#region Hashtag
+	hashtag: r =>
+		P((input, i) => {
+			const text = input.substr(i);
+			const match = text.match(/^#([^\s\.,!\?#]+)/i);
+			if (!match) return P.makeFailure(i, 'not a hashtag');
+			if (input[i - 1] != ' ' && input[i - 1] != null) return P.makeFailure(i, 'require space before "#"');
+			return P.makeSuccess(i + match[0].length, makeNode('hashtag', { hashtag: match[1] }));
+		}),
+	//#endregion
+
+	//#region Inline code
+	inlineCode: r =>
+		P.regexp(/`(.+?)`/, 1)
+		.map(x => makeNode('inlineCode', { code: x })),
+	//#endregion
+
+	//#region Link
+	link: r =>
+		P.seqObj(
+			['silent', P.string('?').fallback(null).map(x => x != null)] as any,
+			P.string('['),
+			['text', P.regexp(/[^\n\[\]]+/)] as any,
+			P.string(']'),
+			P.string('('),
+			['url', r.url] as any,
+			P.string(')'),
+		)
+		.map((x: any) => {
+			return makeNodeWithChildren('link', P.alt(
+				r.big,
+				r.bold,
+				r.motion,
+				r.emoji,
+				r.text
+			).atLeast(1).tryParse(x.text), {
+				silent: x.silent,
+				url: x.url.props.url
+			});
+		}),
+	//#endregion
+
+	//#region Math
+	math: r =>
+		P.regexp(/\\\((.+?)\\\)/, 1)
+		.map(x => makeNode('math', { formula: x })),
+	//#endregion
+
+	//#region Mention
+	mention: r =>
+		P((input, i) => {
+			const text = input.substr(i);
+			const match = text.match(/^@[a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9])?/i);
+			if (!match) return P.makeFailure(i, 'not a mention');
+			if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a mention');
+			return P.makeSuccess(i + match[0].length, match[0]);
+		})
+		.map(x => {
+			const { username, host } = parseAcct(x.substr(1));
+			const canonical = host != null ? `@${username}@${toUnicode(host)}` : x;
+			return makeNode('mention', {
+				canonical, username, host, acct: x
+			});
+		}),
+	//#endregion
+
+	//#region Motion
+	motion: r =>
+		P.alt(P.regexp(/\(\(\(([\s\S]+?)\)\)\)/, 1), P.regexp(/<motion>(.+?)<\/motion>/, 1))
+		.map(x => makeNodeWithChildren('motion', P.alt(
+			r.bold,
+			r.mention,
+			r.emoji,
+			r.text
+		).atLeast(1).tryParse(x))),
+	//#endregion
+
+	//#region Quote
+	quote: r =>
+		newline.then(P((input, i) => {
+			const text = input.substr(i);
+			if (!text.match(/^>[\s\S]+?/)) return P.makeFailure(i, 'not a quote');
+			const quote: string[] = [];
+			text.split('\n').some(line => {
+				if (line.startsWith('>')) {
+					quote.push(line);
+					return false;
+				} else {
+					return true;
+				}
+			});
+			const qInner = quote.join('\n').replace(/^>/gm, '').replace(/^ /gm, '');
+			if (qInner == '') return P.makeFailure(i, 'not a quote');
+			const contents = r.root.tryParse(qInner);
+			return P.makeSuccess(i + quote.join('\n').length, makeNodeWithChildren('quote', contents));
+		})),
+	//#endregion
+
+	//#region Search
+	search: r =>
+		newline.then(P((input, i) => {
+			const text = input.substr(i);
+			const match = text.match(/^(.+?)( | )(検索|\[検索\]|Search|\[Search\])(\n|$)/i);
+			if (!match) return P.makeFailure(i, 'not a search');
+			return P.makeSuccess(i + match[0].length, makeNode('search', { query: match[1], content: match[0].trim() }));
+		})),
+	//#endregion
+
+	//#region Title
+	title: r =>
+		newline.then(P((input, i) => {
+			const text = input.substr(i);
+			const match = text.match(/^((【|\[)(.+?)(】|]))(\n|$)/);
+			if (!match) return P.makeFailure(i, 'not a title');
+			const q = match[1].trim().substring(1, match[1].length - 1);
+			const contents = P.alt(
+				r.big,
+				r.bold,
+				r.motion,
+				r.url,
+				r.link,
+				r.mention,
+				r.hashtag,
+				r.emoji,
+				r.inlineCode,
+				r.text
+			).atLeast(1).tryParse(q);
+			return P.makeSuccess(i + match[0].length, makeNodeWithChildren('title', contents));
+		})),
+	//#endregion
+
+	//#region URL
+	url: r =>
+		P((input, i) => {
+			const text = input.substr(i);
+			const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.,=\+\-]+/);
+			if (!match) return P.makeFailure(i, 'not a url');
+			let url = match[0];
+			const before = input[i - 1];
+			if (url.endsWith('.')) url = url.substr(0, url.lastIndexOf('.'));
+			if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(','));
+			if (url.endsWith(')') && before == '(') url = url.substr(0, url.lastIndexOf(')'));
+			if (url.endsWith(']') && before == '[') url = url.substr(0, url.lastIndexOf(']'));
+			return P.makeSuccess(i + url.length, url);
+		})
+		.map(x => makeNode('url', { url: x })),
+	//#endregion
+});
+
+export default mfm;
diff --git a/src/mfm/parse/core/syntax-highlighter.ts b/src/mfm/syntax-highlight.ts
similarity index 97%
rename from src/mfm/parse/core/syntax-highlighter.ts
rename to src/mfm/syntax-highlight.ts
index 83aac89f1b..3a2b90588b 100644
--- a/src/mfm/parse/core/syntax-highlighter.ts
+++ b/src/mfm/syntax-highlight.ts
@@ -1,4 +1,4 @@
-import { capitalize, toUpperCase } from "../../../prelude/string";
+import { capitalize, toUpperCase } from "../prelude/string";
 
 function escape(text: string) {
 	return text
@@ -308,7 +308,7 @@ const elements: Element[] = [
 ];
 
 // specify lang is todo
-export default (source: string, lang?: string) => {
+export default (source: string, lang?: string): string => {
 	let code = source;
 	let html = '';
 
diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts
index ec66fe41ff..d6e2d612c7 100644
--- a/src/remote/activitypub/renderer/note.ts
+++ b/src/remote/activitypub/renderer/note.ts
@@ -7,7 +7,6 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file';
 import Note, { INote } from '../../../models/note';
 import User from '../../../models/user';
 import toHtml from '../misc/get-note-html';
-import parseMfm from '../../../mfm/parse';
 import Emoji, { IEmoji } from '../../../models/emoji';
 
 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}`;
 	}
 
-	// 省略されたメンションのホストを復元する
-	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 emojis = await getEmojis(note.emojis);
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 0fd983d6c2..b512fe2dda 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -21,8 +21,6 @@ import Meta from '../../models/meta';
 import config from '../../config';
 import registerHashtag from '../register-hashtag';
 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 perUserNotesChart from '../../chart/per-user-notes';
 
@@ -30,7 +28,7 @@ import { erase, unique } from '../../prelude/array';
 import insertNoteUnread from './unread';
 import registerInstance from '../register-instance';
 import Instance from '../../models/instance';
-import { TextElementEmoji } from '../../mfm/parse/elements/emoji';
+import { Node } from '../../mfm/parser';
 
 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 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))) {
 		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[] {
+	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
-	const hashtags = tokens
-		.filter(t => t.type == 'hashtag')
-		.map(t => (t as TextElementHashtag).hashtag)
-		.filter(tag => tag.length <= 100);
+	extract(tokens);
 
 	return unique(hashtags);
 }
 
 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
-	const emojis = tokens
-		.filter(t => t.type == 'emoji' && t.name)
-		.map(t => (t as TextElementEmoji).name)
-		.filter(emoji => emoji.length <= 100);
+	extract(tokens);
 
 	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 [];
 
-	const mentionTokens = tokens
-		.filter(t => t.type == 'mention') as TextElementMention[];
+	const mentions: any[] = [];
+
+	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 =
-		erase(null, await Promise.all(mentionTokens.map(async m => {
+		erase(null, await Promise.all(mentions.map(async m => {
 			try {
-				return await resolveUser(m.username, m.host);
+				return await resolveUser(m.username, m.host ? m.host : user.host);
 			} catch (e) {
 				return null;
 			}
diff --git a/test/mfm.ts b/test/mfm.ts
index 017144545a..f020ffd5a5 100644
--- a/test/mfm.ts
+++ b/test/mfm.ts
@@ -6,102 +6,158 @@ import * as assert from 'assert';
 
 import analyze from '../src/mfm/parse';
 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', () => {
 	it('can be analyzed', () => {
 		const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr');
 		assert.deepEqual([
-			{ type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null },
-			{ type: 'text', content: ' ' },
-			{ type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
-			{ type: 'text', content: ' お腹ペコい ' },
-			{ type: 'emoji', content: ':cat:', name: 'cat' },
-			{ type: 'text', content: ' ' },
-			{ type: 'hashtag', content: '#yryr', hashtag: 'yryr' }
+			node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
+			text(' '),
+			node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
+			text(' お腹ペコい '),
+			node('emoji', { name: 'cat' }),
+			text(' '),
+			node('hashtag', { hashtag: 'yryr' }),
 		], 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', () => {
-		it('bold', () => {
-			const tokens = analyze('**Strawberry** Pasta');
-			assert.deepEqual([
-				{ type: 'bold', content: '**Strawberry**', bold: 'Strawberry' },
-				{ type: 'text', content: ' Pasta' }
-			], tokens);
+		describe('bold', () => {
+			it('simple', () => {
+				const tokens = analyze('**foo**');
+				assert.deepEqual([
+					nodeWithChildren('bold', [
+						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', () => {
 			const tokens = analyze('***Strawberry*** Pasta');
 			assert.deepEqual([
-				{ type: 'big', content: '***Strawberry***', big: 'Strawberry' },
-				{ type: 'text', content: ' Pasta' }
+				nodeWithChildren('big', [
+					text('Strawberry')
+				]),
+				text(' Pasta'),
 			], tokens);
 		});
 
-		it('motion', () => {
-			const tokens1 = analyze('(((Strawberry))) Pasta');
-			assert.deepEqual([
-				{ type: 'motion', content: '(((Strawberry)))', motion: 'Strawberry' },
-				{ type: 'text', content: ' Pasta' }
-			], tokens1);
+		describe('motion', () => {
+			it('by triple brackets', () => {
+				const tokens = analyze('(((foo)))');
+				assert.deepEqual([
+					nodeWithChildren('motion', [
+						text('foo')
+					]),
+				], tokens);
+			});
 
-			const tokens2 = analyze('<motion>Strawberry</motion> Pasta');
-			assert.deepEqual([
-				{ type: 'motion', content: '<motion>Strawberry</motion>', motion: 'Strawberry' },
-				{ type: 'text', content: ' Pasta' }
-			], tokens2);
+			it('by triple brackets (with other texts)', () => {
+				const tokens = analyze('bar(((foo)))bar');
+				assert.deepEqual([
+					text('bar'),
+					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', () => {
 			it('local', () => {
-				const tokens = analyze('@himawari お腹ペコい');
+				const tokens = analyze('@himawari foo');
 				assert.deepEqual([
-					{ type: 'mention', content: '@himawari', canonical: '@himawari', username: 'himawari', host: null },
-					{ type: 'text', content: ' お腹ペコい' }
+					node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
+					text(' foo')
 				], tokens);
 			});
 
 			it('remote', () => {
-				const tokens = analyze('@hima_sub@namori.net お腹ペコい');
+				const tokens = analyze('@hima_sub@namori.net foo');
 				assert.deepEqual([
-					{ type: 'mention', content: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
-					{ type: 'text', content: ' お腹ペコい' }
+					node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
+					text(' foo')
 				], tokens);
 			});
 
 			it('remote punycode', () => {
-				const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah お腹ペコい');
+				const tokens = analyze('@hima_sub@xn--q9j5bya.xn--zckzah foo');
 				assert.deepEqual([
-					{ type: 'mention', content: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' },
-					{ type: 'text', content: ' お腹ペコい' }
+					node('mention', { acct: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }),
+					text(' foo')
 				], tokens);
 			});
 
 			it('ignore', () => {
 				const tokens = analyze('idolm@ster');
 				assert.deepEqual([
-					{ type: 'text', content: 'idolm@ster' }
+					text('idolm@ster')
 				], tokens);
 
 				const tokens2 = analyze('@a\n@b\n@c');
 				assert.deepEqual([
-					{ type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null },
-					{ type: 'text', content: '\n' },
-					{ type: 'mention', content: '@b', canonical: '@b', username: 'b', host: null },
-					{ type: 'text', content: '\n' },
-					{ type: 'mention', content: '@c', canonical: '@c', username: 'c', host: null }
+					node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }),
+					text('\n'),
+					node('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }),
+					text('\n'),
+					node('mention', { acct: '@c', canonical: '@c', username: 'c', host: null })
 				], tokens2);
 
 				const tokens3 = analyze('**x**@a');
 				assert.deepEqual([
-					{ type: 'bold', content: '**x**', bold: 'x' },
-					{ type: 'mention', content: '@a', canonical: '@a', username: 'a', host: null }
+					nodeWithChildren('bold', [
+						text('x')
+					]),
+					node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null })
 				], tokens3);
 			});
 		});
@@ -109,172 +165,294 @@ describe('Text', () => {
 		it('hashtag', () => {
 			const tokens1 = analyze('Strawberry Pasta #alice');
 			assert.deepEqual([
-				{ type: 'text', content: 'Strawberry Pasta ' },
-				{ type: 'hashtag', content: '#alice', hashtag: 'alice' }
+				text('Strawberry Pasta '),
+				node('hashtag', { hashtag: 'alice' })
 			], tokens1);
 
 			const tokens2 = analyze('Foo #bar, baz #piyo.');
 			assert.deepEqual([
-				{ type: 'text', content: 'Foo ' },
-				{ type: 'hashtag', content: '#bar', hashtag: 'bar' },
-				{ type: 'text', content: ', baz ' },
-				{ type: 'hashtag', content: '#piyo', hashtag: 'piyo' },
-				{ type: 'text', content: '.' }
+				text('Foo '),
+				node('hashtag', { hashtag: 'bar' }),
+				text(', baz '),
+				node('hashtag', { hashtag: 'piyo' }),
+				text('.'),
 			], tokens2);
 
 			const tokens3 = analyze('#Foo!');
 			assert.deepEqual([
-				{ type: 'hashtag', content: '#Foo', hashtag: 'Foo' },
-				{ type: 'text', content: '!' },
+				node('hashtag', { hashtag: 'Foo' }),
+				text('!'),
 			], tokens3);
 		});
 
-		it('quote', () => {
-			const tokens1 = analyze('> foo\nbar\nbaz');
-			assert.deepEqual([
-				{ type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' }
-			], tokens1);
+		describe('quote', () => {
+			it('basic', () => {
+				const tokens1 = analyze('> foo');
+				assert.deepEqual([
+					nodeWithChildren('quote', [
+						text('foo')
+					])
+				], tokens1);
 
-			const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter');
-			assert.deepEqual([
-				{ type: 'text', content: 'before' },
-				{ type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' },
-				{ type: 'text', content: 'after' }
-			], tokens2);
+				const tokens2 = analyze('>foo');
+				assert.deepEqual([
+					nodeWithChildren('quote', [
+						text('foo')
+					])
+				], tokens2);
+			});
 
-			const tokens3 = analyze('piyo> foo\nbar\nbaz');
-			assert.deepEqual([
-				{ type: 'text', content: 'piyo> foo\nbar\nbaz' }
-			], tokens3);
+			it('series', () => {
+				const tokens = analyze('> foo\n\n> bar');
+				assert.deepEqual([
+					nodeWithChildren('quote', [
+						text('foo')
+					]),
+					nodeWithChildren('quote', [
+						text('bar')
+					]),
+				], tokens);
+			});
 
-			const tokens4 = analyze('> foo\n> bar\n> baz');
-			assert.deepEqual([
-				{ type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' }
-			], tokens4);
+			it('trailing line break', () => {
+				const tokens1 = analyze('> foo\n');
+				assert.deepEqual([
+					nodeWithChildren('quote', [
+						text('foo')
+					]),
+				], tokens1);
 
-			const tokens5 = analyze('"\nfoo\nbar\nbaz\n"');
-			assert.deepEqual([
-				{ type: 'quote', content: '"\nfoo\nbar\nbaz\n"', quote: 'foo\nbar\nbaz' }
-			], tokens5);
+				const tokens2 = analyze('> foo\n\n');
+				assert.deepEqual([
+					nodeWithChildren('quote', [
+						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', () => {
 			it('simple', () => {
 				const tokens = analyze('https://example.com');
-				assert.deepEqual([{
-					type: 'url',
-					content: 'https://example.com',
-					url: 'https://example.com'
-				}], tokens);
+				assert.deepEqual([
+					node('url', { url: 'https://example.com' })
+				], tokens);
 			});
 
 			it('ignore trailing period', () => {
 				const tokens = analyze('https://example.com.');
-				assert.deepEqual([{
-					type: 'url',
-					content: 'https://example.com',
-					url: 'https://example.com'
-				}, {
-					type: 'text', content: '.'
-				}], tokens);
+				assert.deepEqual([
+					node('url', { url: 'https://example.com' }),
+					text('.')
+				], tokens);
 			});
 
 			it('with comma', () => {
 				const tokens = analyze('https://example.com/foo?bar=a,b');
-				assert.deepEqual([{
-					type: 'url',
-					content: 'https://example.com/foo?bar=a,b',
-					url: 'https://example.com/foo?bar=a,b'
-				}], tokens);
+				assert.deepEqual([
+					node('url', { url: 'https://example.com/foo?bar=a,b' })
+				], tokens);
 			});
 
 			it('ignore trailing comma', () => {
 				const tokens = analyze('https://example.com/foo, bar');
-				assert.deepEqual([{
-					type: 'url',
-					content: 'https://example.com/foo',
-					url: 'https://example.com/foo'
-				}, {
-					type: 'text', content: ', bar'
-				}], tokens);
+				assert.deepEqual([
+					node('url', { url: 'https://example.com/foo' }),
+					text(', bar')
+				], tokens);
 			});
 
 			it('with brackets', () => {
 				const tokens = analyze('https://example.com/foo(bar)');
-				assert.deepEqual([{
-					type: 'url',
-					content: 'https://example.com/foo(bar)',
-					url: 'https://example.com/foo(bar)'
-				}], tokens);
+				assert.deepEqual([
+					node('url', { url: 'https://example.com/foo(bar)' })
+				], tokens);
 			});
 
 			it('ignore parent brackets', () => {
 				const tokens = analyze('(https://example.com/foo)');
-				assert.deepEqual([{
-					type: 'text', content: '('
-				}, {
-					type: 'url',
-					content: 'https://example.com/foo',
-					url: 'https://example.com/foo'
-				}, {
-					type: 'text', content: ')'
-				}], tokens);
+				assert.deepEqual([
+					text('('),
+					node('url', { url: 'https://example.com/foo' }),
+					text(')')
+				], tokens);
 			});
 
 			it('ignore parent brackets with internal brackets', () => {
 				const tokens = analyze('(https://example.com/foo(bar))');
-				assert.deepEqual([{
-					type: 'text', content: '('
-				}, {
-					type: 'url',
-					content: 'https://example.com/foo(bar)',
-					url: 'https://example.com/foo(bar)'
-				}, {
-					type: 'text', content: ')'
-				}], tokens);
+				assert.deepEqual([
+					text('('),
+					node('url', { url: 'https://example.com/foo(bar)' }),
+					text(')')
+				], tokens);
 			});
 		});
 
 		it('link', () => {
-			const tokens = analyze('[ひまさく](https://himasaku.net)');
-			assert.deepEqual([{
-				type: 'link',
-				content: '[ひまさく](https://himasaku.net)',
-				title: 'ひまさく',
-				url: 'https://himasaku.net',
-				silent: false
-			}], tokens);
+			const tokens = analyze('[foo](https://example.com)');
+			assert.deepEqual([
+				nodeWithChildren('link', [
+					text('foo')
+				], { url: 'https://example.com', silent: false })
+			], tokens);
 		});
 
 		it('emoji', () => {
 			const tokens1 = analyze(':cat:');
 			assert.deepEqual([
-				{ type: 'emoji', content: ':cat:', name: 'cat' }
+				node('emoji', { name: 'cat' })
 			], tokens1);
 
 			const tokens2 = analyze(':cat::cat::cat:');
 			assert.deepEqual([
-				{ type: 'emoji', content: ':cat:', name: 'cat' },
-				{ type: 'emoji', content: ':cat:', name: 'cat' },
-				{ type: 'emoji', content: ':cat:', name: 'cat' }
+				node('emoji', { name: 'cat' }),
+				node('emoji', { name: 'cat' }),
+				node('emoji', { name: 'cat' })
 			], tokens2);
 
 			const tokens3 = analyze('🍎');
 			assert.deepEqual([
-				{ type: 'emoji', content: '🍎', emoji: '🍎' }
+				node('emoji', { emoji: '🍎' })
 			], tokens3);
 		});
 
-		it('block code', () => {
-			const tokens = analyze('```\nvar x = "Strawberry Pasta";\n```');
-			assert.equal(tokens[0].type, 'code');
-			assert.equal(tokens[0].content, '```\nvar x = "Strawberry Pasta";\n```');
+		describe('block code', () => {
+			it('simple', () => {
+				const tokens = analyze('```\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', () => {
 			const tokens = analyze('`var x = "Strawberry Pasta";`');
-			assert.equal(tokens[0].type, 'inline-code');
-			assert.equal(tokens[0].content, '`var x = "Strawberry Pasta";`');
+			assert.deepEqual([
+				node('inlineCode', { code: 'var x = "Strawberry Pasta";' })
+			], tokens);
 		});
 
 		it('math', () => {
@@ -282,82 +460,88 @@ describe('Text', () => {
 			const text = `\\(${fomula}\\)`;
 			const tokens = analyze(text);
 			assert.deepEqual([
-				{ type: 'math', content: text, formula: fomula }
+				node('math', { formula: fomula })
 			], tokens);
 		});
 
 		it('search', () => {
 			const tokens1 = analyze('a b c 検索');
 			assert.deepEqual([
-				{ type: 'search', content: 'a b c 検索', query: 'a b c' }
+				node('search', { content: 'a b c 検索', query: 'a b c' })
 			], tokens1);
 
 			const tokens2 = analyze('a b c Search');
 			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);
 
 			const tokens3 = analyze('a b c search');
 			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);
 
 			const tokens4 = analyze('a b c SEARCH');
 			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);
 		});
 
-		it('title', () => {
-			const tokens1 = analyze('【yee】\nhaw');
-			assert.deepEqual(
-				{ type: 'title', content: '【yee】\n', title: 'yee' }
-				, tokens1[0]);
+		describe('title', () => {
+			it('simple', () => {
+				const tokens = analyze('【foo】');
+				assert.deepEqual([
+					nodeWithChildren('title', [
+						text('foo')
+					])
+				], tokens);
+			});
 
-			const tokens2 = analyze('[yee]\nhaw');
-			assert.deepEqual(
-				{ type: 'title', content: '[yee]\n', title: 'yee' }
-				, tokens2[0]);
+			it('require line break', () => {
+				const tokens = analyze('a【foo】');
+				assert.deepEqual([
+					text('a【foo】')
+				], tokens);
+			});
 
-			const tokens3 = analyze('a [a]\nb [b]\nc [c]');
-			assert.deepEqual(
-				{ type: 'text', content: 'a [a]\nb [b]\nc [c]' }
-				, tokens3[0]);
-
-			const tokens4 = analyze('foo\n【bar】\nbuzz');
-			assert.deepEqual([
-				{ type: 'text', content: 'foo' },
-				{ type: 'title', content: '\n【bar】\n', title: 'bar' },
-				{ 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>');
+			it('with before and after texts', () => {
+				const tokens = analyze('before\n【foo】\nafter');
+				assert.deepEqual([
+					text('before'),
+					nodeWithChildren('title', [
+						text('foo')
+					]),
+					text('after')
+				], tokens);
+			});
 		});
 	});
 
 	describe('toHtml', () => {
 		it('br', () => {
 			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);
 		});
 	});
+
+	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);
+	});
 });
diff --git a/webpack.config.ts b/webpack.config.ts
index fd552dd21a..aed417ea52 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -38,6 +38,7 @@ module.exports = {
 		dev: './src/client/app/dev/script.ts',
 		auth: './src/client/app/auth/script.ts',
 		admin: './src/client/app/admin/script.ts',
+		test: './src/client/app/test/script.ts',
 		sw: './src/client/app/sw.js'
 	},
 	module: {