This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"label": "BBCode Mmessage Interpreter",
"label.zh-CN": "BBCode 消息解释器",
"name": "com.msgbyte.bbcode",
"url": "/plugins/com.msgbyte.bbcode/index.js",
"version": "0.0.0",
"author": "msgbyte",
"description": "A plugin for supporting bbcode syntax to interpret rich text messages",
"description.zh-CN": "一个用于支持bbcode语法解释富文本消息的插件",
"requireRestart": true
}

View File

@@ -0,0 +1,17 @@
{
"name": "@plugins/com.msgbyte.bbcode",
"main": "src/index.tsx",
"version": "0.0.0",
"private": true,
"dependencies": {
"@bbob/parser": "^2.7.0",
"highlight.js": "^11.5.1",
"react-highlight": "^0.14.0",
"url-regex": "^5.0.0"
},
"devDependencies": {
"@types/react-highlight": "^0.12.5",
"react": "18.2.0",
"react-dom": "18.2.0"
}
}

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`render <BBCode /> mention with space name 1`] = `
<div>
<span
class="plugin-bbcode-mention-tag"
data-userid="6251986eab331ca2efbba9c6"
>
@
[UserName {"userId":"6251986eab331ca2efbba9c6","fallbackName":"Notify Bot"}]
</span>
<pre
style="display: inline; white-space: break-spaces;"
>
123123
</pre>
</div>
`;

View File

