diff --git a/packages/utils/bbcode/__test__/html.test.ts b/packages/utils/bbcode/__test__/html.test.ts index 68ed8f2af..ba0fc3cc2 100644 --- a/packages/utils/bbcode/__test__/html.test.ts +++ b/packages/utils/bbcode/__test__/html.test.ts @@ -1,5 +1,6 @@ import { STICKER_DOMAIN_URL } from '../constants'; -import { renderNodes, renderNode, render } from '../html'; +import { renderNodes, renderNode, render, renderWithParser } from '../html'; +import { Parser } from '../parser'; import type { VNode } from '../types'; describe('html render vnode', () => { @@ -239,6 +240,29 @@ describe('html render bbcode string', () => { '
ss[b]加粗\n换行了[/b](bgm38) [/fafa [code]
', ); }); + + test('render code by custom converter, nested', () => { + const input = '[b][url]http://qq.com[/url][/b]'; + expect( + render(input, { + url: (node) => { + return '[url]convert map[/url]'; + }, + }), + ).toBe('[url]convert map[/url]'); + }); + test('render code with Parser, nested', () => { + const input = '[b]加粗[size=16]16px[img]http://chii.in/img/ico/bgm88-31.gif[/img][/size][/b]'; + expect( + renderWithParser(new Parser(input, [], ['img']), { + url: (node) => { + return '[url]convert map[/url]'; + }, + }), + ).toBe( + '加粗16px[img]http://chii.in/img/ico/bgm88-31.gif[/img]', + ); + }); test('should render sticker', () => { expect(render('(bgm01)')).toContain('/img/smiles/bgm/01.png'); expect(render('(bgm38)')).toContain('/img/smiles/tv/15.gif'); diff --git a/packages/utils/bbcode/__test__/parser.test.ts b/packages/utils/bbcode/__test__/parser.test.ts index 6ae837c6a..f14ca3ca3 100644 --- a/packages/utils/bbcode/__test__/parser.test.ts +++ b/packages/utils/bbcode/__test__/parser.test.ts @@ -89,6 +89,15 @@ describe('bbcode parser', () => { ]; expect(getNodes(input)).toEqual(expect.arrayContaining(tests)); }); + test('ignore img bbcode', () => { + const input = `存放于其他网络服务器的图片: +[img]http://chii.in/img/ico/bgm88-31.gif[/img]`; + const tests: CodeNodeTypes[] = [ + '存放于其他网络服务器的图片:\n', + '[img]http://chii.in/img/ico/bgm88-31.gif[/img]', + ]; + expect(new Parser(input, [], ['img']).parse()).toEqual(expect.arrayContaining(tests)); + }); test('simple bbcode', () => { const input = `我是[mask]马赛克文字[/mask] [s]删除线文字[/s] @@ -320,6 +329,33 @@ describe('bbcode parser', () => { ]), ); }); + test('merge tags with ignore case', () => { + const fn = (): boolean => true; + const tags = mergeTags( + [ + 'i', + 'b', + { + name: 's', + schema: { + s: fn, + }, + }, + ], + [ + { + name: 'i', + schema: { + i: fn, + }, + }, + 's', + 'mybbcode', + ], + ['i', 's'], + ); + expect(tags).toEqual(expect.arrayContaining(['b', 'mybbcode'])); + }); test('subject bbcode', () => { const tests: Array<[string, CodeNodeTypes[]]> = [ [ diff --git a/packages/utils/bbcode/convert.ts b/packages/utils/bbcode/convert.ts index 0807d5cd5..92b95cf92 100644 --- a/packages/utils/bbcode/convert.ts +++ b/packages/utils/bbcode/convert.ts @@ -39,37 +39,6 @@ function convertImgNode(node: CodeVNode): VNode { return vnode; } -function setVNodeChildren(vnode: VNode, node: CodeVNode): void { - if (node.children) { - vnode.children = node.children.map((c) => convert(c)); - } -} - -function convertUrlNode(node: CodeVNode): VNode { - let href = node.props?.url; - if (!href) { - href = node.children![0] as string; - } - const vnode: VNode = { - type: 'a', - props: { - href, - }, - className: 'l', - }; - if (node.children) { - vnode.children = node.children.map((c) => convert(c)); - } - if (isExternalLink(href)) { - vnode.props = { - ...vnode.props, - target: '_blank', - ref: 'nofollow external noopener noreferrer', - }; - } - return vnode; -} - function convertStickerNode(node: CodeVNode): string { const stickerId = node.props!.stickerId!; let id = -1; @@ -117,18 +86,6 @@ function convertStickerNode(node: CodeVNode): string { return stickerId; } -function convertQuote(node: CodeVNode): VNode { - const q: VNode = { - type: 'q', - }; - setVNodeChildren(q, node); - return { - type: 'div', - className: 'quote', - children: [q], - }; -} - function convertUser(node: CodeVNode): VNode { let userId = node.props?.user; if (!userId) { @@ -144,118 +101,174 @@ function convertUser(node: CodeVNode): VNode { }; } -function toVNode( - node: CodeVNode, - type: string, - props: Pick = {}, -): VNode { - const vnode: VNode = { - type, - ...props, - }; - setVNodeChildren(vnode, node); - return vnode; -} - -const CONVERTER_FN_MAP: Record = { - b: (node) => toVNode(node, 'strong'), - i: (node) => toVNode(node, 'em'), - u: (node) => - toVNode(node, 'span', { - style: { - 'text-decoration': 'underline', - }, - }), - s: (node) => - toVNode(node, 'span', { - style: { - 'text-decoration': 'line-through', - }, - }), - mask: (node) => - toVNode(node, 'span', { - style: { - 'background-color': '#555', - color: '#555', - border: '1px solid #555', - }, - }), - color: (node) => - toVNode(node, 'span', { - style: { - color: node.props!.color!, - }, - }), - size: (node) => - toVNode(node, 'span', { - style: { - 'font-size': node.props!.size! + 'px', - 'line-height': node.props!.size! + 'px', - }, - }), - url: convertUrlNode, - img: convertImgNode, - sticker: convertStickerNode, - quote: convertQuote, - code: (node) => ({ - type: 'pre', - children: node.children, - }), - left: (node) => - toVNode(node, 'p', { - style: { - 'text-align': 'left', - }, - }), - right: (node) => - toVNode(node, 'p', { - style: { - 'text-align': 'right', - }, - }), - center: (node) => - toVNode(node, 'p', { - style: { - 'text-align': 'center', - }, - }), - indent: (node) => toVNode(node, 'blockquote', {}), - align: (node) => - toVNode(node, 'p', { - style: { - 'text-align': node.props!.align!, - }, - }), - float: (node) => - toVNode(node, 'span', { - style: { - float: node.props!.float!, - }, - }), - subject: (node) => - toVNode(node, 'a', { - className: 'l', - }), - user: convertUser, -}; - export function convert( node: CodeNodeTypes, converterMap: Record = {}, ): NodeTypes { - if (typeof node === 'string') { - return node; + const converter = new Converter(converterMap); + return converter.convert(node); +} + +export class Converter { + readonly fnMap: Record = {}; + readonly defaultFnMap: Record = { + b: (node) => this.toVNode(node, 'strong'), + i: (node) => this.toVNode(node, 'em'), + u: (node) => + this.toVNode(node, 'span', { + style: { + 'text-decoration': 'underline', + }, + }), + s: (node) => + this.toVNode(node, 'span', { + style: { + 'text-decoration': 'line-through', + }, + }), + mask: (node) => + this.toVNode(node, 'span', { + style: { + 'background-color': '#555', + color: '#555', + border: '1px solid #555', + }, + }), + color: (node) => + this.toVNode(node, 'span', { + style: { + color: node.props!.color!, + }, + }), + size: (node) => + this.toVNode(node, 'span', { + style: { + 'font-size': node.props!.size! + 'px', + 'line-height': node.props!.size! + 'px', + }, + }), + url: (node) => this.convertUrlNode(node), + img: convertImgNode, + sticker: convertStickerNode, + quote: (node) => this.convertQuote(node), + code: (node) => ({ + type: 'pre', + children: node.children, + }), + left: (node) => + this.toVNode(node, 'p', { + style: { + 'text-align': 'left', + }, + }), + right: (node) => + this.toVNode(node, 'p', { + style: { + 'text-align': 'right', + }, + }), + center: (node) => + this.toVNode(node, 'p', { + style: { + 'text-align': 'center', + }, + }), + indent: (node) => this.toVNode(node, 'blockquote', {}), + align: (node) => + this.toVNode(node, 'p', { + style: { + 'text-align': node.props!.align!, + }, + }), + float: (node) => + this.toVNode(node, 'span', { + style: { + float: node.props!.float!, + }, + }), + subject: (node) => + this.toVNode(node, 'a', { + className: 'l', + }), + user: convertUser, + }; + + constructor(converterMap: Record) { + this.fnMap = converterMap; } - let converterFn = converterMap[node.type]; - if (!converterFn) { - converterFn = CONVERTER_FN_MAP[node.type]; + + convert(node: CodeNodeTypes): NodeTypes { + if (typeof node === 'string') { + return node; + } + const converterFn = this.getConvertFn(node.type); + if (converterFn) { + return converterFn(node); + } + const vnode: VNode = { + type: node.type, + }; + this.setVNodeChildren(vnode, node); + return vnode; } - if (converterFn) { - return converterFn(node); + + getConvertFn(type: string): ConverterFn | undefined { + let converterFn = this.fnMap[type]; + if (!converterFn) { + converterFn = this.defaultFnMap[type]; + } + return converterFn; + } + + toVNode(node: CodeVNode, type: string, props: Pick = {}): VNode { + const vnode: VNode = { + type, + ...props, + }; + this.setVNodeChildren(vnode, node); + return vnode; + } + + setVNodeChildren(vnode: VNode, node: CodeVNode): void { + if (node.children) { + vnode.children = node.children.map((c) => this.convert(c)); + } + } + + convertQuote(node: CodeVNode): VNode { + const q: VNode = { + type: 'q', + }; + this.setVNodeChildren(q, node); + return { + type: 'div', + className: 'quote', + children: [q], + }; + } + + convertUrlNode(node: CodeVNode): VNode { + let href = node.props?.url; + if (!href) { + href = node.children![0] as string; + } + const vnode: VNode = { + type: 'a', + props: { + href, + }, + className: 'l', + }; + if (node.children) { + vnode.children = node.children.map((c) => this.convert(c)); + } + if (isExternalLink(href)) { + vnode.props = { + ...vnode.props, + target: '_blank', + ref: 'nofollow external noopener noreferrer', + }; + } + return vnode; } - const vnode: VNode = { - type: node.type, - }; - setVNodeChildren(vnode, node); - return vnode; } diff --git a/packages/utils/bbcode/html.ts b/packages/utils/bbcode/html.ts index 849709022..3131ef16e 100644 --- a/packages/utils/bbcode/html.ts +++ b/packages/utils/bbcode/html.ts @@ -1,5 +1,5 @@ import { UnreadableCodeError } from '../index'; -import { convert } from './convert'; +import { Converter } from './convert'; import { Parser } from './parser'; import type { CodeNodeTypes, ConverterFn, NodeTypes, VNode } from './types'; @@ -84,10 +84,18 @@ export function renderNodes(nodes: NodeTypes[], parentNode?: VNode): string { } export function render(rawStr: string, converterMap: Record = {}): string { + return renderWithParser(new Parser(rawStr), converterMap); +} + +export function renderWithParser( + parser: Parser, + converterMap: Record = {}, +): string { let result = ''; - const nodes: CodeNodeTypes[] = new Parser(rawStr).parse(); + const nodes: CodeNodeTypes[] = parser.parse(); + const converter = new Converter(converterMap); nodes.forEach((node) => { - result += renderNode(convert(node, converterMap)); + result += renderNode(converter.convert(node)); }); return result; } diff --git a/packages/utils/bbcode/index.ts b/packages/utils/bbcode/index.ts index 9e4b939b8..4e6de7b76 100644 --- a/packages/utils/bbcode/index.ts +++ b/packages/utils/bbcode/index.ts @@ -1,4 +1,4 @@ -export { render } from './html'; +export { render, renderWithParser } from './html'; export * from './parser'; export { convert } from './convert'; export * from './types'; diff --git a/packages/utils/bbcode/parser.ts b/packages/utils/bbcode/parser.ts index 3bc02cd9f..669fbc624 100644 --- a/packages/utils/bbcode/parser.ts +++ b/packages/utils/bbcode/parser.ts @@ -123,15 +123,23 @@ const DEFAULT_TAGS: ITag[] = [ ]; // 合并 tag 配置。新的覆盖旧的 -export function mergeTags(tagList: ITag[], toMergeTags: ITag[]): ITag[] { - const results: ITag[] = [...toMergeTags]; - tagList.forEach((tag) => { +export function mergeTags( + tagList: ITag[], + toMergeTags: ITag[], + ignoreTagNames: string[] = [], +): ITag[] { + let results: ITag[] = [...toMergeTags]; + const getTagName = (tag: ITag) => { let name = ''; if (typeof tag === 'string') { name = tag; } else { name = tag.name; } + return name; + }; + tagList.forEach((tag) => { + const name = getTagName(tag); const idx = results.findIndex((t) => { if (typeof t === 'string') { return t === name; @@ -142,6 +150,9 @@ export function mergeTags(tagList: ITag[], toMergeTags: ITag[]): ITag[] { results.push(tag); } }); + results = results.filter((tag) => { + return !ignoreTagNames.includes(getTagName(tag)); + }); return results; } @@ -153,13 +164,13 @@ export class Parser { private readonly tagStack: string[]; private readonly validTags: ITag[]; - constructor(input: string, tags: ITag[] = []) { + constructor(input: string, tags: ITag[] = [], ignoreTagNames: string[] = []) { this.input = input; this.pos = 0; this.ctxStack = []; this.tagStack = []; // 解析器支持的 tag; sticker 用来表示 Bangumi 的表情,不是 bbcode - this.validTags = mergeTags(DEFAULT_TAGS, tags); + this.validTags = mergeTags(DEFAULT_TAGS, tags, ignoreTagNames); } parse(): CodeNodeTypes[] {