refactor(web): bbcode 消息渲染插件

pull/13/head
moonrailgun 4 years ago
parent f59a3fa685
commit 1bfd1c76d6

@ -44,6 +44,7 @@ export { useRafState } from './hooks/useRafState';
export { useUpdateRef } from './hooks/useUpdateRef'; export { useUpdateRef } from './hooks/useUpdateRef';
// manager // manager
export { buildRegFn } from './manager/buildRegFn';
export { buildRegList } from './manager/buildRegList'; export { buildRegList } from './manager/buildRegList';
export { buildRegMap } from './manager/buildRegMap'; export { buildRegMap } from './manager/buildRegMap';
export { getStorage, setStorage, useStorage } from './manager/storage'; export { getStorage, setStorage, useStorage } from './manager/storage';

@ -0,0 +1,3 @@
{
"externalDeps": ["react"]
}

@ -21,7 +21,7 @@
"clsx": "^1.1.1", "clsx": "^1.1.1",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"mini-star": "^0.0.24", "mini-star": "^1.0.0",
"p-min-delay": "^4.0.0", "p-min-delay": "^4.0.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",

@ -0,0 +1,9 @@
{
"label": "BBCode 消息解释器",
"name": "com.msgbyte.bbcode",
"url": "/plugins/com.msgbyte.miaolang/index.js",
"version": "0.0.0",
"author": "msgbyte",
"description": "消息解释器渲染BBCode类型的消息",
"requireRestart": true
}

@ -0,0 +1,10 @@
{
"name": "@plugins/com.msgbyte.bbcode",
"main": "src/index.tsx",
"version": "0.0.0",
"private": true,
"dependencies": {
"@bbob/parser": "^2.7.0",
"url-regex": "^5.0.0"
}
}

@ -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';