@@ -0,0 +1,11 @@
import { render } from '@testing-library/react';
import React from 'react';
import BBCode from '../render';
describe('render <BBCode />', () => {
test('mention with space name', () => {
const raw = '[at=6251986eab331ca2efbba9c6]Notify Bot[/at] 123123';
const wrapper = render(<BBCode plainText={raw} />);
expect(wrapper.container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,41 @@
import { preProcessLinkText, preProcessText } from '../index';
describe('bbcode common', () => {
describe('preprocess text', () => {
it('simple url parse', () => {
const text = preProcessLinkText('http://baidu.com');
expect(text).toBe('[url]http://baidu.com[/url]');
});
it('mix text and url parse', () => {
const text = preProcessLinkText('open:http://baidu.com');
expect(text).toBe('open:[url]http://baidu.com[/url]');
});
it('mix text and more url parse', () => {
const text = preProcessLinkText(
'open:http://baidu.com and http://google.com'
);
expect(text).toBe(
'open:[url]http://baidu.com[/url] and [url]http://google.com[/url]'
);
});
});
describe('preProcessText', () => {
test.each([
['https://baidu.com', '[url]https://baidu.com[/url]'],
['[url]https://baidu.com[/url]', '[url]https://baidu.com[/url]'],
[
'[url=https://baidu.com]百度[/url]',
'[url="https://baidu.com"]百度[/url]',
],
[
'[url=https://baidu.com alt=test]百度[/url]',
'[url="https://baidu.com" alt="test"]百度[/url]',
],
])('%s', (input, output) => {
expect(preProcessText(input)).toBe(output);
});
});
});

View File

@@ -0,0 +1,75 @@
import bbcodeParser from '../parser';
describe('bbcode parser', () => {
test('simple text', () => {
const ast = bbcodeParser.parse('text');
expect(ast).toMatchObject(['text']);
});
test('simple text in []', () => {
const ast = bbcodeParser.parse('[text]');
expect(ast).toMatchObject(['[text]']);
});
test('non text in bbcode tag', () => {
const ast = bbcodeParser.parse('[url][/url]');
expect(ast).toMatchObject(['[url]']);
});
test('space char in bbcode tag', () => {
const ast = bbcodeParser.parse('[url] [/url]');
expect(ast).toMatchObject([
{
tag: 'url',
attrs: {},
content: [' '],
},
]);
});
describe('tag url', () => {
test('with plain text', () => {
const ast = bbcodeParser.parse('[url]http://baidu.com[/url]');
expect(ast).toMatchObject([
{
tag: 'url',
attrs: {},
content: ['http://baidu.com'],
},
]);
});
test('with custom text', () => {
const ast = bbcodeParser.parse('[url=http://baidu.com]a[/url]');
expect(ast).toMatchObject([
{
tag: 'url',
attrs: { url: 'http://baidu.com' },
content: ['a'],
},
]);
});
});
test('with space', () => {
const ast = bbcodeParser.parse(
'[at=6251986eab331ca2efbba9c6]Notify Bot[/at] 123123'
);
expect(ast).toMatchObject([
{
tag: 'at',
attrs: { at: '6251986eab331ca2efbba9c6' },
content: ['Notify', ' ', 'Bot'],
},
' ',
'123123',
]);
});
});

View File

@@ -0,0 +1,17 @@
import { bbcodeToPlainText } from '../serialize';
describe('bbcodeToPlainText should be ok', () => {
test.each([
['normal', 'normal'],
['with space', 'with space'],
['image [img]http://image.url[/img]', 'image [图片]'],
['url [url]http://link.url[/url]', 'url http://link.url'],
['url2 [url=http://baidu.com]a[/url]', 'url2 a'],
['at [at=uuid]name[/at]', 'at @name'],
['[emoji]smile[/emoji]', ':smile:'],
])('%s', (input, output) => {
const plain = bbcodeToPlainText(input);
expect(output).toBe(plain);
});
});

View File

@@ -0,0 +1,25 @@
import type { AstNodeObj } from '../type';
import { getUrlTagRealUrl } from '../utils';
describe('getUrlTagRealUrl', () => {
test.each([
[
{
tag: 'url',
attrs: { url: 'https://baidu.com' },
content: ['百度'],
},
'https://baidu.com',
],
[
{
tag: 'url',
attrs: {},
content: ['https://baidu.com'],
},
'https://baidu.com',
],
])('%o => %s', (input: AstNodeObj, output) => {
expect(getUrlTagRealUrl(input)).toBe(output);
});
});

View File

@@ -0,0 +1,31 @@
import React, { Fragment } from 'react';
import bbcodeParser from './parser';
import urlRegex from 'url-regex';
/**
* 客户端预处理文本
* @param plainText 服务端文本
*/
export function preProcessLinkText(plainText: string): string {
const text = plainText.replace(
urlRegex({ exact: false, strict: true }),
'[url]$&[/url]'
); // 将聊天记录中的url提取成bbcode 需要过滤掉被bbcode包住的部分
return text;
}
// 处理所有的预处理文本
export function preProcessText(plainText: string): string {
return bbcodeParser.preProcessText(plainText, preProcessLinkText);
}
interface BBCodeProps {
plainText: string;
}
export const BBCode: React.FC<BBCodeProps> = React.memo(({ plainText }) => {
const bbcodeComponent = bbcodeParser.render(preProcessText(plainText ?? ''));
return <Fragment>{bbcodeComponent}</Fragment>;
});
BBCode.displayName = 'BBCode';

View File

@@ -0,0 +1,176 @@
import React, { ComponentType, ReactNode } from 'react';
import type { TagProps, AstNode } from './type';
import { parse } from '@bbob/parser';
import _last from 'lodash/last';
import _set from 'lodash/set';
import _get from 'lodash/get';
import _has from 'lodash/has';
import _isObject from 'lodash/isObject';
import _isArray from 'lodash/isArray';
import _isEmpty from 'lodash/isEmpty';
import _mapKeys from 'lodash/mapKeys';
import _toPairs from 'lodash/toPairs';
/**
* 通用的bbcode解释器
* 一个纯语言实现
*/
type StringTagComponent = ComponentType<{ children?: string }> | string;
type ObjectTagComponent = ComponentType<TagProps>;
type TagMapComponent = StringTagComponent | ObjectTagComponent;
const tagMap: { [tag: string]: TagMapComponent } = {};
/**
* 注册一个组件到内部的tagMap中
* @param tagName 标签名
* @param component 组件
*/
export const registerBBCodeTag = (
tagName: string,
component: TagMapComponent
) => {
tagMap[tagName] = component;
};
const DefaultBBCodeComponent: React.FC<TagProps> = React.memo((props) => {
if (_has(tagMap, '_text')) {
const Component = tagMap['_text'] as StringTagComponent;
return <Component>{props.node.content.join('')}</Component>;
} else {
return null;
}
});
DefaultBBCodeComponent.displayName = 'DefaultBBCodeComponent';
/**
* 获取BBCode标签组件
*/
export const getBBCodeTag = (tagName: string): TagMapComponent => {
return tagMap[tagName] ?? DefaultBBCodeComponent;
};
/**
* BBCode 解析器
*/
class BBCodeParser {
options = {
onlyAllowTags: Object.keys(tagMap),
onError: (err: any) => {
console.warn(err.message, err.lineNumber, err.columnNumber);
},
};
/**
* 将文本中没有被bbcode标签包裹住的部分进行预处理后重新拼装成bbcode字符串
*/
preProcessText(input: string, processFn: (text: string) => string): string {
const ast = parse(input, this.options) as AstNode[];
return ast
.map((node, index) => {
if (typeof node === 'string') {
// 此处进行预处理
const text = node;
return processFn(text);
}
const { tag, content, attrs } = node;
const attrsStr = _toPairs(attrs)
.map(([key, value]) => {
/**
* 增加双引号以解决value中可能会出现空格的问题
*/
if (key === value) {
return `="${value}"`;
} else {
return ` ${key}="${value}"`;
}
})
// NOTICE: 这里排序看起来好像有问题但是attrs的顺序是有序的所以没有问题
.join('');
return `[${tag}${attrsStr}]${content.join('')}[/${tag}]`;
})
.join('');
}
// 将bbcode字符串转化为AstNode
parse(input: string): AstNode[] {
try {
return parse(input, this.options).map((node: AstNode) => {
if (_isObject(node)) {
const content = _get(node, 'content');
const attrs = _get(node, 'attrs');
if (_isEmpty(attrs) && _isArray(content) && content.length === 0) {
// 如果是[text]这种格式的话会被误解析成一个节点
// 做一下特殊处理
// NOTICE: 这种处理方式会将[url][/url]解析成字符串[url]
// 最好的解决方案是自己写一个BBCode的词法解释器
const tag = _get(node, 'tag');
if (typeof tag === 'string') {
return `[${tag}]`;
}
}
// 将[url=http://baidu.com] 解析出的attrs: { 'http://baidu.com': 'http://baidu.com' }
// 转换为attrs: { 'url': 'http://baidu.com' }
_set(
node,
'attrs',
_mapKeys(attrs, (value, key) => {
if (value === key) {
return node.tag;
} else {
return key;
}
})
);
}
return node;
});
} catch (e) {
console.warn(e);
return [];
}
}
render(input: string): ReactNode[] {
const ast = this.parse(input);
return ast
.reduce<AstNode[]>((prev, curr) => {
if (typeof curr === 'string' && typeof _last(prev) === 'string') {
// 合并字符串, 使其渲染时能公用一个Text组件
prev[prev.length - 1] += curr;
} else {
prev.push(curr);
}
return prev;
}, [])
.map<ReactNode>((node, index) => {
if (typeof node === 'string') {
if (_has(tagMap, '_text')) {
const Component = tagMap['_text'] as StringTagComponent;
return <Component key={index}>{node}</Component>;
} else {
return node;
}
}
if (typeof node === 'object') {
const Component = getBBCodeTag(node.tag);
return <Component key={index} node={node} />;
}
return null;
});
}
}
const bbcodeParser = new BBCodeParser();
export default bbcodeParser;

View File

@@ -0,0 +1,36 @@
import bbcodeParser from './parser';
import type { AstNode } from './type';
import _isNil from 'lodash/isNil';
function bbcodeNodeToPlainText(node: AstNode): string {
if (_isNil(node)) {
return '';
}
if (typeof node === 'string') {
return String(node);
} else {
if (node.tag === 'img') {
return '[图片]';
}
if (node.tag === 'emoji') {
return `:${node.content.join('')}:`;
}
if (node.tag === 'at') {
return `@${node.content.join('')}`;
}
return (node.content ?? [])
.map((sub) => bbcodeNodeToPlainText(sub))
.join('');
}
}
/**
* 将 BBCode 转化为普通的字符串
*/
export function bbcodeToPlainText(bbcode: string): string {
const ast = bbcodeParser.parse(bbcode);
return ast.map(bbcodeNodeToPlainText).join('');
}

View File

@@ -0,0 +1,13 @@
export type AstNode = AstNodeObj | AstNodeStr;
export type AstNodeObj = {
tag: string;
attrs: Record<string, string>;
content: AstNode[];
};
export type AstNodeStr = string;
export interface TagProps {
node: AstNodeObj;
}

View File

@@ -0,0 +1,9 @@
import type { AstNodeObj } from './type';
/**
* 获取 url 节点的url地址
* @param urlTag url节点
*/
export function getUrlTagRealUrl(urlTag: AstNodeObj): string {
return urlTag.attrs.url ?? urlTag.content.join('');
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import CodeRender from 'react-highlight';
import 'highlight.js/styles/default.css';
const Highlight: React.FC<{
language: string;
code: string;
}> = React.memo((props) => {
const CodeComponent = (CodeRender as any).default as typeof CodeRender; // TODO: ministar编译问题先跳过
return <CodeComponent className={props.language}>{props.code}</CodeComponent>;
});
Highlight.displayName = 'Highlight';
export default Highlight;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import {
Loadable,
regMessageRender,
regMessageTextDecorators,
} from '@capital/common';
const PLUGIN_ID = 'com.msgbyte.bbcode';
// 预加载
import('./render');
const BBCode = Loadable(() => import('./render'), {
componentName: `${PLUGIN_ID}:renderComponent`,
fallback: null,
});
let serialize: (bbcode: string) => string;
import('./bbcode/serialize').then((module) => {
serialize = module.bbcodeToPlainText;
});
regMessageRender((message) => {
return <BBCode plainText={message} />;
});
regMessageTextDecorators(() => ({
url: (url, label?) =>
label ? `[url=${url}]${label}[/url]` : `[url]${url}[/url]`,
image: (plain, attrs) => {
if (attrs.height && attrs.width) {
return `[img height=${attrs.height} width=${attrs.width}]${plain}[/img]`;
}
return `[img]${plain}[/img]`;
},
card: (plain, attrs) => {
const h = [
'card',
...Object.entries(attrs).map(([k, v]) => `${k}=${v}`),
].join(' ');
return `[${h}]${plain}[/card]`;
},
mention: (userId, userName) => `[at=${userId}]${userName}[/at]`,
emoji: (emojiCode) => `[emoji]${emojiCode}[/emoji]`,
serialize: (plain: string) => (serialize ? serialize(plain) : plain),
}));

View File

@@ -0,0 +1,4 @@
import { BBCode } from './bbcode';
import './tags/__all__';
export default BBCode;

View File

@@ -0,0 +1,10 @@
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const BoldTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
return <b>{text}</b>;
});
BoldTag.displayName = 'BoldTag';

View File

@@ -0,0 +1,17 @@
import { Card } from '@capital/component';
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const CardTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const label = node.content.join('');
const attrs = node.attrs ?? {};
const payload: any = {
label,
...attrs,
};
return <Card type={payload.type} payload={payload} />;
});
CardTag.displayName = 'CardTag';

View File

@@ -0,0 +1,14 @@
import { Loadable } from '@capital/common';
import React from 'react';
import type { TagProps } from '../bbcode/type';
const Highlight = Loadable(() => import('../components/Highlight'));
export const CodeTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
const language = node.attrs.language ?? 'bash';
return <Highlight language={language} code={text} />;
});
CodeTag.displayName = 'CodeTag';

