From d9f35594e7c0a2689d4974ec34c111de11927bbe Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Sat, 8 Feb 2025 17:51:18 +0000
Subject: [PATCH] Changed internal parser selection value from integers to a
 string enum.

---
 assets/misuzu.js/forum/editor.jsx             | 33 ++++++------
 assets/misuzu.js/messages/messages.js         | 12 ++---
 assets/misuzu.js/parsing.js                   |  7 +--
 ...2_08_153444_new_parsing_method_storage.php | 54 +++++++++++++++++++
 public-legacy/forum/posting.php               | 46 ++++++++--------
 public-legacy/profile.php                     | 10 ++--
 public-legacy/settings/data.php               |  6 +--
 src/Forum/ForumPostInfo.php                   | 12 ++---
 src/Forum/ForumPostsData.php                  | 44 +++++++++++----
 src/Info/InfoRoutes.php                       |  4 +-
 src/Messages/MessageInfo.php                  | 12 ++---
 src/Messages/MessagesData.php                 | 41 ++++++++++----
 src/Messages/MessagesRoutes.php               | 26 ++++-----
 src/News/NewsRoutes.php                       |  4 +-
 src/Parsers/Parser.php                        | 45 ----------------
 src/Parsers/Parsers.php                       | 21 ++++++++
 src/Parsers/TextFormat.php                    |  8 +++
 src/TemplatingExtension.php                   | 14 +++--
 src/Users/UserInfo.php                        | 22 ++++----
 src/Users/UsersData.php                       | 38 ++++++-------
 templates/_layout/input.twig                  |  4 +-
 templates/changelog/change.twig               |  2 +-
 templates/forum/macros.twig                   |  4 +-
 templates/forum/posting.twig                  |  5 +-
 templates/home/landing.twig                   |  2 +-
 templates/manage/users/note.twig              |  2 +-
 templates/manage/users/notes.twig             |  4 +-
 templates/messages/compose.twig               |  4 +-
 templates/messages/thread.twig                |  4 +-
 templates/news/macros.twig                    |  4 +-
 templates/profile/index.twig                  |  8 +--
 tools/cron                                    | 12 ++---
 32 files changed, 301 insertions(+), 213 deletions(-)
 create mode 100644 database/2025_02_08_153444_new_parsing_method_storage.php
 delete mode 100644 src/Parsers/Parser.php
 create mode 100644 src/Parsers/Parsers.php
 create mode 100644 src/Parsers/TextFormat.php

