feat(cli): introduce "rocket lint"

This commit is contained in:
Thomas Allmer
2022-08-21 18:35:27 +02:00
parent 0ed3d6d0e9
commit a48dcd849b
12 changed files with 385 additions and 253 deletions

View File

@@ -0,0 +1,15 @@
---
'@rocket/cli': patch
---
Introducing `rocket lint` to verify if all your links are correct.
There are two modes:
```bash
# check existing production build in _site (need to execute "rocket build" before)
rocket lint
# run a fast html only build and then check it
rocket lint --build-html
```

View File

@@ -54,7 +54,7 @@
"dependencies": {
"@rocket/building-rollup": "^0.4.0",
"@rocket/engine": "^0.2.6",
"@web/rollup-plugin-copy": "^0.3.0",
"check-html-links": "^0.2.3",
"colorette": "^2.0.16",
"commander": "^9.0.0",
"fs-extra": "^9.0.1",

View File

@@ -1,78 +1,18 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { Engine } from '@rocket/engine/server';
import { gatherFiles } from '@rocket/engine';
import { fromRollup } from '@web/dev-server-rollup';
import { rollup } from 'rollup';
import path from 'path';
import { rollupPluginHTML } from '@web/rollup-plugin-html';
import { createMpaConfig, createServiceWorkerConfig } from '@rocket/building-rollup';
import { adjustPluginOptions } from 'plugins-manager';
import { existsSync } from 'fs';
import { readFile, unlink, writeFile } from 'fs/promises';
import { readFile, writeFile } from 'fs/promises';
import puppeteer from 'puppeteer';
/**
* @param {object} config
*/
async function buildAndWrite(config) {
const bundle = await rollup(config);
if (Array.isArray(config.output)) {
await bundle.write(config.output[0]);
await bundle.write(config.output[1]);
} else {
await bundle.write(config.output);
}
}
async function productionBuild(config) {
const defaultSetupPlugins = [];
if (config.pathPrefix) {
defaultSetupPlugins.push(
adjustPluginOptions(rollupPluginHTML, { absolutePathPrefix: config.pathPrefix }),
);
}
const mpaConfig = createMpaConfig({
input: '**/*.html',
output: {
dir: config.outputDir,
},
// custom
rootDir: path.resolve(config.outputDevDir),
absoluteBaseUrl: config.absoluteBaseUrl,
setupPlugins: [
...defaultSetupPlugins,
...config.setupDevServerAndBuildPlugins,
...config.setupBuildPlugins,
],
});
const finalConfig =
typeof config.adjustBuildOptions === 'function'
? config.adjustBuildOptions(mpaConfig)
: mpaConfig;
await buildAndWrite(finalConfig);
const { serviceWorkerSourcePath } = config;
if (existsSync(serviceWorkerSourcePath)) {
const serviceWorkerConfig = createServiceWorkerConfig({
input: serviceWorkerSourcePath,
output: {
file: path.join(path.resolve(config.outputDir), config.serviceWorkerName),
},
});
await buildAndWrite(serviceWorkerConfig);
}
}
import { buildHtml } from './build/buildHtml.js';
import { buildOpenGraphImages } from './build/buildOpenGraphImages.js';
import { buildJavaScriptOptimizedOutput } from './build/buildJavaScriptOptimizedOutput.js';
export class RocketBuild {
/**
* @param {import('commander').Command} program
* @param {import('./RocketCli.js').RocketCli} cli
*/
async setupCommand(program, cli) {
this.cli = cli;
@@ -87,32 +27,30 @@ export class RocketBuild {
}
async build() {
await this.cli.events.dispatchEventDone('build-start');
await this.cli.clearOutputDir();
await this.cli.clearOutputDevDir();
this.engine = new Engine();
this.engine.setOptions({
docsDir: this.cli.options.inputDir,
outputDir: this.cli.options.outputDevDir,
setupPlugins: this.cli.options.setupEnginePlugins,
longFileHeaderWidth: this.cli.options.longFileHeaderWidth,
longFileHeaderComment: this.cli.options.longFileHeaderComment,
renderMode: 'production',
clearOutputDir: this.cli.options.clearOutputDir,
});
console.log('Engine building...');
await this.engine.build({ autoStop: this.cli.options.buildAutoStop });
if (this.cli.options.buildOpenGraphImages) {
console.log('Generating Open Graph Images...');
await this.buildOpenGraphImages();
if (!this.cli) {
return;
}
// for typescript as `this.cli.options.outputDir` supports other inputs as well
// but the cli will normalize it to a string before calling plugins
if (typeof this.cli.options.outputDir !== 'string') {
return;
}
if (this.cli.options.buildOptimize) {
await this.cli.events.dispatchEventDone('build-start');
// 1. build html
this.engine = await buildHtml(this.cli);
// 2. build open graph images
if (this.cli.options.buildOpenGraphImages) {
console.log('Generating Open Graph Images...');
await buildOpenGraphImages(this.cli);
}
// 3. build optimized output
if (this.cli.options.buildOptimize && this.engine) {
console.log('Optimize Production Build...');
await productionBuild(this.cli.options);
await this.engine.copyPublicFilesTo(this.cli.options.outputDir);
await buildJavaScriptOptimizedOutput(this.cli, this.engine);
}
// hackfix 404.html by making all asset urls absolute (rollup always makes them relative) which will break if netlify serves the content form a different url
@@ -130,87 +68,4 @@ export class RocketBuild {
await this.cli.events.dispatchEventDone('build-end');
}
async buildOpenGraphImages() {
const openGraphFiles = await gatherFiles(this.cli.options.outputDevDir, {
fileEndings: ['.opengraph.html'],
});
if (openGraphFiles.length === 0) {
return;
}
// TODO: enable URL support in the Engine and remove this "workaround"
if (
typeof this.cli.options.inputDir !== 'string' ||
typeof this.cli.options.outputDevDir !== 'string'
) {
return;
}
const withWrap = this.cli.options.setupDevServerAndBuildPlugins
? this.cli.options.setupDevServerAndBuildPlugins.map(modFunction => {
modFunction.wrapPlugin = fromRollup;
return modFunction;
})
: [];
this.engine = new Engine();
this.engine.setOptions({
docsDir: this.cli.options.inputDir,
outputDir: this.cli.options.outputDevDir,
setupPlugins: this.cli.options.setupEnginePlugins,
open: false,
clearOutputDir: false,
adjustDevServerOptions: this.cli.options.adjustDevServerOptions,
setupDevServerMiddleware: this.cli.options.setupDevServerMiddleware,
setupDevServerPlugins: [...this.cli.options.setupDevServerPlugins, ...withWrap],
});
try {
await this.engine.start();
const browser = await puppeteer.launch();
const page = await browser.newPage();
// In 2022 Twitter & Facebook recommend a size of 1200x628 - we capture with 2 dpr for retina displays
await page.setViewport({
width: 1200,
height: 628,
deviceScaleFactor: 2,
});
for (const openGraphFile of openGraphFiles) {
const relUrl = path.relative(this.cli.options.outputDevDir, openGraphFile);
const imagePath = openGraphFile.replace('.opengraph.html', '.opengraph.png');
const htmlPath = openGraphFile.replace('.opengraph.html', '.html');
const relImageUrl = path.basename(imagePath);
let htmlString = await readFile(htmlPath, 'utf8');
if (!htmlString.includes('<meta property="og:image"')) {
if (htmlString.includes('</head>')) {
htmlString = htmlString.replace(
'</head>',
[
' <meta property="og:image:width" content="2400">',
' <meta property="og:image:height" content="1256">',
` <meta property="og:image" content="./${relImageUrl}">`,
' </head>',
].join('\n'),
);
}
}
const url = `http://localhost:${this.engine.devServer.config.port}/${relUrl}`;
await page.goto(url, { waitUntil: 'networkidle0' });
await page.screenshot({ path: imagePath });
await unlink(openGraphFile);
await writeFile(htmlPath, htmlString);
}
await browser.close();
await this.engine.stop();
} catch (e) {
console.log('Could not start dev server to generate open graph images');
console.error(e);
}
}
}

View File

@@ -2,6 +2,7 @@
import { Command } from 'commander';
import { RocketStart } from './RocketStart.js';
import { RocketBuild } from './RocketBuild.js';
import { RocketLint } from './RocketLint.js';
import { RocketUpgrade } from './RocketUpgrade.js';
import { RocketPreview } from './RocketPreview.js';
// import { ignore } from './images/ignore.js';
@@ -53,6 +54,10 @@ export class RocketCli {
absoluteBaseUrl: '',
clearOutputDir: true,
lint: {
buildHtml: false,
},
// /** @type {{[key: string]: ImagePreset}} */
// imagePresets: {
// responsive: {
@@ -179,7 +184,7 @@ export class RocketCli {
let pluginsMeta = [
{ plugin: RocketStart, options: {} },
{ plugin: RocketBuild, options: {} },
// { plugin: RocketLint },
{ plugin: RocketLint, options: {} },
{ plugin: RocketUpgrade, options: {} },
{ plugin: RocketPreview, options: {} },
];

View File

@@ -1,81 +1,85 @@
// /* eslint-disable */
// // @ts-nocheck
/* eslint-disable @typescript-eslint/ban-ts-comment */
// /** @typedef {import('../types/main').RocketCliOptions} RocketCliOptions */
// @ts-ignore
import { CheckHtmlLinksCli } from 'check-html-links';
import { bold, gray } from 'colorette';
import { existsSync } from 'fs';
import path from 'path';
import { buildHtml } from './build/buildHtml.js';
// import { CheckHtmlLinksCli } from 'check-html-links';
export class RocketLint {
options = {
buildHtml: false,
};
// export class RocketLint {
// static pluginName = 'RocketLint';
// commands = ['start', 'build', 'lint'];
/**
* @param {import('commander').Command} program
* @param {import('./RocketCli.js').RocketCli} cli
*/
async setupCommand(program, cli) {
this.cli = cli;
this.active = true;
// /**
// * @param {RocketCliOptions} config
// */
// setupCommand(config) {
// if (config.command === 'lint') {
// config.watch = false;
// }
// return config;
// }
program
.command('lint')
.option('-i, --input-dir <path>', 'path to where to search for source files')
.option('-b, --build-html', 'do a quick html only build and then check links')
.action(async options => {
const { cliOptions, ...lintOptions } = options;
cli.setOptions({
...cliOptions,
lint: lintOptions,
});
this.options = { ...this.options, ...cli.options.lint };
cli.activePlugin = this;
// /**
// * @param {object} options
// * @param {RocketCliOptions} options.config
// * @param {any} options.argv
// */
// async setup({ config, argv, eleventy }) {
// this.__argv = argv;
// this.config = {
// lintInputDir: config.outputDevDir,
// lintExecutesEleventyBefore: true,
// ...config,
// };
// this.eleventy = eleventy;
// }
await this.lint();
});
}
// async lintCommand() {
// if (this.config.lintExecutesEleventyBefore) {
// await this.eleventy.write();
// // updated will trigger linting
// } else {
// await this.__lint();
// }
// }
async lint() {
if (!this.cli) {
return;
}
// async __lint() {
// if (this.config?.pathPrefix) {
// console.log('INFO: RocketLint currently does not support being used with a pathPrefix');
// return;
// }
// for typescript as `this.cli.options.outputDir` supports other inputs as well
// but the cli will normalize it to a string before calling plugins
if (
typeof this.cli.options.outputDevDir !== 'string' ||
typeof this.cli.options.outputDir !== 'string'
) {
return;
}
// const checkLinks = new CheckHtmlLinksCli();
// checkLinks.setOptions({
// ...this.config.checkLinks,
// rootDir: this.config.lintInputDir,
// printOnError: false,
// continueOnError: true,
// });
if (this.options.buildHtml) {
await buildHtml(this.cli);
}
// const { errors, message } = await checkLinks.run();
// if (errors.length > 0) {
// if (this.config.command === 'start') {
// console.log(message);
// } else {
// throw new Error(message);
// }
// }
// }
const folderToCheck = this.options.buildHtml
? this.cli.options.outputDevDir
: this.cli.options.outputDir;
// async postCommand() {
// if (this.config.watch === false) {
// await this.__lint();
// }
// }
const rootIndexHtml = path.join(folderToCheck, 'index.html');
if (!existsSync(rootIndexHtml)) {
console.log(`${bold(`👀 Linting Production Build`)}`);
console.log('');
console.log(` 🛑 No index.html found in the build directory ${gray(`${rootIndexHtml}`)}`);
console.log(' 🤔 Did you forget to run `rocket build` before?');
console.log('');
return;
}
// async updated() {
// if (this.config.watch === true) {
// await this.__lint();
// }
// }
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { buildHtml: _drop, ...userCheckHtmlLinksOptions } = this.options;
const checkLinks = new CheckHtmlLinksCli();
checkLinks.setOptions({
rootDir: folderToCheck,
printOnError: true,
continueOnError: false,
...userCheckHtmlLinksOptions,
});
await checkLinks.run();
}
}

View File

@@ -0,0 +1,28 @@
import { Engine } from '@rocket/engine/server';
/**
* @param {import('../RocketCli.js').RocketCli} cli
* @returns
*/
export async function buildHtml(cli) {
// TODO: enable URL support in the Engine and remove this typescript "workaround"
if (typeof cli.options.inputDir !== 'string' || typeof cli.options.outputDevDir !== 'string') {
return;
}
await cli.clearOutputDevDir();
const engine = new Engine();
engine.setOptions({
docsDir: cli.options.inputDir,
outputDir: cli.options.outputDevDir,
setupPlugins: cli.options.setupEnginePlugins,
longFileHeaderWidth: cli.options.longFileHeaderWidth,
longFileHeaderComment: cli.options.longFileHeaderComment,
renderMode: 'production',
clearOutputDir: cli.options.clearOutputDir,
});
console.log('Engine building...');
await engine.build({ autoStop: cli.options.buildAutoStop });
return engine;
}

View File

@@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import path from 'path';
import { existsSync } from 'fs';
import { rollup } from 'rollup';
// @ts-ignore
import { createMpaConfig, createServiceWorkerConfig } from '@rocket/building-rollup';
// import { rollupPluginHTML } from '@web/rollup-plugin-html';
// import { adjustPluginOptions } from 'plugins-manager';
/**
* @param {import('rollup').RollupOptions} config
*/
async function buildAndWrite(config) {
if (!config.output) {
return;
}
const bundle = await rollup(config);
if (Array.isArray(config.output)) {
await bundle.write(config.output[0]);
await bundle.write(config.output[1]);
} else {
await bundle.write(config.output);
}
}
/**
* @param {import('../RocketCli.js').RocketCli} cli
* @param {import('@rocket/engine/server').Engine} engine
* @returns
*/
export async function buildJavaScriptOptimizedOutput(cli, engine) {
const config = cli.options;
// for typescript as `this.cli.options.outputDir` supports other inputs as well
// but the cli will normalize it to a string before calling plugins
if (typeof config.outputDir !== 'string' || typeof config.outputDevDir !== 'string') {
return;
}
await cli.clearOutputDir();
// TODO: pathPrefix is currently not supported
// const defaultSetupPlugins = [];
// if (config.pathPrefix) {
// defaultSetupPlugins.push(
// adjustPluginOptions(rollupPluginHTML, { absolutePathPrefix: config.pathPrefix }),
// );
// }
const mpaConfig = createMpaConfig({
input: '**/*.html',
output: {
dir: config.outputDir,
},
// custom
rootDir: path.resolve(config.outputDevDir),
absoluteBaseUrl: config.absoluteBaseUrl,
setupPlugins: [
// ...defaultSetupPlugins,
...config.setupDevServerAndBuildPlugins,
...config.setupBuildPlugins,
],
});
const finalConfig =
typeof config.adjustBuildOptions === 'function'
? config.adjustBuildOptions(mpaConfig)
: mpaConfig;
await buildAndWrite(finalConfig);
const { serviceWorkerSourcePath } = config;
if (existsSync(serviceWorkerSourcePath)) {
const serviceWorkerConfig = createServiceWorkerConfig({
input: serviceWorkerSourcePath,
output: {
file: path.join(path.resolve(config.outputDir), config.serviceWorkerName),
},
});
await buildAndWrite(serviceWorkerConfig);
}
// copy static files over
await engine.copyPublicFilesTo(config.outputDir);
}

View File

@@ -0,0 +1,95 @@
import { gatherFiles } from '@rocket/engine';
import { Engine } from '@rocket/engine/server';
import { fromRollup } from '@web/dev-server-rollup';
import { readFile, unlink, writeFile } from 'fs/promises';
import puppeteer from 'puppeteer';
import path from 'path';
/**
* @param {import('../RocketCli.js').RocketCli} cli
* @returns
*/
export async function buildOpenGraphImages(cli) {
const openGraphFiles = await gatherFiles(cli.options.outputDevDir, {
fileEndings: ['.opengraph.html'],
});
if (openGraphFiles.length === 0) {
return;
}
// TODO: enable URL support in the Engine and remove this typescript "workaround"
if (typeof cli.options.inputDir !== 'string' || typeof cli.options.outputDevDir !== 'string') {
return;
}
const withWrap = cli.options.setupDevServerAndBuildPlugins
? cli.options.setupDevServerAndBuildPlugins.map(modFunction => {
modFunction.wrapPlugin = fromRollup;
return modFunction;
})
: [];
const engine = new Engine();
engine.setOptions({
docsDir: cli.options.inputDir,
outputDir: cli.options.outputDevDir,
setupPlugins: cli.options.setupEnginePlugins,
open: false,
clearOutputDir: false,
adjustDevServerOptions: cli.options.adjustDevServerOptions,
setupDevServerMiddleware: cli.options.setupDevServerMiddleware,
setupDevServerPlugins: [...cli.options.setupDevServerPlugins, ...withWrap],
});
try {
await engine.start();
if (!engine?.devServer?.config.port) {
return;
}
const browser = await puppeteer.launch();
const page = await browser.newPage();
// In 2022 Twitter & Facebook recommend a size of 1200x628 - we capture with 2 dpr for retina displays
await page.setViewport({
width: 1200,
height: 628,
deviceScaleFactor: 2,
});
for (const openGraphFile of openGraphFiles) {
const relUrl = path.relative(cli.options.outputDevDir, openGraphFile);
const imagePath = openGraphFile.replace('.opengraph.html', '.opengraph.png');
const htmlPath = openGraphFile.replace('.opengraph.html', '.html');
const relImageUrl = path.basename(imagePath);
let htmlString = await readFile(htmlPath, 'utf8');
if (!htmlString.includes('<meta property="og:image"')) {
if (htmlString.includes('</head>')) {
htmlString = htmlString.replace(
'</head>',
[
' <meta property="og:image:width" content="2400">',
' <meta property="og:image:height" content="1256">',
` <meta property="og:image" content="./${relImageUrl}">`,
' </head>',
].join('\n'),
);
}
}
const url = `http://localhost:${engine.devServer.config.port}/${relUrl}`;
await page.goto(url, { waitUntil: 'networkidle0' });
await page.screenshot({ path: imagePath });
await unlink(openGraphFile);
await writeFile(htmlPath, htmlString);
}
await browser.close();
await engine.stop();
} catch (e) {
console.log('Could not start dev server to generate open graph images');
console.error(e);
}
}

View File

@@ -47,6 +47,11 @@ export interface FullRocketCliOptions extends Pick<FullRocketPreset, PresetKeys>
// rarely used
configFile: string;
outputDevDir: URL | string;
lint: {
buildHtml: boolean;
[key: string]: any;
};
}
export type RocketCliOptions = Partial<FullRocketCliOptions>;

View File

@@ -34,6 +34,40 @@ const { resolve } = createRequire(new URL('.', import.meta.url));
A few things are usually needed before going live "for real".
## Make sure all links are correct
When you launch a website you don't want the first feedback to be "that link doesn't work".
To prevent this we want to execute `rocket lint` before going live.
It will make sure all internal links are correct by using [check-html-links](../../30--tools/40--check-html-links/10--overview.rocket.md).
Typically we deploy via a Continuous Integration system like GitHub Actions or Netlify Deploy.
We can also integrate the lint command into that process.
```
rocket build
rocket lint
```
### Fixing broken links
If found a couple of broken links on your page and you want to fix them and verify that they are now correct it might be a little time consuming to create a full production build every time.
The reason is that a production build is doing a lot of things
1. Generate HTML
2. Generate & Inject Open Graph Images
3. Optimize Images (not available yet)
4. Optimize JavaScript
But there is a way around this. We can use an optional flag `--build-html` which means it will run only (1) and then lint that (non-optimized) HTML output.
So for a more time efficient way of validating link use
```bash
rocket lint --build-html
```
Note: We can do this as 2-4 generally does not impact links/references (as long as the optimizations scripts do not have related bugs)
## Add a Not Found Page
When a user enters a URL that does not exist, a "famous" 404 Page Not Found error occurs.

View File

@@ -1363,6 +1363,16 @@
"id": "go-live",
"level": 1
},
{
"text": "Make sure all links are correct",
"id": "make-sure-all-links-are-correct",
"level": 2
},
{
"text": "Fixing broken links",
"id": "fixing-broken-links",
"level": 3
},
{
"text": "Add a Not Found Page",
"id": "add-a-not-found-page",

View File

@@ -2084,13 +2084,6 @@
terser "^5.14.2"
whatwg-fetch "^3.5.0"
"@web/rollup-plugin-copy@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@web/rollup-plugin-copy/-/rollup-plugin-copy-0.3.0.tgz#fe999b2ea3dd71c8e663e6947fc2eb92a221e8e8"
integrity sha512-QNNtE7Svhk0/p21etaR0JQXYhlMgTAg/HmRXDMmQHMf3uOUWsWMGiJa96P49RRVJut1ECB5FDFeBUgFEmegysQ==
dependencies:
glob "^7.1.6"
"@web/rollup-plugin-html@^1.8.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@web/rollup-plugin-html/-/rollup-plugin-html-1.11.0.tgz#46c2bcb3a3b9db55d53b897ffc7e7ef2f618a052"