View File

@@ -0,0 +1,10 @@
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const DeleteTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
return <del>{text}</del>;
});
DeleteTag.displayName = 'DeleteTag';

View File

@@ -0,0 +1,11 @@
import { Emoji } from '@capital/component';
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const EmojiTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const code = node.content.join('');
return <Emoji emoji={code} />;
});
EmojiTag.displayName = 'EmojiTag';

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { getPopupContainer } from '@capital/common';
import { Image } from '@capital/component';
import type { TagProps } from '../bbcode/type';
const MAX_HEIGHT = 320;
const MAX_WIDTH = 320;
const imageStyle: React.CSSProperties = {
maxHeight: MAX_HEIGHT,
maxWidth: MAX_WIDTH,
width: 'auto',
};
function parseImageAttr(attr: { height: string; width: string }): {
height?: number;
width?: number;
} {
const height = Number(attr.height);
const width = Number(attr.width);
if (!(height > 0 && width > 0)) {
// 确保宽高为数字且均大于0
return {};
}
if (height <= MAX_HEIGHT && width <= MAX_WIDTH) {
return {
height,
width,
};
}
const ratio = Math.max(height / MAX_HEIGHT, width / MAX_WIDTH);
return {
height: height / ratio,
width: width / ratio,
};
}
export const ImgTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
const url = node.attrs.url ?? text;
return (
<div className="inline-block" onContextMenu={(e) => e.stopPropagation()}>
<Image
style={{
...imageStyle,
...parseImageAttr(node.attrs as any),
}}
preview={{
getContainer: getPopupContainer,
}}
src={url}
/>
</div>
);
});
ImgTag.displayName = 'ImgTag';

