mirror of
https://github.com/modernweb-dev/rocket.git
synced 2026-03-21 08:51:18 +00:00
Compare commits
4 Commits
check-html
...
@mdjs/mdjs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d6aad12a | ||
|
|
ee6b404aaa | ||
|
|
8ba8939c67 | ||
|
|
8e095b792e |
@@ -1,5 +1,11 @@
|
||||
# @rocket/cli
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8e095b7: Watching `_assets`, `_data`, `_includes` for changes to trigger updated automatically
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rocket/cli",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,35 @@ export class RocketEleventy extends Eleventy {
|
||||
await super.write();
|
||||
await this.__rocketCli.update();
|
||||
}
|
||||
|
||||
// forks it so we can watch for changes but don't include them while building
|
||||
getChokidarConfig() {
|
||||
let ignores = this.eleventyFiles.getGlobWatcherIgnores();
|
||||
|
||||
const keepWatching = [
|
||||
path.join(this.__rocketCli.config._inputDirCwdRelative, '_assets', '**'),
|
||||
path.join(this.__rocketCli.config._inputDirCwdRelative, '_data', '**'),
|
||||
path.join(this.__rocketCli.config._inputDirCwdRelative, '_includes', '**'),
|
||||
];
|
||||
|
||||
ignores = ignores.filter(ignore => !keepWatching.includes(ignore));
|
||||
// debug("Ignoring watcher changes to: %o", ignores);
|
||||
|
||||
let configOptions = this.config.chokidarConfig;
|
||||
|
||||
// can’t override these yet
|
||||
// TODO maybe if array, merge the array?
|
||||
delete configOptions.ignored;
|
||||
|
||||
return Object.assign(
|
||||
{
|
||||
ignored: ignores,
|
||||
ignoreInitial: true,
|
||||
// also interesting: awaitWriteFinish
|
||||
},
|
||||
configOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RocketCli {
|
||||
|
||||
@@ -55,6 +55,10 @@ export async function readOutput(
|
||||
type = 'build',
|
||||
} = {},
|
||||
) {
|
||||
if (!cli || !cli.config) {
|
||||
throw new Error(`No valid cli provided to readOutput - you passed a ${typeof cli}: ${cli}`);
|
||||
}
|
||||
|
||||
const outputDir = type === 'build' ? cli.config.outputDir : cli.config.outputDevDir;
|
||||
let text = await fs.promises.readFile(path.join(outputDir, fileName));
|
||||
text = text.toString();
|
||||
@@ -116,6 +120,7 @@ export async function execute(cli, configFileDir) {
|
||||
await cli.setup();
|
||||
cli.config.outputDevDir = path.join(configFileDir, '__output-dev');
|
||||
cli.config.devServer.open = false;
|
||||
cli.config.devServer.port = 8080;
|
||||
cli.config.watch = false;
|
||||
cli.config.outputDir = path.join(configFileDir, '__output');
|
||||
await cli.run();
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
executeBuild,
|
||||
executeStart,
|
||||
readBuildOutput,
|
||||
readOutput,
|
||||
readStartOutput,
|
||||
setFixtureDir,
|
||||
} from '@rocket/cli/test-helpers';
|
||||
@@ -29,37 +28,27 @@ describe('RocketCli computedConfig', () => {
|
||||
it('will extract a title from markdown and set first folder as section', async () => {
|
||||
cli = await executeStart('computed-config-fixtures/headlines/rocket.config.js');
|
||||
|
||||
const indexHtml = await readOutput(cli, 'index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
const [indexTitle, indexSection] = indexHtml.split('\n');
|
||||
expect(indexTitle).to.equal('Root');
|
||||
expect(indexSection).to.be.undefined;
|
||||
|
||||
const subHtml = await readOutput(cli, 'sub/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const subHtml = await readStartOutput(cli, 'sub/index.html');
|
||||
const [subTitle, subSection] = subHtml.split('\n');
|
||||
expect(subTitle).to.equal('Root: Sub');
|
||||
expect(subSection).to.equal('sub');
|
||||
|
||||
const subSubHtml = await readOutput(cli, 'sub/subsub/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const subSubHtml = await readStartOutput(cli, 'sub/subsub/index.html');
|
||||
const [subSubTitle, subSubSection] = subSubHtml.split('\n');
|
||||
expect(subSubTitle).to.equal('Sub: SubSub');
|
||||
expect(subSubSection).to.equal('sub');
|
||||
|
||||
const sub2Html = await readOutput(cli, 'sub2/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const sub2Html = await readStartOutput(cli, 'sub2/index.html');
|
||||
const [sub2Title, sub2Section] = sub2Html.split('\n');
|
||||
expect(sub2Title).to.equal('Root: Sub2');
|
||||
expect(sub2Section).to.equal('sub2');
|
||||
|
||||
const withDataHtml = await readOutput(cli, 'with-data/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const withDataHtml = await readStartOutput(cli, 'with-data/index.html');
|
||||
const [withDataTitle, withDataSection] = withDataHtml.split('\n');
|
||||
expect(withDataTitle).to.equal('Set via data');
|
||||
expect(withDataSection).be.undefined;
|
||||
@@ -199,9 +188,7 @@ describe('RocketCli computedConfig', () => {
|
||||
it('can be configured via setupEleventyComputedConfig', async () => {
|
||||
cli = await executeStart('computed-config-fixtures/setup/addPlugin.rocket.config.js');
|
||||
|
||||
const indexHtml = await readOutput(cli, 'index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
expect(indexHtml).to.equal('test-value');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,86 +1,25 @@
|
||||
import chai from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import { RocketCli } from '../src/RocketCli.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
import {
|
||||
executeBuild,
|
||||
executeLint,
|
||||
executeStart,
|
||||
expectThrowsAsync,
|
||||
readBuildOutput,
|
||||
readStartOutput,
|
||||
setFixtureDir,
|
||||
} from '@rocket/cli/test-helpers';
|
||||
|
||||
const { expect } = chai;
|
||||
|
||||
/**
|
||||
* @param {function} method
|
||||
* @param {string} errorMessage
|
||||
*/
|
||||
async function expectThrowsAsync(method, { errorMatch, errorMessage } = {}) {
|
||||
let error = null;
|
||||
try {
|
||||
await method();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(error).to.be.an('Error', 'No error was thrown');
|
||||
if (errorMatch) {
|
||||
expect(error.message).to.match(errorMatch);
|
||||
}
|
||||
if (errorMessage) {
|
||||
expect(error.message).to.equal(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RocketCli e2e', () => {
|
||||
let cli;
|
||||
|
||||
async function readOutput(
|
||||
fileName,
|
||||
{
|
||||
stripServiceWorker = false,
|
||||
stripToBody = false,
|
||||
stripStartEndWhitespace = true,
|
||||
type = 'build',
|
||||
} = {},
|
||||
) {
|
||||
const outputDir = type === 'build' ? cli.config.outputDir : cli.config.outputDevDir;
|
||||
let text = await fs.promises.readFile(path.join(outputDir, fileName));
|
||||
text = text.toString();
|
||||
if (stripToBody) {
|
||||
const bodyOpenTagEnd = text.indexOf('>', text.indexOf('<body') + 1) + 1;
|
||||
const bodyCloseTagStart = text.indexOf('</body>');
|
||||
text = text.substring(bodyOpenTagEnd, bodyCloseTagStart);
|
||||
}
|
||||
if (stripServiceWorker) {
|
||||
const scriptOpenTagEnd = text.indexOf('<script inject-service-worker');
|
||||
const scriptCloseTagStart = text.indexOf('</script>', scriptOpenTagEnd) + 9;
|
||||
text = text.substring(0, scriptOpenTagEnd) + text.substring(scriptCloseTagStart);
|
||||
}
|
||||
if (stripStartEndWhitespace) {
|
||||
text = text.trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
await cli.setup();
|
||||
cli.config.outputDevDir = path.join(__dirname, 'e2e-fixtures', '__output-dev');
|
||||
cli.config.devServer.open = false;
|
||||
cli.config.devServer.port = 8080;
|
||||
cli.config.watch = false;
|
||||
cli.config.outputDir = path.join(__dirname, 'e2e-fixtures', '__output');
|
||||
await cli.run();
|
||||
}
|
||||
|
||||
async function executeLint(pathToConfig) {
|
||||
cli = new RocketCli({
|
||||
argv: ['lint', '--config-file', path.join(__dirname, pathToConfig.split('/').join(path.sep))],
|
||||
});
|
||||
await execute();
|
||||
}
|
||||
|
||||
before(() => {
|
||||
// ignore colors in tests as most CIs won't support it
|
||||
chalk.level = 0;
|
||||
setFixtureDir(import.meta.url);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -90,79 +29,40 @@ describe('RocketCli e2e', () => {
|
||||
});
|
||||
|
||||
it('can add a unified plugin via the config', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'build',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'unified-plugin', 'rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
const indexHtml = await readOutput('index.html', {
|
||||
stripServiceWorker: true,
|
||||
stripToBody: true,
|
||||
});
|
||||
|
||||
cli = await executeStart('e2e-fixtures/unified-plugin/rocket.config.js');
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
expect(indexHtml).to.equal('<p>See a 🐶</p>');
|
||||
});
|
||||
|
||||
describe('eleventy in config', () => {
|
||||
// TODO: find out while this has a side effect and breaks other tests
|
||||
it.skip('can modify eleventy via an elventy function in the config', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'content', 'eleventy.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
const indexHtml = await readOutput('index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
cli = await executeStart('e2e-fixtures/content/eleventy.rocket.config.js');
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
expect(indexHtml).to.equal(
|
||||
['# BEFORE #', '<p>Content inside <code>docs/index.md</code></p>'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('will throw if you try to set options by returning an object', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'content', 'eleventy-return.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
|
||||
await expectThrowsAsync(() => execute(), {
|
||||
errorMatch: /Error in your Eleventy config file.*/,
|
||||
});
|
||||
await expectThrowsAsync(
|
||||
() => executeStart('e2e-fixtures/content/eleventy-return.rocket.config.js'),
|
||||
{
|
||||
errorMatch: /Error in your Eleventy config file.*/,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupDevAndBuildPlugins in config', () => {
|
||||
it('can add a rollup plugin via setupDevAndBuildPlugins for build command', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'build',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'rollup-plugin', 'devbuild.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
const inlineModule = await readOutput('e97af63d.js');
|
||||
cli = await executeBuild('e2e-fixtures/rollup-plugin/devbuild.rocket.config.js');
|
||||
const inlineModule = await readBuildOutput(cli, 'e97af63d.js');
|
||||
expect(inlineModule).to.equal('var a={test:"data"};console.log(a);');
|
||||
});
|
||||
|
||||
it('can add a rollup plugin via setupDevAndBuildPlugins for start command', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'rollup-plugin', 'devbuild.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
cli = await executeStart('e2e-fixtures/rollup-plugin/devbuild.rocket.config.js');
|
||||
|
||||
const response = await fetch('http://localhost:8080/test-data.json');
|
||||
expect(response.ok).to.be.true; // no server error
|
||||
@@ -173,88 +73,45 @@ describe('RocketCli e2e', () => {
|
||||
});
|
||||
|
||||
it('can add a rollup plugin for dev & build and modify a build only plugin via the config', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'build',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'rollup-plugin', 'devbuild-build.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
const inlineModule = await readOutput('e97af63d.js');
|
||||
cli = await executeBuild('e2e-fixtures/rollup-plugin/devbuild-build.rocket.config.js');
|
||||
const inlineModule = await readBuildOutput(cli, 'e97af63d.js');
|
||||
expect(inlineModule).to.equal('var a={test:"data"};console.log(a);');
|
||||
|
||||
const swCode = await readOutput('my-service-worker.js');
|
||||
const swCode = await readBuildOutput(cli, 'my-service-worker.js');
|
||||
expect(swCode).to.not.be.undefined;
|
||||
});
|
||||
|
||||
it('can adjust the inputDir', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'change-input-dir', 'rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
cli = await executeStart('e2e-fixtures/change-input-dir/rocket.config.js');
|
||||
|
||||
const indexHtml = await readOutput('index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
expect(indexHtml).to.equal('<p>Markdown in <code>docs/page/index.md</code></p>');
|
||||
});
|
||||
|
||||
it('can access main rocket config values via {{rocketConfig.value}}', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'rocket-config-in-template', 'rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
cli = await executeStart('e2e-fixtures/rocket-config-in-template/rocket.config.js');
|
||||
|
||||
const indexHtml = await readOutput('index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const indexHtml = await readStartOutput(cli, 'index.html');
|
||||
expect(indexHtml).to.equal(
|
||||
'<p>You can show Rocket config data like rocketConfig.absoluteBaseUrl = <a href="http://test-domain.com/">http://test-domain.com/</a></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('can add a pathPrefix that will not influence the start command', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'start',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'content', 'pathPrefix.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
cli = await executeStart('e2e-fixtures/content/pathPrefix.rocket.config.js');
|
||||
|
||||
const linkHtml = await readOutput('link/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const linkHtml = await readStartOutput(cli, 'link/index.html');
|
||||
expect(linkHtml).to.equal(
|
||||
['<p><a href="../">home</a></p>', '<p><a href="/">absolute home</a></p>'].join('\n'),
|
||||
);
|
||||
const assetHtml = await readOutput('use-assets/index.html', {
|
||||
type: 'start',
|
||||
});
|
||||
const assetHtml = await readStartOutput(cli, 'use-assets/index.html');
|
||||
expect(assetHtml).to.equal('<link rel="stylesheet" href="/_merged_assets/some.css">');
|
||||
});
|
||||
|
||||
it('can add a pathPrefix that will be used in the build command', async () => {
|
||||
cli = new RocketCli({
|
||||
argv: [
|
||||
'build',
|
||||
'--config-file',
|
||||
path.join(__dirname, 'e2e-fixtures', 'content', 'pathPrefix.rocket.config.js'),
|
||||
],
|
||||
});
|
||||
await execute();
|
||||
cli = await executeBuild('e2e-fixtures/content/pathPrefix.rocket.config.js');
|
||||
|
||||
const linkHtml = await readOutput('link/index.html', {
|
||||
const linkHtml = await readBuildOutput(cli, 'link/index.html', {
|
||||
stripServiceWorker: true,
|
||||
stripToBody: true,
|
||||
});
|
||||
@@ -263,7 +120,7 @@ describe('RocketCli e2e', () => {
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const assetHtml = await readOutput('use-assets/index.html', {
|
||||
const assetHtml = await readBuildOutput(cli, 'use-assets/index.html', {
|
||||
stripServiceWorker: true,
|
||||
});
|
||||
expect(assetHtml).to.equal(
|
||||
|
||||
@@ -6,7 +6,7 @@ import json from '@rollup/plugin-json';
|
||||
import { addPlugin, adjustPluginOptions } from 'plugins-manager';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outputDir = path.join(__dirname, '..', '__output');
|
||||
const outputDir = path.join(__dirname, '__output');
|
||||
|
||||
/** @type {Partial<import("../../../types/main").RocketCliOptions>} */
|
||||
const config = {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# @mdjs/mdjs-preview
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ee6b404: Pass on the shadowRoot to the story function
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 15e0abe: Clean up dependencies - add Types
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mdjs/mdjs-preview",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { LitElement, html, css } from 'lit-element';
|
||||
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} StoryOptions
|
||||
* @property {ShadowRoot | null} StoryOptions.shadowRoot
|
||||
*/
|
||||
|
||||
/** @typedef {(options?: StoryOptions) => ReturnType<LitElement['render']>} LitHtmlStoryFn */
|
||||
|
||||
/**
|
||||
* Renders a story within a preview frame
|
||||
*
|
||||
* @element mdjs-preview
|
||||
* @prop {StoryFn} [story=(() => TemplateResult)] Function that returns the story
|
||||
*/
|
||||
export class MdJsPreview extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -28,6 +41,7 @@ export class MdJsPreview extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.code = '';
|
||||
/** @type {LitHtmlStoryFn} */
|
||||
this.story = () => html` <p>Loading...</p> `;
|
||||
this.codeHasHtml = false;
|
||||
}
|
||||
@@ -35,7 +49,7 @@ export class MdJsPreview extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div id="wrapper">
|
||||
<div>${this.story()}</div>
|
||||
<div>${this.story({ shadowRoot: this.shadowRoot })}</div>
|
||||
<button id="showCodeButton" @click=${this.toggleShowCode}>show code</button>
|
||||
</div>
|
||||
${this.codeHasHtml ? unsafeHTML(this.code) : html`<pre><code>${this.code}</code></pre>`}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# @mdjs/mdjs-story
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ee6b404: Pass on the shadowRoot to the story function
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 15e0abe: Clean up dependencies - add Types
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mdjs/mdjs-story",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { LitElement, html } from 'lit-element';
|
||||
|
||||
/**
|
||||
* @typedef {object} StoryOptions
|
||||
* @property {ShadowRoot | null} StoryOptions.shadowRoot
|
||||
*/
|
||||
|
||||
/** @typedef {(options?: StoryOptions) => ReturnType<LitElement['render']>} LitHtmlStoryFn */
|
||||
|
||||
/**
|
||||
* Renders a story
|
||||
*
|
||||
* @element mdjs-story
|
||||
* @prop {StoryFn} [story=(() => TemplateResult)] Function that returns the story
|
||||
*/
|
||||
export class MdJsStory extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -11,10 +24,11 @@ export class MdJsStory extends LitElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.story = () => html` <p>Loading...</p> `;
|
||||
/** @type {LitHtmlStoryFn} */
|
||||
this.story = () => html`<p>Loading...</p>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.story();
|
||||
return this.story({ shadowRoot: this.shadowRoot });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user