BBCode Parser

Parse BBCode into AST and convert into HTML

Fra og med 16.09.2025. Se den nyeste version.

Dette script bør ikke installeres direkte. Det er et bibliotek, som andre scripts kan inkludere med metadirektivet // @require https://update.greasyforks.org/scripts/549682/1661584/BBCode%20Parser.js

// ==UserScript==
// @name               BBCode Parser
// @namespace          https://greasyforks.org/users/667968-pyudng
// @version            0.1
// @description        Parse BBCode into AST and convert into HTML
// @author             PY-DNG
// @license            GPL-3.0-or-later
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */
/* eslint-disable no-fallthrough */

var BBCodeParser = (function __MAIN__() {
    'use strict';

    class ASTToken {
        /** @type {string} Token类型 */
        type;
        /** @type {number} 第一个字符的index */
        start;
        /** @type {number} 最后一个字符的index+1 */
        end;
        /** @type {string} 源代码 */
        code;

        /**
         * @param {Pick<ASTToken, keyof typeof ASTToken.prototype>} details
         */
        constructor(details = {}) {
            Object.assign(this, details);
        }
    }

    class BBCodeToken extends ASTToken {
        /** @type {'bbcode'} */
        type = 'bbcode';
        /** @type {'open' | 'close'} */
        sub_type;
        /** @type {string} */
        tag_name;
        /** @type {string | null} */
        attribute;

        /**
         * @param {Pick<BBCodeToken, keyof typeof BBCodeToken.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    class TextToken extends ASTToken {
        /** @type {'text'} */
        type = 'text';

        /**
         * @param {Pick<TextToken, keyof typeof TextToken.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    class HTMLToken extends ASTToken {
        /** @type {'html'} */
        type = 'html';
        /** @type {'open' | 'close'} */
        sub_type;

        /**
         * @param {Pick<HTMLToken, keyof typeof HTMLToken.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    class ASTNode {
        /** @type {'bbcode' | '#string'} 节点类型 */
        type;
        /** @type {number} 第一个字符的token index */
        token_start;
        /** @type {number} 最后一个字符的token index + 1 */
        token_end;
        /** @type {number} 第一个字符的code index */
        start;
        /** @type {number} 最后一个字符的code index + 1 */
        end;
        /** @type {string} 源代码 */
        code;
        /** @type {ASTToken[]} 包含的全部Token */
        tokens;
        /** @type {ASTNode[]} 子节点 */
        children;
        /** @type {string} 子节点全部代码 */
        content;

        /**
         * @param {Pick<ASTNode, keyof typeof ASTNode.prototype>} details
         */
        constructor(details = {}) {
            Object.assign(this, details);
        }
    }

    class BBCodeNode extends ASTNode {
        /** @type {'bbcode'} 节点类型 */
        type = 'bbcode';
        /** @type {string | null} bbcode节点属性值 */
        attribute;
        /** @type {string} 节点名称 */
        tag_name;

        /**
         * @param {Pick<BBCodeNode, keyof typeof BBCodeNode.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    class TextNode extends ASTNode {
        /** @type {'#text'} 节点类型 */
        type = '#text';

        /**
         * @param {Pick<TextNode, keyof typeof TextNode.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    class HTMLNode extends ASTNode {
        /** @type {'html'} 节点类型 */
        type = 'html';

        /**
         * @param {Pick<HTMLNode, keyof typeof HTMLNode.prototype>} details
         */
        constructor(details = {}) {
            super();
            Object.assign(this, details);
        }
    }

    /**
     * BBCode解析器
     */
    class BBCodeParser {
        static Reg = {
            StartToken: /\[[a-z0-9\-_]+\]/i,
            EndToken: /\[\/[a-z0-9\-_]+\]/i,
        };

        /** @typedef {(attribute: string | null, content: string | null) => string} TagTransformer */
        /**
         * BBCode转html规则
         * @typedef {Object} TagDefination
         * @property {TagTransformer} openTag - 将开标签从bbcode转换成html的实现函数
         * @property {TagTransformer | null} closeTag - 将闭标签从bbcode转换成html的实现函数;不存在时,代表该规则仅有开标签(如`[hr]`)
         */
        /** @type {Record<string, TagDefination>} */
        tags = {};

        constructor() {}

        /**
         * 将bbcode代码解析为Token流
         * @param {string} bbcode 
         * @return {ASTToken[]}
         */
        parseTokens(bbcode) {
            /** @type {ASTToken[]} */
            const tokens = [];

            // 分词
            for (let i = 0; i < bbcode.length; i++) {
                const char = bbcode.charAt(i);

                switch (char) {
                    case '[': {
                        // [ 开头,可能是BBCode标签
                        const token = findBBCodeToken(i);
                        if (token) {
                            // 确实是BBCode标签
                            tokens.push(token);
                            i = token.end - 1;
                            break;
                        } else {
                            // 不是BBCode标签,不break,进入default case当作普通文字处理
                        }
                    }
                    default: {
                        // 普通文字
                        const token = findPlainTextToken(i);
                        tokens.push(token);
                        i = token.end - 1;
                    }
                }
            }

            return tokens;

            /**
             * 从给定位置向后扫描,寻找BBCode起始/结束标签Token
             * @param {number} i 
             * @returns {null | ASTToken}
             */
            function findBBCodeToken(i) {
                let j = i + 1;
                bbcode.charAt(j) === '/' && j++;
                for (; j < bbcode.length; j++) {
                    const char = bbcode.charAt(j);

                    if (char === ']') {
                        const token = new BBCodeToken({
                            sub_type: bbcode.charAt(i+1) === '/' ? 'close' : 'open',
                            code: bbcode.substring(i, j+1),
                            start: i,
                            end: j + 1,
                        });
                        const content = token.code.substring(
                            token.sub_type === 'open' ? 1 : 2,
                            token.code.length - 1,
                        );
                        if (content.includes('=')) {
                            const index = content.indexOf('=');
                            [token.tag_name, token.attribute] = [
                                content.substring(0, index),
                                content.substring(index + 1)
                            ];
                        } else {
                            token.tag_name = content;
                            token.attribute = null;
                        }
                        return token;
                    }
                    if ('\r\n'.includes(char)) return null;
                }
                return null;
            }

            /**
             * 从给定位置向后扫描,寻找纯文本Token
             * @param {number} i 
             * @returns {ASTToken}
             */
            function findPlainTextToken(i) {
                // 根据后续换行符、[、]的位置判断后续是否有bbcode token
                const next_newline = [...bbcode].slice(i).findIndex(c => '\r\n'.includes(c));
                const next_left_bracket = bbcode.indexOf('[', i);
                const next_right_bracket = bbcode.indexOf(']', i);
                const has_bbcode_token =
                    next_left_bracket > -1 &&
                    next_left_bracket < next_right_bracket &&
                    (next_left_bracket - next_newline) * (next_right_bracket - next_newline) > 0;
                
                // 计算当前纯文本token截止位置
                const end = has_bbcode_token ? 
                    // 后续如果有bbcode token,则纯文本token截止到bbcode token之前
                    next_left_bracket:
                    // 后续如果没有bbcode token,则纯文本token截止到bbcode代码末尾
                    bbcode.length;
                return new TextToken({
                    code: bbcode.substring(i, end),
                    start: i,
                    end,
                });
            }
        }

        /**
         * 将Token流解析为AST
         * @param {ASTToken[]} tokens 
         * @returns {ASTNode[]}
         */
        parseAST(tokens) {
            /** @type {number[]} 存放可以拥有子节点的节点的首Token的index */
            const open_indexes = [];
            /** @type {ASTNode[]} 存放结果节点 */
            const nodes = [];

            for (let i = 0; i < tokens.length; i++) {
                const token = tokens[i];

                const accept_children = token.type === 'bbcode' && this.tags[token.tag_name]?.closeTag;
                const is_open_token = token.sub_type === 'open';

                if (accept_children) {
                    // 可以拥有子节点的节点的开标签token入栈
                    is_open_token && open_indexes.push(i);

                    // 闭标签出栈,转换为AST节点存储
                    if (!is_open_token) {
                        /** @type {number} */
                        const open_index = open_indexes.pop();
                        const open_token = tokens[open_index];
                        if (open_token.tag_name === token.tag_name) {
                            // 创建节点
                            const node_tokens = tokens.slice(open_index, i + 1);
                            const node = new BBCodeNode({
                                type: 'bbcode',
                                tag_name: token.tag_name,
                                tokens: node_tokens,
                                attribute: open_token.attribute,
                                code: node_tokens.reduce((code, token) => code + token.code, ''),
                                children: [],
                                content: '',
                                token_start: open_index,
                                token_end: i + 1,
                                start: open_token.start,
                                end: token.end,
                            });
                            // 将节点范围内的已有节点添加为子节点
                            nodes
                                .filter(n => n.token_start > node.token_start)
                                .forEach(n => node.children.push(nodes.splice(nodes.indexOf(n), 1)[0]));
                            // 计算content
                            node.content = accept_children ?
                                node.children.reduce((content, child) => content + child.code, '') :
                                null;
                            nodes.push(node);
                        }
                    }
                } else {
                    // 封装后不能拥有子节点的Token,直接封装为节点
                    const node_details = {
                        children: [],
                        content: '',
                        tokens: [token],
                        code: token.code,
                        token_start: i,
                        token_end: i + 1,
                        start: token.start,
                        end: token.end,
                    };
                    switch (token.type) {
                        case 'bbcode': {
                            node_details.attribute = token.attribute;
                            nodes.push(new BBCodeNode(node_details));
                            break;
                        }
                        case 'text': {
                            nodes.push(new TextNode(node_details));
                            break;
                        }
                    }
                }
            }

            return nodes;
        }

        /**
         * 将BBCode AST转化为HTML(旧版方法)
         * @deprecated
         * @param {ASTNode[]} nodes 
         * @returns {string}
         */
        toHTML_legacy(nodes) {
            return nodes.reduce((html, node) => {
                switch (node.type) {
                    case 'bbcode': {
                        if (!Object.hasOwn(this.tags, node.tag_name)) {
                            // 节点类型未定义,当作纯文本节点处理,不break
                        } else {
                            // 将开闭标签转化为html,子节点调用toHTML递归处理
                            const tag = this.tags[node.tag_name];
                            html +=
                                tag.openTag(node.attribute, node.content) +
                                this.toHTML(node.children) +
                                tag.closeTag?.(node.attribute, node.content) ?? '';
                            break;
                        }
                    }
                    case '#text': {
                        // 纯文本节点转化成html无需任何处理
                        html += node.code;
                        break;
                    }
                }
                return html;
            }, '');
        }

        /**
         * 将BBCode AST转化为HTML AST
         * @param {ASTNode[]} bbcode_nodes
         * @returns {ASTNode[]}
         */
        toHTMLAST(bbcode_nodes) {
            // 转换bbcode node为html node
            const html_nodes = bbcode_nodes.map(node => {
                switch (node.type) {
                    case 'bbcode': {
                        if (!Object.hasOwn(this.tags, node.tag_name)) {
                            // 节点类型未定义,当作纯文本节点处理,不break
                        } else {
                            // 将开闭标签转化为html,子节点调用toHTMLAST递归处理
                            const tag = this.tags[node.tag_name];

                            // 开标签
                            const open_token = new HTMLToken({
                                sub_type: 'open',
                                code: tag.openTag(node.attribute, node.content),
                                start: -1,
                                end: -1,
                            });

                            // 子节点
                            const content_nodes = tag.closeTag ? this.toHTMLAST(node.children) : [];
                            const content = content_nodes.reduce((code, n) => code + n.code, '');
                            const content_tokens = content_nodes.reduce((tokens, n) => ((tokens.push(...n.tokens), tokens)), []);

                            // 闭标签
                            const close_token = new HTMLToken({
                                sub_type: 'close',
                                code: tag.closeTag?.(node.attribute, node.content) ?? '',
                                start: -1,
                                end: -1,
                            });

                            // 创建HTML节点
                            const html_node = new HTMLNode({
                                code: open_token.code +
                                    content +
                                    close_token.code,
                                children: content_nodes,
                                content: content,
                                tokens: [open_token, ...(tag.closeTag ? [...content_tokens, close_token] : [])],
                                start: open_token.start,
                                end: close_token.end,
                                token_start: -1,
                                token_end: -1,
                            });
                            
                            return html_node;
                        }
                    }
                    case '#text': {
                        // 纯文本节点转化成html,仍然是text node
                        const tokens = node.tokens.map(token => new TextToken({
                            code: token.code,
                            start: -1,
                            end: -1,
                        }));
                        const text_node = new TextNode({
                            children: node.children,
                            code: node.code,
                            content: node.content,
                            tokens: tokens,
                            start: -1,
                            end: -1,
                            token_start: -1,
                            token_end: -1,
                        });

                        return text_node;
                    }
                }
            });

            // 为html node计算start / end / token_start / token_end
            calcIndex(html_nodes);

            /**
             * 递归地为节点计算start / end / token_start / token_end
             * @param {ASTNode[]} nodes - 需要计算的全部节点
             * @param {number} [code_i=0] - 起始基准字符index
             * @param {number} [token_i=0] - 起始基准token index
             */
            function calcIndex(nodes, code_i = 0, token_i = 0) {
                nodes.forEach(node => {
                    // 递归计算子节点
                    calcIndex(
                        node.children,
                        code_i + node.tokens[0].code.length,
                        token_i + 1,
                    );

                    // 计算本节点的所有token的start / end
                    let token_code_i = code_i;
                    node.tokens.forEach(token => {
                        token.start = token_code_i;
                        token_code_i += token.code.length;
                        token.end = token_code_i;
                    });

                    // 计算本节点的start / end / token_start / token_end
                    node.start = code_i;
                    code_i += node.code.length;
                    node.end = code_i;
                    node.token_start = token_i;
                    token_i += node.tokens.length;
                    node.token_end = token_i;
                });
            }

            return html_nodes;
        }

        /**
         * 将HTML AST转化为HTML
         * @param {ASTNode[]} html_ast 
         * @returns {string}
         */
        toHTML(html_ast) {
            return html_ast.map(node => node.code).join('');
        }

        /**
         * 解析bbcode,输出对象格式结果
         * @param {string} bbcode 
         */
        parse(bbcode) {
            const tokens = this.parseTokens(bbcode);
            const ast = this.parseAST(tokens);
            const html_ast = this.toHTMLAST(ast);
            const html = this.toHTML(html_ast);

            return {
                tokens,
                ast,
                html_ast,
                html,
                locate,
            };

            /**
             * @typedef {Object} LocationRange
             * @property {number} start - 区域起始index,区域内首元素的index
             * @property {number} end - 区域结束index,区域内尾元素的index + 1
             */
            /**
             * @overload
             * @param {number} start - html字符区间第一个字符index
             * @param {number} end - html字符区间最后一格字符index + 1
             * @returns {LocationRange} bbcode代码字符位置范围
             */
            function locate() {
                if (arguments.length === 2) {
                    return locateRange(...arguments);
                }

                /**
                 * 根据给定html区间定位bbcode的代码区间  
                 * 注意:bbcode转换为html后,精准度最高只能到节点与节点对应
                 * @param {number} start - html字符区间第一个字符index
                 * @param {number} end - html字符区间最后一格字符index + 1
                 * @returns {LocationRange} bbcode字符区间
                 */
                function locateRange(start, end) {
                    const html_tokens = html_ast.reduce(/** @param {ASTToken[]} tokens */(tokens, node) => ((tokens.push(...node.tokens), tokens)), []);
                    const start_token_i = html_tokens.findLastIndex(token => token.start <= start);
                    const end_token_i = html_tokens.findIndex(token => token.end >= end);

                    return {
                        start: tokens[start_token_i].start,
                        end: tokens[end_token_i].end,
                    };
                }
            }
        }

        /**
         * 注册(不可用)bbcode标签实现
         * @param {Record<string, TagDefination>} tags 
         */
        register(tags) {
            Object.assign(this.tags, tags);
        }
    }

    return {
        ASTToken, BBCodeToken, TextToken, HTMLToken,
        ASTNode, BBCodeNode, TextNode, HTMLNode,
        BBCodeParser,
    };
}) ();

