mirror of
https://github.com/tenrok/BBob.git
synced 2026-06-20 20:00:33 +03:00
feat: typescript support (#185)
* feat: initial typescript support * feat: typescript support * feat(plugin-helper): move files to typescript * chore: update lock files * feat: preset types * fix: build * fix: benchmark * fix: remove pnpm cache * fix: bench action * fix: pnpm recursive install * fix: nx cache * fix: lock file * fix: workflows * fix: lerna support in pnpm * fix: pnpm workspace * fix: remove unused files * fix: pnpm lock file * fix: update lerna for support pnpm * fix: lerna bootstrap * fix: rollup build * fix: update nx * fix: build * fix: add nx dep target * fix: remove nx cache * fix: workflow run on push only for master * fix: test workflow run on push only for master * fix: remove parallel for gen types * fix: benchmark * fix: benchmark imports * fix: pnpm * fix: types errors and pnpm * fix: types * fix: types * refactor: parser * fix(parser): tests * fix: preset tests * fix: react types * fix: react type declarations * fix: pnpm lock file * fix: react preset types * fix: lock file * fix: vue2 types * feat: dev container support * fix: types * fix: types * refactor: rewrite pkg-task, add nx gen-types deps, fix react/render.ts * refactor: types * fix: types * fix: rename gen-types to types * fix: nx build order * fix: nx reset * fix: define nx deps explicit * fix: build * fix: nx * fix: nx order build * fix: nx deps * fix: bbob cli tests * fix: tests * fix: cli tests and import * fix: test cover * fix: cli cover
This commit is contained in:
@@ -2,3 +2,6 @@ coverage
|
||||
dist
|
||||
lib
|
||||
es
|
||||
types
|
||||
test/*.d.ts
|
||||
test/*.map
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
src
|
||||
!dist
|
||||
|
||||
@@ -20,13 +20,24 @@
|
||||
"core"
|
||||
],
|
||||
"dependencies": {
|
||||
"@bbob/parser": "workspace:*"
|
||||
"@bbob/parser": "*",
|
||||
"@bbob/plugin-helper": "*"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"module": "es/index.js",
|
||||
"jsnext:main": "es/index.js",
|
||||
"browser": "dist/index.js",
|
||||
"browserName": "BbobCore",
|
||||
"types": "types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./types/index.d.ts",
|
||||
"import": "./es/index.js",
|
||||
"require": "./lib/index.js",
|
||||
"browser": "./dist/index.min.js",
|
||||
"umd": "./dist/index.min.js"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/JiLiZART/bbob",
|
||||
"author": "Nikolay Kostyurin <jilizart@gmail.com>",
|
||||
"license": "MIT",
|
||||
@@ -38,19 +49,22 @@
|
||||
"url": "git://github.com/JiLiZART/bbob.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build:commonjs": "../../scripts/pkg-task build-commonjs",
|
||||
"build:es": "../../scripts/pkg-task build-es",
|
||||
"build:umd": "../../scripts/pkg-task build-umd",
|
||||
"build": "npm run build:commonjs && npm run build:es && npm run build:umd",
|
||||
"test": "../../scripts/pkg-task test",
|
||||
"cover": "../../scripts/pkg-task cover",
|
||||
"lint": "../../scripts/pkg-task lint",
|
||||
"size": "../../scripts/pkg-task size",
|
||||
"bundlesize": "../../scripts/pkg-task bundlesize"
|
||||
"build:commonjs": "pkg-task",
|
||||
"build:es": "pkg-task",
|
||||
"build:umd": "pkg-task",
|
||||
"build": "pkg-task",
|
||||
"test": "pkg-task",
|
||||
"cover": "pkg-task",
|
||||
"lint": "pkg-task",
|
||||
"size": "pkg-task",
|
||||
"bundlesize": "pkg-task",
|
||||
"types": "pkg-task",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "lib/index.js"
|
||||
"path": "./dist/index.min.js",
|
||||
"limit": "4.5 KB"
|
||||
}
|
||||
],
|
||||
"bundlesize": [
|
||||
|
||||
Generated
+3
@@ -8,3 +8,6 @@ dependencies:
|
||||
'@bbob/parser':
|
||||
specifier: workspace:*
|
||||
version: link:../bbob-parser
|
||||
'@bbob/plugin-helper':
|
||||
specifier: workspace:*
|
||||
version: link:../bbob-plugin-helper
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
let C1 = 'C1'
|
||||
let C2 = 'C2'
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
C1 = '"parser" is not a function, please pass to "process(input, { parser })" right function'
|
||||
C2 = '"render" function not defined, please pass to "process(input, { render })"'
|
||||
}
|
||||
|
||||
export { C1, C2 }
|
||||
@@ -1,62 +0,0 @@
|
||||
import { parse } from '@bbob/parser';
|
||||
import { iterate, match } from './utils';
|
||||
|
||||
function walk(cb) {
|
||||
return iterate(this, cb);
|
||||
}
|
||||
|
||||
export default function bbob(plugs) {
|
||||
const plugins = typeof plugs === 'function' ? [plugs] : plugs || [];
|
||||
|
||||
let options = {
|
||||
skipParse: false,
|
||||
};
|
||||
|
||||
return {
|
||||
process(input, opts) {
|
||||
options = opts || {};
|
||||
|
||||
const parseFn = options.parser || parse;
|
||||
const renderFn = options.render;
|
||||
const data = options.data || null;
|
||||
|
||||
if (typeof parseFn !== 'function') {
|
||||
throw new Error('"parser" is not a function, please pass to "process(input, { parser })" right function');
|
||||
}
|
||||
|
||||
let tree = options.skipParse
|
||||
? input || []
|
||||
: parseFn(input, options);
|
||||
|
||||
// raw tree before modification with plugins
|
||||
const raw = tree;
|
||||
|
||||
tree.messages = [];
|
||||
tree.options = options;
|
||||
tree.walk = walk;
|
||||
tree.match = match;
|
||||
|
||||
plugins.forEach((plugin) => {
|
||||
tree = plugin(tree, {
|
||||
parse: parseFn,
|
||||
render: renderFn,
|
||||
iterate,
|
||||
match,
|
||||
data,
|
||||
}) || tree;
|
||||
});
|
||||
|
||||
return {
|
||||
get html() {
|
||||
if (typeof renderFn !== 'function') {
|
||||
throw new Error('"render" function not defined, please pass to "process(input, { render })"');
|
||||
}
|
||||
return renderFn(tree, tree.options);
|
||||
},
|
||||
tree,
|
||||
raw,
|
||||
messages: tree.messages,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { parse } from '@bbob/parser';
|
||||
import { iterate, match } from './utils';
|
||||
import { C1, C2 } from './errors'
|
||||
|
||||
import type { IterateCallback } from './utils';
|
||||
import type { NodeContent, PartialNodeContent } from "@bbob/plugin-helper";
|
||||
import type { BBobCore, BBobCoreOptions, BBobCoreTagNodeTree, BBobPlugins } from "./types";
|
||||
|
||||
export * from './types'
|
||||
|
||||
export function createTree<Options extends BBobCoreOptions = BBobCoreOptions>(tree: NodeContent[], options: Options) {
|
||||
const extendedTree = tree as BBobCoreTagNodeTree
|
||||
|
||||
extendedTree.messages = [...(extendedTree.messages || [])]
|
||||
extendedTree.options = {...options, ...extendedTree.options}
|
||||
extendedTree.walk = function walkNodes(cb: IterateCallback<NodeContent>) {
|
||||
return iterate(this, cb);
|
||||
}
|
||||
extendedTree.match = function matchNodes(expr: PartialNodeContent | PartialNodeContent[], cb: IterateCallback<NodeContent>) {
|
||||
return match(this, expr, cb)
|
||||
}
|
||||
|
||||
return extendedTree
|
||||
}
|
||||
|
||||
export default function bbob<InputValue = string | NodeContent[], Options extends BBobCoreOptions = BBobCoreOptions>(
|
||||
plugs?: BBobPlugins
|
||||
): BBobCore<InputValue, Options> {
|
||||
const plugins = typeof plugs === 'function' ? [plugs] : plugs || [];
|
||||
const mockRender = () => ""
|
||||
|
||||
return {
|
||||
process(input, opts) {
|
||||
const options = opts || { skipParse: false, parser: parse, render: mockRender, data: null } as BBobCoreOptions
|
||||
const parseFn = options.parser || parse;
|
||||
const renderFn = options.render;
|
||||
const data = options.data || null;
|
||||
|
||||
if (typeof parseFn !== 'function') {
|
||||
throw new Error(C1);
|
||||
}
|
||||
|
||||
// raw tree before modification with plugins
|
||||
const raw = options.skipParse && Array.isArray(input) ? input : parseFn(input as string, options);
|
||||
let tree = options.skipParse && Array.isArray(input) ? createTree((input || []) as NodeContent[], options) : createTree(raw, options)
|
||||
|
||||
for (let idx = 0; idx < plugins.length; idx++) {
|
||||
const plugin = plugins[idx]
|
||||
|
||||
if (typeof plugin === 'function' && renderFn) {
|
||||
const newTree = plugin(tree, {
|
||||
parse: parseFn,
|
||||
render: renderFn,
|
||||
iterate,
|
||||
data,
|
||||
})
|
||||
|
||||
tree = createTree(newTree || tree, options)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get html() {
|
||||
if (typeof renderFn !== 'function') {
|
||||
throw new Error(C2);
|
||||
}
|
||||
|
||||
return renderFn(tree, tree.options);
|
||||
},
|
||||
tree,
|
||||
raw,
|
||||
messages: tree.messages,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ParseOptions, TagNode } from "@bbob/parser";
|
||||
import type {
|
||||
NodeContent,
|
||||
PartialNodeContent,
|
||||
TagNodeTree,
|
||||
} from "@bbob/plugin-helper";
|
||||
import type { IterateCallback, iterate } from "./utils";
|
||||
|
||||
export interface BBobCoreOptions<
|
||||
Data = unknown | null,
|
||||
Options extends ParseOptions = ParseOptions
|
||||
> extends ParseOptions {
|
||||
skipParse?: boolean;
|
||||
parser?: (source: string, options?: Options) => TagNode[];
|
||||
render?: (ast: TagNodeTree, options?: Options) => string;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
export interface BbobPluginOptions<
|
||||
Options extends ParseOptions = ParseOptions
|
||||
> {
|
||||
parse: BBobCoreOptions["parser"];
|
||||
render: (ast: TagNodeTree, options?: Options) => string;
|
||||
iterate: typeof iterate;
|
||||
data: unknown | null;
|
||||
}
|
||||
|
||||
export interface BBobPluginFunction {
|
||||
(tree: BBobCoreTagNodeTree, options: BbobPluginOptions): BBobCoreTagNodeTree;
|
||||
}
|
||||
|
||||
export interface BBobCore<
|
||||
InputValue = string | TagNode[],
|
||||
Options extends BBobCoreOptions = BBobCoreOptions
|
||||
> {
|
||||
process(
|
||||
input: InputValue,
|
||||
opts?: Options
|
||||
): {
|
||||
readonly html: string;
|
||||
tree: BBobCoreTagNodeTree;
|
||||
raw: TagNode[] | string;
|
||||
messages: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface BBobCoreTagNodeTree extends Array<NodeContent> {
|
||||
messages: unknown[];
|
||||
options: BBobCoreOptions;
|
||||
walk: (cb: IterateCallback<NodeContent>) => BBobCoreTagNodeTree;
|
||||
match: (
|
||||
expression: PartialNodeContent | PartialNodeContent[],
|
||||
cb: IterateCallback<NodeContent>
|
||||
) => BBobCoreTagNodeTree;
|
||||
}
|
||||
|
||||
export type BBobPlugins = BBobPluginFunction | BBobPluginFunction[];
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
const isObj = (value) => (typeof value === 'object');
|
||||
const isBool = (value) => (typeof value === 'boolean');
|
||||
|
||||
export function iterate(t, cb) {
|
||||
const tree = t;
|
||||
|
||||
if (Array.isArray(tree)) {
|
||||
for (let idx = 0; idx < tree.length; idx++) {
|
||||
tree[idx] = iterate(cb(tree[idx]), cb);
|
||||
}
|
||||
} else if (tree && isObj(tree) && tree.content) {
|
||||
iterate(tree.content, cb);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function same(expected, actual) {
|
||||
if (typeof expected !== typeof actual) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isObj(expected) || expected === null) {
|
||||
return expected === actual;
|
||||
}
|
||||
|
||||
if (Array.isArray(expected)) {
|
||||
return expected.every((exp) => [].some.call(actual, (act) => same(exp, act)));
|
||||
}
|
||||
|
||||
return Object.keys(expected).every((key) => {
|
||||
const ao = actual[key];
|
||||
const eo = expected[key];
|
||||
|
||||
if (isObj(eo) && eo !== null && ao !== null) {
|
||||
return same(eo, ao);
|
||||
}
|
||||
|
||||
if (isBool(eo)) {
|
||||
return eo !== (ao === null);
|
||||
}
|
||||
|
||||
return ao === eo;
|
||||
});
|
||||
}
|
||||
|
||||
export function match(expression, cb) {
|
||||
return Array.isArray(expression)
|
||||
? iterate(this, (node) => {
|
||||
for (let idx = 0; idx < expression.length; idx++) {
|
||||
if (same(expression[idx], node)) {
|
||||
return cb(node);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
})
|
||||
: iterate(this, (node) => (same(expression, node) ? cb(node) : node));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => (typeof value === 'object' && value !== null);
|
||||
const isBool = (value: unknown): value is boolean => (typeof value === 'boolean');
|
||||
|
||||
export type IterateCallback<Content> = (node: Content) => Content
|
||||
|
||||
export function iterate<Content, Iterable = ArrayLike<Content> | Content>(t: Iterable, cb: IterateCallback<Content>): Iterable {
|
||||
const tree = t;
|
||||
|
||||
if (Array.isArray(tree)) {
|
||||
for (let idx = 0; idx < tree.length; idx++) {
|
||||
tree[idx] = iterate(cb(tree[idx]), cb);
|
||||
}
|
||||
} else if (isObj(tree) && 'content' in tree) {
|
||||
iterate(tree.content, cb);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function same(expected: unknown, actual: unknown): boolean {
|
||||
if (typeof expected !== typeof actual) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isObj(expected) || expected === null) {
|
||||
return expected === actual;
|
||||
}
|
||||
|
||||
if (Array.isArray(expected)) {
|
||||
return expected.every((exp) => [].some.call(actual, (act) => same(exp, act)));
|
||||
}
|
||||
|
||||
if (isObj(expected) && isObj(actual)) {
|
||||
return Object.keys(expected).every((key) => {
|
||||
const ao = actual[key];
|
||||
const eo = expected[key];
|
||||
|
||||
if (isObj(eo) && isObj(ao)) {
|
||||
return same(eo, ao);
|
||||
}
|
||||
|
||||
if (isBool(eo)) {
|
||||
return eo !== (ao === null);
|
||||
}
|
||||
|
||||
return ao === eo;
|
||||
});
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function match<Content, Iterable = ArrayLike<Content>>(
|
||||
t: Iterable,
|
||||
expression: Content | ArrayLike<Content>,
|
||||
cb: IterateCallback<Content>
|
||||
) {
|
||||
if (Array.isArray(expression)) {
|
||||
return iterate<Content, Iterable>(t, (node) => {
|
||||
for (let idx = 0; idx < expression.length; idx++) {
|
||||
if (same(expression[idx], node)) {
|
||||
return cb(node);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
})
|
||||
}
|
||||
|
||||
return iterate<Content, Iterable>(t, (node) => (same(expression, node) ? cb(node) : node));
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { TagNode } from '@bbob/parser'
|
||||
import core from '../src'
|
||||
import core, { BBobPluginFunction, BBobPlugins } from '../src'
|
||||
import { isTagNode } from "@bbob/plugin-helper";
|
||||
|
||||
const stringify = val => JSON.stringify(val);
|
||||
const stringify = (val: unknown) => JSON.stringify(val);
|
||||
|
||||
const process = (plugins, input) => core(plugins).process(input, { render: stringify });
|
||||
const process = (plugins: BBobPlugins, input: string) => core(plugins).process(input, { render: stringify });
|
||||
|
||||
describe('@bbob/core', () => {
|
||||
test('parse bbcode string to ast and html', () => {
|
||||
@@ -22,17 +23,27 @@ describe('@bbob/core', () => {
|
||||
});
|
||||
|
||||
test('plugin walk api node', () => {
|
||||
const testPlugin = () => (tree) => tree.walk(node => {
|
||||
if (node.tag === 'mytag') {
|
||||
node.attrs = {
|
||||
pass: 1
|
||||
};
|
||||
const testPlugin = () => {
|
||||
|
||||
node.content.push('Test');
|
||||
}
|
||||
const plugin: BBobPluginFunction = (tree) => tree.walk(node => {
|
||||
if (isTagNode(node)) {
|
||||
if (node?.tag === 'mytag') {
|
||||
node.attrs = {
|
||||
pass: 1
|
||||
};
|
||||
|
||||
return node
|
||||
});
|
||||
if (Array.isArray(node.content)) {
|
||||
node.content.push('Test');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return node
|
||||
});
|
||||
|
||||
return plugin
|
||||
}
|
||||
|
||||
const res = process([testPlugin()], '[mytag size="15px"]Large Text[/mytag]');
|
||||
const ast = res.tree;
|
||||
@@ -56,13 +67,18 @@ describe('@bbob/core', () => {
|
||||
});
|
||||
|
||||
test('plugin walk api string', () => {
|
||||
const testPlugin = () => (tree) => tree.walk(node => {
|
||||
if (node === ':)') {
|
||||
return TagNode.create('test-tag')
|
||||
}
|
||||
const testPlugin = () => {
|
||||
|
||||
return node
|
||||
});
|
||||
const plugin: BBobPluginFunction = (tree) => tree.walk(node => {
|
||||
if (node === ':)') {
|
||||
return TagNode.create('test-tag')
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return plugin
|
||||
};
|
||||
|
||||
const res = process([testPlugin()], '[mytag]Large Text :)[/mytag]');
|
||||
const ast = res.tree;
|
||||
@@ -89,13 +105,18 @@ describe('@bbob/core', () => {
|
||||
});
|
||||
|
||||
test('plugin match api', () => {
|
||||
const testPlugin = () => (tree) => tree.match([{ tag: 'mytag1' }, { tag: 'mytag2' }], node => {
|
||||
if (node.attrs) {
|
||||
node.attrs['pass'] = 1
|
||||
}
|
||||
const testPlugin = () => {
|
||||
|
||||
return node
|
||||
});
|
||||
const plugin: BBobPluginFunction = (tree) => tree.match([{ tag: 'mytag1' }, { tag: 'mytag2' }], node => {
|
||||
if (isTagNode(node) && node.attrs) {
|
||||
node.attrs['pass'] = 1
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return plugin
|
||||
};
|
||||
|
||||
const res = process([testPlugin()], `[mytag1 size="15"]Tag1[/mytag1][mytag2 size="16"]Tag2[/mytag2][mytag3]Tag3[/mytag3]`);
|
||||
const ast = res.tree;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { iterate, match, same } from '../src/utils';
|
||||
|
||||
const stringify = val => JSON.stringify(val);
|
||||
|
||||
import { isTagNode } from "@bbob/plugin-helper";
|
||||
|
||||
describe('@bbob/core utils', () => {
|
||||
test('iterate', () => {
|
||||
@@ -14,7 +12,12 @@ describe('@bbob/core utils', () => {
|
||||
}];
|
||||
|
||||
const resultArr = iterate(testArr, node => {
|
||||
node.pass = 1;
|
||||
if (typeof node === 'object' && node !== null) {
|
||||
return {
|
||||
...node,
|
||||
pass: 1
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
@@ -31,7 +34,7 @@ describe('@bbob/core utils', () => {
|
||||
}
|
||||
];
|
||||
|
||||
expect(stringify(resultArr)).toEqual(stringify(expected));
|
||||
expect(resultArr).toEqual(expected);
|
||||
});
|
||||
test('match', () => {
|
||||
const testArr = [
|
||||
@@ -43,24 +46,25 @@ describe('@bbob/core utils', () => {
|
||||
{ tag: 'mytag6', six: 1 },
|
||||
];
|
||||
|
||||
testArr.match = match;
|
||||
|
||||
const resultArr = testArr.match([{ tag: 'mytag1' }, { tag: 'mytag2' }], node => {
|
||||
node.pass = 1;
|
||||
const resultArr = match(testArr, [{ tag: 'mytag1' }, { tag: 'mytag2' }], node => {
|
||||
if (isTagNode(node)) {
|
||||
node.attrs = node.attrs || {}
|
||||
node.attrs.pass = 1
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
const expected = [
|
||||
{ tag: 'mytag1', one: 1, pass: 1 },
|
||||
{ tag: 'mytag2', two: 1, pass: 1 },
|
||||
{ tag: 'mytag1', one: 1, attrs: { pass: 1 } },
|
||||
{ tag: 'mytag2', two: 1, attrs: { pass: 1 } },
|
||||
{ tag: 'mytag3', three: 1 },
|
||||
{ tag: 'mytag4', four: 1 },
|
||||
{ tag: 'mytag5', five: 1 },
|
||||
{ tag: 'mytag6', six: 1 },
|
||||
];
|
||||
|
||||
expect(stringify(resultArr)).toEqual(stringify(expected))
|
||||
expect(resultArr).toEqual(expected)
|
||||
})
|
||||
|
||||
describe('same', () => {
|
||||
@@ -79,5 +83,8 @@ describe('@bbob/core utils', () => {
|
||||
test('same object', () => {
|
||||
expect(same({ foo: true, bar: 'test' }, { foo: true, bar: 'test', ext: true })).toBe(true)
|
||||
})
|
||||
test('same string', () => {
|
||||
expect(same('bar', 'bar')).toBe(true)
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "./types"
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user