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[] {