// 测试
if (typeof GM_info === 'undefined') {
    const simpleTag = tagName => ({
        openTag(params, content) {
            return `<${ tagName }>`;
        },
        closeTag(params, content) {
            return `</${ tagName }>`;
        }
    });
    /** @type {Record<string, TagDefination>} */
    const ADD_TAGS = {
        'size': {
            // 文库的size不支持非整数字体大小值
            openTag(params, content) {
                const size = params;
                if (!size.includes('.')) {
                    return `<span style="font-size: ${ size }px;">`;
                } else {
                    return `[size=${ size }]`;
                }
            },
            closeTag(params, content) {
                const size = params;
                if (!size.includes('.')) {
                    return '</span>';
                } else {
                    return '[/size]';
                }
            },
        },
        'b': simpleTag('b'),
        'i': simpleTag('i'),
        'u': simpleTag('u'),
        'd': simpleTag('del'),
        'color': {
            openTag(params, content) {
                return `<span style="color: #${ params };">`;
            },
            closeTag(params, content) {
                return '</span>'
            },
        },
        'code': {
            openTag(params, content) {
                return '<div class="jieqiCode"><code><pre>';
            },
            closeTag(params, content) {
                return '</pre></code></div>'
            },
        },
        'quote': {
            openTag(params, content) {
                return 'Quote:<div class="jieqiQuote">';
            },
            closeTag(params, content) {
                return '</div>'
            },
        },
        'url': {
            openTag(params, content) {
                let url = params ?? content;
                url = url.startsWith('http://') || url.startsWith('https://') ? url : 'http://' + url;
                return `<a href="${ url }" target="_blank">`;
            },
            closeTag(params, content) {
                return '</a>'
            },
        },
        'email': {
            openTag(params, content) {
                return `<a href="mailto:${ content }">`;
            },
            closeTag(params, content) {
                return '</a>'
            },
        },
        'align': {
            openTag(params, content) {
                return `<p align="${ params }">`;
            },
            closeTag(params, content) {
                return '</p>';
            }
        },
    };

    const code = '[size=9]Size 9[/size][b]Bold[/b][i]Italic[/i][u]Underline[/u][d]Deleted[/d][color=FF6600]Orange Text[/color][code]Source code[/code][quote]Quated text[/quote][url=httpexample.com/]https://example.com/[/url][email][email protected][/email] https://example.jpg /:~[align=left]Align left[/align][align=center]Align center[/align][align=right]Align right[/align]\n\n[quote][code][u][d][i][b][size=24][color=FF9900]Nested[/color][/size][/b][/i][/d][/u][/code][/quote]';
    const parser = new BBCodeParser.BBCodeParser();
    parser.register(ADD_TAGS);

    const tokens = parser.parseTokens(code);
    console.log(tokens);

    const ast = parser.parseAST(tokens);
    console.log(ast);

    const html_ast = parser.toHTMLAST(ast);
    console.log(html_ast);

    const html = parser.toHTML(html_ast);
    console.log(html);

    const result = parser.parse(code);
    console.log(result);
}
长期地址
遇到问题?请前往 GitHub 提 Issues。