View File

@@ -0,0 +1,10 @@
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const ItalicTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
return <i>{text}</i>;
});
ItalicTag.displayName = 'ItalicTag';

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Markdown } from '@capital/component';
import type { TagProps } from '../bbcode/type';
export const MarkdownTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
return <Markdown raw={text} />;
});
MarkdownTag.displayName = 'MarkdownTag';

View File

@@ -0,0 +1,16 @@
import { UserName } from '@capital/component';
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const MentionTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const fallbackName = node.content.join('');
const userId = node.attrs.at;
return (
<span className="plugin-bbcode-mention-tag" data-userid={userId}>
@{<UserName userId={userId} fallbackName={fallbackName} />}
</span>
);
});
MentionTag.displayName = 'MentionTag';

View File

@@ -0,0 +1,11 @@
import React, { PropsWithChildren } from 'react';
import type { TagProps } from '../bbcode/type';
export const PlainText: React.FC<PropsWithChildren<TagProps>> = React.memo(
(props) => (
<pre style={{ display: 'inline', whiteSpace: 'break-spaces' }}>
{props.children}
</pre>
)
);
PlainText.displayName = 'PlainText';

View File

@@ -0,0 +1,10 @@
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const UnderlinedTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
return <ins>{text}</ins>;
});
UnderlinedTag.displayName = 'UnderlinedTag';

