diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index b58ba37ecb..8c10bdee28 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -4,7 +4,7 @@ import signin from './signin.vue';
 import signup from './signup.vue';
 import forkit from './forkit.vue';
 import nav from './nav.vue';
-import postHtml from './post-html';
+import postHtml from './post-html.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/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue
index 94f87fd709..25ceab85a1 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -4,13 +4,13 @@
 		<img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/>
 	</router-link>
 	<div class="content">
-		<div class="balloon" :data-no-text="message.text == null">
+		<div class="balloon" :data-no-text="message.textHtml == null">
 			<p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p>
 			<button class="delete-button" v-if="isMe" title="%i18n:common.delete%">
 				<img src="/assets/desktop/messaging/delete.png" alt="Delete"/>
 			</button>
 			<div class="content" v-if="!message.isDeleted">
-				<mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/>
+				<mk-post-html class="text" v-if="message.textHtml" ref="text" :html="message.textHtml" :i="os.i"/>
 				<div class="file" v-if="message.file">
 					<a :href="message.file.url" target="_blank" :title="message.file.name">
 						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
@@ -38,21 +38,32 @@ import getAcct from '../../../../../common/user/get-acct';
 
 export default Vue.extend({
 	props: ['message'],
+	data() {
+		return {
+			urls: []
+		};
+	},
 	computed: {
 		acct() {
 			return getAcct(this.message.user);
 		},
 		isMe(): boolean {
 			return this.message.userId == (this as any).os.i.id;
-		},
-		urls(): string[] {
-			if (this.message.ast) {
-				return this.message.ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
+		}
+	},
+	watch: {
+		message: {
+			handler(newMessage, oldMessage) {
+				if (!oldMessage || newMessage.textHtml !== oldMessage.textHtml) {
+					this.$nextTick(() => {
+						const elements = this.$refs.text.$el.getElementsByTagName('a');
+
+						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
+							.map(({ href }) => href);
+					});
+				}
+			},
+			immediate: true
 		}
 	}
 });
diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts
deleted file mode 100644
index 39d783aac5..0000000000
--- a/src/client/app/common/views/components/post-html.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import Vue from 'vue';
-import * as emojilib from 'emojilib';
-import getAcct from '../../../../../common/user/get-acct';
-import { url } from '../../../config';
-import MkUrl from './url.vue';
-
-const flatten = list => list.reduce(
-	(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
-);
-
-export default Vue.component('mk-post-html', {
-	props: {
-		ast: {
-			type: Array,
-			required: true
-		},
-		shouldBreak: {
-			type: Boolean,
-			default: true
-		},
-		i: {
-			type: Object,
-			default: null
-		}
-	},
-	render(createElement) {
-		const els = flatten((this as any).ast.map(token => {
-			switch (token.type) {
-				case 'text':
-					const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
-
-					if ((this as any).shouldBreak) {
-						const x = text.split('\n')
-							.map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]);
-						x[x.length - 1].pop();
-						return x;
-					} else {
-						return createElement('span', text.replace(/\n/g, ' '));
-					}
-
-				case 'bold':
-					return createElement('strong', token.bold);
-
-				case 'url':
-					return createElement(MkUrl, {
-						props: {
-							url: token.content,
-							target: '_blank'
-						}
-					});
-
-				case 'link':
-					return createElement('a', {
-						attrs: {
-							class: 'link',
-							href: token.url,
-							target: '_blank',
-							title: token.url
-						}
-					}, token.title);
-
-				case 'mention':
-					return (createElement as any)('a', {
-						attrs: {
-							href: `${url}/@${getAcct(token)}`,
-							target: '_blank',
-							dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token)
-						},
-						directives: [{
-							name: 'user-preview',
-							value: token.content
-						}]
-					}, token.content);
-
-				case 'hashtag':
-					return createElement('a', {
-						attrs: {
-							href: `${url}/search?q=${token.content}`,
-							target: '_blank'
-						}
-					}, token.content);
-
-				case 'code':
-					return createElement('pre', [
-						createElement('code', {
-							domProps: {
-								innerHTML: token.html
-							}
-						})
-					]);
-
-				case 'inline-code':
-					return createElement('code', {
-						domProps: {
-							innerHTML: token.html
-						}
-					});
-
-				case 'quote':
-					const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
-
-					if ((this as any).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);
-					} else {
-						return createElement('span', {
-							attrs: {
-								class: 'quote'
-							}
-						}, text2.replace(/\n/g, ' '));
-					}
-
-				case 'emoji':
-					const emoji = emojilib.lib[token.emoji];
-					return createElement('span', emoji ? emoji.char : token.content);
-
-				default:
-					console.log('unknown ast type:', token.type);
-			}
-		}));
-
-		const _els = [];
-		els.forEach((el, i) => {
-			if (el.tag == 'br') {
-				if (els[i - 1].tag != 'div') {
-					_els.push(el);
-				}
-			} else {
-				_els.push(el);
-			}
-		});
-
-		return createElement('span', _els);
-	}
-});
diff --git a/src/client/app/common/views/components/post-html.vue b/src/client/app/common/views/components/post-html.vue
new file mode 100644
index 0000000000..1c949052b9
--- /dev/null
+++ b/src/client/app/common/views/components/post-html.vue
@@ -0,0 +1,103 @@
+<template><div class="mk-post-html" v-html="html"></div></template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getAcct from '../../../../../common/user/get-acct';
+import { url } from '../../../config';
+
+function markUrl(a) {
+	while (a.firstChild) {
+		a.removeChild(a.firstChild);
+	}
+
+	const schema = document.createElement('span');
+	const delimiter = document.createTextNode('//');
+	const host = document.createElement('span');
+	const pathname = document.createElement('span');
+	const query = document.createElement('span');
+	const hash = document.createElement('span');
+
+	schema.className = 'schema';
+	schema.textContent = a.protocol;
+
+	host.className = 'host';
+	host.textContent = a.host;
+
+	pathname.className = 'pathname';
+	pathname.textContent = a.pathname;
+
+	query.className = 'query';
+	query.textContent = a.search;
+
+	hash.className = 'hash';
+	hash.textContent = a.hash;
+
+	a.appendChild(schema);
+	a.appendChild(delimiter);
+	a.appendChild(host);
+	a.appendChild(pathname);
+	a.appendChild(query);
+	a.appendChild(hash);
+}
+
+function markMe(me, a) {
+	a.setAttribute("data-is-me", me && `${url}/@${getAcct(me)}` == a.href);
+}
+
+function markTarget(a) {
+	a.setAttribute("target", "_blank");
+}
+
+export default Vue.component('mk-post-html', {
+	props: {
+		html: {
+			type: String,
+			required: true
+		},
+		i: {
+			type: Object,
+			default: null
+		}
+	},
+	watch {
+		html: {
+			handler() {
+				this.$nextTick(() => [].forEach.call(this.$el.getElementsByTagName('a'), a => {
+					if (a.href === a.textContent) {
+						markUrl(a);
+					} else {
+						markMe((this as any).i, a);
+					}
+
+					markTarget(a);
+				}));
+			},
+			immediate: true,
+		}
+	}
+});
+</script>
+
+<style lang="stylus">
+.mk-post-html
+	a
+		word-break break-all
+
+		> .schema
+			opacity 0.5
+
+		> .host
+			font-weight bold
+
+		> .pathname
+			opacity 0.8
+
+		> .query
+			opacity 0.5
+
+		> .hash
+			font-style italic
+
+	p
+		margin 0
+</style>
diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue
deleted file mode 100644
index 14d4fc82f3..0000000000
--- a/src/client/app/common/views/components/url.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<a class="mk-url" :href="url" :target="target">
-	<span class="schema">{{ schema }}//</span>
-	<span class="hostname">{{ hostname }}</span>
-	<span class="port" v-if="port != ''">:{{ port }}</span>
-	<span class="pathname" v-if="pathname != ''">{{ pathname }}</span>
-	<span class="query">{{ query }}</span>
-	<span class="hash">{{ hash }}</span>
-	%fa:external-link-square-alt%
-</a>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-export default Vue.extend({
-	props: ['url', 'target'],
-	data() {
-		return {
-			schema: null,
-			hostname: null,
-			port: null,
-			pathname: null,
-			query: null,
-			hash: null
-		};
-	},
-	created() {
-		const url = new URL(this.url);
-
-		this.schema = url.protocol;
-		this.hostname = url.hostname;
-		this.port = url.port;
-		this.pathname = url.pathname;
-		this.query = url.search;
-		this.hash = url.hash;
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.mk-url
-	word-break break-all
-
-	> [data-fa]
-		padding-left 2px
-		font-size .9em
-		font-weight 400
-		font-style normal
-
-	> .schema
-		opacity 0.5
-
-	> .hostname
-		font-weight bold
-
-	> .pathname
-		opacity 0.8
-
-	> .query
-		opacity 0.5
-
-	> .hash
-		font-style italic
-
-</style>
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index 8f6199732a..f379029f9f 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -15,7 +15,7 @@
 				</div>
 			</header>
 			<div class="text">
-				<mk-post-html :ast="post.ast"/>
+				<mk-post-html :html="post.textHtml"/>
 			</div>
 		</div>
 	</div>
diff --git a/src/client/app/desktop/views/components/post-detail.sub.vue b/src/client/app/desktop/views/components/post-detail.sub.vue
index 285b5dedee..b6148d9b28 100644
--- a/src/client/app/desktop/views/components/post-detail.sub.vue
+++ b/src/client/app/desktop/views/components/post-detail.sub.vue
@@ -16,7 +16,7 @@
 			</div>
 		</header>
 		<div class="body">
-			<mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/>
+			<mk-post-html v-if="post.textHtml" :html="post.textHtml" :i="os.i" :class="$style.text"/>
 			<div class="media" v-if="post.media > 0">
 				<mk-media-list :media-list="post.media"/>
 			</div>
diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue
index 1811e22bad..e75ebe34b4 100644
--- a/src/client/app/desktop/views/components/post-detail.vue
+++ b/src/client/app/desktop/views/components/post-detail.vue
@@ -38,7 +38,7 @@
 			</router-link>
 		</header>
 		<div class="body">
-			<mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/>
+			<mk-post-html :class="$style.text" v-if="p.text" ref="text" :text="p.text" :i="os.i"/>
 			<div class="media" v-if="p.media.length > 0">
 				<mk-media-list :media-list="p.media"/>
 			</div>
@@ -109,6 +109,7 @@ export default Vue.extend({
 			context: [],
 			contextFetching: false,
 			replies: [],
+			urls: []
 		};
 	},
 	computed: {
@@ -130,15 +131,6 @@ export default Vue.extend({
 		},
 		title(): string {
 			return dateStringify(this.p.createdAt);
-		},
-		urls(): string[] {
-			if (this.p.ast) {
-				return this.p.ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
 		}
 	},
 	mounted() {
@@ -170,6 +162,21 @@ export default Vue.extend({
 			}
 		}
 	},
+	watch: {
+		post: {
+			handler(newPost, oldPost) {
+				if (!oldPost || newPost.text !== oldPost.text) {
+					this.$nextTick(() => {
+						const elements = this.$refs.text.$el.getElementsByTagName('a');
+
+						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
+							.map(({ href }) => href);
+					});
+				}
+			},
+			immediate: true
+		}
+	},
 	methods: {
 		fetchContext() {
 			this.contextFetching = true;
diff --git a/src/client/app/desktop/views/components/posts.post.vue b/src/client/app/desktop/views/components/posts.post.vue
index aa1f1db41c..f3566c81bf 100644
--- a/src/client/app/desktop/views/components/posts.post.vue
+++ b/src/client/app/desktop/views/components/posts.post.vue
@@ -38,7 +38,7 @@
 				</p>
 				<div class="text">
 					<a class="reply" v-if="p.reply">%fa:reply%</a>
-					<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+					<mk-post-html v-if="p.textHtml" ref="text" :html="p.textHtml" :i="os.i" :class="$style.text"/>
 					<a class="rp" v-if="p.repost">RP:</a>
 				</div>
 				<div class="media" v-if="p.media.length > 0">
@@ -112,7 +112,8 @@ export default Vue.extend({
 		return {
 			isDetailOpened: false,
 			connection: null,
-			connectionId: null
+			connectionId: null,
+			urls: []
 		};
 	},
 	computed: {
@@ -140,15 +141,6 @@ export default Vue.extend({
 		},
 		url(): string {
 			return `/@${this.acct}/${this.p.id}`;
-		},
-		urls(): string[] {
-			if (this.p.ast) {
-				return this.p.ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
 		}
 	},
 	created() {
@@ -190,6 +182,21 @@ export default Vue.extend({
 			(this as any).os.stream.dispose(this.connectionId);
 		}
 	},
+	watch: {
+		post: {
+			handler(newPost, oldPost) {
+				if (!oldPost || newPost.textHtml !== oldPost.textHtml) {
+					this.$nextTick(() => {
+						const elements = this.$refs.text.$el.getElementsByTagName('a');
+
+						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
+							.map(({ href }) => href);
+					});
+				}
+			},
+			immediate: true
+		}
+	},
 	methods: {
 		capture(withHandler = false) {
 			if ((this as any).os.isSignedIn) {
@@ -450,7 +457,7 @@ export default Vue.extend({
 					font-size 1.1em
 					color #717171
 
-					>>> .quote
+					>>> blockquote
 						margin 8px
 						padding 6px 12px
 						color #aaa
diff --git a/src/client/app/desktop/views/components/sub-post-content.vue b/src/client/app/desktop/views/components/sub-post-content.vue
index 1f5ce38984..58c81e7552 100644
--- a/src/client/app/desktop/views/components/sub-post-content.vue
+++ b/src/client/app/desktop/views/components/sub-post-content.vue
@@ -2,7 +2,7 @@
 <div class="mk-sub-post-content">
 	<div class="body">
 		<a class="reply" v-if="post.replyId">%fa:reply%</a>
-		<mk-post-html :ast="post.ast" :i="os.i"/>
+		<mk-post-html ref="text" :html="post.textHtml" :i="os.i"/>
 		<a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a>
 	</div>
 	<details v-if="post.media.length > 0">
diff --git a/src/client/app/mobile/views/components/post-detail.vue b/src/client/app/mobile/views/components/post-detail.vue
index 6411011b89..77a73426f2 100644
--- a/src/client/app/mobile/views/components/post-detail.vue
+++ b/src/client/app/mobile/views/components/post-detail.vue
@@ -38,7 +38,7 @@
 			</div>
 		</header>
 		<div class="body">
-			<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+			<mk-post-html v-if="p.text" :ast="p.text" :i="os.i" :class="$style.text"/>
 			<div class="tags" v-if="p.tags && p.tags.length > 0">
 				<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
 			</div>
@@ -103,6 +103,7 @@ export default Vue.extend({
 			context: [],
 			contextFetching: false,
 			replies: [],
+			urls: []
 		};
 	},
 	computed: {
@@ -127,15 +128,6 @@ export default Vue.extend({
 					.map(key => this.p.reactionCounts[key])
 					.reduce((a, b) => a + b)
 				: 0;
-		},
-		urls(): string[] {
-			if (this.p.ast) {
-				return this.p.ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
 		}
 	},
 	mounted() {
@@ -167,6 +159,21 @@ export default Vue.extend({
 			}
 		}
 	},
+	watch: {
+		post: {
+			handler(newPost, oldPost) {
+				if (!oldPost || newPost.text !== oldPost.text) {
+					this.$nextTick(() => {
+						const elements = this.$refs.text.$el.getElementsByTagName('a');
+
+						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
+							.map(({ href }) => href);
+					});
+				}
+			},
+			immediate: true
+		}
+	},
 	methods: {
 		fetchContext() {
 			this.contextFetching = true;
diff --git a/src/client/app/mobile/views/components/post.vue b/src/client/app/mobile/views/components/post.vue
index 52fb095372..96ec9632f1 100644
--- a/src/client/app/mobile/views/components/post.vue
+++ b/src/client/app/mobile/views/components/post.vue
@@ -37,7 +37,7 @@
 					<a class="reply" v-if="p.reply">
 						%fa:reply%
 					</a>
-					<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+					<mk-post-html v-if="p.text" ref="text" :text="p.text" :i="os.i" :class="$style.text"/>
 					<a class="rp" v-if="p.repost != null">RP:</a>
 				</div>
 				<div class="media" v-if="p.media.length > 0">
@@ -90,7 +90,8 @@ export default Vue.extend({
 	data() {
 		return {
 			connection: null,
-			connectionId: null
+			connectionId: null,
+			urls: []
 		};
 	},
 	computed: {
@@ -118,15 +119,6 @@ export default Vue.extend({
 		},
 		url(): string {
 			return `/@${this.pAcct}/${this.p.id}`;
-		},
-		urls(): string[] {
-			if (this.p.ast) {
-				return this.p.ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
 		}
 	},
 	created() {
@@ -168,6 +160,21 @@ export default Vue.extend({
 			(this as any).os.stream.dispose(this.connectionId);
 		}
 	},
+	watch: {
+		post: {
+			handler(newPost, oldPost) {
+				if (!oldPost || newPost.text !== oldPost.text) {
+					this.$nextTick(() => {
+						const elements = this.$refs.text.$el.getElementsByTagName('a');
+
+						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
+							.map(({ href }) => href);
+					});
+				}
+			},
+			immediate: true
+		}
+	},
 	methods: {
 		capture(withHandler = false) {
 			if ((this as any).os.isSignedIn) {
@@ -389,7 +396,7 @@ export default Vue.extend({
 					font-size 1.1em
 					color #717171
 
-					>>> .quote
+					>>> blockquote
 						margin 8px
 						padding 6px 12px
 						color #aaa
diff --git a/src/client/app/mobile/views/components/sub-post-content.vue b/src/client/app/mobile/views/components/sub-post-content.vue
index 5ff88089aa..955bb406b4 100644
--- a/src/client/app/mobile/views/components/sub-post-content.vue
+++ b/src/client/app/mobile/views/components/sub-post-content.vue
@@ -2,7 +2,7 @@
 <div class="mk-sub-post-content">
 	<div class="body">
 		<a class="reply" v-if="post.replyId">%fa:reply%</a>
-		<mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/>
+		<mk-post-html v-if="post.text" :ast="post.text" :i="os.i"/>
 		<a class="rp" v-if="post.repostId">RP: ...</a>
 	</div>
 	<details v-if="post.media.length > 0">
diff --git a/src/client/docs/api/entities/post.yaml b/src/client/docs/api/entities/post.yaml
index da79866ba1..7077700129 100644
--- a/src/client/docs/api/entities/post.yaml
+++ b/src/client/docs/api/entities/post.yaml
@@ -27,8 +27,14 @@ props:
     type: "string"
     optional: true
     desc:
-      ja: "投稿の本文"
-      en: "The text of this post"
+      ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)"
+      en: "The text of this post (in Markdown like format if local)"
+  - name: "textHtml"
+    type: "string"
+    optional: true
+    desc:
+      ja: "投稿の本文 (HTML) (投稿時は無視)"
+      en: "The text of this post (in HTML. Ignored when posting.)"
   - name: "mediaIds"
     type: "id(DriveFile)[]"
     optional: true
diff --git a/src/common/text/html.ts b/src/common/text/html.ts
new file mode 100644
index 0000000000..797f3b3f33
--- /dev/null
+++ b/src/common/text/html.ts
@@ -0,0 +1,83 @@
+import { lib as emojilib } from 'emojilib';
+import { JSDOM } from 'jsdom';
+
+const handlers = {
+	bold({ document }, { bold }) {
+		const b = document.createElement('b');
+		b.textContent = bold;
+		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 = '/search?q=#' + hashtag;
+		a.textContent = hashtag;
+	},
+
+	'inline-code'({ document }, { code }) {
+		const element = document.createElement('code');
+		element.textContent = code;
+		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 }) {
+		const a = document.createElement('a');
+		a.href = '/' + content;
+		a.textContent = content;
+		document.body.appendChild(a);
+	},
+
+	quote({ document }, { quote }) {
+		const blockquote = document.createElement('blockquote');
+		blockquote.textContent = quote;
+		document.body.appendChild(blockquote);
+	},
+
+	text({ document }, { content }) {
+		for (const text of content.split('\n')) {
+			const node = document.createTextNode(text);
+			document.body.appendChild(node);
+
+			const br = document.createElement('br');
+			document.body.appendChild(br);
+		}
+	},
+
+	url({ document }, { url }) {
+		const a = document.createElement('a');
+		a.href = url;
+		a.textContent = url;
+		document.body.appendChild(a);
+	}
+};
+
+export default tokens => {
+	const { window } = new JSDOM('');
+
+	for (const token of tokens) {
+		handlers[token.type](window, token);
+	}
+
+	return `<p>${window.document.body.innerHTML}</p>`;
+};
diff --git a/src/common/text/core/syntax-highlighter.ts b/src/common/text/parse/core/syntax-highlighter.ts
similarity index 100%
rename from src/common/text/core/syntax-highlighter.ts
rename to src/common/text/parse/core/syntax-highlighter.ts
diff --git a/src/common/text/elements/bold.ts b/src/common/text/parse/elements/bold.ts
similarity index 100%
rename from src/common/text/elements/bold.ts
rename to src/common/text/parse/elements/bold.ts
diff --git a/src/common/text/elements/code.ts b/src/common/text/parse/elements/code.ts
similarity index 100%
rename from src/common/text/elements/code.ts
rename to src/common/text/parse/elements/code.ts
diff --git a/src/common/text/elements/emoji.ts b/src/common/text/parse/elements/emoji.ts
similarity index 100%
rename from src/common/text/elements/emoji.ts
rename to src/common/text/parse/elements/emoji.ts
diff --git a/src/common/text/elements/hashtag.ts b/src/common/text/parse/elements/hashtag.ts
similarity index 100%
rename from src/common/text/elements/hashtag.ts
rename to src/common/text/parse/elements/hashtag.ts
diff --git a/src/common/text/elements/inline-code.ts b/src/common/text/parse/elements/inline-code.ts
similarity index 100%
rename from src/common/text/elements/inline-code.ts
rename to src/common/text/parse/elements/inline-code.ts
diff --git a/src/common/text/elements/link.ts b/src/common/text/parse/elements/link.ts
similarity index 100%
rename from src/common/text/elements/link.ts
rename to src/common/text/parse/elements/link.ts
diff --git a/src/common/text/elements/mention.ts b/src/common/text/parse/elements/mention.ts
similarity index 82%
rename from src/common/text/elements/mention.ts
rename to src/common/text/parse/elements/mention.ts
index d05a76649d..2025dfdaad 100644
--- a/src/common/text/elements/mention.ts
+++ b/src/common/text/parse/elements/mention.ts
@@ -1,7 +1,7 @@
 /**
  * Mention
  */
-import parseAcct from '../../../common/user/parse-acct';
+import parseAcct from '../../../../common/user/parse-acct';
 
 module.exports = text => {
 	const match = text.match(/^(?:@[a-zA-Z0-9\-]+){1,2}/);
diff --git a/src/common/text/elements/quote.ts b/src/common/text/parse/elements/quote.ts
similarity index 100%
rename from src/common/text/elements/quote.ts
rename to src/common/text/parse/elements/quote.ts
diff --git a/src/common/text/elements/url.ts b/src/common/text/parse/elements/url.ts
similarity index 100%
rename from src/common/text/elements/url.ts
rename to src/common/text/parse/elements/url.ts
diff --git a/src/common/text/index.ts b/src/common/text/parse/index.ts
similarity index 100%
rename from src/common/text/index.ts
rename to src/common/text/parse/index.ts
diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts
index 8bee657c34..974ee54ab8 100644
--- a/src/models/messaging-message.ts
+++ b/src/models/messaging-message.ts
@@ -3,7 +3,6 @@ import deepcopy = require('deepcopy');
 import { pack as packUser } from './user';
 import { pack as packFile } from './drive-file';
 import db from '../db/mongodb';
-import parse from '../common/text';
 
 const MessagingMessage = db.get<IMessagingMessage>('messagingMessages');
 export default MessagingMessage;
@@ -12,6 +11,7 @@ export interface IMessagingMessage {
 	_id: mongo.ObjectID;
 	createdAt: Date;
 	text: string;
+	textHtml: string;
 	userId: mongo.ObjectID;
 	recipientId: mongo.ObjectID;
 	isRead: boolean;
@@ -60,11 +60,6 @@ export const pack = (
 	_message.id = _message._id;
 	delete _message._id;
 
-	// Parse text
-	if (_message.text) {
-		_message.ast = parse(_message.text);
-	}
-
 	// Populate user
 	_message.user = await packUser(_message.userId, me);
 
diff --git a/src/models/post.ts b/src/models/post.ts
index 9bc0c1d3b9..6c853e4f81 100644
--- a/src/models/post.ts
+++ b/src/models/post.ts
@@ -8,7 +8,6 @@ import { pack as packChannel } from './channel';
 import Vote from './poll-vote';
 import Reaction from './post-reaction';
 import { pack as packFile } from './drive-file';
-import parse from '../common/text';
 
 const Post = db.get<IPost>('posts');
 
@@ -31,6 +30,7 @@ export type IPost = {
 	repostId: mongo.ObjectID;
 	poll: any; // todo
 	text: string;
+	textHtml: string;
 	cw: string;
 	userId: mongo.ObjectID;
 	appId: mongo.ObjectID;
@@ -103,11 +103,6 @@ export const pack = async (
 	delete _post.mentions;
 	if (_post.geo) delete _post.geo.type;
 
-	// Parse text
-	if (_post.text) {
-		_post.ast = parse(_post.text);
-	}
-
 	// Populate user
 	_post.user = packUser(_post.userId, meId);
 
diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts
index d8ffa9fdec..3d3b204da5 100644
--- a/src/server/api/endpoints/messaging/messages/create.ts
+++ b/src/server/api/endpoints/messaging/messages/create.ts
@@ -11,6 +11,8 @@ import DriveFile from '../../../../../models/drive-file';
 import { pack } from '../../../../../models/messaging-message';
 import publishUserStream from '../../../event';
 import { publishMessagingStream, publishMessagingIndexStream, pushSw } from '../../../event';
+import html from '../../../../../common/text/html';
+import parse from '../../../../../common/text/parse';
 import config from '../../../../../conf';
 
 /**
@@ -74,6 +76,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 		fileId: file ? file._id : undefined,
 		recipientId: recipient._id,
 		text: text ? text : undefined,
+		textHtml: text ? html(parse(text)) : undefined,
 		userId: user._id,
 		isRead: false
 	});
diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts
index aa7e93c28f..5342f77728 100644
--- a/src/server/api/endpoints/posts/create.ts
+++ b/src/server/api/endpoints/posts/create.ts
@@ -3,7 +3,8 @@
  */
 import $ from 'cafy';
 import deepEqual = require('deep-equal');
-import parse from '../../../../common/text';
+import html from '../../../../common/text/html';
+import parse from '../../../../common/text/parse';
 import { default as Post, IPost, isValidText, isValidCw } from '../../../../models/post';
 import { default as User, ILocalAccount, IUser } from '../../../../models/user';
 import { default as Channel, IChannel } from '../../../../models/channel';
@@ -259,6 +260,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 		repostId: repost ? repost._id : undefined,
 		poll: poll,
 		text: text,
+		textHtml: tokens === null ? null : html(tokens),
 		cw: cw,
 		tags: tags,
 		userId: user._id,
diff --git a/tools/migration/nighthike/7.js b/tools/migration/nighthike/7.js
new file mode 100644
index 0000000000..c5055da8ba
--- /dev/null
+++ b/tools/migration/nighthike/7.js
@@ -0,0 +1,16 @@
+// for Node.js interpretation
+
+const Message = require('../../../built/models/messaging-message').default;
+const Post = require('../../../built/models/post').default;
+const html = require('../../../built/common/text/html').default;
+const parse = require('../../../built/common/text/parse').default;
+
+Promise.all([Message, Post].map(async model => {
+	const documents = await model.find();
+
+	return Promise.all(documents.map(({ _id, text }) => model.update(_id, {
+		$set: {
+			textHtml: html(parse(text))
+		}
+	})));
+})).catch(console.error).then(process.exit);