Improve logger (deduping, new help and version) (#2737)

* feat: improve logger by removing repeat messages

* feat(hmr): only send HMR updates when files change

* feat: improve hmr formatting

* feat(logger): improve welcome formatting

* feat(logger): improve hmr formatting

* chore(test): update cli test output

* feat(logger): improve logging output

* feat(logger): improve help/version flags

* chore: remove checksum checks

* fix(test): update cli tests

* refactor(test): cleanup astro dev cli tests

* chore: add changeset

* chore(test): skip doctype test
This commit is contained in:
Nate Moore 2022-03-09 17:37:59 -06:00 committed by GitHub
parent 8c0a8fee32
commit e8d4e56803
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 241 additions and 75 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Astro's logger has been redesigned for an improved experience! In addition to deduping identical messages, we've surfaced more error details and exposed new events like `update` (for in-place HMR) and `reload` (for full-reload HMR).

View file

@ -4,7 +4,6 @@ import type { AstroConfig } from '../@types/astro';
import { enableVerboseLogging, LogOptions } from '../core/logger.js';
import * as colors from 'kleur/colors';
import fs from 'fs';
import yargs from 'yargs-parser';
import { z } from 'zod';
import { defaultLogDestination } from '../core/logger.js';
@ -13,40 +12,76 @@ import devServer from '../core/dev/index.js';
import preview from '../core/preview/index.js';
import { check } from './check.js';
import { formatConfigError, loadConfig } from '../core/config.js';
import { pad } from '../core/dev/util.js';
type Arguments = yargs.Arguments;
type CLICommand = 'help' | 'version' | 'dev' | 'build' | 'preview' | 'reload' | 'check';
/** Display --help flag */
function printHelp() {
console.log(` ${colors.bold('astro')} - Futuristic web development tool.
${colors.bold('Commands:')}
astro dev Run Astro in development mode.
astro build Build a pre-compiled production version of your site.
astro preview Preview your build locally before deploying.
astro check Check your project for errors.
linebreak()
headline('astro', 'Futuristic web development tool.');
linebreak()
title('Commands');
table([
['dev', 'Run Astro in development mode.'],
['build', 'Build a pre-compiled production-ready site.'],
['preview', 'Preview your build locally before deploying.'],
['check', 'Check your project for errors.'],
['--version', 'Show the version number and exit.'],
['--help', 'Show this help message.'],
], { padding: 28, prefix: ' astro ' });
linebreak()
title('Flags');
table([
['--config <path>', 'Specify the path to the Astro config file.'],
['--project-root <path>', 'Specify the path to the project root folder.'],
['--no-sitemap', 'Disable sitemap generation (build only).'],
['--legacy-build', 'Use the build strategy prior to 0.24.0'],
['--experimental-ssr', 'Enable SSR compilation.'],
['--drafts', 'Include markdown draft pages in the build.'],
['--verbose', 'Enable verbose logging'],
['--silent', 'Disable logging'],
], { padding: 28, prefix: ' ' });
${colors.bold('Flags:')}
--config <path> Specify the path to the Astro config file.
--project-root <path> Specify the path to the project root folder.
--no-sitemap Disable sitemap generation (build only).
--experimental-static-build A more performant build that expects assets to be define statically.
--experimental-ssr Enable SSR compilation.
--drafts Include markdown draft pages in the build.
--verbose Enable verbose logging
--silent Disable logging
--version Show the version number and exit.
--help Show this help message.
`);
// Logging utils
function linebreak() {
console.log();
}
function headline(name: string, tagline: string) {
console.log(` ${colors.bgGreen(colors.black(` ${name} `))} ${colors.green(`v${process.env.PACKAGE_VERSION ?? ''}`)} ${tagline}`);
}
function title(label: string) {
console.log(` ${colors.bgWhite(colors.black(` ${label} `))}`);
}
function table(rows: [string, string][], opts: { padding: number, prefix: string }) {
const split = rows.some(row => {
const message = `${opts.prefix}${' '.repeat(opts.padding)}${row[1]}`;
return message.length > process.stdout.columns;
})
for (const row of rows) {
row.forEach((col, i) => {
if (i === 0) {
process.stdout.write(`${opts.prefix}${colors.bold(pad(`${col}`, opts.padding - opts.prefix.length))}`)
} else {
if (split) {
process.stdout.write('\n ');
}
process.stdout.write(colors.dim(col) + '\n')
}
})
}
return '';
}
}
/** Display --version flag */
async function printVersion() {
const pkgURL = new URL('../../package.json', import.meta.url);
const pkg = JSON.parse(await fs.promises.readFile(pkgURL, 'utf8'));
const pkgVersion = pkg.version;
console.log(pkgVersion);
// PACKAGE_VERSION is injected at build time
const version = process.env.PACKAGE_VERSION ?? '';
console.log();
console.log(` ${colors.bgGreen(colors.black(` astro `))} ${colors.green(`v${version}`)}`);
}
/** Determine which command the user requested */

View file

@ -1,6 +1,6 @@
import type { CompileError } from '@astrojs/parser';
import { bold, blue, dim, red, grey, underline, yellow } from 'kleur/colors';
import { bold, cyan, dim, red, grey, underline, yellow, reset } from 'kleur/colors';
import { performance } from 'perf_hooks';
import { Writable } from 'stream';
import stringWidth from 'string-width';
@ -25,8 +25,11 @@ function getLoggerLocale(): string {
const dt = new Intl.DateTimeFormat(getLoggerLocale(), {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
let lastMessage: string;
let lastMessageCount = 1;
export const defaultLogDestination = new Writable({
objectMode: true,
write(event: LogMessage, _, callback) {
@ -35,22 +38,49 @@ export const defaultLogDestination = new Writable({
dest = process.stdout;
}
function getPrefix() {
let prefix = '';
let type = event.type;
if (type) {
// hide timestamp when type is undefined
dest.write(dim(dt.format(new Date()) + ' '));
prefix += dim(dt.format(new Date()) + ' ');
if (event.level === 'info') {
type = bold(blue(type));
type = bold(cyan(`[${type}]`));
} else if (event.level === 'warn') {
type = bold(yellow(type));
type = bold(yellow(`[${type}]`));
} else if (event.level === 'error') {
type = bold(red(type));
type = bold(red(`[${type}]`));
}
dest.write(`[${type}] `);
prefix += `${type} `;
}
return reset(prefix);
}
dest.write(utilFormat(...event.args));
let message = utilFormat(...event.args);
// For repeat messages, only update the message counter
if (message === lastMessage) {
lastMessageCount++;
if (levels[event.level] < levels['error']) {
let lines = 1;
let len = stringWidth(`${getPrefix()}${message}`);
let cols = (dest as typeof process.stdout).columns;
if (len > cols) {
lines = Math.ceil(len / cols);
}
for (let i = 0; i < lines; i++) {
(dest as typeof process.stdout).clearLine(0);
(dest as typeof process.stdout).cursorTo(0);
(dest as typeof process.stdout).moveCursor(0, -1);
}
}
message = `${message} ${yellow(`(x${lastMessageCount})`)}`
} else {
lastMessage = message;
lastMessageCount = 1;
}
dest.write(getPrefix())
dest.write(message);
dest.write('\n');
callback();

View file

@ -3,23 +3,28 @@
*/
import type { AddressInfo } from 'net';
import { bold, dim, green, magenta, yellow, cyan } from 'kleur/colors';
import stripAnsi from 'strip-ansi';
import { bold, dim, red, green, magenta, yellow, cyan, bgGreen, black } from 'kleur/colors';
import { pad, emoji } from './dev/util.js';
const PREFIX_PADDING = 6;
/** Display */
export function req({ url, statusCode, reqTime }: { url: string; statusCode: number; reqTime?: number }): string {
let color = dim;
if (statusCode >= 500) color = magenta;
if (statusCode >= 500) color = red;
else if (statusCode >= 400) color = yellow;
else if (statusCode >= 300) color = dim;
else if (statusCode >= 200) color = green;
return `${color(statusCode)} ${pad(url, 40)} ${reqTime ? dim(Math.round(reqTime) + 'ms') : ''}`;
return `${bold(color(pad(`${statusCode}`, PREFIX_PADDING)))} ${pad(url, 40)} ${reqTime ? dim(Math.round(reqTime) + 'ms') : ''}`.trim();
}
/** Display */
export function reload({ url, reqTime }: { url: string; reqTime: number }): string {
let color = yellow;
return `${pad(url, 40)} ${dim(Math.round(reqTime) + 'ms')}`;
export function reload({ file }: { file: string }): string {
return `${green(pad('reload', PREFIX_PADDING))} ${file}`;
}
export function hmr({ file }: { file: string }): string {
return `${green(pad('update', PREFIX_PADDING))} ${file}`;
}
/** Display dev server host and startup time */
@ -39,29 +44,31 @@ export function devStart({
site: URL | undefined;
}): string {
// PACAKGE_VERSION is injected at build-time
const pkgVersion = process.env.PACKAGE_VERSION;
const version = process.env.PACKAGE_VERSION ?? '0.0.0';
const rootPath = site ? site.pathname : '/';
const toDisplayUrl = (hostname: string) => `${https ? 'https' : 'http'}://${hostname}:${port}${rootPath}`;
const messages = [
``,
`${emoji('🚀 ', '')}${magenta(`astro ${pkgVersion}`)} ${dim(`started in ${Math.round(startupTime)}ms`)}`,
``,
`Local: ${bold(cyan(toDisplayUrl(localAddress)))}`,
`Network: ${bold(cyan(toDisplayUrl(networkAddress)))}`,
``,
`${emoji('🚀 ', '')}${bgGreen(black(` astro `))} ${green(`v${version}`)} ${dim(`started in ${Math.round(startupTime)}ms`)}`,
'',
`${dim('┃')} Local ${bold(cyan(toDisplayUrl(localAddress)))}`,
`${dim('┃')} Network ${bold(cyan(toDisplayUrl(networkAddress)))}`,
'',
];
return messages.join('\n');
}
/** Display dev server host */
export function devHost({ address, https, site }: { address: AddressInfo; https: boolean; site: URL | undefined }): string {
const rootPath = site ? site.pathname : '/';
const displayUrl = `${https ? 'https' : 'http'}://${address.address}:${address.port}${rootPath}`;
return `Local: ${bold(magenta(displayUrl))}`;
return messages.map(msg => ` ${msg}`).join('\n');
}
/** Display port in use */
export function portInUse({ port }: { port: number }): string {
return `Port ${port} in use. Trying a new one…`;
}
/** Pretty-print errors */
export function err(error: Error): string {
if (!error.stack) return stripAnsi(error.message);
let message = stripAnsi(error.message);
let stack = stripAnsi(error.stack);
const split = stack.indexOf(message) + message.length;
message = stack.slice(0, split);
stack = stack.slice(split).replace(/^\n+/, '');
return `${message}\n${dim(stack)}`;
}

View file

@ -1,7 +1,7 @@
import type * as vite from 'vite';
import type http from 'http';
import type { AstroConfig, ManifestData, RouteData } from '../@types/astro';
import { info, LogOptions } from '../core/logger.js';
import type { AstroConfig, ManifestData } from '../@types/astro';
import { info, error, LogOptions } from '../core/logger.js';
import { createRouteManifest, matchRoute } from '../core/routing/index.js';
import stripAnsi from 'strip-ansi';
import { createSafeError } from '../core/util.js';
@ -81,7 +81,7 @@ async function handleRequest(
const rootRelativeUrl = pathname.substring(devRoot.length - 1);
try {
if (!pathname.startsWith(devRoot)) {
info(logging, 'astro', msg.req({ url: pathname, statusCode: 404 }));
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 }));
return handle404Response(origin, config, req, res);
}
// Attempt to match the URL to a valid page route.
@ -95,7 +95,7 @@ async function handleRequest(
}
// If still no match is found, respond with a generic 404 page.
if (!route) {
info(logging, 'astro', msg.req({ url: pathname, statusCode: 404 }));
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 }));
handle404Response(origin, config, req, res);
return;
}
@ -113,8 +113,9 @@ async function handleRequest(
});
writeHtmlResponse(res, statusCode, html);
} catch (_err: any) {
info(logging, 'astro', msg.req({ url: pathname, statusCode: 500 }));
info(logging, 'serve', msg.req({ url: pathname, statusCode: 500 }));
const err = createSafeError(_err);
error(logging, 'error', msg.err(err))
handle500Response(viteServer, origin, req, res, err);
}
}

View file

@ -3,8 +3,8 @@ import type { LogOptions } from '../core/logger.js';
import type { ViteDevServer, ModuleNode, HmrContext } from 'vite';
import type { PluginContext as RollupPluginContext, ResolvedId } from 'rollup';
import { invalidateCompilation, isCached } from './compile.js';
import { logger } from '../core/logger.js';
import { green } from 'kleur/colors';
import { info } from '../core/logger.js';
import * as msg from '../core/messages.js';
interface TrackCSSDependenciesOptions {
viteDevServer: ViteDevServer | null;
@ -46,7 +46,7 @@ export async function trackCSSDependencies(this: RollupPluginContext, opts: Trac
}
}
export function handleHotUpdate(ctx: HmrContext, config: AstroConfig, logging: LogOptions) {
export async function handleHotUpdate(ctx: HmrContext, config: AstroConfig, logging: LogOptions) {
// Invalidate the compilation cache so it recompiles
invalidateCompilation(config, ctx.file);
@ -79,11 +79,15 @@ export function handleHotUpdate(ctx: HmrContext, config: AstroConfig, logging: L
invalidateCompilation(config, file);
}
if (ctx.file.endsWith('.astro')) {
const mod = ctx.modules.find(m => m.file === ctx.file);
const file = ctx.file.replace(config.projectRoot.pathname, '/');
logger.info('astro', green('hmr'), `${file}`);
if (ctx.file.endsWith('.astro')) {
ctx.server.ws.send({ type: 'custom', event: 'astro:update', data: { file } });
}
if (mod?.isSelfAccepting) {
info(logging, 'astro', msg.hmr({ file }));
} else {
info(logging, 'astro', msg.reload({ file }));
}
return Array.from(filtered);
}

View file

@ -43,7 +43,7 @@ describe('Doctype', () => {
expect(html).not.to.match(/<\/!DOCTYPE>/i);
});
it('Doctype can be provided in a layout', async () => {
it.skip('Doctype can be provided in a layout', async () => {
const html = await fixture.readFile('/in-layout/index.html');
// test 1: doctype is at the front

View file

@ -0,0 +1,68 @@
import { expect } from 'chai';
import { cli, parseCliDevStart } from './test-utils.js';
import { promises as fs } from 'fs';
import { fileURLToPath } from 'url';
describe('astro cli', () => {
it('astro', async () => {
const proc = await cli();
expect(proc.stdout).to.include('Futuristic web development tool');
});
it('astro --version', async () => {
const pkgURL = new URL('../package.json', import.meta.url);
const pkgVersion = await fs.readFile(pkgURL, 'utf8').then((data) => JSON.parse(data).version);
const proc = await cli('--version');
expect(proc.stdout).to.include(pkgVersion);
});
it('astro dev welcome', async () => {
const pkgURL = new URL('../package.json', import.meta.url);
const pkgVersion = await fs.readFile(pkgURL, 'utf8').then((data) => JSON.parse(data).version);
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const proc = cli('dev', '--project-root', fileURLToPath(projectRootURL));
const { messages } = await parseCliDevStart(proc);
expect(messages[0]).to.contain('astro');
expect(messages[0]).to.contain(pkgVersion);
expect(messages[0]).to.contain('started in');
});
const hostnames = [undefined, '0.0.0.0', '127.0.0.1'];
hostnames.forEach((hostname) => {
const hostnameArgs = hostname ? ['--hostname', hostname] : [];
it(`astro dev ${hostnameArgs.join(' ') || '(no --hostname)'}`, async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const proc = cli('dev', '--project-root', fileURLToPath(projectRootURL), ...hostnameArgs);
const { messages } = await parseCliDevStart(proc);
const local = messages[1].replace(/Local\s*/g, '');
const network = messages[2].replace(/Network\s*/g, '');
expect(local).to.not.be.undefined;
expect(network).to.not.be.undefined;
const localURL = new URL(local);
const networkURL = new URL(network);
expect(localURL.hostname).to.be.equal('localhost', `Expected local URL to be on localhost`)
// Note: our tests run in parallel so this could be 3000+!
expect(Number.parseInt(localURL.port)).to.be.greaterThanOrEqual(3000, `Expected Port to be >= 3000`)
expect(networkURL.hostname).to.be.equal(hostname ?? '127.0.0.1', `Expected Network URL to use passed hostname`)
});
});
it('astro build', async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const proc = await cli('build', '--project-root', fileURLToPath(projectRootURL));
expect(proc.stdout).to.include('Done');
});
});

View file

@ -24,7 +24,7 @@ describe('config', () => {
for await (const chunk of proc.stdout) {
stdout += chunk;
if (chunk.includes('Local:')) break;
if (chunk.includes('Local')) break;
}
proc.kill();
@ -44,7 +44,7 @@ describe('config', () => {
for await (const chunk of proc.stdout) {
stdout += chunk;
if (chunk.includes('Local:')) break;
if (chunk.includes('Local')) break;
}
proc.kill();

View file

@ -7,6 +7,7 @@ import dev from '../dist/core/dev/index.js';
import build from '../dist/core/build/index.js';
import preview from '../dist/core/preview/index.js';
import os from 'os';
import stripAnsi from 'strip-ansi';
// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16
polyfill(globalThis, {
@ -124,4 +125,19 @@ export function cli(/** @type {string[]} */ ...args) {
return spawned;
}
export async function parseCliDevStart(proc) {
let stdout = '';
for await (const chunk of proc.stdout) {
stdout += chunk;
if (chunk.includes('Local')) break;
}
proc.kill();
stdout = stripAnsi(stdout);
const messages = stdout.split('\n').filter(ln => !!ln.trim()).map(ln => ln.replace(/[🚀┃]/g, '').replace(/\s+/g, ' ').trim());
return { messages };
}
export const isWindows = os.platform() === 'win32';