Merge branch 'master' into runtime-module-federation-enablement
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -1,5 +1,50 @@
|
||||
# @backstage/cli
|
||||
|
||||
## 0.35.4-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 20131c5: Added support for CSS exports in package builds. When a package declares a CSS file in its `exports` field (e.g., `"./styles.css": "./src/styles.css"`), the CLI will automatically bundle it during `backstage-cli package build`, resolving any `@import` statements. The export path is rewritten from `src/` to `dist/` at publish time.
|
||||
|
||||
Fixed `backstage-cli repo fix` to not add `typesVersions` entries for non-script exports like CSS files.
|
||||
|
||||
- 6ce4a13: Removed `/alpha` from `scaffolderActionsExtensionPoint` import
|
||||
- 73351c2: Updated dependency `webpack` to `~5.104.0`.
|
||||
- Updated dependencies
|
||||
- @backstage/integration@1.20.0-next.2
|
||||
- @backstage/cli-node@0.2.18-next.1
|
||||
- @backstage/config-loader@1.10.8-next.0
|
||||
|
||||
## 0.35.4-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5e3ef57: Added support for the new `peerModules` metadata field in `package.json`. This field allows plugin packages to declare modules that should be installed alongside them for cross-plugin integrations. The field is validated by `backstage-cli repo fix --publish`.
|
||||
- Updated dependencies
|
||||
- @backstage/integration@1.20.0-next.1
|
||||
- @backstage/cli-node@0.2.18-next.1
|
||||
|
||||
## 0.35.3-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cfd8103: Updated catalog provider module template to use stable catalog extension points from `@backstage/plugin-catalog-node` instead of alpha exports.
|
||||
- 7455dae: Use node prefix on native imports
|
||||
- 4fc7bf0: Bump to tar v7
|
||||
- 122d39c: Completely removed support for the deprecated `app.experimental.packages` configuration. Replace existing usage directly with `app.packages`.
|
||||
- 69d880e: Bump to latest zod to ensure it has the latest features
|
||||
- Updated dependencies
|
||||
- @backstage/config-loader@1.10.8-next.0
|
||||
- @backstage/eslint-plugin@0.2.1-next.0
|
||||
- @backstage/integration@1.19.3-next.0
|
||||
- @backstage/cli-common@0.1.18-next.0
|
||||
- @backstage/cli-node@0.2.17-next.0
|
||||
- @backstage/catalog-model@1.7.6
|
||||
- @backstage/config@1.3.6
|
||||
- @backstage/errors@1.2.7
|
||||
- @backstage/release-manifests@0.0.13
|
||||
- @backstage/types@1.2.2
|
||||
|
||||
## 0.35.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"version": "0.35.2",
|
||||
"version": "0.35.4-next.2",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"backstage": {
|
||||
"role": "cli"
|
||||
@@ -119,6 +119,7 @@
|
||||
"p-queue": "^6.6.2",
|
||||
"pirates": "^4.0.6",
|
||||
"postcss": "^8.1.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-dev-utils": "^12.0.0-next.60",
|
||||
@@ -192,7 +193,7 @@
|
||||
"msw": "^1.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"terser-webpack-plugin": "^5.1.3",
|
||||
"webpack": "~5.103.0",
|
||||
"webpack": "~5.104.0",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -207,7 +208,7 @@
|
||||
"jsdom": "^27.1.0",
|
||||
"mini-css-extract-plugin": "^2.4.2",
|
||||
"terser-webpack-plugin": "^5.1.3",
|
||||
"webpack": "~5.103.0",
|
||||
"webpack": "~5.104.0",
|
||||
"webpack-dev-server": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -33,18 +33,25 @@ const defaultIndex = {
|
||||
ext: '.ts',
|
||||
};
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
||||
|
||||
function parseEntryPoint(mount: string, path: string): EntryPoint {
|
||||
const ext = extname(path);
|
||||
|
||||
let name = mount;
|
||||
if (name === '.') {
|
||||
name = 'index';
|
||||
} else if (name.startsWith('./')) {
|
||||
name = name.slice(2);
|
||||
}
|
||||
if (name.includes('/')) {
|
||||
|
||||
// Script entry points can't have slashes because we create backward-compat
|
||||
// directories for them. Non-script files (like CSS) can have nested paths.
|
||||
if (name.includes('/') && SCRIPT_EXTS.includes(ext)) {
|
||||
throw new Error(`Mount point '${mount}' may not contain multiple slashes`);
|
||||
}
|
||||
|
||||
return { mount, path, name, ext: extname(path) };
|
||||
return { mount, path, name, ext };
|
||||
}
|
||||
|
||||
export function readEntryPoints(pkg: BackstagePackageJson): Array<EntryPoint> {
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
*/
|
||||
|
||||
import { OptionValues } from 'commander';
|
||||
import fs from 'fs-extra';
|
||||
import { buildPackage, Output } from '../../../lib/builder';
|
||||
import { findRoleFromCommand } from '../../../../../lib/role';
|
||||
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
|
||||
import {
|
||||
BackstagePackageJson,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
} from '@backstage/cli-node';
|
||||
import { paths } from '../../../../../lib/paths';
|
||||
import { buildFrontend } from '../../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../../lib/buildBackend';
|
||||
@@ -93,8 +98,13 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
outputs.add(Output.types);
|
||||
}
|
||||
|
||||
const packageJson = (await fs.readJson(
|
||||
paths.resolveTarget('package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
|
||||
return buildPackage({
|
||||
outputs,
|
||||
packageJson,
|
||||
minify: Boolean(opts.minify),
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
OutputPlugin,
|
||||
} from 'rollup';
|
||||
|
||||
import { forwardFileImports } from './plugins';
|
||||
import { forwardFileImports, cssEntryPoints } from './plugins';
|
||||
import { BuildOptions, Output } from './types';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
@@ -275,6 +275,7 @@ export async function makeRollupConfigs(
|
||||
target: 'ES2023',
|
||||
minify: options.minify,
|
||||
}),
|
||||
cssEntryPoints({ entryPoints, targetDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ export const buildPackage = async (options: BuildOptions) => {
|
||||
|
||||
const rollupConfigs = await makeRollupConfigs(options);
|
||||
|
||||
await fs.remove(resolvePath(options.targetDir ?? paths.targetDir, 'dist'));
|
||||
const targetDir = options.targetDir ?? paths.targetDir;
|
||||
await fs.remove(resolvePath(targetDir, 'dist'));
|
||||
|
||||
const buildTasks = rollupConfigs.map(rollupBuild);
|
||||
|
||||
|
||||
@@ -22,9 +22,25 @@ import {
|
||||
PluginContext,
|
||||
} from 'rollup';
|
||||
|
||||
import { forwardFileImports } from './plugins';
|
||||
import { forwardFileImports, cssEntryPoints } from './plugins';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
// Helper to call generateBundle hook which can be a function or ObjectHook
|
||||
async function callGenerateBundle(
|
||||
plugin: ReturnType<typeof cssEntryPoints>,
|
||||
ctx: PluginContext,
|
||||
options: NormalizedOutputOptions,
|
||||
bundle: Record<string, OutputChunk | OutputAsset>,
|
||||
isWrite: boolean,
|
||||
) {
|
||||
const hook = plugin.generateBundle;
|
||||
if (typeof hook === 'function') {
|
||||
await hook.call(ctx, options, bundle, isWrite);
|
||||
} else if (hook && typeof hook === 'object' && 'handler' in hook) {
|
||||
await hook.handler.call(ctx, options, bundle, isWrite);
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
meta: {
|
||||
rollupVersion: '0.0.0',
|
||||
@@ -194,3 +210,139 @@ describe('forwardFileImports', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cssEntryPoints', () => {
|
||||
it('should be created with correct name', () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [],
|
||||
targetDir: '/dev',
|
||||
});
|
||||
expect(plugin.name).toBe('backstage-css-entry-points');
|
||||
});
|
||||
|
||||
describe('with createMockDirectory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
const emittedFiles: Array<{ fileName: string; source: string }> = [];
|
||||
const emitContext = {
|
||||
...context,
|
||||
emitFile: (file: { fileName: string; source: string }) => {
|
||||
emittedFiles.push(file);
|
||||
return 'asset-id';
|
||||
},
|
||||
} as unknown as PluginContext;
|
||||
|
||||
beforeEach(() => {
|
||||
emittedFiles.length = 0;
|
||||
mockDir.setContent({
|
||||
dev: {
|
||||
src: {
|
||||
css: {
|
||||
'styles.css': '@import "./base.css";\n.root { color: red; }',
|
||||
'base.css': '.base { margin: 0; }',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit when isWrite is false', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(emittedFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should emit only CSS entry points with resolved imports', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
// Non-CSS entry should be ignored
|
||||
{ mount: '.', path: './src/index.ts', name: 'index', ext: '.ts' },
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
|
||||
// Only CSS file should be emitted, not the .ts entry
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
expect(emittedFiles[0].fileName).toBe('css/styles.css');
|
||||
expect(emittedFiles[0].source).toContain('.base { margin: 0; }');
|
||||
expect(emittedFiles[0].source).toContain('.root { color: red; }');
|
||||
expect(emittedFiles[0].source).not.toContain('@import');
|
||||
});
|
||||
|
||||
it('should only emit once per output directory', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
// First call should emit
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
|
||||
// Second call to same dir should not emit again
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
|
||||
// Call to different dir should emit
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist2') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import postcss from 'postcss';
|
||||
import postcssImport from 'postcss-import';
|
||||
import {
|
||||
dirname,
|
||||
resolve as resolvePath,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
OutputChunk,
|
||||
HasModuleSideEffects,
|
||||
} from 'rollup';
|
||||
import { EntryPoint } from '../../../../lib/entryPoints';
|
||||
|
||||
type ForwardFileImportsOptions = {
|
||||
include: Array<string | RegExp> | string | RegExp | null;
|
||||
@@ -169,3 +172,58 @@ export function forwardFileImports(options: ForwardFileImportsOptions) {
|
||||
},
|
||||
} satisfies Plugin;
|
||||
}
|
||||
|
||||
interface CssEntryPointsOptions {
|
||||
entryPoints: EntryPoint[];
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollup plugin that bundles CSS entry points using postcss-import.
|
||||
* CSS files declared in package.json exports are processed and emitted
|
||||
* as part of the Rollup bundle.
|
||||
*/
|
||||
export function cssEntryPoints(options: CssEntryPointsOptions): Plugin {
|
||||
const cssEntries = options.entryPoints.filter(ep => ep.ext === '.css');
|
||||
|
||||
// Track output directories we've already emitted CSS to, to avoid duplicates
|
||||
// when Rollup runs generateBundle multiple times (once per output format)
|
||||
const generatedFor = new Set<string>();
|
||||
|
||||
return {
|
||||
name: 'backstage-css-entry-points',
|
||||
|
||||
async generateBundle(outputOptions, _bundle, isWrite) {
|
||||
if (!isWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = outputOptions.dir || dirname(outputOptions.file!);
|
||||
if (generatedFor.has(dir)) {
|
||||
return;
|
||||
}
|
||||
generatedFor.add(dir);
|
||||
|
||||
for (const entryPoint of cssEntries) {
|
||||
const sourcePath = resolvePath(options.targetDir, entryPoint.path);
|
||||
// Strip the src/ prefix to create an output filename relative to the Rollup output directory
|
||||
const outputPath = entryPoint.path.replace(/^(\.\/)?src\//, '');
|
||||
|
||||
// Read source CSS
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
|
||||
// Bundle @import statements using postcss-import
|
||||
const result = await postcss([postcssImport()]).process(source, {
|
||||
from: sourcePath,
|
||||
});
|
||||
|
||||
// Emit the bundled CSS as an asset
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: outputPath,
|
||||
source: result.css,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +148,11 @@ async function rewriteEntryPoints(
|
||||
|
||||
for (const entryPoint of entryPoints) {
|
||||
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
|
||||
outputExports[entryPoint.mount] = entryPoint.path;
|
||||
// Non-script files (like CSS) get their paths rewritten from src/ to dist/
|
||||
outputExports[entryPoint.mount] = entryPoint.path.replace(
|
||||
/^(\.\/)?src\//,
|
||||
'./dist/',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,13 @@ import {
|
||||
resolve as resolvePath,
|
||||
posix,
|
||||
relative as relativePath,
|
||||
extname,
|
||||
} from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
|
||||
|
||||
/**
|
||||
* A mutable object representing a package.json file with potential fixes.
|
||||
*/
|
||||
@@ -122,29 +125,46 @@ export function fixPackageExports(pkg: FixablePackage) {
|
||||
}
|
||||
const newPath = trimRelative(path);
|
||||
|
||||
// Only script files and package.json should be added to typesVersions
|
||||
if (typeof value === 'string') {
|
||||
typeEntries[newPath] = [trimRelative(value)];
|
||||
if (SCRIPT_EXTS.includes(extname(value))) {
|
||||
typeEntries[newPath] = [trimRelative(value)];
|
||||
}
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (typeof value.types === 'string') {
|
||||
typeEntries[newPath] = [trimRelative(value.types)];
|
||||
} else if (typeof value.default === 'string') {
|
||||
} else if (
|
||||
typeof value.default === 'string' &&
|
||||
SCRIPT_EXTS.includes(extname(value.default))
|
||||
) {
|
||||
typeEntries[newPath] = [trimRelative(value.default)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const typesVersions = { '*': typeEntries };
|
||||
if (existingTypesVersions !== JSON.stringify(typesVersions)) {
|
||||
const newPkgEntries = Object.entries(pkg.packageJson).filter(
|
||||
([name]) => name !== 'typesVersions',
|
||||
);
|
||||
newPkgEntries.splice(
|
||||
newPkgEntries.findIndex(([name]) => name === 'exports') + 1,
|
||||
0,
|
||||
['typesVersions', typesVersions],
|
||||
);
|
||||
const hasTypeEntries = Object.keys(typeEntries).length > 0;
|
||||
const typesVersions = hasTypeEntries ? { '*': typeEntries } : undefined;
|
||||
|
||||
pkg.packageJson = Object.fromEntries(newPkgEntries) as BackstagePackageJson;
|
||||
if (existingTypesVersions !== JSON.stringify(typesVersions)) {
|
||||
if (pkg.packageJson.typesVersions) {
|
||||
// Update in place to preserve field order
|
||||
if (typesVersions) {
|
||||
pkg.packageJson.typesVersions = typesVersions;
|
||||
} else {
|
||||
delete pkg.packageJson.typesVersions;
|
||||
}
|
||||
} else if (typesVersions) {
|
||||
// Insert after exports when adding for the first time
|
||||
const newPkgEntries = Object.entries(pkg.packageJson);
|
||||
newPkgEntries.splice(
|
||||
newPkgEntries.findIndex(([name]) => name === 'exports') + 1,
|
||||
0,
|
||||
['typesVersions', typesVersions],
|
||||
);
|
||||
pkg.packageJson = Object.fromEntries(
|
||||
newPkgEntries,
|
||||
) as BackstagePackageJson;
|
||||
}
|
||||
pkg.changed = true;
|
||||
}
|
||||
|
||||
@@ -430,6 +450,47 @@ export function fixPluginPackages(
|
||||
}
|
||||
}
|
||||
|
||||
export function fixPeerModules(pkg: FixablePackage) {
|
||||
const pkgBackstage = pkg.packageJson.backstage;
|
||||
const role = pkgBackstage?.role;
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerModules = pkgBackstage.peerModules;
|
||||
if (peerModules === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packagePath = relativePath(
|
||||
paths.targetRoot,
|
||||
resolvePath(pkg.dir, 'package.json'),
|
||||
);
|
||||
|
||||
// Validate that peerModules is only used on plugin packages
|
||||
if (role !== 'backend-plugin' && role !== 'frontend-plugin') {
|
||||
throw new Error(
|
||||
`The 'backstage.peerModules' field in "${pkg.packageJson.name}" can only be used on plugin packages (backend-plugin or frontend-plugin), but package has role '${role}' in "${packagePath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that peerModules is an array
|
||||
if (!Array.isArray(peerModules)) {
|
||||
throw new Error(
|
||||
`Invalid 'backstage.peerModules' field in "${pkg.packageJson.name}", must be an array of package names in "${packagePath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that all entries are non-empty strings
|
||||
for (const entry of peerModules) {
|
||||
if (typeof entry !== 'string' || entry.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid entry in 'backstage.peerModules' field in "${pkg.packageJson.name}", all entries must be non-empty package name strings in "${packagePath}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void;
|
||||
|
||||
export async function command(opts: OptionValues): Promise<void> {
|
||||
@@ -444,6 +505,7 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
fixRepositoryField,
|
||||
fixPluginId,
|
||||
fixPluginPackages,
|
||||
fixPeerModules,
|
||||
// Run the publish preflight check too, to make sure we don't uncover errors during publishing
|
||||
publishPreflightCheck,
|
||||
);
|
||||
|
||||
Vendored
+6
@@ -259,3 +259,9 @@ declare module 'webpack-node-externals' {
|
||||
}
|
||||
|
||||
declare module '@esbuild-kit/cjs-loader' {}
|
||||
|
||||
declare module 'postcss-import' {
|
||||
import { Plugin } from 'postcss';
|
||||
|
||||
export default function postcssImport(): Plugin;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createBackendModule } from "@backstage/backend-plugin-api";
|
||||
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
|
||||
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node';
|
||||
import { createExampleAction } from "./actions/example";
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user