您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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, }; }) ();