feat: use external @astrojs/check (#7892)

* feat: use @astrojs/check

* fix: what happened in my rebase??

* nit: adjust with feedback
This commit is contained in:
Erika 2023-08-02 19:12:03 +02:00 committed by Emanuele Stoppa
parent 32669cd475
commit 997a0db8a4
6 changed files with 160 additions and 520 deletions

View file

@ -0,0 +1,5 @@
---
'astro': major
---
The `astro check` command now requires an external package `@astrojs/check` and an install of `typescript` in your project. This was done in order to make the main `astro` package smaller and give more flexibility to users in regard to the version of TypeScript they use.

View file

@ -117,7 +117,6 @@
"dependencies": {
"@astrojs/compiler": "^1.8.0",
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/language-server": "^1.0.0",
"@astrojs/markdown-remark": "workspace:*",
"@astrojs/telemetry": "workspace:*",
"@babel/core": "^7.22.5",
@ -165,7 +164,6 @@
"string-width": "^5.1.2",
"strip-ansi": "^7.1.0",
"tsconfig-resolver": "^3.0.1",
"typescript": "*",
"unist-util-visit": "^4.1.2",
"vfile": "^5.3.7",
"vite": "^4.4.6",
@ -197,6 +195,7 @@
"@types/send": "^0.17.1",
"@types/server-destroy": "^1.0.1",
"@types/unist": "^2.0.6",
"@astrojs/check": "^0.1.0",
"astro-scripts": "workspace:*",
"chai": "^4.3.7",
"cheerio": "1.0.0-rc.12",

View file

@ -1,396 +1,33 @@
import {
AstroCheck,
DiagnosticSeverity,
type GetDiagnosticsResult,
} from '@astrojs/language-server';
import type { FSWatcher } from 'chokidar';
import glob from 'fast-glob';
import { bold, dim, red, yellow } from 'kleur/colors';
import { createRequire } from 'module';
import fs from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ora from 'ora';
import type { Arguments as Flags } from 'yargs-parser';
import type { AstroSettings } from '../../@types/astro';
import { resolveConfig } from '../../core/config/config.js';
import { createNodeLogging } from '../../core/config/logging.js';
import { createSettings } from '../../core/config/settings.js';
import type { LogOptions } from '../../core/logger/core.js';
import { debug, info } from '../../core/logger/core.js';
import { printHelp } from '../../core/messages.js';
import type { syncInternal } from '../../core/sync';
import { eventCliSession, telemetry } from '../../events/index.js';
import { runHookConfigSetup } from '../../integrations/index.js';
import { flagsToAstroInlineConfig } from '../flags.js';
import { printDiagnostic } from './print.js';
import path from 'node:path';
import type { Arguments } from 'yargs-parser';
import { error, info } from '../../core/logger/core.js';
import { createLoggingFromFlags } from '../flags.js';
import { getPackage } from '../install-package.js';
type DiagnosticResult = {
errors: number;
warnings: number;
hints: number;
};
export async function check(flags: Arguments) {
const logging = createLoggingFromFlags(flags);
const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root };
const checkPackage = await getPackage<typeof import('@astrojs/check')>(
'@astrojs/check',
logging,
getPackageOpts,
['typescript']
);
const typescript = await getPackage('typescript', logging, getPackageOpts);
export type CheckPayload = {
/**
* Flags passed via CLI
*/
flags: Flags;
};
type CheckFlags = {
/**
* Whether the `check` command should watch for `.astro` and report errors
* @default {false}
*/
watch: boolean;
};
/**
*
* Types of response emitted by the checker
*/
export enum CheckResult {
/**
* Operation finished without errors
*/
ExitWithSuccess,
/**
* Operation finished with errors
*/
ExitWithError,
/**
* The consumer should not terminate the operation
*/
Listen,
}
const ASTRO_GLOB_PATTERN = '**/*.astro';
/**
* Checks `.astro` files for possible errors.
*
* If the `--watch` flag is provided, the command runs indefinitely and provides diagnostics
* when `.astro` files are modified.
*
* Every time an astro files is modified, content collections are also generated.
*
* @param {CheckPayload} options Options passed {@link AstroChecker}
* @param {Flags} options.flags Flags coming from the CLI
*/
export async function check({ flags }: CheckPayload): Promise<AstroChecker | undefined> {
if (flags.help || flags.h) {
printHelp({
commandName: 'astro check',
usage: '[...flags]',
tables: {
Flags: [
['--watch', 'Watch Astro files for changes and re-run checks.'],
['--help (-h)', 'See all available flags.'],
],
},
description: `Runs diagnostics against your project and reports errors to the console.`,
});
if (!checkPackage || !typescript) {
error(
logging,
'check',
'The `@astrojs/check` and `typescript` packages are required for this command to work. Please manually install them into your project and try again.'
);
return;
}
// Load settings
const inlineConfig = flagsToAstroInlineConfig(flags);
const logging = createNodeLogging(inlineConfig);
const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'check');
telemetry.record(eventCliSession('check', userConfig, flags));
const settings = createSettings(astroConfig, fileURLToPath(astroConfig.root));
const { check: checker, parseArgsAsCheckConfig } = checkPackage;
const checkFlags = parseFlags(flags);
if (checkFlags.watch) {
info(logging, 'check', 'Checking files in watch mode');
} else {
info(logging, 'check', 'Checking files');
}
const config = parseArgsAsCheckConfig(process.argv);
const { syncInternal } = await import('../../core/sync/index.js');
const root = settings.config.root;
const require = createRequire(import.meta.url);
const diagnosticChecker = new AstroCheck(
root.toString(),
require.resolve('typescript/lib/tsserverlibrary.js', {
paths: [root.toString()],
})
);
return new AstroChecker({
syncInternal,
settings,
fileSystem: fs,
logging,
diagnosticChecker,
isWatchMode: checkFlags.watch,
});
}
type CheckerConstructor = {
diagnosticChecker: AstroCheck;
isWatchMode: boolean;
syncInternal: typeof syncInternal;
settings: Readonly<AstroSettings>;
logging: Readonly<LogOptions>;
fileSystem: typeof fs;
};
/**
* Responsible to check files - classic or watch mode - and report diagnostics.
*
* When in watch mode, the class does a whole check pass, and then starts watching files.
* When a change occurs to an `.astro` file, the checker builds content collections again and lint all the `.astro` files.
*/
export class AstroChecker {
readonly #diagnosticsChecker: AstroCheck;
readonly #shouldWatch: boolean;
readonly #syncInternal: CheckerConstructor['syncInternal'];
readonly #settings: AstroSettings;
readonly #logging: LogOptions;
readonly #fs: typeof fs;
#watcher?: FSWatcher;
#filesCount: number;
#updateDiagnostics: NodeJS.Timeout | undefined;
constructor({
diagnosticChecker,
isWatchMode,
syncInternal,
settings,
fileSystem,
logging,
}: CheckerConstructor) {
this.#diagnosticsChecker = diagnosticChecker;
this.#shouldWatch = isWatchMode;
this.#syncInternal = syncInternal;
this.#logging = logging;
this.#settings = settings;
this.#fs = fileSystem;
this.#filesCount = 0;
}
/**
* Check all `.astro` files once and then finishes the operation.
*/
public async check(): Promise<CheckResult> {
return await this.#checkAllFiles(true);
}
/**
* Check all `.astro` files and then start watching for changes.
*/
public async watch(): Promise<CheckResult> {
await this.#checkAllFiles(true);
await this.#watch();
return CheckResult.Listen;
}
/**
* Stops the watch. It terminates the inner server.
*/
public async stop() {
await this.#watcher?.close();
}
/**
* Whether the checker should run in watch mode
*/
public get isWatchMode(): boolean {
return this.#shouldWatch;
}
async #openDocuments() {
this.#filesCount = await openAllDocuments(
this.#settings.config.root,
[],
this.#diagnosticsChecker
);
}
/**
* Lint all `.astro` files, and report the result in console. Operations executed, in order:
* 1. Compile content collections.
* 2. Optionally, traverse the file system for `.astro` files and saves their paths.
* 3. Get diagnostics for said files and print the result in console.
*
* @param openDocuments Whether the operation should open all `.astro` files
*/
async #checkAllFiles(openDocuments: boolean): Promise<CheckResult> {
// Run `astro:config:setup` before syncing to initialize integrations.
// We do this manually as we're calling `syncInternal` directly.
const syncSettings = await runHookConfigSetup({
settings: this.#settings,
logging: this.#logging,
command: 'build',
});
const processExit = await this.#syncInternal(syncSettings, {
logging: this.#logging,
fs: this.#fs,
});
// early exit on sync failure
if (processExit === 1) return processExit;
let spinner = ora(
` Getting diagnostics for Astro files in ${fileURLToPath(this.#settings.config.root)}`
).start();
if (openDocuments) {
await this.#openDocuments();
}
let diagnostics = await this.#diagnosticsChecker.getDiagnostics();
spinner.succeed();
let brokenDownDiagnostics = this.#breakDownDiagnostics(diagnostics);
this.#logDiagnosticsSeverity(brokenDownDiagnostics);
return brokenDownDiagnostics.errors > 0
? CheckResult.ExitWithError
: CheckResult.ExitWithSuccess;
}
#checkForDiagnostics() {
clearTimeout(this.#updateDiagnostics);
// @ematipico: I am not sure of `setTimeout`. I would rather use a debounce but let's see if this works.
// Inspiration from `svelte-check`.
this.#updateDiagnostics = setTimeout(async () => await this.#checkAllFiles(false), 500);
}
/**
* This function is responsible to attach events to the server watcher
*/
async #watch() {
const { default: chokidar } = await import('chokidar');
this.#watcher = chokidar.watch(
join(fileURLToPath(this.#settings.config.root), ASTRO_GLOB_PATTERN),
{
ignored: ['**/node_modules/**'],
ignoreInitial: true,
}
);
this.#watcher.on('add', (file) => {
this.#addDocument(file);
this.#filesCount += 1;
this.#checkForDiagnostics();
});
this.#watcher.on('change', (file) => {
this.#addDocument(file);
this.#checkForDiagnostics();
});
this.#watcher.on('unlink', (file) => {
this.#diagnosticsChecker.removeDocument(file);
this.#filesCount -= 1;
this.#checkForDiagnostics();
});
}
/**
* Add a document to the diagnostics checker
* @param filePath Path to the file
*/
#addDocument(filePath: string) {
const text = fs.readFileSync(filePath, 'utf-8');
this.#diagnosticsChecker.upsertDocument({
uri: pathToFileURL(filePath).toString(),
text,
});
}
/**
* Logs the result of the various diagnostics
*
* @param result Result emitted by AstroChecker.#breakDownDiagnostics
*/
#logDiagnosticsSeverity(result: Readonly<DiagnosticResult>) {
info(
this.#logging,
'diagnostics',
[
bold(`Result (${this.#filesCount} file${this.#filesCount === 1 ? '' : 's'}): `),
bold(red(`${result.errors} ${result.errors === 1 ? 'error' : 'errors'}`)),
bold(yellow(`${result.warnings} ${result.warnings === 1 ? 'warning' : 'warnings'}`)),
dim(`${result.hints} ${result.hints === 1 ? 'hint' : 'hints'}\n`),
].join(`\n${dim('-')} `)
);
}
/**
* It loops through all diagnostics and break down diagnostics that are errors, warnings or hints.
*/
#breakDownDiagnostics(diagnostics: Readonly<GetDiagnosticsResult[]>): DiagnosticResult {
let result: DiagnosticResult = {
errors: 0,
warnings: 0,
hints: 0,
};
diagnostics.forEach((diag) => {
diag.diagnostics.forEach((d) => {
info(this.#logging, 'diagnostics', `\n ${printDiagnostic(diag.fileUri, diag.text, d)}`);
switch (d.severity) {
case DiagnosticSeverity.Error: {
result.errors++;
break;
}
case DiagnosticSeverity.Warning: {
result.warnings++;
break;
}
case DiagnosticSeverity.Hint: {
result.hints++;
break;
}
}
});
});
return result;
}
}
/**
* Open all Astro files in the given directory and return the number of files found.
*/
async function openAllDocuments(
workspaceUri: URL,
filePathsToIgnore: string[],
checker: AstroCheck
): Promise<number> {
const files = await glob(ASTRO_GLOB_PATTERN, {
cwd: fileURLToPath(workspaceUri),
ignore: ['node_modules/**'].concat(filePathsToIgnore.map((ignore) => `${ignore}/**`)),
absolute: true,
});
for (const file of files) {
debug('check', `Adding file ${file} to the list of files to check.`);
const text = fs.readFileSync(file, 'utf-8');
checker.upsertDocument({
uri: pathToFileURL(file).toString(),
text,
});
}
return files.length;
}
/**
* Parse flags and sets defaults
*/
function parseFlags(flags: Flags): CheckFlags {
return {
watch: flags.watch ?? false,
};
info(logging, 'check', `Getting diagnostics for Astro files in ${path.resolve(config.root)}...`);
return await checker(config);
}

