BBCode Parser

Parse BBCode into AST and convert into HTML

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/549682/1661829/BBCode%20Parser.js을(를) 사용하여 포함하는 라이브러리입니다.

// ==UserScript==
// @name               BBCode Parser
// @namespace          https://greasyfork.org/users/667968-pyudng
// @version            0.2
// @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);
        }
    }

    class BBCodeSyntaxError extends Error {
        /** @typedef {typeof BBCodeSyntaxError.ErrorTypes[keyof typeof BBCodeSyntaxError.ErrorTypes]} ErrorType */
        static ErrorTypes = {
            TagMismatch: 1,
            UnclosedTag: 2,
            NoOpentag:   3,
        };
        /** @type {ErrorType} 错误类型 */
        type;
        /** @type {any} 任何附加的错误信息,可以省略 */
        info = null;

        /**
         * @param {string} message
         * @param {ErrorType} type - 错误类型
         * @param {any} [info=null] - 任何附加的错误信息,可以省略
         */
        constructor(message, type, info) {
            super(message);
            this.name = "BBCodeSyntaxError";
            this.type = type;
            this.info = info;

            // 保持正确的堆栈跟踪
            if (Error.captureStackTrace) {
                Error.captureStackTrace(this, BBCodeSyntaxError);
            }
        }
    }

    /**
     * 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 
         * @param {BBCodeSyntaxError[]} [errors=[]] - 错误数组,如在解析过程中发现/出现错误就添加到这个数组中,可省略
         * @return {ASTToken[]}
         */
        parseTokens(bbcode, errors = []) {
            /** @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 
         * @param {BBCodeSyntaxError[]} [errors=[]] - 错误数组,如在解析过程中发现/出现错误就添加到这个数组中,可省略
         * @returns {ASTNode[]}
         */
        parseAST(tokens, errors = []) {
            /** @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_index !== undefined && 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 if (open_index === undefined) {
                            // 没有开标签,产生错误
                            const err = new BBCodeSyntaxError(
                                `No open tag for close tag: ${ token.tag_name }`,
                                BBCodeSyntaxError.ErrorTypes.NoOpentag,
                                { close_token: token },
                            );
                            errors.push(err);

                            // 按照源代码将闭标签创建为文本节点
                            const close_node = new TextNode({
                                children: [],
                                code: token.code,
                                content: '',
                                tokens: [token],
                                token_start: i,
                                token_end: i + 1,
                                start: token.start,
                                end: token.end,
                            });
                            nodes.push(close_node);
                        } else {
                            // 闭标签和开标签不匹配,产生错误
                            const err = new BBCodeSyntaxError(
                                `Tags mismatch: ${ open_token.tag_name }, ${ token.tag_name }`,
                                BBCodeSyntaxError.ErrorTypes.TagMismatch,
                                { open_token, close_token: token },
                            );
                            errors.push(err);

                            // 按照源代码分别将开标签和闭标签创建为文本节点
                            const open_node = new TextNode({
                                children: [],
                                code: open_token.code,
                                content: '',
                                tokens: [open_token],
                                token_start: open_index,
                                token_end: open_index + 1,
                                start: open_token.start,
                                end: open_token.end,
                            });
                            const close_node = new TextNode({
                                children: [],
                                code: token.code,
                                content: '',
                                tokens: [token],
                                token_start: i,
                                token_end: i + 1,
                                start: token.start,
                                end: token.end,
                            });
                            // 插入到节点列表
                            nodes.splice(nodes.findIndex(node => node.start >= open_token.start), 0, open_node);
                            nodes.push(close_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;
                        }
                    }
                }
            }

            // token遍历完毕仍有未闭合标签,产生错误
            if (open_indexes.length) {
                const err = new BBCodeSyntaxError(
                    `Unclosed tag: ${ open_indexes.map(i => tokens[i].tag_name).join(', ') }`,
                    BBCodeSyntaxError.ErrorTypes.UnclosedTag,
                    { tokens: open_indexes.map(i => tokens[i]) },
                );
                errors.push(err);
            }

            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
         * @param {BBCodeSyntaxError[]} [errors=[]] - 错误数组,如在解析过程中发现/出现错误就添加到这个数组中,可省略
         * @returns {ASTNode[]}
         */
        toHTMLAST(bbcode_nodes, error = []) {
            // 转换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) {
            /** @type {BBCodeSyntaxError[]} */
            const errors = [];
            const tokens = this.parseTokens(bbcode, errors);
            const ast = this.parseAST(tokens, errors);
            const html_ast = this.toHTMLAST(ast, errors);
            const html = this.toHTML(html_ast);

            return {
                tokens,
                ast,
                html_ast,
                html,
                errors,
                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,
        BBCodeSyntaxError,
        BBCodeParser,
    };
}) ();