diff --git a/.changeset/three-onions-repeat.md b/.changeset/three-onions-repeat.md new file mode 100644 index 000000000000..1781defcc5ab --- /dev/null +++ b/.changeset/three-onions-repeat.md @@ -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. diff --git a/packages/astro/package.json b/packages/astro/package.json index 3aa3c444864d..78e880487afd 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/cli/check/index.ts b/packages/astro/src/cli/check/index.ts index 96bee308dc81..5ad031714a50 100644 --- a/packages/astro/src/cli/check/index.ts +++ b/packages/astro/src/cli/check/index.ts @@ -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'; - -type DiagnosticResult = { - errors: number; - warnings: number; - hints: number; -}; - -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 { - 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.`, - }); - 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 checkFlags = parseFlags(flags); - if (checkFlags.watch) { - info(logging, 'check', 'Checking files in watch mode'); - } else { - info(logging, 'check', 'Checking files'); - } - - 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, +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'; + +export async function check(flags: Arguments) { + const logging = createLoggingFromFlags(flags); + const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root }; + const checkPackage = await getPackage( + '@astrojs/check', logging, - diagnosticChecker, - isWatchMode: checkFlags.watch, - }); -} - -type CheckerConstructor = { - diagnosticChecker: AstroCheck; - - isWatchMode: boolean; - - syncInternal: typeof syncInternal; - - settings: Readonly; - - logging: Readonly; - - 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 { - return await this.#checkAllFiles(true); - } - - /** - * Check all `.astro` files and then start watching for changes. - */ - public async watch(): Promise { - 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 { - // 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, - }); - } + getPackageOpts, + ['typescript'] + ); + const typescript = await getPackage('typescript', logging, getPackageOpts); - /** - * Logs the result of the various diagnostics - * - * @param result Result emitted by AstroChecker.#breakDownDiagnostics - */ - #logDiagnosticsSeverity(result: Readonly) { - 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('-')} `) + 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; } - /** - * It loops through all diagnostics and break down diagnostics that are errors, warnings or hints. - */ - #breakDownDiagnostics(diagnostics: Readonly): 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 { - 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, - }); - } + const { check: checker, parseArgsAsCheckConfig } = checkPackage; - return files.length; -} + const config = parseArgsAsCheckConfig(process.argv); -/** - * 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); } diff --git a/packages/astro/src/cli/check/print.ts b/packages/astro/src/cli/check/print.ts deleted file mode 100644 index bd8de2ddbb5d..000000000000 --- a/packages/astro/src/cli/check/print.ts +++ /dev/null @@ -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(); -} diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index d16ea91e247b..fdf43201fb37 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -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'); diff --git a/packages/astro/src/cli/install-package.ts b/packages/astro/src/cli/install-package.ts new file mode 100644 index 000000000000..8793d9985396 --- /dev/null +++ b/packages/astro/src/cli/install-package.ts @@ -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( + packageName: string, + logging: LogOptions, + options: GetPackageOptions, + otherDeps: string[] = [] +): Promise { + 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 { + 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; + } +}