View file

@ -1,119 +0,0 @@
import { DiagnosticSeverity, offsetAt, type Diagnostic } from '@astrojs/language-server';
import {
bgRed,
bgWhite,
bgYellow,
black,
bold,
cyan,
gray,
red,
white,
yellow,
} from 'kleur/colors';
import { fileURLToPath } from 'node:url';
import stringWidth from 'string-width';
export function printDiagnostic(filePath: string, text: string, diag: Diagnostic): string {
let result = [];
// Lines and characters are 0-indexed, so we need to add 1 to the offset to get the actual line and character
const realStartLine = diag.range.start.line + 1;
const realStartCharacter = diag.range.start.character + 1;
// IDE friendly path that user can CTRL+Click to open the file at a specific line / character
const IDEFilePath = `${bold(cyan(fileURLToPath(filePath)))}:${bold(yellow(realStartLine))}:${bold(
yellow(realStartCharacter)
)}`;
result.push(
`${IDEFilePath} ${bold(getColorForSeverity(diag, getStringForSeverity(diag)))}: ${diag.message}`
);
// Optionally add the line before the error to add context if not empty
const previousLine = getLine(diag.range.start.line - 1, text);
if (previousLine) {
result.push(`${getPrintableLineNumber(realStartLine - 1)} ${gray(previousLine)}`);
}
// Add the line with the error
const str = getLine(diag.range.start.line, text);
const lineNumStr = realStartLine.toString().padStart(2, '0');
const lineNumLen = lineNumStr.length;
result.push(`${getBackgroundForSeverity(diag, lineNumStr)} ${str}`);
// Adds tildes under the specific range where the diagnostic is
const tildes = generateString('~', diag.range.end.character - diag.range.start.character);
// NOTE: This is not perfect, if the line include any characters that is made of multiple characters, for example
// regionals flags, but the terminal can't display it, then the number of spaces will be wrong. Not sure how to fix.
const beforeChars = stringWidth(str.substring(0, diag.range.start.character));
const spaces = generateString(' ', beforeChars + lineNumLen - 1);
result.push(` ${spaces}${bold(getColorForSeverity(diag, tildes))}`);
const nextLine = getLine(diag.range.start.line + 1, text);
if (nextLine) {
result.push(`${getPrintableLineNumber(realStartLine + 1)} ${gray(nextLine)}`);
}
// Force a new line at the end
result.push('');
return result.join('\n');
}
function generateString(str: string, len: number): string {
return Array.from({ length: len }, () => str).join('');
}
function getStringForSeverity(diag: Diagnostic): string {
switch (diag.severity) {
case DiagnosticSeverity.Error:
return 'Error';
case DiagnosticSeverity.Warning:
return 'Warning';
case DiagnosticSeverity.Hint:
return 'Hint';
default:
return 'Unknown';
}
}
function getColorForSeverity(diag: Diagnostic, text: string): string {
switch (diag.severity) {
case DiagnosticSeverity.Error:
return red(text);
case DiagnosticSeverity.Warning:
return yellow(text);
case DiagnosticSeverity.Hint:
return gray(text);
default:
return text;
}
}
function getBackgroundForSeverity(diag: Diagnostic, text: string): string {
switch (diag.severity) {
case DiagnosticSeverity.Error:
return bgRed(white(text));
case DiagnosticSeverity.Warning:
return bgYellow(white(text));
case DiagnosticSeverity.Hint:
return bgWhite(black(text));
default:
return text;
}
}
function getPrintableLineNumber(line: number): string {
return bgWhite(black(line.toString().padStart(2, '0')));
}
function getLine(line: number, text: string): string {
return text
.substring(
offsetAt({ line, character: 0 }, text),
offsetAt({ line, character: Number.MAX_SAFE_INTEGER }, text)
)
.replace(/\t/g, ' ')
.trimEnd();
}