View File

@@ -0,0 +1,49 @@
import { Link } from '@capital/component';
import React from 'react';
import styled from 'styled-components';
import type { TagProps } from '../bbcode/type';
const UnderlineSpan = styled.span`
text-decoration: underline;
text-decoration-style: dotted;
`;
export const UrlTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
const url = node.attrs.url ?? text;
if (url.startsWith('/')) {
// 内部地址,使用 react-router 进行导航
return (
<Link to={url} onContextMenu={(e) => e.stopPropagation()}>
<UnderlineSpan>{text}</UnderlineSpan>
</Link>
);
}
if (url.startsWith(window.location.origin)) {
// 内部地址,使用 react-router 进行导航
return (
<Link
to={url.replace(window.location.origin, '')}
onContextMenu={(e) => e.stopPropagation()}
>
<UnderlineSpan>{text}</UnderlineSpan>
</Link>
);
}
return (
<a
href={url}
title={text}
target="_blank"
rel="noopener noreferrer"
onContextMenu={(e) => e.stopPropagation()}
>
<UnderlineSpan>{text}</UnderlineSpan>
</a>
);
});
UrlTag.displayName = 'UrlTag';

View File

@@ -0,0 +1,32 @@
import { registerBBCodeTag } from '../bbcode/parser';
import { CodeTag } from './CodeTag';
import { ImgTag } from './ImgTag';
import { MentionTag } from './MentionTag';
import { PlainText } from './PlainText';
import { UrlTag } from './UrlTag';
import { EmojiTag } from './EmojiTag';
import { MarkdownTag } from './MarkdownTag';
import { BoldTag } from './BoldTag';
import { ItalicTag } from './ItalicTag';
import { UnderlinedTag } from './UnderlinedTag';
import { DeleteTag } from './DeleteTag';
import { CardTag } from './CardTag';
import './styles.less';
/**
* Reference: https://en.wikipedia.org/wiki/BBCode
*/
registerBBCodeTag('_text', PlainText);
registerBBCodeTag('b', BoldTag);
registerBBCodeTag('i', ItalicTag);
registerBBCodeTag('u', UnderlinedTag);
registerBBCodeTag('s', DeleteTag);
registerBBCodeTag('url', UrlTag);
registerBBCodeTag('img', ImgTag);
registerBBCodeTag('code', CodeTag);
registerBBCodeTag('at', MentionTag);
registerBBCodeTag('emoji', EmojiTag);
registerBBCodeTag('markdown', MarkdownTag);
registerBBCodeTag('md', MarkdownTag); // alias
registerBBCodeTag('card', CardTag);

View File

@@ -0,0 +1,9 @@
.plugin-bbcode-mention-tag {
background-color: rgba(88, 101, 242, 0.3);
border-radius: 2px;
padding: 0 4px;
&:hover {
background-color: rgb(88, 101, 242);
}
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"paths": {
"@capital/*": ["../../src/plugin/*"],
}
}
}

View File

@@ -0,0 +1 @@
declare module '@bbob/parser';