Initial commit
This commit is contained in:
commit
0ed31ebc0f
6
.eslintrc.js
Normal file
6
.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
const { extendConfig } = require("@aet/eslint-rules");
|
||||
|
||||
module.exports = extendConfig({
|
||||
plugins: ["react"],
|
||||
});
|
132
.gitignore
vendored
Normal file
132
.gitignore
vendored
Normal file
@ -0,0 +1,132 @@
|
||||
*.draft.*
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.runtime": "node"
|
||||
}
|
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@aet/babel-tailwind",
|
||||
"version": "0.0.1",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsup --dts",
|
||||
"test": "vitest"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@aet/eslint-rules": "^0.0.18",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/node": "^20.11.30",
|
||||
"esbuild": "^0.20.2",
|
||||
"esbuild-register": "^3.5.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": "^3.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.3",
|
||||
"@emotion/hash": "^0.9.1",
|
||||
"lodash": "^4.17.21",
|
||||
"postcss": "^8.4.38"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "avoid",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 90,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
}
|
3591
pnpm-lock.yaml
generated
Normal file
3591
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
src/index.test.ts
Normal file
73
src/index.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type TailwindPluginOptions,
|
||||
babelPlugin,
|
||||
getClassName,
|
||||
getTailwindPlugins,
|
||||
} from "./index";
|
||||
|
||||
const folder = resolve(import.meta.dirname, "temp");
|
||||
|
||||
describe("babel-tailwind", () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function write(path: string, content: string) {
|
||||
const resolved = resolve(folder, path);
|
||||
await fs.writeFile(resolved, content);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function compile(options: TailwindPluginOptions, javascript: string) {
|
||||
const tailwind = getTailwindPlugins(options);
|
||||
const result = await build({
|
||||
bundle: true,
|
||||
write: false,
|
||||
external: ["react/jsx-runtime"],
|
||||
outdir: "dist",
|
||||
format: "esm",
|
||||
entryPoints: [await write("index.tsx", javascript)],
|
||||
plugins: [babelPlugin({ getPlugins: () => [tailwind.babel] }), tailwind.esbuild],
|
||||
});
|
||||
|
||||
const { errors, warnings, outputFiles } = result;
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(0);
|
||||
|
||||
return outputFiles;
|
||||
}
|
||||
|
||||
it("renders", async () => {
|
||||
const outputFiles = await compile(
|
||||
{
|
||||
tailwindConfig: {},
|
||||
clsx: "emotion",
|
||||
},
|
||||
/* tsx */ `
|
||||
export function Hello() {
|
||||
return (
|
||||
<div css="text-center">
|
||||
Hello, world!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`
|
||||
);
|
||||
expect(outputFiles).toHaveLength(2);
|
||||
|
||||
const js = outputFiles.find(file => file.path.endsWith(".js"))!;
|
||||
const css = outputFiles.find(file => file.path.endsWith(".css"))!;
|
||||
|
||||
const clsName = getClassName("text-center");
|
||||
expect(js.text).toContain(`className: "${clsName}"`);
|
||||
expect(css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
|
||||
});
|
||||
});
|
318
src/index.ts
Normal file
318
src/index.ts
Normal file
@ -0,0 +1,318 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { once } from "lodash";
|
||||
import hash from "@emotion/hash";
|
||||
import type babel from "@babel/core";
|
||||
import { type NodePath, type types as t, transform } from "@babel/core";
|
||||
import { type Plugin } from "esbuild";
|
||||
import tailwind, { type Config } from "tailwindcss";
|
||||
import postcss from "postcss";
|
||||
|
||||
const definePlugin =
|
||||
<T>(fn: (runtime: typeof babel) => babel.Visitor<babel.PluginPass & T>) =>
|
||||
(runtime: typeof babel) => {
|
||||
const plugin: babel.PluginObj<babel.PluginPass & T> = {
|
||||
visitor: fn(runtime),
|
||||
};
|
||||
return plugin as babel.PluginObj;
|
||||
};
|
||||
|
||||
const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expression =>
|
||||
attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr;
|
||||
|
||||
function matchPath(
|
||||
nodePath: NodePath<t.Node | null | undefined>,
|
||||
fns: (dig: (nodePath: NodePath) => void) => babel.Visitor
|
||||
) {
|
||||
if (!nodePath.node) return;
|
||||
const fn = fns(path => matchPath(path, fns))[nodePath.node.type] as any;
|
||||
fn?.(nodePath);
|
||||
}
|
||||
|
||||
const ESBUILD_NAMESPACE = "babel-tailwind";
|
||||
|
||||
export interface TailwindPluginOptions {
|
||||
/**
|
||||
* Tailwind CSS configuration
|
||||
*/
|
||||
tailwindConfig: Omit<Config, "content">;
|
||||
|
||||
/**
|
||||
* Additional PostCSS plugins (optional)
|
||||
*/
|
||||
postCSSPlugins?: postcss.AcceptedPlugin[];
|
||||
|
||||
/**
|
||||
* Attribute to use for tailwind classes in JSX
|
||||
* @default "css"
|
||||
*/
|
||||
jsxAttributeName?: string;
|
||||
|
||||
/**
|
||||
* The prefix to use for the generated class names.
|
||||
* @default className => `tw-${hash(className)}`
|
||||
*/
|
||||
getClassName?: GetClassName;
|
||||
|
||||
/**
|
||||
* Preferred library for classnames
|
||||
*/
|
||||
clsx: "clsx" | "classnames" | "emotion";
|
||||
}
|
||||
|
||||
type GetClassName = (className: string) => string;
|
||||
|
||||
/**
|
||||
* Hashes and prefixes a string of Tailwind class names.
|
||||
* @example getClassName("p-2 text-center") // "tw-1r6fxxz"
|
||||
*/
|
||||
export const getClassName: GetClassName = cls => "tw-" + hash(cls);
|
||||
|
||||
const babelTailwind = (
|
||||
styleMap: Map<string, string>,
|
||||
{
|
||||
clsx,
|
||||
getClassName: getClass = getClassName,
|
||||
jsxAttributeName = "css",
|
||||
}: TailwindPluginOptions
|
||||
) =>
|
||||
definePlugin<{
|
||||
getCx: () => t.Identifier;
|
||||
tailwindMap: Map<string, string>;
|
||||
}>(({ types: t }) => ({
|
||||
Program: {
|
||||
enter(path, state) {
|
||||
let cx: t.Identifier;
|
||||
|
||||
state.tailwindMap = new Map();
|
||||
|
||||
function getClsxImport() {
|
||||
switch (clsx) {
|
||||
case "emotion":
|
||||
return t.importDeclaration(
|
||||
[t.importSpecifier(cx, t.identifier("cx"))],
|
||||
t.stringLiteral("@emotion/css")
|
||||
);
|
||||
case "clsx":
|
||||
return t.importDeclaration(
|
||||
[t.importDefaultSpecifier(cx)],
|
||||
t.stringLiteral("clsx")
|
||||
);
|
||||
case "classnames":
|
||||
return t.importDeclaration(
|
||||
[t.importDefaultSpecifier(cx)],
|
||||
t.stringLiteral("classnames")
|
||||
);
|
||||
default:
|
||||
throw new Error("Unknown clsx library");
|
||||
}
|
||||
}
|
||||
|
||||
state.getCx = () => {
|
||||
if (cx == null) {
|
||||
cx = path.scope.generateUidIdentifier("cx");
|
||||
path.node.body.unshift(getClsxImport());
|
||||
}
|
||||
return t.cloneNode(cx);
|
||||
};
|
||||
},
|
||||
|
||||
exit({ node }, { filename, tailwindMap }) {
|
||||
if (!tailwindMap.size) return;
|
||||
if (!filename) {
|
||||
throw new Error("babel: missing state.filename");
|
||||
}
|
||||
|
||||
const cssName = basename(filename, extname(filename)) + ".css";
|
||||
node.body.unshift(
|
||||
t.importDeclaration([], t.stringLiteral(`tailwind:./${cssName}`))
|
||||
);
|
||||
|
||||
const result = Array.from(tailwindMap)
|
||||
.map(([className, value]) => `.${className} {\n @apply ${value}\n}`)
|
||||
.join("\n");
|
||||
styleMap.set(join(dirname(filename), cssName), result);
|
||||
},
|
||||
},
|
||||
|
||||
TaggedTemplateExpression(path, { tailwindMap }) {
|
||||
const {
|
||||
tag,
|
||||
quasi: { quasis, expressions },
|
||||
} = path.node;
|
||||
if (!t.isIdentifier(tag, { name: "tw" })) return;
|
||||
|
||||
if (expressions.length) {
|
||||
throw new Error("tw`` should not contain expressions");
|
||||
}
|
||||
|
||||
const value = quasis[0].value.cooked;
|
||||
if (value) {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
const className = getClass(trimmed);
|
||||
tailwindMap.set(className, trimmed);
|
||||
path.replaceWith(t.stringLiteral(className));
|
||||
}
|
||||
},
|
||||
|
||||
JSXAttribute(path, { tailwindMap, getCx }) {
|
||||
const { name } = path.node;
|
||||
const valuePath = path.get("value");
|
||||
if (name.name !== jsxAttributeName || !valuePath.node) return;
|
||||
|
||||
const parent = path.parent as t.JSXOpeningElement;
|
||||
const classNameAttribute = parent.attributes.find(
|
||||
(attr): attr is t.JSXAttribute =>
|
||||
t.isJSXAttribute(attr) && attr.name.name === "className"
|
||||
);
|
||||
|
||||
matchPath(valuePath, go => ({
|
||||
StringLiteral(path) {
|
||||
const { value } = path.node;
|
||||
if (value) {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
const className = getClass(trimmed);
|
||||
tailwindMap.set(className, trimmed);
|
||||
path.replaceWith(t.stringLiteral(className));
|
||||
}
|
||||
},
|
||||
JSXExpressionContainer(path) {
|
||||
go(path.get("expression"));
|
||||
},
|
||||
ConditionalExpression(path) {
|
||||
go(path.get("consequent"));
|
||||
go(path.get("alternate"));
|
||||
},
|
||||
LogicalExpression(path) {
|
||||
go(path.get("right"));
|
||||
},
|
||||
CallExpression(path) {
|
||||
for (const arg of path.get("arguments")) {
|
||||
go(arg);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (classNameAttribute) {
|
||||
const attrValue = classNameAttribute.value!;
|
||||
|
||||
// If both are string literals, we can merge them directly here
|
||||
if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePath.node)) {
|
||||
attrValue.value +=
|
||||
(attrValue.value.at(-1) === " " ? "" : " ") + valuePath.node.value;
|
||||
} else {
|
||||
classNameAttribute.value = t.jsxExpressionContainer(
|
||||
t.callExpression(getCx(), [
|
||||
extractJSXContainer(attrValue),
|
||||
extractJSXContainer(valuePath.node),
|
||||
])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
parent.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier("className"), valuePath.node)
|
||||
);
|
||||
}
|
||||
path.remove();
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* An esbuild plugin that processes files with Babel if `getPlugins` returns any plugins.
|
||||
*/
|
||||
export const babelPlugin = ({
|
||||
filter = /\.[jt]sx?$/,
|
||||
getPlugins,
|
||||
}: {
|
||||
filter?: RegExp;
|
||||
getPlugins(file: { path: string; contents: string }): babel.PluginItem[];
|
||||
}): Plugin => ({
|
||||
name: "babel-plugin",
|
||||
setup(build) {
|
||||
build.onLoad({ filter }, ({ path }) => {
|
||||
const load = once(() => readFileSync(path, "utf-8"));
|
||||
const plugins = getPlugins({
|
||||
path,
|
||||
get contents() {
|
||||
return load();
|
||||
},
|
||||
});
|
||||
|
||||
if (!plugins.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = transform(load(), {
|
||||
parserOpts: {
|
||||
plugins: ["jsx", "typescript"],
|
||||
},
|
||||
filename: path,
|
||||
plugins,
|
||||
})!;
|
||||
|
||||
return {
|
||||
contents: code!,
|
||||
loader: extname(path).slice(1) as "js" | "ts",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const tailwindPlugin = (
|
||||
styleMap: Map<string, string>,
|
||||
{ tailwindConfig, postCSSPlugins = [] }: TailwindPluginOptions
|
||||
): Plugin => ({
|
||||
name: "tailwind",
|
||||
|
||||
setup(build) {
|
||||
const post = postcss([
|
||||
tailwind({
|
||||
...tailwindConfig,
|
||||
content: [{ raw: "<br>", extension: "html" }],
|
||||
}),
|
||||
...postCSSPlugins,
|
||||
]);
|
||||
|
||||
build.onResolve({ filter: /^tailwind:.+\.css$/ }, ({ path, importer }) => {
|
||||
const resolved = join(dirname(importer), path.replace(/^tailwind:/, ""));
|
||||
if (styleMap.has(resolved)) {
|
||||
return {
|
||||
path: resolved,
|
||||
namespace: ESBUILD_NAMESPACE,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: ESBUILD_NAMESPACE }, async ({ path }) => {
|
||||
if (!styleMap.has(path)) return;
|
||||
const result = await post.process(styleMap.get(path)!, { from: undefined });
|
||||
|
||||
return {
|
||||
contents: result.css,
|
||||
loader: "css",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Main entry. Returns the esbuild and babel plugins for tailwind.
|
||||
* @example
|
||||
* import { build } from "esbuild";
|
||||
* import getTailwindPlugins, { babelPlugin } from "babel-tailwind";
|
||||
*
|
||||
* const tailwind = getTailwindPlugins(options);
|
||||
* const result = await build({
|
||||
* plugins: [
|
||||
* babelPlugin({ getPlugins: () => [tailwind.babel] }),
|
||||
* tailwind.esbuild,
|
||||
* ],
|
||||
* });
|
||||
*/
|
||||
export function getTailwindPlugins(options: TailwindPluginOptions) {
|
||||
const styleMap = new Map<string, string>();
|
||||
return {
|
||||
babel: babelTailwind(styleMap, options),
|
||||
esbuild: tailwindPlugin(styleMap, options),
|
||||
};
|
||||
}
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"allowArbitraryExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"stripInternal": true,
|
||||
"target": "esnext",
|
||||
"useUnknownInCatchVariables": false
|
||||
}
|
||||
}
|
15
tsup.config.ts
Normal file
15
tsup.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
outDir: "dist",
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
clean: true,
|
||||
esbuildOptions(options) {
|
||||
options.banner = {
|
||||
...options.banner,
|
||||
js: "/* eslint-disable */",
|
||||
};
|
||||
},
|
||||
});
|
5
vitest.config.mts
Normal file
5
vitest.config.mts
Normal file
@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user