diff --git a/assets/misuzu.js/forum/editor.jsx b/assets/misuzu.js/forum/editor.jsx
index d0719538..22e58528 100644
--- a/assets/misuzu.js/forum/editor.jsx
+++ b/assets/misuzu.js/forum/editor.jsx
@@ -55,17 +55,16 @@ const MszForumEditor = function(form) {
             uploadElemProgressText.textContent = '';
 
             const insertTheLinkIntoTheBoxEx2 = () => {
-                const parserMode = parseInt(parserElem.value);
                 let insertText = location.protocol + fileInfo.url;
 
-                if(parserMode == 1) { // bbcode
+                if(parserElem.value == 'bb') { // bbcode
                     if(fileInfo.isImage())
                         insertText = `[img]${fileInfo.url}[/img]`;
                     else if(fileInfo.isAudio())
                         insertText = `[audio]${fileInfo.url}[/audio]`;
                     else if(fileInfo.isVideo())
                         insertText = `[video]${fileInfo.url}[/video]`;
-                } else if(parserMode == 2) { // markdown
+                } else if(parserElem.value == 'md') { // markdown
                     if(fileInfo.isMedia())
                         insertText = `![](${fileInfo.url})`;
                 }
@@ -181,13 +180,16 @@ const MszForumEditor = function(form) {
     const renderPreview = async (parser, text) => {
         if(typeof text !== 'string')
             return '';
+        if(typeof parser !== 'string')
+            return '';
 
         const formData = new FormData;
-        formData.append('post[mode]', 'preview');
-        formData.append('post[text]', text);
-        formData.append('post[parser]', parseInt(parser));
+        formData.append('preview', '1');
+        formData.append('text', text);
+        formData.append('format', parser);
 
-        return (await $xhr.post('/forum/posting.php', { authed: true }, formData)).body;
+        const { body } = await $xhr.post('/forum/posting.php', { authed: true }, formData);
+        return body;
     };
 
     const previewBtn = <button class="input__button" type="button" value="preview">Preview</button>;
@@ -200,8 +202,8 @@ const MszForumEditor = function(form) {
             modeElem.textContent = modeElem.dataset.original;
             modeElem.dataset.original = null;
         } else {
-            const postText = textElem.value,
-                postParser = parseInt(parserElem.value);
+            const postText = textElem.value;
+            const postParser = parserElem.value;
 
             if(lastPostText === postText && lastPostParser === postParser) {
                 previewElem.removeAttribute('hidden');
@@ -223,7 +225,7 @@ const MszForumEditor = function(form) {
                     MszShowMessageBox('Failed to render preview.');
                 })
                 .then(body => {
-                    previewElem.classList.toggle('markdown', postParser === 2);
+                    previewElem.classList.toggle('markdown', postParser === 'md');
 
                     lastPostText = postText;
                     lastPostParser = postParser;
@@ -248,28 +250,27 @@ const MszForumEditor = function(form) {
     switchButtons(parserElem.value);
 
     parserElem.addEventListener('change', () => {
-        const postParser = parseInt(parserElem.value);
-        switchButtons(postParser);
+        switchButtons(parserElem.value);
 
         if(previewElem.hasAttribute('hidden'))
             return;
 
         // dunno if this would even be possible, but ech
-        if(postParser === lastPostParser)
+        if(parserElem.value === lastPostParser)
             return;
 
         parserElem.setAttribute('disabled', 'disabled');
         previewBtn.setAttribute('disabled', 'disabled');
         previewBtn.classList.add('input__button--busy');
 
-        renderPreview(postParser, lastPostText)
+        renderPreview(parserElem.value, lastPostText)
             .catch(() => {
                 previewElem.innerHTML = '';
                 MszShowMessageBox('Failed to render preview.');
             })
             .then(body => {
-                previewElem.classList.add('markdown', postParser === 2);
-                lastPostParser = postParser;
+                previewElem.classList.add('markdown', parserElem.value === 'md');
+                lastPostParser = parserElem.value;
                 previewElem.innerHTML = body;
 
                 MszEmbed.handle($queryAll('.js-msz-embed-media'));
diff --git a/assets/misuzu.js/messages/messages.js b/assets/misuzu.js/messages/messages.js
index 597af8ec..cc4025c5 100644
--- a/assets/misuzu.js/messages/messages.js
+++ b/assets/misuzu.js/messages/messages.js
@@ -29,11 +29,11 @@ const MszMessages = () => {
         return false;
     };
 
-    const msgsCreate = async (title, text, parser, draft, recipient, replyTo) => {
+    const msgsCreate = async (title, text, format, draft, recipient, replyTo) => {
         const formData = new FormData;
         formData.append('title', title);
         formData.append('body', text);
-        formData.append('parser', parser);
+        formData.append('format', format);
         formData.append('draft', draft);
         formData.append('recipient', recipient);
         formData.append('reply', replyTo);
@@ -45,11 +45,11 @@ const MszMessages = () => {
         return body;
     };
 
-    const msgsUpdate = async (messageId, title, text, parser, draft) => {
+    const msgsUpdate = async (messageId, title, text, format, draft) => {
         const formData = new FormData;
         formData.append('title', title);
         formData.append('body', text);
-        formData.append('parser', parser);
+        formData.append('format', format);
         formData.append('draft', draft);
 
         const { body } = await $xhr.post(`/messages/${encodeURIComponent(messageId)}`, { type: 'json', csrf: true }, formData);
@@ -139,14 +139,14 @@ const MszMessages = () => {
                         form.message,
                         form.title,
                         form.body,
-                        form.parser,
+                        form.format,
                         form.draft
                     );
                 } else {
                     result = await msgsCreate(
                         form.title,
                         form.body,
-                        form.parser,
+                        form.format,
                         form.draft,
                         form.recipient,
                         form.reply || ''
diff --git a/assets/misuzu.js/parsing.js b/assets/misuzu.js/parsing.js
index d4fc08f2..9b8efb3d 100644
--- a/assets/misuzu.js/parsing.js
+++ b/assets/misuzu.js/parsing.js
@@ -36,12 +36,9 @@ const MszParsing = (() => {
     ];
 
     const getTagsFor = parser => {
-        if(typeof parser !== 'number')
-            parser = parseInt(parser);
-
-        if(parser === 1)
+        if(parser === 'bb')
             return bbTags;
-        if(parser === 2)
+        if(parser === 'md')
             return mdTags;
 
         return [];
diff --git a/database/2025_02_08_153444_new_parsing_method_storage.php b/database/2025_02_08_153444_new_parsing_method_storage.php
new file mode 100644
index 00000000..9d492278
--- /dev/null
+++ b/database/2025_02_08_153444_new_parsing_method_storage.php
@@ -0,0 +1,54 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class NewParsingMethodStorage_20250208_153444 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        // msz_forum_posts
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_forum_posts
+                ADD COLUMN post_text_format ENUM('','bb','md') NOT NULL DEFAULT '' AFTER post_text,
+                ADD INDEX posts_text_format_index (post_text_format);
+        SQL);
+        $conn->execute(<<<SQL
+            UPDATE msz_forum_posts
+            SET post_text_format = IF(post_parse = 2, 'md', IF(post_parse = 1, 'bb', ''));
+        SQL);
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_forum_posts
+                DROP COLUMN post_parse,
+                DROP INDEX posts_parse_index;
+        SQL);
+
+        // msz_messages
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_messages
+                ADD COLUMN msg_body_format ENUM('','bb','md') NOT NULL DEFAULT '' AFTER msg_body;
+        SQL);
+        $conn->execute(<<<SQL
+            UPDATE msz_messages
+            SET msg_body_format = IF(msg_parser = 2, 'md', IF(msg_parser = 1, 'bb', ''));
+        SQL);
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_messages
+                DROP COLUMN msg_parser;
+        SQL);
+
+        // msz_users
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_users
+                ADD COLUMN user_about_content_format ENUM('','bb','md') NOT NULL DEFAULT '' AFTER user_about_content,
+                ADD COLUMN user_signature_content_format ENUM('','bb','md') NOT NULL DEFAULT '' AFTER user_signature_content;
+        SQL);
+        $conn->execute(<<<SQL
+            UPDATE msz_users
+            SET user_about_content_format = IF(user_about_parser = 2, 'md', IF(user_about_parser = 1, 'bb', '')),
+                user_signature_content_format = IF(user_signature_parser = 2, 'md', IF(user_signature_parser = 1, 'bb', ''));
+        SQL);
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_users
+                DROP COLUMN user_about_parser,
+                DROP COLUMN user_signature_parser;
+        SQL);
+    }
+}
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 410806a0..58f24a73 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -4,7 +4,7 @@ namespace Misuzu;
 use stdClass;
 use RuntimeException;
 use Misuzu\Forum\{ForumCategoryInfo,ForumPostInfo,ForumTopicInfo};
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\{Parsers,TextFormat};
 use Index\XDateTime;
 use Carbon\CarbonImmutable;
 
@@ -19,8 +19,23 @@ $currentUserId = $currentUser->id;
 if($msz->usersCtx->hasActiveBan($currentUser))
     Template::throwError(403);
 
+if(filter_has_var(INPUT_POST, 'preview')) {
+    header('Content-Type: text/plain; charset=utf-8');
+
+    $text = (string)filter_input(INPUT_POST, 'text');
+    $format = TextFormat::tryFrom((string)filter_input(INPUT_POST, 'format'));
+    if($format === null) {
+        http_response_code(400);
+        return;
+    }
+
+    http_response_code(200);
+    echo Parsers::instance($format)->parseText(htmlspecialchars($text));
+    return;
+}
+
 $forumPostingModes = [
-    'create', 'edit', 'quote', 'preview',
+    'create', 'edit', 'quote',
 ];
 
 if(!empty($_POST)) {
@@ -38,22 +53,6 @@ if(!empty($_POST)) {
 if(!in_array($mode, $forumPostingModes, true))
     Template::throwError(400);
 
-if($mode === 'preview') {
-    header('Content-Type: text/plain; charset=utf-8');
-
-    $postText = (string)($_POST['post']['text']);
-    $postParser = (int)($_POST['post']['parser']);
-
-    if(!Parser::isValid($postParser)) {
-        http_response_code(400);
-        return;
-    }
-
-    http_response_code(200);
-    echo Parser::instance($postParser)->parseText(htmlspecialchars($postText));
-    return;
-}
-
 if(empty($postId) && empty($topicId) && empty($forumId))
     Template::throwError(404);
 
@@ -144,7 +143,7 @@ $notices = [];
 if(!empty($_POST)) {
     $topicTitle = $_POST['post']['title'] ?? '';
     $postText = $_POST['post']['text'] ?? '';
-    $postParser = (int)($_POST['post']['parser'] ?? Parser::BBCODE);
+    $postParser = TextFormat::tryFrom((string)($_POST['post']['parser'] ?? '')) ?? TextFormat::BBCode;
     $topicType = isset($_POST['post']['type']) ? $_POST['post']['type'] : null;
     $postSignature = isset($_POST['post']['signature']);
 
@@ -192,9 +191,6 @@ if(!empty($_POST)) {
             }
         }
 
-        if(!Parser::isValid($postParser))
-            $notices[] = 'Invalid parser selected.';
-
         $postTextLengths = $cfg->getValues([
             ['forum.post.minLength:i', 1],
             ['forum.post.maxLength:i', 60000],
@@ -245,7 +241,7 @@ if(!empty($_POST)) {
                         (string)$postId,
                         remoteAddr: $_SERVER['REMOTE_ADDR'],
                         body: $postText,
-                        bodyParser: $postParser,
+                        bodyFormat: $postParser,
                         displaySignature: $postSignature,
                         bumpEdited: $markUpdated
                     );
@@ -294,9 +290,9 @@ if($mode === 'edit') { // $post is pretty much sure to be populated at this poin
 
 try {
     $lastPostInfo = $msz->forumCtx->posts->getPost(userInfo: $currentUser, getLast: true, deleted: false);
-    $selectedParser = $lastPostInfo->parser;
+    $selectedParser = $lastPostInfo->bodyFormat;
 } catch(RuntimeException $ex) {
-    $selectedParser = Parser::BBCODE;
+    $selectedParser = TextFormat::BBCode;
 }
 
 Template::render('forum.posting', [
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 22915836..6d10a53a 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -5,7 +5,7 @@ use stdClass;
 use InvalidArgumentException;
 use RuntimeException;
 use Index\ByteFormat;
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\TextFormat;
 use Misuzu\Users\{User,UsersContext};
 use Misuzu\Users\Assets\UserAvatarAsset;
 use Misuzu\Users\Assets\UserBackgroundAsset;
@@ -141,11 +141,11 @@ if($isEditing) {
                     $notices[] = 'You\'re not allowed to edit your about page.';
                 } else {
                     $aboutText  = (string)($_POST['about']['text'] ?? '');
-                    $aboutParse = (int)($_POST['about']['parser'] ?? Parser::PLAIN);
+                    $aboutParse = TextFormat::tryFrom((string)($_POST['about']['parser'] ?? '')) ?? TextFormat::Plain;
                     $aboutValid = $msz->usersCtx->users->validateProfileAbout($aboutParse, $aboutText);
 
                     if($aboutValid === '')
-                        $msz->usersCtx->users->updateUser($userInfo, aboutBody: $aboutText, aboutBodyParser: $aboutParse);
+                        $msz->usersCtx->users->updateUser($userInfo, aboutBody: $aboutText, aboutBodyFormat: $aboutParse);
                     else
                         $notices[] = $msz->usersCtx->users->validateProfileAboutText($aboutValid);
                 }
@@ -156,11 +156,11 @@ if($isEditing) {
                     $notices[] = 'You\'re not allowed to edit your forum signature.';
                 } else {
                     $sigText  = (string)($_POST['signature']['text'] ?? '');
-                    $sigParse = (int)($_POST['signature']['parser'] ?? Parser::PLAIN);
+                    $sigParse = TextFormat::tryFrom((string)($_POST['signature']['parser'] ?? '') ?? TextFormat::Plain;
                     $sigValid = $msz->usersCtx->users->validateForumSignature($sigParse, $sigText);
 
                     if($sigValid === '')
-                        $msz->usersCtx->users->updateUser($userInfo, signatureBody: $sigText, signatureBodyParser: $sigParse);
+                        $msz->usersCtx->users->updateUser($userInfo, signatureBody: $sigText, signatureBodyFormat: $sigParse);
                     else
                         $notices[] = $msz->usersCtx->users->validateForumSignatureText($sigValid);
                 }
diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php
index 84ebcd98..2b4cd89a 100644
--- a/public-legacy/settings/data.php
+++ b/public-legacy/settings/data.php
@@ -133,12 +133,12 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_categories',    ['category_id:s', 'category_name:s', 'user_id:s:n', 'category_created:t', 'category_locked:t:n'], 'user_id');
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_posts',         ['comment_id:s', 'category_id:s', 'user_id:s:n', 'comment_reply_to:s:n', 'comment_text:s', 'comment_created:t', 'comment_pinned:t:n', 'comment_edited:t:n', 'comment_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_votes',         ['comment_id:s', 'user_id:s', 'comment_vote:i']);
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_posts',            ['post_id:s', 'topic_id:s', 'forum_id:s', 'user_id:s:n', 'post_remote_addr:a', 'post_text:s', 'post_parse:i', 'post_display_signature:b', 'post_created:t', 'post_edited:t:n', 'post_deleted:t:n']);
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_posts',            ['post_id:s', 'topic_id:s', 'forum_id:s', 'user_id:s:n', 'post_remote_addr:a', 'post_text:s', 'post_text_format:s', 'post_display_signature:b', 'post_created:t', 'post_edited:t:n', 'post_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics',           ['topic_id:s', 'forum_id:s', 'user_id:s:n', 'topic_type:i', 'topic_title:s', 'topic_count_views:i', 'topic_created:t', 'topic_bumped:t', 'topic_deleted:t:n', 'topic_locked:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_redirects', ['topic_id:s', 'user_id:s:n', 'redir_url:s', 'redir_created:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_track',     ['user_id:s', 'topic_id:s', 'forum_id:s', 'track_last_read:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'login_attempts',         ['attempt_id:s', 'user_id:s:n', 'attempt_success:b', 'attempt_remote_addr:a', 'attempt_country:s', 'attempt_created:t', 'attempt_user_agent:s']);
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'messages',               ['msg_id:s', 'msg_owner_id:s', 'msg_author_id:s:n', 'msg_recipient_id:s:n', 'msg_reply_to:s:n', 'msg_title:s', 'msg_body:s', 'msg_parser:i', 'msg_created:t', 'msg_sent:t:n', 'msg_read:t:n', 'msg_deleted:t:n'], 'msg_owner_id');
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'messages',               ['msg_id:s', 'msg_owner_id:s', 'msg_author_id:s:n', 'msg_recipient_id:s:n', 'msg_reply_to:s:n', 'msg_title:s', 'msg_body:s', 'msg_body_format:s', 'msg_created:t', 'msg_sent:t:n', 'msg_read:t:n', 'msg_deleted:t:n'], 'msg_owner_id');
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts',             ['post_id:s', 'category_id:s', 'user_id:s:n', 'comment_section_id:s:n', 'post_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_access',          ['acc_id:s', 'app_id:s', 'user_id:s:n', 'acc_token:n', 'acc_scope:s', 'acc_created:t', 'acc_expires:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_authorise',       ['auth_id:s', 'app_id:s', 'user_id:s', 'uri_id:s', 'auth_challenge_code:n', 'auth_challenge_method:s', 'auth_scope:s', 'auth_code:n', 'auth_created:t', 'auth_expires:t']);
@@ -148,7 +148,7 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'perms_calculated',       ['user_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_calculated:i']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'profile_fields_values',  ['field_id:s', 'user_id:s', 'format_id:s', 'field_value:s']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'sessions',               ['session_id:s', 'user_id:s', 'session_key:n', 'session_remote_addr_first:a', 'session_remote_addr_last:a:n', 'session_user_agent:s', 'session_country:s', 'session_expires:t', 'session_expires_bump:b', 'session_created:t', 'session_active:t:n']);
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'users',                  ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_background_settings:i:n', 'user_title:s:n']);
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'users',                  ['user_id:s', 'user_name:s', 'user_password:n', 'user_email:s', 'user_remote_addr_first:a', 'user_remote_addr_last:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'user_display_role_id:s:n', 'user_about_content:s:n', 'user_about_content_format:s', 'user_signature_content:s:n', 'user_signature_content_format:s', 'user_background_settings:i:n', 'user_title:s:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_bans',             ['ban_id:s', 'user_id:s', 'mod_id:n', 'ban_severity:i', 'ban_reason_public:s', 'ban_reason_private:s', 'ban_created:t', 'ban_expires:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_birthdates',       ['user_id:s', 'birth_year:i:n', 'birth_month:i', 'birth_day:i']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'users_password_resets',  ['reset_id:s', 'user_id:s', 'reset_remote_addr:a', 'reset_requested:t', 'reset_code:n']);
diff --git a/src/Forum/ForumPostInfo.php b/src/Forum/ForumPostInfo.php
index 33e72b8d..bc0c32c8 100644
--- a/src/Forum/ForumPostInfo.php
+++ b/src/Forum/ForumPostInfo.php
@@ -1,10 +1,10 @@
 <?php
 namespace Misuzu\Forum;
 
-use Misuzu\Parsers\Parser;
 use Carbon\CarbonImmutable;
 use Index\XDateTime;
 use Index\Db\DbResult;
+use Misuzu\Parsers\TextFormat;
 
 class ForumPostInfo {
     public function __construct(
@@ -14,7 +14,7 @@ class ForumPostInfo {
         public private(set) ?string $userId,
         public private(set) string $remoteAddress,
         public private(set) string $body,
-        public private(set) int $parser,
+        public private(set) TextFormat $bodyFormat,
         public private(set) bool $shouldDisplaySignature,
         public private(set) int $createdTime,
         public private(set) ?int $editedTime,
@@ -29,7 +29,7 @@ class ForumPostInfo {
             userId: $result->getStringOrNull(3),
             remoteAddress: $result->getString(4),
             body: $result->getString(5),
-            parser: $result->getInteger(6),
+            bodyFormat: TextFormat::tryFrom($result->getString(6)) ?? TextFormat::Plain,
             shouldDisplaySignature: $result->getBoolean(7),
             createdTime: $result->getInteger(8),
             editedTime: $result->getIntegerOrNull(9),
@@ -38,15 +38,15 @@ class ForumPostInfo {
     }
 
     public bool $isBodyPlain {
-        get => $this->parser === Parser::PLAIN;
+        get => $this->bodyFormat === TextFormat::Plain;
     }
 
     public bool $isBodyBBCode {
-        get => $this->parser === Parser::BBCODE;
+        get => $this->bodyFormat === TextFormat::BBCode;
     }
 
     public bool $isBodyMarkdown {
-        get => $this->parser === Parser::MARKDOWN;
+        get => $this->bodyFormat === TextFormat::Markdown;
     }
 
     public CarbonImmutable $createdAt {
diff --git a/src/Forum/ForumPostsData.php b/src/Forum/ForumPostsData.php
index 82b4285c..65fb6389 100644
--- a/src/Forum/ForumPostsData.php
+++ b/src/Forum/ForumPostsData.php
@@ -7,6 +7,7 @@ use stdClass;
 use Carbon\CarbonImmutable;
 use Index\Db\{DbConnection,DbStatementCache,DbTools};
 use Misuzu\Pagination;
+use Misuzu\Parsers\TextFormat;
 use Misuzu\Users\UserInfo;
 
 class ForumPostsData {
@@ -131,7 +132,16 @@ class ForumPostsData {
         $hasPagination = $pagination !== null;
 
         $args = 0;
-        $query = 'SELECT post_id, topic_id, forum_id, user_id, INET6_NTOA(post_remote_addr), post_text, post_parse, post_display_signature, UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_edited), UNIX_TIMESTAMP(post_deleted) FROM msz_forum_posts';
+        $query = <<<SQL
+            SELECT post_id, topic_id, forum_id, user_id,
+                INET6_NTOA(post_remote_addr),
+                post_text, post_text_format,
+                post_display_signature,
+                UNIX_TIMESTAMP(post_created),
+                UNIX_TIMESTAMP(post_edited),
+                UNIX_TIMESTAMP(post_deleted)
+            FROM msz_forum_posts
+        SQL;
         if($hasCategoryInfo) {
             ++$args;
             if(is_array($categoryInfo))
@@ -207,7 +217,16 @@ class ForumPostsData {
             throw new InvalidArgumentException('At least one of the four first arguments must be specified.');
 
         $values = [];
-        $query = 'SELECT post_id, topic_id, forum_id, user_id, INET6_NTOA(post_remote_addr), post_text, post_parse, post_display_signature, UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_edited), UNIX_TIMESTAMP(post_deleted) FROM msz_forum_posts';
+        $query = <<<SQL
+            SELECT post_id, topic_id, forum_id, user_id,
+                INET6_NTOA(post_remote_addr),
+                post_text, post_text_format,
+                post_display_signature,
+                UNIX_TIMESTAMP(post_created),
+                UNIX_TIMESTAMP(post_edited),
+                UNIX_TIMESTAMP(post_deleted)
+            FROM msz_forum_posts
+        SQL;
         if($hasPostId) {
             $query .= ' WHERE post_id = ?';
             $values[] = $postId;
@@ -265,7 +284,7 @@ class ForumPostsData {
         UserInfo|string|null $userInfo,
         string $remoteAddr,
         string $body,
-        int $bodyParser,
+        TextFormat|string $bodyFormat,
         bool $displaySignature,
         ForumCategoryInfo|string|null $categoryInfo = null
     ): ForumPostInfo {
@@ -281,13 +300,20 @@ class ForumPostsData {
         if($userInfo instanceof UserInfo)
             $userInfo = $userInfo->id;
 
-        $stmt = $this->cache->get('INSERT INTO msz_forum_posts (topic_id, forum_id, user_id, post_remote_addr, post_text, post_parse, post_display_signature) VALUES (?, ?, ?, INET6_ATON(?), ?, ?, ?)');
+        if(is_string($bodyFormat))
+            $bodyFormat = TextFormat::from($bodyFormat);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_forum_posts (
+                topic_id, forum_id, user_id, post_remote_addr, post_text, post_text_format, post_display_signature
+            ) VALUES (?, ?, ?, INET6_ATON(?), ?, ?, ?)
+        SQL);
         $stmt->nextParameter($topicInfo);
         $stmt->nextParameter($categoryInfo);
         $stmt->nextParameter($userInfo);
         $stmt->nextParameter($remoteAddr);
         $stmt->nextParameter($body);
-        $stmt->nextParameter($bodyParser);
+        $stmt->nextParameter($bodyFormat->value);
         $stmt->nextParameter($displaySignature ? 1 : 0);
         $stmt->execute();
 
@@ -298,7 +324,7 @@ class ForumPostsData {
         ForumPostInfo|string $postInfo,
         ?string $remoteAddr = null,
         ?string $body = null,
-        ?int $bodyParser = null,
+        TextFormat|string|null $bodyFormat = null,
         ?bool $displaySignature = null,
         bool $bumpEdited = true
     ): void {
@@ -318,9 +344,9 @@ class ForumPostsData {
             $values[] = $body;
         }
 
-        if($bodyParser !== null) {
-            $fields[] = 'post_parse = ?';
-            $values[] = $bodyParser;
+        if($bodyFormat !== null) {
+            $fields[] = 'post_text_format = ?';
+            $values[] = is_string($bodyFormat) ? TextFormat::from($bodyFormat)->value : $bodyFormat->value;
         }
 
         if($displaySignature !== null) {
diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php
index 0e82130b..f500964a 100644
--- a/src/Info/InfoRoutes.php
+++ b/src/Info/InfoRoutes.php
@@ -5,7 +5,7 @@ use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\Template;
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\{Parsers,TextFormat};
 
 class InfoRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
@@ -116,7 +116,7 @@ class InfoRoutes implements RouteHandler, UrlSource {
         if($titleFormat !== '')
             $title = sprintf($titleFormat, $title);
 
-        $body = Parser::instance(Parser::MARKDOWN)->parseText($body);
+        $body = Parsers::instance(TextFormat::Markdown)->parseText($body);
 
         return Template::renderRaw('info.view', [
             'document' => [
diff --git a/src/Messages/MessageInfo.php b/src/Messages/MessageInfo.php
index f5f1d520..f5fed639 100644
--- a/src/Messages/MessageInfo.php
+++ b/src/Messages/MessageInfo.php
@@ -1,9 +1,9 @@
 <?php
 namespace Misuzu\Messages;
 
-use Misuzu\Parsers\Parser;
 use Carbon\CarbonImmutable;
 use Index\Db\DbResult;
+use Misuzu\Parsers\TextFormat;
 
 class MessageInfo {
     public function __construct(
@@ -14,7 +14,7 @@ class MessageInfo {
         public private(set) ?string $replyToId,
         public private(set) string $title,
         public private(set) string $body,
-        public private(set) int $parser,
+        public private(set) TextFormat $bodyFormat,
         public private(set) int $createdTime,
         public private(set) ?int $sentTime,
         public private(set) ?int $readTime,
@@ -30,7 +30,7 @@ class MessageInfo {
             replyToId: $result->getStringOrNull(4),
             title: $result->getString(5),
             body: $result->getString(6),
-            parser: $result->getInteger(7),
+            bodyFormat: TextFormat::tryFrom($result->getString(7)) ?? TextFormat::Plain,
             createdTime: $result->getInteger(8),
             sentTime: $result->getIntegerOrNull(9),
             readTime: $result->getIntegerOrNull(10),
@@ -39,15 +39,15 @@ class MessageInfo {
     }
 
     public bool $isBodyPlain {
-        get => $this->parser === Parser::PLAIN;
+        get => $this->bodyFormat === TextFormat::Plain;
     }
 
     public bool $isBodyBBCode {
-        get => $this->parser === Parser::BBCODE;
+        get => $this->bodyFormat === TextFormat::BBCode;
     }
 
     public bool $isBodyMarkdown {
-        get => $this->parser === Parser::MARKDOWN;
+        get => $this->bodyFormat === TextFormat::Markdown;
     }
 
     public CarbonImmutable $createdAt {
diff --git a/src/Messages/MessagesData.php b/src/Messages/MessagesData.php
index e218b669..b6a649af 100644
--- a/src/Messages/MessagesData.php
+++ b/src/Messages/MessagesData.php
@@ -6,6 +6,7 @@ use RuntimeException;
 use DateTimeInterface;
 use Index\Db\{DbConnection,DbStatementCache,DbTools};
 use Misuzu\Pagination;
+use Misuzu\Parsers\TextFormat;
 use Misuzu\Users\UserInfo;
 
 class MessagesData {
@@ -104,7 +105,13 @@ class MessagesData {
         $hasPagination = $pagination !== null;
 
         $args = 0;
-        $query = 'SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, FROM_BASE64(msg_title), FROM_BASE64(msg_body), msg_parser, UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent), UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted) FROM msz_messages';
+        $query = <<<SQL
+            SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to,
+                FROM_BASE64(msg_title), FROM_BASE64(msg_body), msg_body_format,
+                UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent),
+                UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted)
+            FROM msz_messages
+        SQL;
         if($hasOwnerInfo) {
             ++$args;
             $query .= ' WHERE msg_owner_id = ?';
@@ -160,7 +167,14 @@ class MessagesData {
         bool $useReplyTo = false
     ): MessageInfo {
         $stmt = $this->cache->get(sprintf(
-            'SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, FROM_BASE64(msg_title), FROM_BASE64(msg_body), msg_parser, UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent), UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted) FROM msz_messages WHERE msg_id = %s AND msg_owner_id = ?',
+            <<<SQL
+                SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to,
+                    FROM_BASE64(msg_title), FROM_BASE64(msg_body), msg_body_format,
+                    UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent),
+                    UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted)
+                FROM msz_messages
+                WHERE msg_id = %s AND msg_owner_id = ?
+            SQL,
             !$useReplyTo || $messageInfoOrId instanceof MessageInfo ? '?' : '(SELECT msg_reply_to FROM msz_messages WHERE msg_id = ?)'
         ));
 
@@ -185,12 +199,21 @@ class MessagesData {
         UserInfo|string|null $recipientInfo,
         string $title,
         string $body,
-        int $parser,
+        TextFormat|string $bodyFormat,
         MessageInfo|string|null $replyTo = null,
         DateTimeInterface|int|null $sentAt = null,
         DateTimeInterface|int|null $readAt = null
     ): MessageInfo {
-        $stmt = $this->cache->get('INSERT INTO msz_messages (msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, msg_title, msg_body, msg_parser, msg_sent, msg_read) VALUES (?, ?, ?, ?, ?, TO_BASE64(?), TO_BASE64(?), ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?))');
+        if(is_string($bodyFormat))
+            $bodyFormat = TextFormat::from($bodyFormat);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_messages (
+                msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to,
+                msg_title, msg_body, msg_body_format,
+                msg_sent, msg_read
+            ) VALUES (?, ?, ?, ?, ?, TO_BASE64(?), TO_BASE64(?), ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?))
+        SQL);
         $stmt->nextParameter($messageId);
         $stmt->nextParameter($ownerInfo instanceof UserInfo ? $ownerInfo->id : $ownerInfo);
         $stmt->nextParameter($authorInfo instanceof UserInfo ? $authorInfo->id : $authorInfo);
@@ -198,7 +221,7 @@ class MessagesData {
         $stmt->nextParameter($replyTo instanceof MessageInfo ? $replyTo->id : $replyTo);
         $stmt->nextParameter($title);
         $stmt->nextParameter($body);
-        $stmt->nextParameter($parser);
+        $stmt->nextParameter($bodyFormat->value);
         $stmt->nextParameter($sentAt instanceof DateTimeInterface ? (int)$sentAt->format('U') : $sentAt);
         $stmt->nextParameter($readAt instanceof DateTimeInterface ? (int)$readAt->format('U') : $readAt);
         $stmt->execute();
@@ -211,7 +234,7 @@ class MessagesData {
         MessageInfo|string|null $messageInfo = null,
         ?string $title = null,
         ?string $body = null,
-        ?int $parser = null,
+        TextFormat|string|null $bodyFormat = null,
         DateTimeInterface|int|null|false $sentAt = false,
         DateTimeInterface|int|null|false $readAt = false
     ): void {
@@ -240,9 +263,9 @@ class MessagesData {
             $setValues[] = $body;
         }
 
-        if($parser !== null) {
-            $setQuery[] = 'msg_parser = ?';
-            $setValues[] = $parser;
+        if($bodyFormat !== null) {
+            $setQuery[] = 'msg_body_format = ?';
+            $setValues[] = is_string($bodyFormat) ? TextFormat::from($bodyFormat)->value : $bodyFormat->value;
         }
 
         if($sentAt !== false) {
diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php
index fec94943..f0da0f1d 100644
--- a/src/Messages/MessagesRoutes.php
+++ b/src/Messages/MessagesRoutes.php
@@ -12,7 +12,7 @@ use Index\Http\Routing\{HttpGet,HttpMiddleware,HttpPost,RouteHandler,RouteHandle
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CSRF,Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\TextFormat;
 use Misuzu\Perms\PermissionsData;
 use Misuzu\Users\{UsersContext,UserInfo};
 
@@ -295,12 +295,12 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     /** @return ?array{error: array{name: string, text: string, args?: scalar[]}} */
-    private function checkMessageFields(string $title, string $body, int $parser): ?array {
-        if(!Parser::isValid($parser))
+    private function checkMessageFields(string $title, string $body, ?TextFormat $format): ?array {
+        if($format === null)
             return [
                 'error' => [
-                    'name' => 'msgs:invalid_parser',
-                    'text' => 'Invalid parser selected.',
+                    'name' => 'msgs:invalid_format',
+                    'text' => 'Invalid format selected.',
                 ],
             ];
 
@@ -368,10 +368,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
         $replyTo = (string)$request->content->getParam('reply');
         $title = (string)$request->content->getParam('title');
         $body = (string)$request->content->getParam('body');
-        $parser = (int)$request->content->getParam('parser', FILTER_SANITIZE_NUMBER_INT);
+        $format = TextFormat::tryFrom((string)$request->content->getParam('format'));
         $draft = !empty($request->content->getParam('draft'));
 
-        $error = $this->checkMessageFields($title, $body, $parser);
+        $error = $this->checkMessageFields($title, $body, $format);
         if($error !== null)
             return $error;
 
@@ -433,7 +433,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
             recipientInfo: $recipientInfo,
             title: $title,
             body: $body,
-            parser: $parser,
+            bodyFormat: $format,
             replyTo: $replyToInfo,
             sentAt: $sentAt
         );
@@ -447,7 +447,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
                 recipientInfo: $recipientInfo,
                 title: $title,
                 body: $body,
-                parser: $parser,
+                bodyFormat: $format,
                 replyTo: $replyToInfo,
                 sentAt: $sentAt
             );
@@ -470,10 +470,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
         $title = (string)$request->content->getParam('title');
         $body = (string)$request->content->getParam('body');
-        $parser = (int)$request->content->getParam('parser', FILTER_SANITIZE_NUMBER_INT);
+        $format = TextFormat::tryFrom((string)$request->content->getParam('format'));
         $draft = !empty($request->content->getParam('draft'));
 
-        $error = $this->checkMessageFields($title, $body, $parser);
+        $error = $this->checkMessageFields($title, $body, $format);
         if($error !== null)
             return $error;
 
@@ -526,7 +526,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
             messageInfo: $messageInfo,
             title: $title,
             body: $body,
-            parser: $parser,
+            bodyFormat: $format,
             sentAt: $sentAt,
         );
 
@@ -539,7 +539,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
                 recipientInfo: $messageInfo->recipientId,
                 title: $title,
                 body: $body,
-                parser: $parser,
+                bodyFormat: $format,
                 replyTo: $messageInfo->replyToId,
                 sentAt: $sentAt
             );
diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php
index 6343348b..5a32c9a9 100644
--- a/src/News/NewsRoutes.php
+++ b/src/News/NewsRoutes.php
@@ -10,7 +10,7 @@ use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Comments\{CommentsData,CommentsCategory,CommentsEx};
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\{Parsers,TextFormat};
 use Misuzu\Users\{UsersContext,UserInfo};
 
 class NewsRoutes implements RouteHandler, UrlSource {
@@ -210,7 +210,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
                 $userInfo = $post['user'];
 
                 $item->title = $postInfo->title;
-                $item->description = Parser::instance(Parser::MARKDOWN)->parseText($postInfo->body);
+                $item->description = Parsers::instance(TextFormat::Markdown)->parseText($postInfo->body);
                 $item->createdAt = $postInfo->createdTime;
                 $item->contentUrl = $siteUrl . $this->urls->format('news-post', ['post' => $postInfo->id]);
                 $item->commentsUrl = $siteUrl . $this->urls->format('news-post-comments', ['post' => $postInfo->id]);
diff --git a/src/Parsers/Parser.php b/src/Parsers/Parser.php
deleted file mode 100644
index 02586858..00000000
--- a/src/Parsers/Parser.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-namespace Misuzu\Parsers;
-
-use InvalidArgumentException;
-use Misuzu\Parsers\BBCode\BBCodeParser;
-
-final class Parser {
-    public const PLAIN = 0;
-    public const BBCODE = 1;
-    public const MARKDOWN = 2;
-
-    private const PARSERS = [
-        self::PLAIN => PlainParser::class,
-        self::BBCODE => BBCodeParser::class,
-        self::MARKDOWN => MarkdownParser::class,
-    ];
-    public const NAMES = [
-        self::PLAIN    => 'Plain text',
-        self::BBCODE   => 'BB Code',
-        self::MARKDOWN => 'Markdown',
-    ];
-
-    /** @var array<int, ParserInterface> */
-    private static $instances = [];
-
-    public static function isValid(int $parser): bool {
-        return array_key_exists($parser, self::PARSERS);
-    }
-
-    public static function name(int $parser): string {
-        return self::isValid($parser) ? self::NAMES[$parser] : '';
-    }
-
-    public static function instance(int $parser): ParserInterface {
-        if(!self::isValid($parser))
-            throw new InvalidArgumentException('Invalid parser.');
-
-        if(!isset(self::$instances[$parser])) {
-            $className = self::PARSERS[$parser];
-            self::$instances[$parser] = new $className;
-        }
-
-        return self::$instances[$parser];
-    }
-}
diff --git a/src/Parsers/Parsers.php b/src/Parsers/Parsers.php
new file mode 100644
index 00000000..c0d4498e
--- /dev/null
+++ b/src/Parsers/Parsers.php
@@ -0,0 +1,21 @@
+<?php
+namespace Misuzu\Parsers;
+
+final class Parsers {
+    /** @var array<int, ParserInterface> */
+    private static array $instances = [];
+
+    public static function instance(TextFormat|string $format): ParserInterface {
+        if(is_string($format))
+            $format = TextFormat::from($format);
+
+        if(!array_key_exists($format->value, self::$instances))
+            self::$instances[$format->value] = match($format) {
+                TextFormat::Plain => new PlainParser,
+                TextFormat::BBCode => new BBCode\BBCodeParser,
+                TextFormat::Markdown => new MarkdownParser,
+            };
+
+        return self::$instances[$format->value];
+    }
+}
diff --git a/src/Parsers/TextFormat.php b/src/Parsers/TextFormat.php
new file mode 100644
index 00000000..ead63aae
--- /dev/null
+++ b/src/Parsers/TextFormat.php
@@ -0,0 +1,8 @@
+<?php
+namespace Misuzu\Parsers;
+
+enum TextFormat: string {
+    case Plain = '';
+    case BBCode = 'bb';
+    case Markdown = 'md';
+}
diff --git a/src/TemplatingExtension.php b/src/TemplatingExtension.php
index 19e1c297..f4c91de5 100644
--- a/src/TemplatingExtension.php
+++ b/src/TemplatingExtension.php
@@ -2,10 +2,10 @@
 namespace Misuzu;
 
 use DateTimeInterface;
+use Carbon\CarbonImmutable;
 use Misuzu\MisuzuContext;
 use Misuzu\Tools;
-use Misuzu\Parsers\Parser;
-use Carbon\CarbonImmutable;
+use Misuzu\Parsers\{Parsers,TextFormat};
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -22,7 +22,8 @@ final class TemplatingExtension extends AbstractExtension {
     public function getFilters() {
         return [
             new TwigFilter('country_name', Tools::countryName(...)),
-            new TwigFilter('parse_text', fn(string $text, int $parser): string => Parser::instance($parser)->parseText($text)),
+            new TwigFilter('parse_text', fn(string $text, TextFormat|string $parser): string => Parsers::instance($parser)->parseText($text)),
+            new TwigFilter('parse_md', fn(string $text): string => Parsers::instance(TextFormat::Markdown)->parseText($text)),
             new TwigFilter('time_format', $this->timeFormat(...)),
         ];
     }
@@ -41,6 +42,13 @@ final class TemplatingExtension extends AbstractExtension {
             new TwigFunction('msz_header_menu', $this->getHeaderMenu(...)),
             new TwigFunction('msz_user_menu', $this->getUserMenu(...)),
             new TwigFunction('msz_manage_menu', $this->getManageMenu(...)),
+            new TwigFunction('parser_options', function() {
+                return [
+                    TextFormat::Plain->value => 'Plain text',
+                    TextFormat::BBCode->value => 'BB Code',
+                    TextFormat::Markdown->value => 'Markdown',
+                ];
+            }),
         ];
     }
 
diff --git a/src/Users/UserInfo.php b/src/Users/UserInfo.php
index 7909e50b..201d964a 100644
--- a/src/Users/UserInfo.php
+++ b/src/Users/UserInfo.php
@@ -1,10 +1,10 @@
 <?php
 namespace Misuzu\Users;
 
-use Misuzu\Parsers\Parser;
 use Carbon\CarbonImmutable;
 use Index\Colour\Colour;
 use Index\Db\DbResult;
+use Misuzu\Parsers\TextFormat;
 
 class UserInfo {
     public function __construct(
@@ -22,9 +22,9 @@ class UserInfo {
         public private(set) ?int $deletedTime,
         public private(set) ?string $displayRoleId,
         public private(set) ?string $aboutBody,
-        public private(set) int $aboutBodyParser,
+        public private(set) TextFormat $aboutBodyFormat,
         public private(set) ?string $signatureBody,
-        public private(set) int $signatureBodyParser,
+        public private(set) TextFormat $signatureBodyFormat,
         public private(set) ?int $backgroundSettings,
         public private(set) ?string $title,
     ) {}
@@ -45,9 +45,9 @@ class UserInfo {
             deletedTime: $result->getIntegerOrNull(11),
             displayRoleId: $result->getStringOrNull(12),
             aboutBody: $result->getStringOrNull(13),
-            aboutBodyParser: $result->getInteger(14),
+            aboutBodyFormat: TextFormat::tryFrom($result->getString(14)) ?? TextFormat::Plain,
             signatureBody: $result->getStringOrNull(15),
-            signatureBodyParser: $result->getInteger(16),
+            signatureBodyFormat: TextFormat::tryFrom($result->getString(16)) ?? TextFormat::Plain,
             backgroundSettings: $result->getIntegerOrNull(17),
             title: $result->getString(18),
         );
@@ -90,26 +90,26 @@ class UserInfo {
     }
 
     public bool $isAboutBodyPlain {
-        get => $this->aboutBodyParser === Parser::PLAIN;
+        get => $this->aboutBodyFormat === TextFormat::Plain;
     }
 
     public bool $isAboutBodyBBCode {
-        get => $this->aboutBodyParser === Parser::BBCODE;
+        get => $this->aboutBodyFormat === TextFormat::BBCode;
     }
 
     public bool $isAboutBodyMarkdown {
-        get => $this->aboutBodyParser === Parser::MARKDOWN;
+        get => $this->aboutBodyFormat === TextFormat::Markdown;
     }
 
     public bool $isSignatureBodyPlain {
-        get => $this->signatureBodyParser === Parser::PLAIN;
+        get => $this->signatureBodyFormat === TextFormat::Plain;
     }
 
     public bool $isSignatureBodyBBCode {
-        get => $this->signatureBodyParser === Parser::BBCODE;
+        get => $this->signatureBodyFormat === TextFormat::BBCode;
     }
 
     public bool $isSignatureBodyMarkdown {
-        get => $this->signatureBodyParser === Parser::MARKDOWN;
+        get => $this->signatureBodyFormat === TextFormat::Markdown;
     }
 }
diff --git a/src/Users/UsersData.php b/src/Users/UsersData.php
index d58ce9e7..c98d0c41 100644
--- a/src/Users/UsersData.php
+++ b/src/Users/UsersData.php
@@ -9,7 +9,7 @@ use Index\Colour\Colour;
 use Index\Db\{DbConnection,DbStatementCache,DbTools};
 use Misuzu\Pagination;
 use Misuzu\Tools;
-use Misuzu\Parsers\Parser;
+use Misuzu\Parsers\Parsers;
 
 class UsersData {
     private DbStatementCache $cache;
@@ -177,8 +177,8 @@ class UsersData {
                 UNIX_TIMESTAMP(u.user_active),
                 UNIX_TIMESTAMP(u.user_deleted),
                 u.user_display_role_id,
-                u.user_about_content, u.user_about_parser,
-                u.user_signature_content, u.user_signature_parser,
+                u.user_about_content, u.user_about_content_format,
+                u.user_signature_content, u.user_signature_content_format,
                 u.user_background_settings, u.user_title
             FROM msz_users AS u
         SQL;
@@ -286,8 +286,8 @@ class UsersData {
                 UNIX_TIMESTAMP(user_active),
                 UNIX_TIMESTAMP(user_deleted),
                 user_display_role_id,
-                user_about_content, user_about_parser,
-                user_signature_content, user_signature_parser,
+                user_about_content, user_about_content_format,
+                user_signature_content, user_signature_content_format,
                 user_background_settings, user_title
             FROM msz_users
         SQL;
@@ -358,9 +358,9 @@ class UsersData {
         ?Colour $colour = null,
         RoleInfo|string|null $displayRoleInfo = null,
         ?string $aboutBody = null,
-        ?int $aboutBodyParser = null,
+        ?int $aboutBodyFormat = null,
         ?string $signatureBody = null,
-        ?int $signatureBodyParser = null,
+        ?int $signatureBodyFormat = null,
         ?int $backgroundSettings = null,
         ?string $title = null
     ): void {
@@ -408,24 +408,24 @@ class UsersData {
             $values[] = $displayRoleInfo;
         }
 
-        if($aboutBody !== null && $aboutBodyParser !== null) {
-            if(self::validateProfileAbout($aboutBodyParser, $aboutBody) !== '')
-                throw new InvalidArgumentException('$aboutBody and $aboutBodyParser contain invalid data!');
+        if($aboutBody !== null && $aboutBodyFormat !== null) {
+            if(self::validateProfileAbout($aboutBodyFormat, $aboutBody) !== '')
+                throw new InvalidArgumentException('$aboutBody and $aboutBodyFormat contain invalid data!');
 
             $fields[] = 'user_about_content = ?';
             $values[] = $aboutBody;
-            $fields[] = 'user_about_parser = ?';
-            $values[] = $aboutBodyParser;
+            $fields[] = 'user_about_content_format = ?';
+            $values[] = $aboutBodyFormat;
         }
 
-        if($signatureBody !== null && $signatureBodyParser !== null) {
-            if(self::validateForumSignature($signatureBodyParser, $signatureBody) !== '')
-                throw new InvalidArgumentException('$signatureBody and $signatureBodyParser contain invalid data!');
+        if($signatureBody !== null && $signatureBodyFormat !== null) {
+            if(self::validateForumSignature($signatureBodyFormat, $signatureBody) !== '')
+                throw new InvalidArgumentException('$signatureBody and $signatureBodyFormat contain invalid data!');
 
             $fields[] = 'user_signature_content = ?';
             $values[] = $signatureBody;
-            $fields[] = 'user_signature_parser = ?';
-            $values[] = $signatureBodyParser;
+            $fields[] = 'user_signature_content_format = ?';
+            $values[] = $signatureBodyFormat;
         }
 
         if($backgroundSettings !== null) {
@@ -710,7 +710,7 @@ class UsersData {
     }
 
     public static function validateProfileAbout(int $parser, string $text): string {
-        if(!Parser::isValid($parser))
+        if(!Parsers::isValid($parser))
             return 'parser';
 
         $length = strlen($text);
@@ -730,7 +730,7 @@ class UsersData {
     }
 
     public static function validateForumSignature(int $parser, string $text): string {
-        if(!Parser::isValid($parser))
+        if(!Parsers::isValid($parser))
             return 'parser';
 
         $length = strlen($text);
diff --git a/templates/_layout/input.twig b/templates/_layout/input.twig
index 13f2f3ff..27f60d27 100644
--- a/templates/_layout/input.twig
+++ b/templates/_layout/input.twig
@@ -85,7 +85,7 @@
 
 {% macro input_select_option(value, key, selected) %}
 {% apply spaceless %}
-    <option{% if key|length > 0 %} value="{{ key }}"{% endif %}{% if selected %} selected{% endif %}>
+    <option{% if key is not null %} value="{{ key }}"{% endif %}{% if selected %} selected{% endif %}>
         {{ value }}
     </option>
 {% endapply %}
@@ -101,7 +101,7 @@
         {% endfor %}>
         {% for key, value in options %}
             {% set option_value = value_name|length > 0 ? value[value_name] : value %}
-            {% set option_key = only_values ? '' : (key_name|length > 0 ? value[key_name] : key) %}
+            {% set option_key = only_values ? null : (key_name|length > 0 ? value[key_name] : key) %}
             {{ input_select_option(option_value, option_key, option_key|default(option_value) == selected) }}
         {% endfor %}
     </select>
diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig
index 5455b446..cecb46e3 100644
--- a/templates/changelog/change.twig
+++ b/templates/changelog/change.twig
@@ -60,7 +60,7 @@
             <h1>{{ title }}</h1>
 
             {% if change_info.body is not empty %}
-                {{ change_info.body|escape|parse_text(2)|raw }}
+                {{ change_info.body|escape|parse_md|raw }}
             {% else %}
                 <p>This change has no additional notes.</p>
             {% endif %}
diff --git a/templates/forum/macros.twig b/templates/forum/macros.twig
index c0aef375..49acd475 100644
--- a/templates/forum/macros.twig
+++ b/templates/forum/macros.twig
@@ -466,7 +466,7 @@
     {% set post_edited = post.info.editedTime %}
     {% set post_is_deleted = post.info.deleted %}
     {% set post_is_op = post.isOriginalPost %}
-    {% set post_body = post.info.body|escape|parse_text(post.info.parser) %}
+    {% set post_body = post.info.body|escape|parse_text(post.info.bodyFormat) %}
     {% set post_is_markdown = post.info.isBodyMarkdown %}
     {% set post_show_signature = post.info.shouldDisplaySignature %}
     {% set post_can_be_deleted = post.info.canBeDeleted %}
@@ -482,7 +482,7 @@
         {% set author_created = post.user.createdTime %}
         {% set author_posts_count = post.postsCount %}
         {% set author_is_op = post.isOriginalPoster %}
-        {% set signature_body = post.user.signatureBody|default('')|escape|parse_text(post.user.signatureBodyParser) %}
+        {% set signature_body = post.user.signatureBody|default('')|escape|parse_text(post.user.signatureBodyFormat) %}
         {% set signature_is_markdown = post.user.isSignatureBodyMarkdown %}
     {% endif %}
 
diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig
index af330ef8..e56cbf22 100644
--- a/templates/forum/posting.twig
+++ b/templates/forum/posting.twig
@@ -80,9 +80,8 @@
                 <div class="forum__post__options">
                     <div class="forum__post__settings">
                         {{ input_select(
-                            'post[parser]',
-                            constant('\\Misuzu\\Parsers\\Parser::NAMES'),
-                            posting_defaults.parser|default(posting_post.info.parser|default(posting_user_preferred_parser)),
+                            'post[parser]', parser_options(),
+                            posting_defaults.parser|default(posting_post.info.bodyFormat|default(posting_user_preferred_parser)).value,
                             null, null, false, 'forum__post__dropdown js-forum-posting-parser'
                         ) }}
                         {% if is_opening and posting_types|length > 1 %}
diff --git a/templates/home/landing.twig b/templates/home/landing.twig
index 9ffcef4b..59908cad 100644
--- a/templates/home/landing.twig
+++ b/templates/home/landing.twig
@@ -169,7 +169,7 @@
 {% for post in featured_news %}
             <div class="landingv2-news-post markdown">
                 <h1>{{ post.title }}</h1>
-                <p>{{ post.firstParagraph|escape|parse_text(2)|raw }}</p>
+                <p>{{ post.firstParagraph|escape|parse_md|raw }}</p>
                 <div class="landingv2-news-post-options">
                     <a href="{{ url('news-post', {'post': post.id}) }}" class="landingv2-news-post-option">Continue reading</a>
                     | <time datetime="{{ post.createdTime|date('c') }}" title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_format }}</time>
diff --git a/templates/manage/users/note.twig b/templates/manage/users/note.twig
index 9160d8e3..89145f78 100644
--- a/templates/manage/users/note.twig
+++ b/templates/manage/users/note.twig
@@ -57,7 +57,7 @@
 
             {% if not note_new and note_info.body is not empty %}
                 <div class="manage__note__body markdown manage__note--viewing">
-                    {{ note_info.body|escape|parse_text(2)|raw }}
+                    {{ note_info.body|escape|parse_md|raw }}
                 </div>
             {% else %}
                 <div class="manage__note__nobody manage__note--viewing">
diff --git a/templates/manage/users/notes.twig b/templates/manage/users/notes.twig
index c0f8d510..2b18958f 100644
--- a/templates/manage/users/notes.twig
+++ b/templates/manage/users/notes.twig
@@ -71,9 +71,9 @@
                 {% if note.info.body is not empty %}
                     <div class="manage__notes__item__body markdown">
                         {% if notes_filtering %}
-                            {{ note.info.body|escape|parse_text(2)|raw }}
+                            {{ note.info.body|escape|parse_md|raw }}
                         {% else %}
-                            {{ note.info.firstParagraph|escape|parse_text(2)|raw }}
+                            {{ note.info.firstParagraph|escape|parse_md|raw }}
                         {% endif %}
                     </div>
                 {% else %}
diff --git a/templates/messages/compose.twig b/templates/messages/compose.twig
index e32b206d..9977f765 100644
--- a/templates/messages/compose.twig
+++ b/templates/messages/compose.twig
@@ -28,7 +28,7 @@
 
                 <div class="warning">
                     <div class="warning__content">
-                        <p>UI is VERY not final. It will be not awful before 2025 I promise for real this time!!!</p>
+                        <p>UI is VERY not final. It will be not awful before <del>2025</del> 2026 I promise for real this time!!!</p>
                         <p>I need to clean up a lot of code first because a lot of things are specifically written for the forum editor and it will become a big mess otherwise.</p>
                     </div>
                 </div>
@@ -54,7 +54,7 @@
                     <div class="messages-reply-actions js-messages-reply-actions" hidden></div>
                     <div class="messages-reply-options">
                         <div class="messages-reply-settings">
-                            {{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), '1', null, null, null, 'js-messages-reply-parser') }}
+                            {{ input_select('format', parser_options(), 'bb', null, null, null, 'js-messages-reply-parser') }}
                         </div>
                         <div class="messages-reply-buttons">
                             <button class="input__button js-messages-reply-save" name="draft" value="1">Save draft</button>
diff --git a/templates/messages/thread.twig b/templates/messages/thread.twig
index 6443e974..229f8963 100644
--- a/templates/messages/thread.twig
+++ b/templates/messages/thread.twig
@@ -92,7 +92,7 @@
                     <div class="messages-message-subject">
                         <h1>{{ message.info.title }}</h1>
                     </div>
-                    <div class="messages-message-body{% if message.info.isBodyMarkdown %} markdown{% endif %}">{{ message.info.body|escape|parse_text(message.info.parser)|raw }}</div>
+                    <div class="messages-message-body{% if message.info.isBodyMarkdown %} markdown{% endif %}">{{ message.info.body|escape|parse_text(message.info.bodyFormat)|raw }}</div>
                 </article>
 
                 {% if can_send_messages %}
@@ -127,7 +127,7 @@
                                 <div class="messages-reply-actions js-messages-reply-actions" hidden></div>
                                 <div class="messages-reply-options">
                                     <div class="messages-reply-settings">
-                                        {{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), draft_info.parser|default('1'), null, null, null, 'js-messages-reply-parser') }}
+                                        {{ input_select('format', parser_options(), draft_info.bodyFormat.value|default('bb'), null, null, null, 'js-messages-reply-parser') }}
                                     </div>
                                     <div class="messages-reply-buttons">
                                         <button class="input__button js-messages-reply-save" name="draft" value="1">Save draft</button>
diff --git a/templates/news/macros.twig b/templates/news/macros.twig
index eca74d4a..23af4ea6 100644
--- a/templates/news/macros.twig
+++ b/templates/news/macros.twig
@@ -36,7 +36,7 @@
         </div>
         <div class="news__preview__content markdown">
             <div class="news__preview__text">
-                {{ post.post.firstParagraph|escape|parse_text(2)|raw }}
+                {{ post.post.firstParagraph|escape|parse_md|raw }}
             </div>
             <div class="news__preview__links">
                 <a href="{{ url('news-post', {'post': post.post.id}) }}" class="news__preview__link">Continue reading</a>
@@ -91,7 +91,7 @@
 
         <div class="news__post__text markdown">
             <h1>{{ post.title }}</h1>
-            {{ post.body|escape|parse_text(2)|raw }}
+            {{ post.body|escape|parse_md|raw }}
         </div>
     </div>
 {% endmacro %}
diff --git a/templates/profile/index.twig b/templates/profile/index.twig
index adaa190e..889809b3 100644
--- a/templates/profile/index.twig
+++ b/templates/profile/index.twig
@@ -266,12 +266,12 @@
 
                                 {% if profile_is_editing %}
                                     <div class="profile__signature__editor">
-                                        {{ input_select('about[parser]', constant('\\Misuzu\\Parsers\\Parser::NAMES'), profile_user.aboutBodyParser, '', '', false, 'profile__about__select') }}
+                                        {{ input_select('about[parser]', parser_options(), profile_user.aboutBodyFormat.value, '', '', false, 'profile__about__select') }}
                                         <textarea name="about[text]" class="input__textarea profile__about__text" id="about-textarea">{{ profile_user.aboutBody }}</textarea>
                                     </div>
                                 {% else %}
                                     <div class="profile__about__content{% if profile_is_editing %} profile__about__content--edit{% elseif profile_user.isAboutBodyMarkdown %} markdown{% endif %}">
-                                        {{ profile_user.aboutBody|escape|parse_text(profile_user.aboutBodyParser)|raw }}
+                                        {{ profile_user.aboutBody|escape|parse_text(profile_user.aboutBodyFormat)|raw }}
                                     </div>
                                 {% endif %}
                             </div>
@@ -283,12 +283,12 @@
 
                                 {% if profile_is_editing %}
                                     <div class="profile__signature__editor">
-                                        {{ input_select('signature[parser]', constant('\\Misuzu\\Parsers\\Parser::NAMES'), profile_user.signatureBodyParser, '', '', false, 'profile__signature__select') }}
+                                        {{ input_select('signature[parser]', parser_options(), profile_user.signatureBodyFormat.value, '', '', false, 'profile__signature__select') }}
                                         <textarea name="signature[text]" class="input__textarea profile__signature__text" id="signature-textarea">{{ profile_user.signatureBody }}</textarea>
                                     </div>
                                 {% else %}
                                     <div class="profile__signature__content{% if profile_is_editing %} profile__signature__content--edit{% elseif profile_user.isSignatureBodyMarkdown %} markdown{% endif %}">
-                                        {{ profile_user.signatureBody|escape|parse_text(profile_user.signatureBodyParser)|raw }}
+                                        {{ profile_user.signatureBody|escape|parse_text(profile_user.signatureBodyFormat)|raw }}
                                     </div>
                                 {% endif %}
                             </div>
diff --git a/tools/cron b/tools/cron
index e1983430..0d28187f 100755
--- a/tools/cron
+++ b/tools/cron
@@ -113,9 +113,9 @@ msz_sched_task_func('Resync statistics counters.', true, function() use ($msz) {
         'forum:posts:visible'          => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_deleted IS NULL',
         'forum:posts:deleted'          => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_deleted IS NOT NULL',
         'forum:posts:edited'           => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_edited IS NOT NULL',
-        'forum:posts:parse:plain'      => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_parse = 0',
-        'forum:posts:parse:bbcode'     => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_parse = 1',
-        'forum:posts:parse:markdown'   => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_parse = 2',
+        'forum:posts:parse:plain'      => "SELECT COUNT(*) FROM msz_forum_posts WHERE post_text_format = ''",
+        'forum:posts:parse:bbcode'     => "SELECT COUNT(*) FROM msz_forum_posts WHERE post_text_format = 'bb'",
+        'forum:posts:parse:markdown'   => "SELECT COUNT(*) FROM msz_forum_posts WHERE post_text_format = 'md'",
         'forum:posts:signature'        => 'SELECT COUNT(*) FROM msz_forum_posts WHERE post_display_signature <> 0',
         'forum:topics:total'           => 'SELECT COUNT(*) FROM msz_forum_topics',
         'forum:topics:type:normal'     => 'SELECT COUNT(*) FROM msz_forum_topics WHERE topic_type = 0',
@@ -141,9 +141,9 @@ msz_sched_task_func('Resync statistics counters.', true, function() use ($msz) {
         'pms:msgs:drafts'              => 'SELECT COUNT(*) FROM msz_messages WHERE msg_sent IS NULL',
         'pms:msgs:unread'              => 'SELECT COUNT(*) FROM msz_messages WHERE msg_read IS NULL',
         'pms:msgs:deleted'             => 'SELECT COUNT(*) FROM msz_messages WHERE msg_deleted IS NOT NULL',
-        'pms:msgs:plain'               => 'SELECT COUNT(*) FROM msz_messages WHERE msg_parser = 0',
-        'pms:msgs:bbcode'              => 'SELECT COUNT(*) FROM msz_messages WHERE msg_parser = 1',
-        'pms:msgs:markdown'            => 'SELECT COUNT(*) FROM msz_messages WHERE msg_parser = 2',
+        'pms:msgs:plain'               => "SELECT COUNT(*) FROM msz_messages WHERE msg_body_format = ''",
+        'pms:msgs:bbcode'              => "SELECT COUNT(*) FROM msz_messages WHERE msg_body_format = 'bb'",
+        'pms:msgs:markdown'            => "SELECT COUNT(*) FROM msz_messages WHERE msg_body_format = 'md'",
     ];
 
     foreach($stats as $name => $query) {