@ -0,0 +1,173 @@
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) => {
console.warn(err.message, err.lineNumber, err.columnNumber);
},
};
/**
* bbcodebbcode
*/
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]) => {
if (key === value) {
return `=${value}`;
} else {
return ` ${key}=${value}`;
}
})
// NOTICE: 这里排序看起来好像有问题但是attrs的顺序是有序的所以没有问题
.join('');
return `[${tag}${attrsStr}]${content}[/${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;

@ -0,0 +1,30 @@
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 '[图片]';
} else {
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('');
}

@ -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;
}

@ -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('');
}

@ -0,0 +1,8 @@
import React from 'react';
import { regMessageRender } from '@capital/common';
import { BBCode } from './bbcode';
import './tags/__all__';
regMessageRender((message) => {
return <BBCode plainText={message} />;
});

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

@ -0,0 +1,15 @@
import React from 'react';
import type { TagProps } from '../bbcode/type';
export const UrlTag: React.FC<TagProps> = React.memo((props) => {
const { node } = props;
const text = node.content.join('');
const url = node.attrs.url ?? text;
return (
<a href={url} title={text} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
});
UrlTag.displayName = 'UrlTag';

@ -0,0 +1,6 @@
import { registerBBCodeTag } from '../bbcode/parser';
import { PlainText } from './PlainText';
import { UrlTag } from './UrlTag';
registerBBCodeTag('_text', PlainText);
registerBBCodeTag('url', UrlTag);

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

@ -0,0 +1,33 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@bbob/parser@^2.7.0":
version "2.7.0"
resolved "https://registry.nlark.com/@bbob/parser/download/@bbob/parser-2.7.0.tgz#ed56a1169d9f69e6defe4512ca65cd004a9a6dcc"
integrity sha1-7VahFp2faebe/kUSymXNAEqabcw=
dependencies:
"@bbob/plugin-helper" "^2.7.0"
"@bbob/plugin-helper@^2.7.0":
version "2.7.0"
resolved "https://registry.nlark.com/@bbob/plugin-helper/download/@bbob/plugin-helper-2.7.0.tgz#e24f4a103a2b4daa71674d751af766c791d1d570"
integrity sha1-4k9KEDorTapxZ011Gvdmx5HR1XA=
ip-regex@^4.1.0:
version "4.3.0"
resolved "https://registry.nlark.com/ip-regex/download/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5"
integrity sha1-aHJ1qw9X+naXj/j03dyKI9WZDbU=
tlds@^1.203.0:
version "1.221.1"
resolved "https://registry.nlark.com/tlds/download/tlds-1.221.1.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.nlark.com%2Ftlds%2Fdownload%2Ftlds-1.221.1.tgz#6cf6bff5eaf30c5618c5801c3f425a6dc61ca0ad"
integrity sha1-bPa/9erzDFYYxYAcP0JabcYcoK0=
url-regex@^5.0.0:
version "5.0.0"
resolved "https://registry.npm.taobao.org/url-regex/download/url-regex-5.0.0.tgz#8f5456ab83d898d18b2f91753a702649b873273a"
integrity sha1-j1RWq4PYmNGLL5F1OnAmSbhzJzo=
dependencies:
ip-regex "^4.1.0"
tlds "^1.203.0"

@ -7,6 +7,7 @@ import {
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import clsx from 'clsx'; import clsx from 'clsx';
import { useRenderPluginMessageInterpreter } from './useRenderPluginMessageInterpreter'; import { useRenderPluginMessageInterpreter } from './useRenderPluginMessageInterpreter';
import { getMessageRender } from '@/plugin/common';
interface ChatMessageItemProps { interface ChatMessageItemProps {
showAvatar: boolean; showAvatar: boolean;
@ -41,7 +42,7 @@ export const ChatMessageItem: React.FC<ChatMessageItemProps> = React.memo(
)} )}
<div className="leading-6 break-words"> <div className="leading-6 break-words">
<span>{payload.content}</span> <span>{getMessageRender(payload.content)}</span>
{/* 解释器按钮 */} {/* 解释器按钮 */}
{useRenderPluginMessageInterpreter(payload.content)} {useRenderPluginMessageInterpreter(payload.content)}

@ -15,4 +15,13 @@ export const builtinPlugins: PluginManifest[] = [
description: '为群组提供创建网页面板的功能', description: '为群组提供创建网页面板的功能',
requireRestart: false, requireRestart: false,
}, },
{
label: 'BBCode',
name: 'com.msgbyte.bbcode',
url: '/plugins/com.msgbyte.bbcode/index.js',
version: '0.0.0',
author: 'msgbyte',
description: 'BBCode 格式消息内容解析',
requireRestart: true,
},
]; ];

@ -1,4 +1,4 @@
import { buildRegList, FastFormFieldMeta } from 'tailchat-shared'; import { buildRegFn, buildRegList, FastFormFieldMeta } from 'tailchat-shared';
/** /**
* *
@ -44,3 +44,11 @@ export interface PluginMessageInterpreter {
*/ */
export const [messageInterpreter, regMessageInterpreter] = export const [messageInterpreter, regMessageInterpreter] =
buildRegList<PluginMessageInterpreter>(); buildRegList<PluginMessageInterpreter>();
/**
*
*
*/
export const [getMessageRender, regMessageRender] = buildRegFn<
(message: string) => React.ReactNode
>('message-render', (message) => message);

@ -809,6 +809,13 @@
magic-string "^0.25.7" magic-string "^0.25.7"
resolve "^1.17.0" resolve "^1.17.0"
"@rollup/plugin-json@^4.1.0":
version "4.1.0"
resolved "https://registry.npm.taobao.org/@rollup/plugin-json/download/@rollup/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3"
integrity sha1-VOCYZ65pY8WThE2L16nHGClElvM=
dependencies:
"@rollup/pluginutils" "^3.0.8"
"@rollup/plugin-node-resolve@^13.0.0": "@rollup/plugin-node-resolve@^13.0.0":
version "13.0.4" version "13.0.4"
resolved "https://registry.nlark.com/@rollup/plugin-node-resolve/download/@rollup/plugin-node-resolve-13.0.4.tgz#b10222f4145a019740acb7738402130d848660c0" resolved "https://registry.nlark.com/@rollup/plugin-node-resolve/download/@rollup/plugin-node-resolve-13.0.4.tgz#b10222f4145a019740acb7738402130d848660c0"
@ -830,7 +837,7 @@
make-dir "^3.1.0" make-dir "^3.1.0"
mime "^2.4.6" mime "^2.4.6"
"@rollup/pluginutils@^3.1.0": "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
version "3.1.0" version "3.1.0"
resolved "https://registry.nlark.com/@rollup/pluginutils/download/@rollup/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" resolved "https://registry.nlark.com/@rollup/pluginutils/download/@rollup/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
integrity sha1-cGtFJO5tyLEDs8mVUz5a1oDAK5s= integrity sha1-cGtFJO5tyLEDs8mVUz5a1oDAK5s=
@ -6751,12 +6758,13 @@ mini-css-extract-plugin@^1.6.2:
schema-utils "^3.0.0" schema-utils "^3.0.0"
webpack-sources "^1.1.0" webpack-sources "^1.1.0"
mini-star@^0.0.24: mini-star@^1.0.0:
version "0.0.24" version "1.0.0"
resolved "https://registry.nlark.com/mini-star/download/mini-star-0.0.24.tgz#93eca283ac2cb45ab6b61ea43f4afe234c1ffb4c" resolved "https://registry.nlark.com/mini-star/download/mini-star-1.0.0.tgz#927e2f87c198b73c169b6137c7313bfffad821cd"
integrity sha1-k+yig6wstFq2th6kP0r+I0wf+0w= integrity sha1-kn4vh8GYtzwWm2E3xzE7//rYIc0=
dependencies: dependencies:
"@rollup/plugin-commonjs" "^19.0.0" "@rollup/plugin-commonjs" "^19.0.0"
"@rollup/plugin-json" "^4.1.0"
"@rollup/plugin-node-resolve" "^13.0.0" "@rollup/plugin-node-resolve" "^13.0.0"
"@rollup/plugin-url" "^6.0.0" "@rollup/plugin-url" "^6.0.0"
cosmiconfig "^7.0.0" cosmiconfig "^7.0.0"

Loading…
Cancel
Save