View file

@ -154,18 +154,12 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
}
case 'check': {
const { check } = await import('./check/index.js');
// We create a server to start doing our operations
const checkServer = await check({ flags });
if (checkServer) {
if (checkServer.isWatchMode) {
await checkServer.watch();
return await new Promise(() => {}); // lives forever
} else {
const checkResult = await checkServer.check();
return process.exit(checkResult);
}
const checkServer = await check(flags);
if (flags.watch) {
return await new Promise(() => {}); // lives forever
} else {
return process.exit(checkServer ? 1 : 0);
}
return;
}
case 'sync': {
const { sync } = await import('./sync/index.js');

View file

@ -0,0 +1,124 @@
import boxen from 'boxen';
import { execa } from 'execa';
import { bold, cyan, dim, magenta } from 'kleur/colors';
import { createRequire } from 'node:module';
import ora from 'ora';
import prompts from 'prompts';
import whichPm from 'which-pm';
import { debug, info, type LogOptions } from '../core/logger/core.js';
type GetPackageOptions = {
skipAsk?: boolean;
cwd?: string;
};
export async function getPackage<T>(
packageName: string,
logging: LogOptions,
options: GetPackageOptions,
otherDeps: string[] = []
): Promise<T | undefined> {
const require = createRequire(options.cwd ?? process.cwd());
let packageImport;
try {
require.resolve(packageName);
// The `require.resolve` is required as to avoid Node caching the failed `import`
packageImport = await import(packageName);
} catch (e) {
info(
logging,
'',
`To continue, Astro requires the following dependency to be installed: ${bold(packageName)}.`
);
const result = await installPackage([packageName, ...otherDeps], options, logging);
if (result) {
packageImport = await import(packageName);
} else {
return undefined;
}
}
return packageImport as T;
}
function getInstallCommand(packages: string[], packageManager: string) {
switch (packageManager) {
case 'npm':
return { pm: 'npm', command: 'install', flags: [], dependencies: packages };
case 'yarn':
return { pm: 'yarn', command: 'add', flags: [], dependencies: packages };
case 'pnpm':
return { pm: 'pnpm', command: 'add', flags: [], dependencies: packages };
default:
return null;
}
}
async function installPackage(
packageNames: string[],
options: GetPackageOptions,
logging: LogOptions
): Promise<boolean> {
const cwd = options.cwd ?? process.cwd();
const packageManager = (await whichPm(cwd)).name ?? 'npm';
const installCommand = getInstallCommand(packageNames, packageManager);
if (!installCommand) {
return false;
}
const coloredOutput = `${bold(installCommand.pm)} ${installCommand.command}${[
'',
...installCommand.flags,
].join(' ')} ${cyan(installCommand.dependencies.join(' '))}`;
const message = `\n${boxen(coloredOutput, {
margin: 0.5,
padding: 0.5,
borderStyle: 'round',
})}\n`;
info(
logging,
null,
`\n ${magenta('Astro will run the following command:')}\n ${dim(
'If you skip this step, you can always run it yourself later'
)}\n${message}`
);
let response;
if (options.skipAsk) {
response = true;
} else {
response = (
await prompts({
type: 'confirm',
name: 'askToContinue',
message: 'Continue?',
initial: true,
})
).askToContinue;
}
if (Boolean(response)) {
const spinner = ora('Installing dependencies...').start();
try {
await execa(
installCommand.pm,
[installCommand.command, ...installCommand.flags, ...installCommand.dependencies],
{ cwd: cwd }
);
spinner.succeed();
return true;
} catch (err) {
debug('add', 'Error installing dependencies', err);
spinner.fail();
return false;
}
} else {
return false;
}
}