mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
feat: add build-docs command (#863)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,4 +10,5 @@ lib/
|
|||||||
output/
|
output/
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
redoc-static.html
|
||||||
|
|||||||
1815
package-lock.json
generated
1815
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
|||||||
"stats": "npm run cli stats resources/pets.yaml",
|
"stats": "npm run cli stats resources/pets.yaml",
|
||||||
"split": "npm run cli split resources/pets.yaml -- --outDir output",
|
"split": "npm run cli split resources/pets.yaml -- --outDir output",
|
||||||
"preview": "npm run cli preview-docs resources/pets.yaml",
|
"preview": "npm run cli preview-docs resources/pets.yaml",
|
||||||
|
"build-docs": "npm run cli build-docs resources/pets.yaml",
|
||||||
"benchmark": "node --expose-gc --noconcurrent_sweeping --predictable packages/core/src/benchmark/benchmark.js",
|
"benchmark": "node --expose-gc --noconcurrent_sweeping --predictable packages/core/src/benchmark/benchmark.js",
|
||||||
"webpack-bundle": "webpack --config webpack.config.ts",
|
"webpack-bundle": "webpack --config webpack.config.ts",
|
||||||
"upload": "node scripts/archive-and-upload-bundle.js",
|
"upload": "node scripts/archive-and-upload-bundle.js",
|
||||||
@@ -55,7 +56,10 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.15",
|
"@types/jest": "^26.0.15",
|
||||||
|
"@types/mark.js": "^8.11.5",
|
||||||
|
"@types/marked": "^4.0.3",
|
||||||
"@types/node": "^17.0.31",
|
"@types/node": "^17.0.31",
|
||||||
|
"@types/react-tabs": "^2.3.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||||
"@typescript-eslint/parser": "^5.33.0",
|
"@typescript-eslint/parser": "^5.33.0",
|
||||||
"eslint": "^8.22.0",
|
"eslint": "^8.22.0",
|
||||||
@@ -63,7 +67,6 @@
|
|||||||
"null-loader": "^4.0.0",
|
"null-loader": "^4.0.0",
|
||||||
"outdent": "^0.7.1",
|
"outdent": "^0.7.1",
|
||||||
"prettier": "^2.1.2",
|
"prettier": "^2.1.2",
|
||||||
"shebang-loader": "0.0.1",
|
|
||||||
"ts-jest": "^26.4.4",
|
"ts-jest": "^26.4.4",
|
||||||
"ts-loader": "^8.0.2",
|
"ts-loader": "^8.0.2",
|
||||||
"ts-node": "^9.0.0",
|
"ts-node": "^9.0.0",
|
||||||
|
|||||||
@@ -40,11 +40,20 @@
|
|||||||
"glob": "^7.1.6",
|
"glob": "^7.1.6",
|
||||||
"glob-promise": "^3.4.0",
|
"glob-promise": "^3.4.0",
|
||||||
"handlebars": "^4.7.6",
|
"handlebars": "^4.7.6",
|
||||||
|
"mobx": "^6.3.2",
|
||||||
"portfinder": "^1.0.26",
|
"portfinder": "^1.0.26",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"redoc": "~2.0.0",
|
||||||
"simple-websocket": "^9.0.0",
|
"simple-websocket": "^9.0.0",
|
||||||
|
"styled-components": "^5.3.0",
|
||||||
"yargs": "17.0.1"
|
"yargs": "17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/configstore": "^5.0.1",
|
||||||
|
"@types/react": "^17.0.8",
|
||||||
|
"@types/react-dom": "^17.0.5",
|
||||||
|
"@types/styled-components": "^5.1.1",
|
||||||
"@types/yargs": "16.0.2",
|
"@types/yargs": "16.0.2",
|
||||||
"typescript": "^4.0.3"
|
"typescript": "^4.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ export const existsSync = jest.fn();
|
|||||||
export const readFileSync = jest.fn(() => '');
|
export const readFileSync = jest.fn(() => '');
|
||||||
export const statSync = jest.fn(() => ({ size: 0 }));
|
export const statSync = jest.fn(() => ({ size: 0 }));
|
||||||
export const createReadStream = jest.fn();
|
export const createReadStream = jest.fn();
|
||||||
|
export const writeFileSync = jest.fn();
|
||||||
|
export const mkdirSync = jest.fn();
|
||||||
|
|||||||
2
packages/cli/src/__mocks__/redoc.ts
Normal file
2
packages/cli/src/__mocks__/redoc.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const loadAndBundleSpec = jest.fn(() => Promise.resolve({ openapi: '3.0.0' }));
|
||||||
|
export const createStore = jest.fn(() => Promise.resolve({ toJS: jest.fn(() => '{}') }));
|
||||||
58
packages/cli/src/__tests__/commands/build-docs.test.ts
Normal file
58
packages/cli/src/__tests__/commands/build-docs.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { createStore, loadAndBundleSpec } from 'redoc';
|
||||||
|
import { renderToString } from 'react-dom/server';
|
||||||
|
import { handlerBuildCommand } from '../../commands/build-docs';
|
||||||
|
import { BuildDocsArgv } from '../../commands/build-docs/types';
|
||||||
|
import { getPageHTML } from '../../commands/build-docs/utils';
|
||||||
|
|
||||||
|
jest.mock('redoc');
|
||||||
|
jest.mock('fs');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
output: '',
|
||||||
|
cdn: false,
|
||||||
|
title: 'Test',
|
||||||
|
disableGoogleFont: false,
|
||||||
|
templateFileName: '',
|
||||||
|
templateOptions: {},
|
||||||
|
redocOptions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('react-dom/server', () => ({
|
||||||
|
renderToString: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('handlebars', () => ({
|
||||||
|
compile: jest.fn(() => jest.fn(() => '<html></html>')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('mkdirp', () => ({
|
||||||
|
sync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('build-docs', () => {
|
||||||
|
it('should return correct html and call function for ssr', async () => {
|
||||||
|
const result = await getPageHTML({}, '../some-path/openapi.yaml', {
|
||||||
|
...config,
|
||||||
|
redocCurrentVersion: '2.0.0',
|
||||||
|
});
|
||||||
|
expect(renderToString).toBeCalledTimes(1);
|
||||||
|
expect(createStore).toBeCalledTimes(1);
|
||||||
|
expect(result).toBe('<html></html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work correctly when calling handlerBuildCommand', async () => {
|
||||||
|
const processExitMock = jest.spyOn(process, 'exit').mockImplementation();
|
||||||
|
await handlerBuildCommand({
|
||||||
|
o: '',
|
||||||
|
cdn: false,
|
||||||
|
title: 'test',
|
||||||
|
disableGoogleFont: false,
|
||||||
|
template: '',
|
||||||
|
templateOptions: {},
|
||||||
|
options: {},
|
||||||
|
api: '../some-path/openapi.yaml',
|
||||||
|
} as BuildDocsArgv);
|
||||||
|
expect(loadAndBundleSpec).toBeCalledTimes(1);
|
||||||
|
expect(processExitMock).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
packages/cli/src/commands/build-docs/index.ts
Normal file
39
packages/cli/src/commands/build-docs/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { loadAndBundleSpec } from 'redoc';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { performance } from 'perf_hooks';
|
||||||
|
|
||||||
|
import { getObjectOrJSON, isURL, getPageHTML } from './utils';
|
||||||
|
import type { BuildDocsArgv } from './types';
|
||||||
|
import { exitWithError, getExecutionTime } from '../../utils';
|
||||||
|
|
||||||
|
export const handlerBuildCommand = async (argv: BuildDocsArgv) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const config = {
|
||||||
|
output: argv.o,
|
||||||
|
cdn: argv.cdn,
|
||||||
|
title: argv.title,
|
||||||
|
disableGoogleFont: argv.disableGoogleFont,
|
||||||
|
templateFileName: argv.template,
|
||||||
|
templateOptions: argv.templateOptions || {},
|
||||||
|
redocOptions: getObjectOrJSON(argv.options),
|
||||||
|
};
|
||||||
|
|
||||||
|
const redocCurrentVersion = require('../../../package.json').dependencies.redoc.substring(1); // remove ~
|
||||||
|
const pathToApi = argv.api;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const elapsed = getExecutionTime(startedAt);
|
||||||
|
const api = await loadAndBundleSpec(isURL(pathToApi) ? pathToApi : resolve(pathToApi));
|
||||||
|
const pageHTML = await getPageHTML(api, pathToApi, { ...config, redocCurrentVersion });
|
||||||
|
|
||||||
|
mkdirSync(dirname(config.output), { recursive: true });
|
||||||
|
writeFileSync(config.output, pageHTML);
|
||||||
|
const sizeInKiB = Math.ceil(Buffer.byteLength(pageHTML) / 1024);
|
||||||
|
process.stderr.write(
|
||||||
|
`\n🎉 bundled successfully in: ${config.output} (${sizeInKiB} KiB) [⏱ ${elapsed}].\n`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
exitWithError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
23
packages/cli/src/commands/build-docs/template.hbs
Normal file
23
packages/cli/src/commands/build-docs/template.hbs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<!-- needed for adaptive design -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{{redocHead}}}
|
||||||
|
{{#unless disableGoogleFont}}<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">{{/unless}}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{{{redocHTML}}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
23
packages/cli/src/commands/build-docs/types.ts
Normal file
23
packages/cli/src/commands/build-docs/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export type BuildDocsOptions = {
|
||||||
|
watch?: boolean;
|
||||||
|
cdn?: boolean;
|
||||||
|
output?: string;
|
||||||
|
title?: string;
|
||||||
|
disableGoogleFont?: boolean;
|
||||||
|
port?: number;
|
||||||
|
templateFileName?: string;
|
||||||
|
templateOptions?: any;
|
||||||
|
redocOptions?: any;
|
||||||
|
redocCurrentVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildDocsArgv = {
|
||||||
|
api: string;
|
||||||
|
o: string;
|
||||||
|
cdn: boolean;
|
||||||
|
title?: string;
|
||||||
|
disableGoogleFont?: boolean;
|
||||||
|
template?: string;
|
||||||
|
templateOptions: Record<string, any>;
|
||||||
|
options: string | Record<string, unknown>;
|
||||||
|
};
|
||||||
116
packages/cli/src/commands/build-docs/utils.ts
Normal file
116
packages/cli/src/commands/build-docs/utils.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { createStore, Redoc } from 'redoc';
|
||||||
|
import { parseYaml, findConfig, Config } from '@redocly/openapi-core';
|
||||||
|
import { renderToString } from 'react-dom/server';
|
||||||
|
import { ServerStyleSheet } from 'styled-components';
|
||||||
|
import { compile } from 'handlebars';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, lstatSync, readFileSync } from 'fs';
|
||||||
|
|
||||||
|
import type { BuildDocsOptions } from './types';
|
||||||
|
import { red, yellow } from 'colorette';
|
||||||
|
import { exitWithError } from '../../utils';
|
||||||
|
|
||||||
|
export function getObjectOrJSON(
|
||||||
|
options: string | Record<string, unknown>
|
||||||
|
): JSON | Record<string, unknown> | Config {
|
||||||
|
switch (typeof options) {
|
||||||
|
case 'object':
|
||||||
|
return options;
|
||||||
|
case 'string':
|
||||||
|
try {
|
||||||
|
if (existsSync(options) && lstatSync(options).isFile()) {
|
||||||
|
return JSON.parse(readFileSync(options, 'utf-8'));
|
||||||
|
} else {
|
||||||
|
return JSON.parse(options);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(
|
||||||
|
red(
|
||||||
|
`Encountered error:\n\n${options}\n\nis neither a file with a valid JSON object neither a stringified JSON object.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
exitWithError(e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const configFile = findConfig();
|
||||||
|
if (configFile) {
|
||||||
|
process.stderr.write(`Found ${configFile} and using features.openapi options`);
|
||||||
|
try {
|
||||||
|
const config = parseYaml(readFileSync(configFile, 'utf-8')) as Config;
|
||||||
|
|
||||||
|
return config['features.openapi'];
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(yellow(`Found ${configFile} but failed to parse: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageHTML(
|
||||||
|
api: any,
|
||||||
|
pathToApi: string,
|
||||||
|
{
|
||||||
|
cdn,
|
||||||
|
title,
|
||||||
|
disableGoogleFont,
|
||||||
|
templateFileName,
|
||||||
|
templateOptions,
|
||||||
|
redocOptions = {},
|
||||||
|
redocCurrentVersion,
|
||||||
|
}: BuildDocsOptions
|
||||||
|
) {
|
||||||
|
process.stderr.write('Prerendering docs');
|
||||||
|
|
||||||
|
const apiUrl = redocOptions.specUrl || (isURL(pathToApi) ? pathToApi : undefined);
|
||||||
|
const store = await createStore(api, apiUrl, redocOptions);
|
||||||
|
const sheet = new ServerStyleSheet();
|
||||||
|
|
||||||
|
const html = renderToString(sheet.collectStyles(React.createElement(Redoc, { store })));
|
||||||
|
const state = await store.toJS();
|
||||||
|
const css = sheet.getStyleTags();
|
||||||
|
|
||||||
|
templateFileName = templateFileName ? templateFileName : join(__dirname, './template.hbs');
|
||||||
|
const template = compile(readFileSync(templateFileName).toString());
|
||||||
|
return template({
|
||||||
|
redocHTML: `
|
||||||
|
<div id="redoc">${html || ''}</div>
|
||||||
|
<script>
|
||||||
|
${`const __redoc_state = ${sanitizeJSONString(JSON.stringify(state))};` || ''}
|
||||||
|
|
||||||
|
var container = document.getElementById('redoc');
|
||||||
|
Redoc.${'hydrate(__redoc_state, container)'};
|
||||||
|
|
||||||
|
</script>`,
|
||||||
|
redocHead:
|
||||||
|
(cdn
|
||||||
|
? '<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>'
|
||||||
|
: `<script src="https://cdn.redoc.ly/redoc/v${redocCurrentVersion}/bundles/redoc.standalone.js"></script>`) +
|
||||||
|
css,
|
||||||
|
title: title || api.info.title || 'ReDoc documentation',
|
||||||
|
disableGoogleFont,
|
||||||
|
templateOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isURL(str: string): boolean {
|
||||||
|
return /^(https?:)\/\//m.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeJSONString(str: string): string {
|
||||||
|
return escapeClosingScriptTag(escapeUnicode(str));
|
||||||
|
}
|
||||||
|
|
||||||
|
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
|
||||||
|
export function escapeClosingScriptTag(str: string): string {
|
||||||
|
return str.replace(/<\/script>/g, '<\\/script>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
|
||||||
|
export function escapeUnicode(str: string): string {
|
||||||
|
return str.replace(/\u2028|\u2029/g, (m) => '\\u202' + (m === '\u2028' ? '8' : '9'));
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@redocly/openapi-core';
|
} from '@redocly/openapi-core';
|
||||||
import { getFallbackApisOrExit } from '../../utils';
|
import { getFallbackApisOrExit } from '../../utils';
|
||||||
import startPreviewServer from './preview-server/preview-server';
|
import startPreviewServer from './preview-server/preview-server';
|
||||||
import type { Skips } from 'cli/src/types';
|
import type { Skips } from '../../types';
|
||||||
|
|
||||||
export async function previewDocs(
|
export async function previewDocs(
|
||||||
argv: {
|
argv: {
|
||||||
|
|||||||
1
packages/cli/src/custom.d.ts
vendored
Normal file
1
packages/cli/src/custom.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
type GenericObject = Record<string, any>;
|
||||||
@@ -12,6 +12,8 @@ import { handlePush, transformPush } from './commands/push';
|
|||||||
import { handleLint } from './commands/lint';
|
import { handleLint } from './commands/lint';
|
||||||
import { handleBundle } from './commands/bundle';
|
import { handleBundle } from './commands/bundle';
|
||||||
import { handleLogin } from './commands/login';
|
import { handleLogin } from './commands/login';
|
||||||
|
import { handlerBuildCommand } from './commands/build-docs';
|
||||||
|
import type { BuildDocsArgv } from './commands/build-docs/types';
|
||||||
const version = require('../package.json').version;
|
const version = require('../package.json').version;
|
||||||
|
|
||||||
yargs
|
yargs
|
||||||
@@ -357,6 +359,61 @@ yargs
|
|||||||
previewDocs(argv);
|
previewDocs(argv);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.command(
|
||||||
|
'build-docs [api]',
|
||||||
|
'build definition into zero-dependency HTML-file',
|
||||||
|
(yargs) => {
|
||||||
|
yargs.positional('api', {
|
||||||
|
describe: 'path or URL to your api',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.option('o', {
|
||||||
|
describe: 'Output file',
|
||||||
|
alias: 'output',
|
||||||
|
type: 'string',
|
||||||
|
default: 'redoc-static.html',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.options('title', {
|
||||||
|
describe: 'Page Title',
|
||||||
|
type: 'string',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.options('disableGoogleFont', {
|
||||||
|
describe: 'Disable Google Font',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.option('cdn', {
|
||||||
|
describe: 'Do not include ReDoc source code into html page, use link to CDN instead',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.options('t', {
|
||||||
|
alias: 'template',
|
||||||
|
describe: 'Path to handlebars page template, see https://git.io/vh8fP for the example ',
|
||||||
|
type: 'string',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.options('templateOptions', {
|
||||||
|
describe:
|
||||||
|
'Additional options that you want pass to template. Use dot notation, e.g. templateOptions.metaDescription',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.options('options', {
|
||||||
|
describe: 'ReDoc options, use dot notation, e.g. options.nativeScrollbars',
|
||||||
|
});
|
||||||
|
|
||||||
|
yargs.demandOption('api');
|
||||||
|
return yargs;
|
||||||
|
},
|
||||||
|
async (argv) => {
|
||||||
|
process.env.REDOCLY_CLI_COMMAND = 'build-docs';
|
||||||
|
handlerBuildCommand(argv as unknown as BuildDocsArgv);
|
||||||
|
}
|
||||||
|
)
|
||||||
.completion('completion', 'Generate completion script.')
|
.completion('completion', 'Generate completion script.')
|
||||||
.demandCommand(1)
|
.demandCommand(1)
|
||||||
.strict().argv;
|
.strict().argv;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"strictFunctionTypes": true,
|
"strictFunctionTypes": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"lib": ["es2020", "es2020.string"],
|
"lib": ["es2020", "es2020.string", "dom"],
|
||||||
"baseUrl": "./packages"
|
"baseUrl": "./packages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
loader: 'ts-loader',
|
loader: 'ts-loader',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
loader: 'shebang-loader',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user