diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts
index 36b8ca32c..dea36cd2a 100644
--- a/src/client/app/common/scripts/note-mixin.ts
+++ b/src/client/app/common/scripts/note-mixin.ts
@@ -80,8 +80,8 @@ export default (opts: Opts = {}) => ({
 				const ast = parse(this.appearNote.text);
 				// TODO: 再帰的にURL要素がないか調べる
 				return unique(ast
-					.filter(t => ((t.name == 'url' || t.name == 'link') && t.props.url && !t.props.silent))
-					.map(t => t.props.url));
+					.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
+					.map(t => t.node.props.url));
 			} else {
 				return null;
 			}
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 fa77fa7af..872dc2d89 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -52,8 +52,8 @@ export default Vue.extend({
 			if (this.message.text) {
 				const ast = parse(this.message.text);
 				return unique(ast
-					.filter(t => ((t.name == 'url' || t.name == 'link') && t.props.url && !t.silent))
-					.map(t => t.props.url));
+					.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
+					.map(t => t.node.props.url));
 			} else {
 				return null;
 			}
diff --git a/src/client/app/common/views/components/mfm.ts b/src/client/app/common/views/components/mfm.ts
index 399e8e884..69ae7638a 100644
--- a/src/client/app/common/views/components/mfm.ts
+++ b/src/client/app/common/views/components/mfm.ts
@@ -1,6 +1,6 @@
 import Vue, { VNode } from 'vue';
 import { length } from 'stringz';
-import { Node } from '../../../../../mfm/parser';
+import { MfmForest } from '../../../../../mfm/parser';
 import parse from '../../../../../mfm/parse';
 import MkUrl from './url.vue';
 import MkMention from './mention.vue';
@@ -9,16 +9,11 @@ import MkFormula from './formula.vue';
 import MkGoogle from './google.vue';
 import syntaxHighlight from '../../../../../mfm/syntax-highlight';
 import { host } from '../../../config';
+import { preorderF, countNodesF } from '../../../../../prelude/tree';
 
-function getTextCount(tokens: Node[]): number {
-	const rootCount = sum(tokens.filter(x => x.name === 'text').map(x => length(x.props.text)));
-	const childrenCount = sum(tokens.filter(x => x.children).map(x => getTextCount(x.children)));
-	return rootCount + childrenCount;
-}
-
-function getChildrenCount(tokens: Node[]): number {
-	const countTree = tokens.filter(x => x.children).map(x => getChildrenCount(x.children));
-	return countTree.length + sum(countTree);
+function sumTextsLength(ts: MfmForest): number {
+	const textNodes = preorderF(ts).filter(n => n.type === 'text');
+	return sum(textNodes.map(x => length(x.props.text)));
 }
 
 export default Vue.component('misskey-flavored-markdown', {
@@ -27,10 +22,6 @@ export default Vue.component('misskey-flavored-markdown', {
 			type: String,
 			required: true
 		},
-		ast: {
-			type: [],
-			required: false
-		},
 		shouldBreak: {
 			type: Boolean,
 			default: true
@@ -55,17 +46,15 @@ export default Vue.component('misskey-flavored-markdown', {
 	render(createElement) {
 		if (this.text == null || this.text == '') return;
 
-		const ast = this.ast == null ?
-			parse(this.text, this.plainText) : // Parse text to ast
-			this.ast as Node[];
+		const ast = parse(this.text, this.plainText);
 
 		let bigCount = 0;
 		let motionCount = 0;
 
-		const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => {
-			switch (token.name) {
+		const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => {
+			switch (token.node.type) {
 				case 'text': {
-					const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+					const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n');
 
 					if (this.shouldBreak) {
 						const x = text.split('\n')
@@ -95,7 +84,7 @@ export default Vue.component('misskey-flavored-markdown', {
 
 				case 'big': {
 					bigCount++;
-					const isLong = getTextCount(token.children) > 10 || getChildrenCount(token.children) > 5;
+					const isLong = sumTextsLength(token.children) > 10 || countNodesF(token.children) > 5;
 					const isMany = bigCount > 3;
 					return (createElement as any)('strong', {
 						attrs: {
@@ -122,7 +111,7 @@ export default Vue.component('misskey-flavored-markdown', {
 
 				case 'motion': {
 					motionCount++;
-					const isLong = getTextCount(token.children) > 10 || getChildrenCount(token.children) > 5;
+					const isLong = sumTextsLength(token.children) > 10 || countNodesF(token.children) > 5;
 					const isMany = motionCount > 3;
 					return (createElement as any)('span', {
 						attrs: {
@@ -139,7 +128,7 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement(MkUrl, {
 						key: Math.random(),
 						props: {
-							url: token.props.url,
+							url: token.node.props.url,
 							target: '_blank',
 							style: 'color:var(--mfmLink);'
 						}
@@ -150,9 +139,9 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement('a', {
 						attrs: {
 							class: 'link',
-							href: token.props.url,
+							href: token.node.props.url,
 							target: '_blank',
-							title: token.props.url,
+							title: token.node.props.url,
 							style: 'color:var(--mfmLink);'
 						}
 					}, genEl(token.children))];
@@ -162,8 +151,8 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement(MkMention, {
 						key: Math.random(),
 						props: {
-							host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
-							username: token.props.username
+							host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host,
+							username: token.node.props.username
 						}
 					})];
 				}
@@ -172,10 +161,10 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement('router-link', {
 						key: Math.random(),
 						attrs: {
-							to: `/tags/${encodeURIComponent(token.props.hashtag)}`,
+							to: `/tags/${encodeURIComponent(token.node.props.hashtag)}`,
 							style: 'color:var(--mfmHashtag);'
 						}
-					}, `#${token.props.hashtag}`)];
+					}, `#${token.node.props.hashtag}`)];
 				}
 
 				case 'blockCode': {
@@ -184,7 +173,7 @@ export default Vue.component('misskey-flavored-markdown', {
 					}, [
 						createElement('code', {
 							domProps: {
-								innerHTML: syntaxHighlight(token.props.code)
+								innerHTML: syntaxHighlight(token.node.props.code)
 							}
 						})
 					])];
@@ -193,7 +182,7 @@ export default Vue.component('misskey-flavored-markdown', {
 				case 'inlineCode': {
 					return [createElement('code', {
 						domProps: {
-							innerHTML: syntaxHighlight(token.props.code)
+							innerHTML: syntaxHighlight(token.node.props.code)
 						}
 					})];
 				}
@@ -227,8 +216,8 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement('mk-emoji', {
 						key: Math.random(),
 						attrs: {
-							emoji: token.props.emoji,
-							name: token.props.name
+							emoji: token.node.props.emoji,
+							name: token.node.props.name
 						},
 						props: {
 							customEmojis: this.customEmojis || customEmojis,
@@ -242,7 +231,7 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement(MkFormula, {
 						key: Math.random(),
 						props: {
-							formula: token.props.formula
+							formula: token.node.props.formula
 						}
 					})];
 				}
@@ -252,13 +241,13 @@ export default Vue.component('misskey-flavored-markdown', {
 					return [createElement(MkGoogle, {
 						key: Math.random(),
 						props: {
-							q: token.props.query
+							q: token.node.props.query
 						}
 					})];
 				}
 
 				default: {
-					console.log('unknown ast type:', token.name);
+					console.log('unknown ast type:', token.node.type);
 
 					return [];
 				}
diff --git a/src/mfm/html.ts b/src/mfm/html.ts
index 8712add05..6af283385 100644
--- a/src/mfm/html.ts
+++ b/src/mfm/html.ts
@@ -2,10 +2,10 @@ const jsdom = require('jsdom');
 const { JSDOM } = jsdom;
 import config from '../config';
 import { INote } from '../models/note';
-import { Node } from './parser';
 import { intersperse } from '../prelude/array';
+import { MfmForest, MfmTree } from './parser';
 
-export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => {
+export default (tokens: MfmForest, mentionedRemoteUsers: INote['mentionedRemoteUsers'] = []) => {
 	if (tokens == null) {
 		return null;
 	}
@@ -14,11 +14,11 @@ export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUser
 
 	const doc = window.document;
 
-	function appendChildren(children: Node[], targetElement: any): void {
-		for (const child of children.map(n => handlers[n.name](n))) targetElement.appendChild(child);
+	function appendChildren(children: MfmForest, targetElement: any): void {
+		for (const child of children.map(t => handlers[t.node.type](t))) targetElement.appendChild(child);
 	}
 
-	const handlers: { [key: string]: (token: Node) => any } = {
+	const handlers: { [key: string]: (token: MfmTree) => any } = {
 		bold(token) {
 			const el = doc.createElement('b');
 			appendChildren(token.children, el);
@@ -58,7 +58,7 @@ export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUser
 		blockCode(token) {
 			const pre = doc.createElement('pre');
 			const inner = doc.createElement('code');
-			inner.innerHTML = token.props.code;
+			inner.innerHTML = token.node.props.code;
 			pre.appendChild(inner);
 			return pre;
 		},
@@ -70,39 +70,39 @@ export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUser
 		},
 
 		emoji(token) {
-			return doc.createTextNode(token.props.emoji ? token.props.emoji : `:${token.props.name}:`);
+			return doc.createTextNode(token.node.props.emoji ? token.node.props.emoji : `:${token.node.props.name}:`);
 		},
 
 		hashtag(token) {
 			const a = doc.createElement('a');
-			a.href = `${config.url}/tags/${token.props.hashtag}`;
-			a.textContent = `#${token.props.hashtag}`;
+			a.href = `${config.url}/tags/${token.node.props.hashtag}`;
+			a.textContent = `#${token.node.props.hashtag}`;
 			a.setAttribute('rel', 'tag');
 			return a;
 		},
 
 		inlineCode(token) {
 			const el = doc.createElement('code');
-			el.textContent = token.props.code;
+			el.textContent = token.node.props.code;
 			return el;
 		},
 
 		math(token) {
 			const el = doc.createElement('code');
-			el.textContent = token.props.formula;
+			el.textContent = token.node.props.formula;
 			return el;
 		},
 
 		link(token) {
 			const a = doc.createElement('a');
-			a.href = token.props.url;
+			a.href = token.node.props.url;
 			appendChildren(token.children, a);
 			return a;
 		},
 
 		mention(token) {
 			const a = doc.createElement('a');
-			const { username, host, acct } = token.props;
+			const { username, host, acct } = token.node.props;
 			switch (host) {
 				case 'github.com':
 					a.href = `https://github.com/${username}`;
@@ -133,7 +133,7 @@ export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUser
 
 		text(token) {
 			const el = doc.createElement('span');
-			const nodes = (token.props.text as string).split('\n').map(x => doc.createTextNode(x));
+			const nodes = (token.node.props.text as string).split('\n').map(x => doc.createTextNode(x));
 
 			for (const x of intersperse('br', nodes)) {
 				el.appendChild(x === 'br' ? doc.createElement('br') : x);
@@ -144,15 +144,15 @@ export default (tokens: Node[], mentionedRemoteUsers: INote['mentionedRemoteUser
 
 		url(token) {
 			const a = doc.createElement('a');
-			a.href = token.props.url;
-			a.textContent = token.props.url;
+			a.href = token.node.props.url;
+			a.textContent = token.node.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;
+			a.href = `https://www.google.com/?#q=${token.node.props.query}`;
+			a.textContent = token.node.props.content;
 			return a;
 		}
 	};
diff --git a/src/mfm/parse.ts b/src/mfm/parse.ts
index 58e126be3..21e4ca651 100644
--- a/src/mfm/parse.ts
+++ b/src/mfm/parse.ts
@@ -1,40 +1,36 @@
-import parser, { Node, plainParser } from './parser';
+import parser, { plainParser, MfmForest, MfmTree } from './parser';
 import * as A from '../prelude/array';
 import * as S from '../prelude/string';
+import { createTree, createLeaf } from '../prelude/tree';
 
-export default (source: string, plainText = false): Node[] => {
+function concatTextTrees(ts: MfmForest): MfmTree {
+	return createLeaf({ type: 'text', props: { text: S.concat(ts.map(x => x.node.props.text)) } });
+}
+
+function concatIfTextTrees(ts: MfmForest): MfmForest {
+	return ts[0].node.type === 'text' ? [concatTextTrees(ts)] : ts;
+}
+
+function concatConsecutiveTextTrees(ts: MfmForest): MfmForest {
+	const us = A.concat(A.groupOn(t => t.node.type, ts).map(concatIfTextTrees));
+	return us.map(t => createTree(t.node, concatConsecutiveTextTrees(t.children)));
+}
+
+function isEmptyTextTree(t: MfmTree): boolean {
+	return t.node.type == 'text' && t.node.props.text === '';
+}
+
+function removeEmptyTextNodes(ts: MfmForest): MfmForest {
+	return ts
+		.filter(t => !isEmptyTextTree(t))
+		.map(t => createTree(t.node, removeEmptyTextNodes(t.children)));
+}
+
+export default (source: string, plainText = false): MfmForest => {
 	if (source == null || source == '') {
 		return null;
 	}
 
-	let nodes: Node[] = plainText ? plainParser.root.tryParse(source) : 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 => {
-		for (const x of es.filter(x => x.children)) {
-			x.children = concatText(x.children);
-			concatTextRecursive(x.children);
-		}
-	};
-
-	nodes = concatText(nodes);
-	concatTextRecursive(nodes);
-
-	const removeEmptyTextNodes = (nodes: Node[]) => {
-		for (const n of nodes.filter(n => n.children)) {
-			n.children = removeEmptyTextNodes(n.children);
-		}
-		return nodes.filter(n => !(n.name == 'text' && n.props.text == ''));
-	};
-
-	nodes = removeEmptyTextNodes(nodes);
-
-	return nodes;
+	const raw = plainText ? plainParser.root.tryParse(source) : parser.root.tryParse(source) as MfmForest;
+	return removeEmptyTextNodes(concatConsecutiveTextTrees(raw));
 };
diff --git a/src/mfm/parser.ts b/src/mfm/parser.ts
index 56c49ba3f..885b7e01c 100644
--- a/src/mfm/parser.ts
+++ b/src/mfm/parser.ts
@@ -2,41 +2,44 @@ import * as P from 'parsimmon';
 import parseAcct from '../misc/acct/parse';
 import { toUnicode } from 'punycode';
 import { takeWhile } from '../prelude/array';
+import { Tree } from '../prelude/tree';
+import * as T from '../prelude/tree';
 
 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;
-};
+type Node<T, P> = { type: T, props: P };
 
-export interface IMentionNode extends Node {
-	props: {
-		canonical: string;
-		username: string;
-		host: string;
-		acct: string;
-	};
+export type MentionNode = Node<'mention', {
+	canonical: string,
+	username: string,
+	host: string,
+	acct: string
+}>;
+
+export type HashtagNode = Node<'hashtag', {
+	hashtag: string
+}>;
+
+export type EmojiNode = Node<'emoji', {
+	name: string
+}>;
+
+export type MfmNode =
+	MentionNode |
+	HashtagNode |
+	EmojiNode |
+	Node<string, any>;
+
+export type MfmTree = Tree<MfmNode>;
+
+export type MfmForest = MfmTree[];
+
+export function createLeaf(type: string, props: any): MfmTree {
+	return T.createLeaf({ type, props });
 }
 
-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);
+export function createTree(type: string, children: MfmForest, props: any): MfmTree {
+	return T.createTree({ type, props }, children);
 }
 
 function getTrailingPosition(x: string): number {
@@ -79,17 +82,17 @@ export const plainParser = P.createLanguage({
 		r.text
 	).atLeast(1),
 
-	text: () => P.any.map(x => makeNode('text', { text: x })),
+	text: () => P.any.map(x => createLeaf('text', { text: x })),
 
 	//#region Emoji
 	emoji: r =>
 		P.alt(
 			P.regexp(/:([a-z0-9_+-]+):/i, 1)
-			.map(x => makeNode('emoji', {
+			.map(x => createLeaf('emoji', {
 				name: x
 			})),
 			P.regexp(emojiRegex)
-			.map(x => makeNode('emoji', {
+			.map(x => createLeaf('emoji', {
 				emoji: x
 			})),
 		),
@@ -119,12 +122,12 @@ const mfm = P.createLanguage({
 		r.text
 	).atLeast(1),
 
-	text: () => P.any.map(x => makeNode('text', { text: x })),
+	text: () => P.any.map(x => createLeaf('text', { text: x })),
 
 	//#region Big
 	big: r =>
 		P.regexp(/^\*\*\*([\s\S]+?)\*\*\*/, 1)
-		.map(x => makeNodeWithChildren('big', P.alt(
+		.map(x => createTree('big', P.alt(
 			r.strike,
 			r.italic,
 			r.mention,
@@ -132,13 +135,13 @@ const mfm = P.createLanguage({
 			r.emoji,
 			r.math,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Small
 	small: r =>
 		P.regexp(/<small>([\s\S]+?)<\/small>/, 1)
-		.map(x => makeNodeWithChildren('small', P.alt(
+		.map(x => createTree('small', P.alt(
 			r.strike,
 			r.italic,
 			r.mention,
@@ -146,7 +149,7 @@ const mfm = P.createLanguage({
 			r.emoji,
 			r.math,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Block code
@@ -156,7 +159,7 @@ const mfm = P.createLanguage({
 				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 }));
+				return P.makeSuccess(i + match[0].length, createLeaf('blockCode', { code: match[2], lang: match[1] ? match[1].trim() : null }));
 			})
 		),
 	//#endregion
@@ -164,7 +167,7 @@ const mfm = P.createLanguage({
 	//#region Bold
 	bold: r =>
 		P.regexp(/\*\*([\s\S]+?)\*\*/, 1)
-		.map(x => makeNodeWithChildren('bold', P.alt(
+		.map(x => createTree('bold', P.alt(
 			r.strike,
 			r.italic,
 			r.mention,
@@ -173,13 +176,13 @@ const mfm = P.createLanguage({
 			r.link,
 			r.emoji,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Center
 	center: r =>
 		P.regexp(/<center>([\s\S]+?)<\/center>/, 1)
-		.map(x => makeNodeWithChildren('center', P.alt(
+		.map(x => createTree('center', P.alt(
 			r.big,
 			r.small,
 			r.bold,
@@ -193,18 +196,18 @@ const mfm = P.createLanguage({
 			r.url,
 			r.link,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Emoji
 	emoji: r =>
 		P.alt(
 			P.regexp(/:([a-z0-9_+-]+):/i, 1)
-			.map(x => makeNode('emoji', {
+			.map(x => createLeaf('emoji', {
 				name: x
 			})),
 			P.regexp(emojiRegex)
-			.map(x => makeNode('emoji', {
+			.map(x => createLeaf('emoji', {
 				emoji: x
 			})),
 		),
@@ -221,20 +224,20 @@ const mfm = P.createLanguage({
 			if (hashtag.match(/^[0-9]+$/)) return P.makeFailure(i, 'not a hashtag');
 			if (input[i - 1] != null && input[i - 1].match(/[a-z0-9]/i)) return P.makeFailure(i, 'not a hashtag');
 			if (hashtag.length > 50) return P.makeFailure(i, 'not a hashtag');
-			return P.makeSuccess(i + ('#' + hashtag).length, makeNode('hashtag', { hashtag: hashtag }));
+			return P.makeSuccess(i + ('#' + hashtag).length, createLeaf('hashtag', { hashtag: hashtag }));
 		}),
 	//#endregion
 
 	//#region Inline code
 	inlineCode: r =>
 		P.regexp(/`([^´\n]+?)`/, 1)
-		.map(x => makeNode('inlineCode', { code: x })),
+		.map(x => createLeaf('inlineCode', { code: x })),
 	//#endregion
 
 	//#region Italic
 	italic: r =>
 		P.regexp(/<i>([\s\S]+?)<\/i>/, 1)
-		.map(x => makeNodeWithChildren('italic', P.alt(
+		.map(x => createTree('italic', P.alt(
 			r.bold,
 			r.strike,
 			r.mention,
@@ -243,7 +246,7 @@ const mfm = P.createLanguage({
 			r.link,
 			r.emoji,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Link
@@ -258,7 +261,7 @@ const mfm = P.createLanguage({
 			P.string(')'),
 		)
 		.map((x: any) => {
-			return makeNodeWithChildren('link', P.alt(
+			return createTree('link', P.alt(
 				r.big,
 				r.small,
 				r.bold,
@@ -269,7 +272,7 @@ const mfm = P.createLanguage({
 				r.text
 			).atLeast(1).tryParse(x.text), {
 				silent: x.silent,
-				url: x.url.props.url
+				url: x.url.node.props.url
 			});
 		}),
 	//#endregion
@@ -277,7 +280,7 @@ const mfm = P.createLanguage({
 	//#region Math
 	math: r =>
 		P.regexp(/\\\((.+?)\\\)/, 1)
-		.map(x => makeNode('math', { formula: x })),
+		.map(x => createLeaf('math', { formula: x })),
 	//#endregion
 
 	//#region Mention
@@ -292,7 +295,7 @@ const mfm = P.createLanguage({
 		.map(x => {
 			const { username, host } = parseAcct(x.substr(1));
 			const canonical = host != null ? `@${username}@${toUnicode(host)}` : x;
-			return makeNode('mention', {
+			return createLeaf('mention', {
 				canonical, username, host, acct: x
 			});
 		}),
@@ -301,7 +304,7 @@ const mfm = P.createLanguage({
 	//#region Motion
 	motion: r =>
 		P.alt(P.regexp(/\(\(\(([\s\S]+?)\)\)\)/, 1), P.regexp(/<motion>(.+?)<\/motion>/, 1))
-		.map(x => makeNodeWithChildren('motion', P.alt(
+		.map(x => createTree('motion', P.alt(
 			r.bold,
 			r.small,
 			r.strike,
@@ -313,7 +316,7 @@ const mfm = P.createLanguage({
 			r.link,
 			r.math,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Quote
@@ -325,7 +328,7 @@ const mfm = P.createLanguage({
 			const qInner = quote.join('\n').replace(/^>/gm, '').replace(/^ /gm, '');
 			if (qInner == '') return P.makeFailure(i, 'not a quote');
 			const contents = r.root.tryParse(qInner);
-			return P.makeSuccess(i + quote.join('\n').length + 1, makeNodeWithChildren('quote', contents));
+			return P.makeSuccess(i + quote.join('\n').length + 1, createTree('quote', contents, {}));
 		})),
 	//#endregion
 
@@ -335,14 +338,14 @@ const mfm = P.createLanguage({
 			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() }));
+			return P.makeSuccess(i + match[0].length, createLeaf('search', { query: match[1], content: match[0].trim() }));
 		})),
 	//#endregion
 
 	//#region Strike
 	strike: r =>
 		P.regexp(/~~(.+?)~~/, 1)
-		.map(x => makeNodeWithChildren('strike', P.alt(
+		.map(x => createTree('strike', P.alt(
 			r.bold,
 			r.italic,
 			r.mention,
@@ -351,7 +354,7 @@ const mfm = P.createLanguage({
 			r.link,
 			r.emoji,
 			r.text
-		).atLeast(1).tryParse(x))),
+		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
 	//#region Title
@@ -376,7 +379,7 @@ const mfm = P.createLanguage({
 				r.inlineCode,
 				r.text
 			).atLeast(1).tryParse(q);
-			return P.makeSuccess(i + match[0].length, makeNodeWithChildren('title', contents));
+			return P.makeSuccess(i + match[0].length, createTree('title', contents, {}));
 		})),
 	//#endregion
 
@@ -392,7 +395,7 @@ const mfm = P.createLanguage({
 			if (url.endsWith(',')) url = url.substr(0, url.lastIndexOf(','));
 			return P.makeSuccess(i + url.length, url);
 		})
-		.map(x => makeNode('url', { url: x })),
+		.map(x => createLeaf('url', { url: x })),
 	//#endregion
 });
 
diff --git a/src/misc/extract-emojis.ts b/src/misc/extract-emojis.ts
new file mode 100644
index 000000000..a7b949f4f
--- /dev/null
+++ b/src/misc/extract-emojis.ts
@@ -0,0 +1,9 @@
+import { EmojiNode, MfmForest } from '../mfm/parser';
+import { preorderF } from '../prelude/tree';
+import { unique } from '../prelude/array';
+
+export default function(mfmForest: MfmForest): string[] {
+	const emojiNodes = preorderF(mfmForest).filter(x => x.type === 'emoji') as EmojiNode[];
+	const emojis = emojiNodes.filter(x => x.props.name && x.props.name.length <= 100).map(x => x.props.name);
+	return unique(emojis);
+}
diff --git a/src/misc/extract-hashtags.ts b/src/misc/extract-hashtags.ts
new file mode 100644
index 000000000..43eaa4590
--- /dev/null
+++ b/src/misc/extract-hashtags.ts
@@ -0,0 +1,9 @@
+import { HashtagNode, MfmForest } from '../mfm/parser';
+import { preorderF } from '../prelude/tree';
+import { unique } from '../prelude/array';
+
+export default function(mfmForest: MfmForest): string[] {
+	const hashtagNodes = preorderF(mfmForest).filter(x => x.type === 'hashtag') as HashtagNode[];
+	const hashtags = hashtagNodes.map(x => x.props.hashtag);
+	return unique(hashtags);
+}
diff --git a/src/misc/extract-mentions.ts b/src/misc/extract-mentions.ts
index 1d844211c..a53a25ffc 100644
--- a/src/misc/extract-mentions.ts
+++ b/src/misc/extract-mentions.ts
@@ -1,19 +1,10 @@
-import parse from '../mfm/parse';
-import { Node, IMentionNode } from '../mfm/parser';
+// test is located in test/extract-mentions
 
-export default function(tokens: ReturnType<typeof parse>): IMentionNode['props'][] {
-	const mentions: IMentionNode['props'][] = [];
+import { MentionNode, MfmForest } from '../mfm/parser';
+import { preorderF } from '../prelude/tree';
 
-	const extract = (tokens: Node[]) => {
-		for (const x of tokens.filter(x => x.name === 'mention')) {
-			mentions.push(x.props);
-		}
-		for (const x of tokens.filter(x => x.children)) {
-			extract(x.children);
-		}
-	};
-
-	extract(tokens);
-
-	return mentions;
+export default function(mfmForest: MfmForest): MentionNode['props'][] {
+	// TODO: 重複を削除
+	const mentionNodes = preorderF(mfmForest).filter(x => x.type === 'mention') as MentionNode[];
+	return mentionNodes.map(x => x.props);
 }
diff --git a/src/prelude/tree.ts b/src/prelude/tree.ts
new file mode 100644
index 000000000..519234a0b
--- /dev/null
+++ b/src/prelude/tree.ts
@@ -0,0 +1,36 @@
+import { concat, sum } from './array';
+
+export type Tree<T> = {
+	node: T,
+	children: Forest<T>;
+};
+
+export type Forest<T> = Tree<T>[];
+
+export function createLeaf<T>(node: T): Tree<T> {
+	return { node, children: [] };
+}
+
+export function createTree<T>(node: T, children: Forest<T>): Tree<T> {
+	return { node, children };
+}
+
+export function hasChildren<T>(t: Tree<T>): boolean {
+	return t.children.length !== 0;
+}
+
+export function preorder<T>(t: Tree<T>): T[] {
+	return [t.node, ...preorderF(t.children)];
+}
+
+export function preorderF<T>(ts: Forest<T>): T[] {
+	return concat(ts.map(preorder));
+}
+
+export function countNodes<T>(t: Tree<T>): number {
+	return preorder(t).length;
+}
+
+export function countNodesF<T>(ts: Forest<T>): number {
+	return sum(ts.map(countNodes));
+}
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index fbf1dc32e..7bdd52883 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -7,7 +7,7 @@ import { publishToFollowers } from '../../../../services/i/update';
 import define from '../../define';
 import getDriveFileUrl from '../../../../misc/get-drive-file-url';
 import parse from '../../../../mfm/parse';
-import { extractEmojis } from '../../../../services/note/create';
+import extractEmojis from '../../../../misc/extract-emojis';
 const langmap = require('langmap');
 
 export const meta = {
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 55d5eed14..248c2372f 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -24,12 +24,13 @@ import isQuote from '../../misc/is-quote';
 import notesChart from '../../chart/notes';
 import perUserNotesChart from '../../chart/per-user-notes';
 
-import { erase, unique } from '../../prelude/array';
+import { erase } from '../../prelude/array';
 import insertNoteUnread from './unread';
 import registerInstance from '../register-instance';
 import Instance from '../../models/instance';
-import { Node } from '../../mfm/parser';
 import extractMentions from '../../misc/extract-mentions';
+import extractEmojis from '../../misc/extract-emojis';
+import extractHashtags from '../../misc/extract-hashtags';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -466,44 +467,6 @@ 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[]) => {
-		for (const x of tokens.filter(x => x.name === 'hashtag')) {
-			hashtags.push(x.props.hashtag);
-		}
-		for (const x of tokens.filter(x => x.children)) {
-			extract(x.children);
-		}
-	};
-
-	// Extract hashtags
-	extract(tokens);
-
-	return unique(hashtags);
-}
-
-export function extractEmojis(tokens: ReturnType<typeof parse>): string[] {
-	const emojis: string[] = [];
-
-	const extract = (tokens: Node[]) => {
-		for (const x of tokens.filter(x => x.name === 'emoji')) {
-			if (x.props.name && x.props.name.length <= 100) {
-				emojis.push(x.props.name);
-			}
-		}
-		for (const x of tokens.filter(x => x.children)) {
-			extract(x.children);
-		}
-	};
-
-	// Extract emojis
-	extract(tokens);
-
-	return unique(emojis);
-}
-
 function index(note: INote) {
 	if (note.text == null || config.elasticsearch == null) return;
 
diff --git a/test/extract-mentions.ts b/test/extract-mentions.ts
new file mode 100644
index 000000000..b32f5dd4b
--- /dev/null
+++ b/test/extract-mentions.ts
@@ -0,0 +1,48 @@
+import * as assert from 'assert';
+
+import extractMentions from '../src/misc/extract-mentions';
+import parse from '../src/mfm/parse';
+
+describe('Extract mentions', () => {
+	it('simple', () => {
+		const ast = parse('@foo @bar @baz');
+		const mentions = extractMentions(ast);
+		assert.deepStrictEqual(mentions, [{
+			username: 'foo',
+			acct: '@foo',
+			canonical: '@foo',
+			host: null
+		}, {
+			username: 'bar',
+			acct: '@bar',
+			canonical: '@bar',
+			host: null
+		}, {
+			username: 'baz',
+			acct: '@baz',
+			canonical: '@baz',
+			host: null
+		}]);
+	});
+
+	it('nested', () => {
+		const ast = parse('@foo **@bar** @baz');
+		const mentions = extractMentions(ast);
+		assert.deepStrictEqual(mentions, [{
+			username: 'foo',
+			acct: '@foo',
+			canonical: '@foo',
+			host: null
+		}, {
+			username: 'bar',
+			acct: '@bar',
+			canonical: '@bar',
+			host: null
+		}, {
+			username: 'baz',
+			acct: '@baz',
+			canonical: '@baz',
+			host: null
+		}]);
+	});
+});
diff --git a/test/mfm.ts b/test/mfm.ts
index dee1bb2ae..4811e1bbb 100644
--- a/test/mfm.ts
+++ b/test/mfm.ts
@@ -6,181 +6,207 @@ import * as assert from 'assert';
 
 import analyze from '../src/mfm/parse';
 import toHtml from '../src/mfm/html';
+import { createTree as tree, createLeaf as leaf, MfmTree } from '../src/mfm/parser';
 
-function _node(name: string, children: any[], props: any) {
-	return children ? { name, children, props } : { name, props };
+function text(text: string): MfmTree {
+	return leaf('text', { text });
 }
 
-function node(name: string, props?: any) {
-	return _node(name, null, props);
-}
+describe('createLeaf', () => {
+	it('creates leaf', () => {
+		assert.deepStrictEqual(leaf('text', { text: 'abc' }), {
+			node: {
+				type: 'text',
+				props: {
+					text: 'abc'
+				}
+			},
+			children: [],
+		});
+	});
+});
 
-function nodeWithChildren(name: string, children: any[], props?: any) {
-	return _node(name, children, props);
-}
+describe('createTree', () => {
+	it('creates tree', () => {
+		const t = tree('tree', [
+			leaf('left', { a: 2 }),
+			leaf('right', { b: 'hi' })
+		], {
+			c: 4
+		});
+		assert.deepStrictEqual(t, {
+			node: {
+				type: 'tree',
+				props: {
+					c: 4
+				}
+			},
+			children: [
+				leaf('left', { a: 2 }),
+				leaf('right', { b: 'hi' })
+			],
+		});
+	});
+});
 
-function text(text: string) {
-	return node('text', { text });
-}
-
-describe('Text', () => {
+describe('MFM', () => {
 	it('can be analyzed', () => {
 		const tokens = analyze('@himawari @hima_sub@namori.net お腹ペコい :cat: #yryr');
-		assert.deepEqual([
-			node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
+		assert.deepStrictEqual(tokens, [
+			leaf('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' }),
+			leaf('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
 			text(' お腹ペコい '),
-			node('emoji', { name: 'cat' }),
+			leaf('emoji', { name: 'cat' }),
 			text(' '),
-			node('hashtag', { hashtag: 'yryr' }),
-		], tokens);
+			leaf('hashtag', { hashtag: 'yryr' }),
+		]);
 	});
 
 	describe('elements', () => {
 		describe('bold', () => {
 			it('simple', () => {
 				const tokens = analyze('**foo**');
-				assert.deepEqual([
-					nodeWithChildren('bold', [
+				assert.deepStrictEqual(tokens, [
+					tree('bold', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 
 			it('with other texts', () => {
 				const tokens = analyze('bar**foo**bar');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('bar'),
-					nodeWithChildren('bold', [
+					tree('bold', [
 						text('foo')
-					]),
+					], {}),
 					text('bar'),
-				], tokens);
+				]);
 			});
 		});
 
 		it('big', () => {
 			const tokens = analyze('***Strawberry*** Pasta');
-			assert.deepEqual([
-				nodeWithChildren('big', [
+			assert.deepStrictEqual(tokens, [
+				tree('big', [
 					text('Strawberry')
-				]),
+				], {}),
 				text(' Pasta'),
-			], tokens);
+			]);
 		});
 
 		it('small', () => {
 			const tokens = analyze('<small>smaller</small>');
-			assert.deepEqual([
-				nodeWithChildren('small', [
+			assert.deepStrictEqual(tokens, [
+				tree('small', [
 					text('smaller')
-				]),
-			], tokens);
+				], {}),
+			]);
 		});
 
 		describe('motion', () => {
 			it('by triple brackets', () => {
 				const tokens = analyze('(((foo)))');
-				assert.deepEqual([
-					nodeWithChildren('motion', [
+				assert.deepStrictEqual(tokens, [
+					tree('motion', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 
 			it('by triple brackets (with other texts)', () => {
 				const tokens = analyze('bar(((foo)))bar');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('bar'),
-					nodeWithChildren('motion', [
+					tree('motion', [
 						text('foo')
-					]),
+					], {}),
 					text('bar'),
-				], tokens);
+				]);
 			});
 
 			it('by <motion> tag', () => {
 				const tokens = analyze('<motion>foo</motion>');
-				assert.deepEqual([
-					nodeWithChildren('motion', [
+				assert.deepStrictEqual(tokens, [
+					tree('motion', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 
 			it('by <motion> tag (with other texts)', () => {
 				const tokens = analyze('bar<motion>foo</motion>bar');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('bar'),
-					nodeWithChildren('motion', [
+					tree('motion', [
 						text('foo')
-					]),
+					], {}),
 					text('bar'),
-				], tokens);
+				]);
 			});
 		});
 
 		describe('mention', () => {
 			it('local', () => {
 				const tokens = analyze('@himawari foo');
-				assert.deepEqual([
-					node('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
+				assert.deepStrictEqual(tokens, [
+					leaf('mention', { acct: '@himawari', canonical: '@himawari', username: 'himawari', host: null }),
 					text(' foo')
-				], tokens);
+				]);
 			});
 
 			it('remote', () => {
 				const tokens = analyze('@hima_sub@namori.net foo');
-				assert.deepEqual([
-					node('mention', { acct: '@hima_sub@namori.net', canonical: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' }),
+				assert.deepStrictEqual(tokens, [
+					leaf('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 foo');
-				assert.deepEqual([
-					node('mention', { acct: '@hima_sub@xn--q9j5bya.xn--zckzah', canonical: '@hima_sub@なもり.テスト', username: 'hima_sub', host: 'xn--q9j5bya.xn--zckzah' }),
+				assert.deepStrictEqual(tokens, [
+					leaf('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([
+				assert.deepStrictEqual(tokens, [
 					text('idolm@ster')
-				], tokens);
+				]);
 
 				const tokens2 = analyze('@a\n@b\n@c');
-				assert.deepEqual([
-					node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }),
+				assert.deepStrictEqual(tokens2, [
+					leaf('mention', { acct: '@a', canonical: '@a', username: 'a', host: null }),
 					text('\n'),
-					node('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }),
+					leaf('mention', { acct: '@b', canonical: '@b', username: 'b', host: null }),
 					text('\n'),
-					node('mention', { acct: '@c', canonical: '@c', username: 'c', host: null })
-				], tokens2);
+					leaf('mention', { acct: '@c', canonical: '@c', username: 'c', host: null })
+				]);
 
 				const tokens3 = analyze('**x**@a');
-				assert.deepEqual([
-					nodeWithChildren('bold', [
+				assert.deepStrictEqual(tokens3, [
+					tree('bold', [
 						text('x')
-					]),
-					node('mention', { acct: '@a', canonical: '@a', username: 'a', host: null })
-				], tokens3);
+					], {}),
+					leaf('mention', { acct: '@a', canonical: '@a', username: 'a', host: null })
+				]);
 
 				const tokens4 = analyze('@\n@v\n@veryverylongusername' /* \n@toolongtobeasamention */ );
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens4, [
 					text('@\n'),
-					node('mention', { acct: '@v', canonical: '@v', username: 'v', host: null }),
+					leaf('mention', { acct: '@v', canonical: '@v', username: 'v', host: null }),
 					text('\n'),
-					node('mention', { acct: '@veryverylongusername', canonical: '@veryverylongusername', username: 'veryverylongusername', host: null }),
+					leaf('mention', { acct: '@veryverylongusername', canonical: '@veryverylongusername', username: 'veryverylongusername', host: null }),
 					// text('\n@toolongtobeasamention')
-				], tokens4);
+				]);
 				/*
 				const tokens5 = analyze('@domain_is@valid.example.com\n@domain_is@.invalid\n@domain_is@invali.d\n@domain_is@invali.d\n@domain_is@-invalid.com\n@domain_is@invalid-.com');
-				assert.deepEqual([
-					node('mention', { acct: '@domain_is@valid.example.com', canonical: '@domain_is@valid.example.com', username: 'domain_is', host: 'valid.example.com' }),
+				assert.deepStrictEqual([
+					leaf('mention', { acct: '@domain_is@valid.example.com', canonical: '@domain_is@valid.example.com', username: 'domain_is', host: 'valid.example.com' }),
 					text('\n@domain_is@.invalid\n@domain_is@invali.d\n@domain_is@invali.d\n@domain_is@-invalid.com\n@domain_is@invalid-.com')
 				], tokens5);
 				*/
@@ -190,470 +216,470 @@ describe('Text', () => {
 		describe('hashtag', () => {
 			it('simple', () => {
 				const tokens = analyze('#alice');
-				assert.deepEqual([
-					node('hashtag', { hashtag: 'alice' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('hashtag', { hashtag: 'alice' })
+				]);
 			});
 
 			it('after line break', () => {
 				const tokens = analyze('foo\n#alice');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('foo\n'),
-					node('hashtag', { hashtag: 'alice' })
-				], tokens);
+					leaf('hashtag', { hashtag: 'alice' })
+				]);
 			});
 
 			it('with text', () => {
 				const tokens = analyze('Strawberry Pasta #alice');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('Strawberry Pasta '),
-					node('hashtag', { hashtag: 'alice' })
-				], tokens);
+					leaf('hashtag', { hashtag: 'alice' })
+				]);
 			});
 
 			it('with text (zenkaku)', () => {
 				const tokens = analyze('こんにちは#世界');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('こんにちは'),
-					node('hashtag', { hashtag: '世界' })
-				], tokens);
+					leaf('hashtag', { hashtag: '世界' })
+				]);
 			});
 
 			it('ignore comma and period', () => {
 				const tokens = analyze('Foo #bar, baz #piyo.');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('Foo '),
-					node('hashtag', { hashtag: 'bar' }),
+					leaf('hashtag', { hashtag: 'bar' }),
 					text(', baz '),
-					node('hashtag', { hashtag: 'piyo' }),
+					leaf('hashtag', { hashtag: 'piyo' }),
 					text('.'),
-				], tokens);
+				]);
 			});
 
 			it('ignore exclamation mark', () => {
 				const tokens = analyze('#Foo!');
-				assert.deepEqual([
-					node('hashtag', { hashtag: 'Foo' }),
+				assert.deepStrictEqual(tokens, [
+					leaf('hashtag', { hashtag: 'Foo' }),
 					text('!'),
-				], tokens);
+				]);
 			});
 
 			it('allow including number', () => {
 				const tokens = analyze('#foo123');
-				assert.deepEqual([
-					node('hashtag', { hashtag: 'foo123' }),
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('hashtag', { hashtag: 'foo123' }),
+				]);
 			});
 
 			it('with brackets', () => {
 				const tokens1 = analyze('(#foo)');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens1, [
 					text('('),
-					node('hashtag', { hashtag: 'foo' }),
+					leaf('hashtag', { hashtag: 'foo' }),
 					text(')'),
-				], tokens1);
+				]);
 
 				const tokens2 = analyze('「#foo」');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens2, [
 					text('「'),
-					node('hashtag', { hashtag: 'foo' }),
+					leaf('hashtag', { hashtag: 'foo' }),
 					text('」'),
-				], tokens2);
+				]);
 			});
 
 			it('with mixed brackets', () => {
 				const tokens = analyze('「#foo(bar)」');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('「'),
-					node('hashtag', { hashtag: 'foo(bar)' }),
+					leaf('hashtag', { hashtag: 'foo(bar)' }),
 					text('」'),
-				], tokens);
+				]);
 			});
 
 			it('with brackets (space before)', () => {
 				const tokens1 = analyze('(bar #foo)');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens1, [
 					text('(bar '),
-					node('hashtag', { hashtag: 'foo' }),
+					leaf('hashtag', { hashtag: 'foo' }),
 					text(')'),
-				], tokens1);
+				]);
 
 				const tokens2 = analyze('「bar #foo」');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens2, [
 					text('「bar '),
-					node('hashtag', { hashtag: 'foo' }),
+					leaf('hashtag', { hashtag: 'foo' }),
 					text('」'),
-				], tokens2);
+				]);
 			});
 
 			it('disallow number only', () => {
 				const tokens = analyze('#123');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('#123'),
-				], tokens);
+				]);
 			});
 
 			it('disallow number only (with brackets)', () => {
 				const tokens = analyze('(#123)');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('(#123)'),
-				], tokens);
+				]);
 			});
 		});
 
 		describe('quote', () => {
 			it('basic', () => {
 				const tokens1 = analyze('> foo');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens1, [
+					tree('quote', [
 						text('foo')
-					])
-				], tokens1);
+					], {})
+				]);
 
 				const tokens2 = analyze('>foo');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens2, [
+					tree('quote', [
 						text('foo')
-					])
-				], tokens2);
+					], {})
+				]);
 			});
 
 			it('series', () => {
 				const tokens = analyze('> foo\n\n> bar');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens, [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('\n'),
-					nodeWithChildren('quote', [
+					tree('quote', [
 						text('bar')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 
 			it('trailing line break', () => {
 				const tokens1 = analyze('> foo\n');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens1, [
+					tree('quote', [
 						text('foo')
-					]),
-				], tokens1);
+					], {}),
+				]);
 
 				const tokens2 = analyze('> foo\n\n');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens2, [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('\n')
-				], tokens2);
+				]);
 			});
 
 			it('multiline', () => {
 				const tokens1 = analyze('>foo\n>bar');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens1, [
+					tree('quote', [
 						text('foo\nbar')
-					])
-				], tokens1);
+					], {})
+				]);
 
 				const tokens2 = analyze('> foo\n> bar');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens2, [
+					tree('quote', [
 						text('foo\nbar')
-					])
-				], tokens2);
+					], {})
+				]);
 			});
 
 			it('multiline with trailing line break', () => {
 				const tokens1 = analyze('> foo\n> bar\n');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens1, [
+					tree('quote', [
 						text('foo\nbar')
-					]),
-				], tokens1);
+					], {}),
+				]);
 
 				const tokens2 = analyze('> foo\n> bar\n\n');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens2, [
+					tree('quote', [
 						text('foo\nbar')
-					]),
+					], {}),
 					text('\n')
-				], tokens2);
+				]);
 			});
 
 			it('with before and after texts', () => {
 				const tokens = analyze('before\n> foo\nafter');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('before\n'),
-					nodeWithChildren('quote', [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('after'),
-				], tokens);
+				]);
 			});
 
 			it('multiple quotes', () => {
 				const tokens = analyze('> foo\nbar\n\n> foo\nbar\n\n> foo\nbar');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens, [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('bar\n\n'),
-					nodeWithChildren('quote', [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('bar\n\n'),
-					nodeWithChildren('quote', [
+					tree('quote', [
 						text('foo')
-					]),
+					], {}),
 					text('bar'),
-				], tokens);
+				]);
 			});
 
 			it('require line break before ">"', () => {
 				const tokens = analyze('foo>bar');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('foo>bar'),
-				], tokens);
+				]);
 			});
 
 			it('nested', () => {
 				const tokens = analyze('>> foo\n> bar');
-				assert.deepEqual([
-					nodeWithChildren('quote', [
-						nodeWithChildren('quote', [
+				assert.deepStrictEqual(tokens, [
+					tree('quote', [
+						tree('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([
+				assert.deepStrictEqual(tokens, [
 					text('foo\n\n'),
-					nodeWithChildren('quote', [
+					tree('quote', [
 						text('a\n'),
-						nodeWithChildren('quote', [
+						tree('quote', [
 							text('b\n\n'),
-							nodeWithChildren('quote', [
+							tree('quote', [
 								text('\nc\n')
-							])
-						]),
+							], {})
+						], {}),
 						text('d')
-					]),
+					], {}),
 					text('\n'),
-				], tokens);
+				]);
 			});
 		});
 
 		describe('url', () => {
 			it('simple', () => {
 				const tokens = analyze('https://example.com');
-				assert.deepEqual([
-					node('url', { url: 'https://example.com' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('url', { url: 'https://example.com' })
+				]);
 			});
 
 			it('ignore trailing period', () => {
 				const tokens = analyze('https://example.com.');
-				assert.deepEqual([
-					node('url', { url: 'https://example.com' }),
+				assert.deepStrictEqual(tokens, [
+					leaf('url', { url: 'https://example.com' }),
 					text('.')
-				], tokens);
+				]);
 			});
 
 			it('with comma', () => {
 				const tokens = analyze('https://example.com/foo?bar=a,b');
-				assert.deepEqual([
-					node('url', { url: 'https://example.com/foo?bar=a,b' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('url', { url: 'https://example.com/foo?bar=a,b' })
+				]);
 			});
 
 			it('ignore trailing comma', () => {
 				const tokens = analyze('https://example.com/foo, bar');
-				assert.deepEqual([
-					node('url', { url: 'https://example.com/foo' }),
+				assert.deepStrictEqual(tokens, [
+					leaf('url', { url: 'https://example.com/foo' }),
 					text(', bar')
-				], tokens);
+				]);
 			});
 
 			it('with brackets', () => {
 				const tokens = analyze('https://example.com/foo(bar)');
-				assert.deepEqual([
-					node('url', { url: 'https://example.com/foo(bar)' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('url', { url: 'https://example.com/foo(bar)' })
+				]);
 			});
 
 			it('ignore parent brackets', () => {
 				const tokens = analyze('(https://example.com/foo)');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('('),
-					node('url', { url: 'https://example.com/foo' }),
+					leaf('url', { url: 'https://example.com/foo' }),
 					text(')')
-				], tokens);
+				]);
 			});
 
 			it('ignore parent brackets 2', () => {
 				const tokens = analyze('(foo https://example.com/foo)');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('(foo '),
-					node('url', { url: 'https://example.com/foo' }),
+					leaf('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([
+				assert.deepStrictEqual(tokens, [
 					text('('),
-					node('url', { url: 'https://example.com/foo(bar)' }),
+					leaf('url', { url: 'https://example.com/foo(bar)' }),
 					text(')')
-				], tokens);
+				]);
 			});
 		});
 
 		describe('link', () => {
 			it('simple', () => {
 				const tokens = analyze('[foo](https://example.com)');
-				assert.deepEqual([
-					nodeWithChildren('link', [
+				assert.deepStrictEqual(tokens, [
+					tree('link', [
 						text('foo')
 					], { url: 'https://example.com', silent: false })
-				], tokens);
+				]);
 			});
 
 			it('simple (with silent flag)', () => {
 				const tokens = analyze('?[foo](https://example.com)');
-				assert.deepEqual([
-					nodeWithChildren('link', [
+				assert.deepStrictEqual(tokens, [
+					tree('link', [
 						text('foo')
 					], { url: 'https://example.com', silent: true })
-				], tokens);
+				]);
 			});
 
 			it('in text', () => {
 				const tokens = analyze('before[foo](https://example.com)after');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('before'),
-					nodeWithChildren('link', [
+					tree('link', [
 						text('foo')
 					], { url: 'https://example.com', silent: false }),
 					text('after'),
-				], tokens);
+				]);
 			});
 
 			it('with brackets', () => {
 				const tokens = analyze('[foo](https://example.com/foo(bar))');
-				assert.deepEqual([
-					nodeWithChildren('link', [
+				assert.deepStrictEqual(tokens, [
+					tree('link', [
 						text('foo')
 					], { url: 'https://example.com/foo(bar)', silent: false })
-				], tokens);
+				]);
 			});
 
 			it('with parent brackets', () => {
 				const tokens = analyze('([foo](https://example.com/foo(bar)))');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('('),
-					nodeWithChildren('link', [
+					tree('link', [
 						text('foo')
 					], { url: 'https://example.com/foo(bar)', silent: false }),
 					text(')')
-				], tokens);
+				]);
 			});
 		});
 
 		it('emoji', () => {
 			const tokens1 = analyze(':cat:');
-			assert.deepEqual([
-				node('emoji', { name: 'cat' })
-			], tokens1);
+			assert.deepStrictEqual(tokens1, [
+				leaf('emoji', { name: 'cat' })
+			]);
 
 			const tokens2 = analyze(':cat::cat::cat:');
-			assert.deepEqual([
-				node('emoji', { name: 'cat' }),
-				node('emoji', { name: 'cat' }),
-				node('emoji', { name: 'cat' })
-			], tokens2);
+			assert.deepStrictEqual(tokens2, [
+				leaf('emoji', { name: 'cat' }),
+				leaf('emoji', { name: 'cat' }),
+				leaf('emoji', { name: 'cat' })
+			]);
 
 			const tokens3 = analyze('🍎');
-			assert.deepEqual([
-				node('emoji', { emoji: '🍎' })
-			], tokens3);
+			assert.deepStrictEqual(tokens3, [
+				leaf('emoji', { emoji: '🍎' })
+			]);
 		});
 
 		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);
+				assert.deepStrictEqual(tokens, [
+					leaf('blockCode', { code: 'var x = "Strawberry Pasta";', lang: null })
+				]);
 			});
 
 			it('can specify language', () => {
 				const tokens = analyze('``` json\n{ "x": 42 }\n```');
-				assert.deepEqual([
-					node('blockCode', { code: '{ "x": 42 }', lang: 'json' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('blockCode', { code: '{ "x": 42 }', lang: 'json' })
+				]);
 			});
 
 			it('require line break before "```"', () => {
 				const tokens = analyze('before```\nfoo\n```');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('before'),
-					node('inlineCode', { code: '`' }),
+					leaf('inlineCode', { code: '`' }),
 					text('\nfoo\n'),
-					node('inlineCode', { code: '`' })
-				], tokens);
+					leaf('inlineCode', { code: '`' })
+				]);
 			});
 
 			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);
+				assert.deepStrictEqual(tokens, [
+					leaf('blockCode', { code: 'foo', lang: null }),
+					leaf('blockCode', { code: 'bar', lang: null }),
+					leaf('blockCode', { code: 'baz', lang: null }),
+				]);
 			});
 
 			it('ignore internal marker', () => {
 				const tokens = analyze('```\naaa```bbb\n```');
-				assert.deepEqual([
-					node('blockCode', { code: 'aaa```bbb', lang: null })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('blockCode', { code: 'aaa```bbb', lang: null })
+				]);
 			});
 
 			it('trim after line break', () => {
 				const tokens = analyze('```\nfoo\n```\nbar');
-				assert.deepEqual([
-					node('blockCode', { code: 'foo', lang: null }),
+				assert.deepStrictEqual(tokens, [
+					leaf('blockCode', { code: 'foo', lang: null }),
 					text('bar')
-				], tokens);
+				]);
 			});
 		});
 
 		describe('inline code', () => {
 			it('simple', () => {
 				const tokens = analyze('`var x = "Strawberry Pasta";`');
-				assert.deepEqual([
-					node('inlineCode', { code: 'var x = "Strawberry Pasta";' })
-				], tokens);
+				assert.deepStrictEqual(tokens, [
+					leaf('inlineCode', { code: 'var x = "Strawberry Pasta";' })
+				]);
 			});
 
 			it('disallow line break', () => {
 				const tokens = analyze('`foo\nbar`');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('`foo\nbar`')
-				], tokens);
+				]);
 			});
 
 			it('disallow ´', () => {
 				const tokens = analyze('`foo´bar`');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('`foo´bar`')
-				], tokens);
+				]);
 			});
 		});
 
@@ -661,92 +687,92 @@ describe('Text', () => {
 			const fomula = 'x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}';
 			const text = `\\(${fomula}\\)`;
 			const tokens = analyze(text);
-			assert.deepEqual([
-				node('math', { formula: fomula })
-			], tokens);
+			assert.deepStrictEqual(tokens, [
+				leaf('math', { formula: fomula })
+			]);
 		});
 
 		it('search', () => {
 			const tokens1 = analyze('a b c 検索');
-			assert.deepEqual([
-				node('search', { content: 'a b c 検索', query: 'a b c' })
-			], tokens1);
+			assert.deepStrictEqual(tokens1, [
+				leaf('search', { content: 'a b c 検索', query: 'a b c' })
+			]);
 
 			const tokens2 = analyze('a b c Search');
-			assert.deepEqual([
-				node('search', { content: 'a b c Search', query: 'a b c' })
-			], tokens2);
+			assert.deepStrictEqual(tokens2, [
+				leaf('search', { content: 'a b c Search', query: 'a b c' })
+			]);
 
 			const tokens3 = analyze('a b c search');
-			assert.deepEqual([
-				node('search', { content: 'a b c search', query: 'a b c' })
-			], tokens3);
+			assert.deepStrictEqual(tokens3, [
+				leaf('search', { content: 'a b c search', query: 'a b c' })
+			]);
 
 			const tokens4 = analyze('a b c SEARCH');
-			assert.deepEqual([
-				node('search', { content: 'a b c SEARCH', query: 'a b c' })
-			], tokens4);
+			assert.deepStrictEqual(tokens4, [
+				leaf('search', { content: 'a b c SEARCH', query: 'a b c' })
+			]);
 		});
 
 		describe('title', () => {
 			it('simple', () => {
 				const tokens = analyze('【foo】');
-				assert.deepEqual([
-					nodeWithChildren('title', [
+				assert.deepStrictEqual(tokens, [
+					tree('title', [
 						text('foo')
-					])
-				], tokens);
+					], {})
+				]);
 			});
 
 			it('require line break', () => {
 				const tokens = analyze('a【foo】');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('a【foo】')
-				], tokens);
+				]);
 			});
 
 			it('with before and after texts', () => {
 				const tokens = analyze('before\n【foo】\nafter');
-				assert.deepEqual([
+				assert.deepStrictEqual(tokens, [
 					text('before\n'),
-					nodeWithChildren('title', [
+					tree('title', [
 						text('foo')
-					]),
+					], {}),
 					text('after')
-				], tokens);
+				]);
 			});
 		});
 
 		describe('center', () => {
 			it('simple', () => {
 				const tokens = analyze('<center>foo</center>');
-				assert.deepEqual([
-					nodeWithChildren('center', [
+				assert.deepStrictEqual(tokens, [
+					tree('center', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 		});
 
 		describe('strike', () => {
 			it('simple', () => {
 				const tokens = analyze('~~foo~~');
-				assert.deepEqual([
-					nodeWithChildren('strike', [
+				assert.deepStrictEqual(tokens, [
+					tree('strike', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 		});
 
 		describe('italic', () => {
 			it('simple', () => {
 				const tokens = analyze('<i>foo</i>');
-				assert.deepEqual([
-					nodeWithChildren('italic', [
+				assert.deepStrictEqual(tokens, [
+					tree('italic', [
 						text('foo')
-					]),
-				], tokens);
+					], {}),
+				]);
 			});
 		});
 	});
@@ -761,22 +787,22 @@ describe('Text', () => {
 
 	it('code block with quote', () => {
 		const tokens = analyze('> foo\n```\nbar\n```');
-		assert.deepEqual([
-			nodeWithChildren('quote', [
+		assert.deepStrictEqual(tokens, [
+			tree('quote', [
 				text('foo')
-			]),
-			node('blockCode', { code: 'bar', lang: null })
-		], tokens);
+			], {}),
+			leaf('blockCode', { code: 'bar', lang: null })
+		]);
 	});
 
 	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', [
+		assert.deepStrictEqual(tokens, [
+			leaf('blockCode', { code: 'before', lang: null }),
+			tree('quote', [
 				text('foo')
-			]),
-			node('blockCode', { code: 'after', lang: null })
-		], tokens);
+			], {}),
+			leaf('blockCode', { code: 'after', lang: null })
+		]);
 	});
 });