diff --git a/.changeset/giant-news-speak.md b/.changeset/giant-news-speak.md new file mode 100644 index 0000000000..f0fb38d64b --- /dev/null +++ b/.changeset/giant-news-speak.md @@ -0,0 +1,18 @@ +--- +'astro': minor +--- + +Added support for updating TypeScript settings automatically when using `astro add` + +The `astro add` command will now automatically update your `tsconfig.json` with the proper TypeScript settings needed for the chosen frameworks. + +For instance, typing `astro add solid` will update your `tsconfig.json` with the following settings, per [Solid's TypeScript guide](https://www.solidjs.com/guides/typescript): + +```json +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + } +} +``` diff --git a/package.json b/package.json index e1d71d61ce..cd298e1ffc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ } } }, + "overrides": { + "tsconfig-resolver>type-fest": "3.0.0" + }, "peerDependencyRules": { "ignoreMissing": [ "rollup", diff --git a/packages/astro/package.json b/packages/astro/package.json index 252a169f6e..16a9a004de 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -116,6 +116,7 @@ "common-ancestor-path": "^1.0.1", "cookie": "^0.5.0", "debug": "^4.3.4", + "deepmerge-ts": "^4.2.2", "diff": "^5.1.0", "eol": "^0.9.1", "es-module-lexer": "^0.10.5", diff --git a/packages/astro/src/core/add/index.ts b/packages/astro/src/core/add/index.ts index 0f4e387edf..396bf7eb9b 100644 --- a/packages/astro/src/core/add/index.ts +++ b/packages/astro/src/core/add/index.ts @@ -3,14 +3,20 @@ import boxen from 'boxen'; import { diffWords } from 'diff'; import { execa } from 'execa'; import { existsSync, promises as fs } from 'fs'; -import { bold, cyan, dim, green, magenta, yellow } from 'kleur/colors'; +import { bold, cyan, dim, green, magenta, red, yellow } from 'kleur/colors'; import ora from 'ora'; import path from 'path'; import preferredPM from 'preferred-pm'; import prompts from 'prompts'; import { fileURLToPath, pathToFileURL } from 'url'; import type yargs from 'yargs-parser'; -import { resolveConfigPath } from '../config/index.js'; +import { loadTSConfig, resolveConfigPath } from '../config/index.js'; +import { + defaultTSConfig, + frameworkWithTSSettings, + presets, + updateTSConfigForFramework, +} from '../config/tsconfig.js'; import { debug, info, LogOptions } from '../logger/core.js'; import * as msg from '../messages.js'; import { printHelp } from '../messages.js'; @@ -239,7 +245,7 @@ export default async function add(names: string[], { cwd, flags, logging, teleme switch (configResult) { case UpdateResult.cancelled: { info(logging, null, msg.cancelled(`Your configuration has ${bold('NOT')} been updated.`)); - return; + break; } case UpdateResult.none: { const pkgURL = new URL('./package.json', configURL); @@ -253,12 +259,12 @@ export default async function add(names: string[], { cwd, flags, logging, teleme ); if (missingDeps.length === 0) { info(logging, null, msg.success(`Configuration up-to-date.`)); - return; + break; } } info(logging, null, msg.success(`Configuration up-to-date.`)); - return; + break; } default: { const list = integrations.map((integration) => ` - ${integration.packageName}`).join('\n'); @@ -273,6 +279,29 @@ export default async function add(names: string[], { cwd, flags, logging, teleme ); } } + + const updateTSConfigResult = await updateTSConfig(cwd, logging, integrations, flags); + + switch (updateTSConfigResult) { + case UpdateResult.none: { + break; + } + case UpdateResult.cancelled: { + info( + logging, + null, + msg.cancelled(`Your TypeScript configuration has ${bold('NOT')} been updated.`) + ); + break; + } + case UpdateResult.failure: { + throw new Error( + `Unknown error parsing tsconfig.json or jsconfig.json. Could not update TypeScript settings.` + ); + } + default: + info(logging, null, msg.success(`Successfully updated TypeScript settings`)); + } } function isAdapter( @@ -471,29 +500,13 @@ async function updateAstroConfig({ return UpdateResult.none; } - let changes = []; - for (const change of diffWords(input, output)) { - let lines = change.value.trim().split('\n').slice(0, change.count); - if (lines.length === 0) continue; - if (change.added) { - if (!change.value.trim()) continue; - changes.push(change.value); - } - } - if (changes.length === 0) { + const diff = getDiffContent(input, output); + + if (!diff) { return UpdateResult.none; } - let diffed = output; - for (let newContent of changes) { - const coloredOutput = newContent - .split('\n') - .map((ln) => (ln ? green(ln) : '')) - .join('\n'); - diffed = diffed.replace(newContent, coloredOutput); - } - - const message = `\n${boxen(diffed, { + const message = `\n${boxen(diff, { margin: 0.5, padding: 0.5, borderStyle: 'round', @@ -533,6 +546,7 @@ interface InstallCommand { flags: string[]; dependencies: string[]; } + async function getInstallIntegrationsCommand({ integrations, cwd = process.cwd(), @@ -727,6 +741,113 @@ export async function validateIntegrations(integrations: string[]): Promise { + const integrations = integrationsInfo.map( + (integration) => integration.id as frameworkWithTSSettings + ); + const firstIntegrationWithTSSettings = integrations.find((integration) => + presets.has(integration) + ); + + if (!firstIntegrationWithTSSettings) { + return UpdateResult.none; + } + + const inputConfig = loadTSConfig(cwd, false); + const configFileName = inputConfig.exists ? inputConfig.path.split('/').pop() : 'tsconfig.json'; + + if (inputConfig.reason === 'invalid-config') { + return UpdateResult.failure; + } + + if (inputConfig.reason === 'not-found') { + debug('add', "Couldn't find tsconfig.json or jsconfig.json, generating one"); + } + + const outputConfig = updateTSConfigForFramework( + inputConfig.exists ? inputConfig.config : defaultTSConfig, + firstIntegrationWithTSSettings + ); + + const input = inputConfig.exists ? JSON.stringify(inputConfig.config, null, 2) : ''; + const output = JSON.stringify(outputConfig, null, 2); + const diff = getDiffContent(input, output); + + if (!diff) { + return UpdateResult.none; + } + + const message = `\n${boxen(diff, { + margin: 0.5, + padding: 0.5, + borderStyle: 'round', + title: configFileName, + })}\n`; + + info( + logging, + null, + `\n ${magenta(`Astro will make the following changes to your ${configFileName}:`)}\n${message}` + ); + + // Every major framework, apart from Vue and Svelte requires different `jsxImportSource`, as such it's impossible to config + // all of them in the same `tsconfig.json`. However, Vue only need `"jsx": "preserve"` for template intellisense which + // can be compatible with some frameworks (ex: Solid), though ultimately run into issues on the current version of Volar + const conflictingIntegrations = [...Object.keys(presets).filter((config) => config !== 'vue')]; + const hasConflictingIntegrations = + integrations.filter((integration) => presets.has(integration)).length > 1 && + integrations.filter((integration) => conflictingIntegrations.includes(integration)).length > 0; + + if (hasConflictingIntegrations) { + info( + logging, + null, + red( + ` ${bold( + 'Caution:' + )} Selected UI frameworks require conflicting tsconfig.json settings, as such only settings for ${bold( + firstIntegrationWithTSSettings + )} were used.\n More information: https://docs.astro.build/en/guides/typescript/#errors-typing-multiple-jsx-frameworks-at-the-same-time\n` + ) + ); + } + + // TODO: Remove this when Volar 1.0 ships, as it fixes the issue. + // Info: https://github.com/johnsoncodehk/volar/discussions/592#discussioncomment-3660903 + if ( + integrations.includes('vue') && + hasConflictingIntegrations && + ((outputConfig.compilerOptions?.jsx !== 'preserve' && + outputConfig.compilerOptions?.jsxImportSource !== undefined) || + integrations.includes('react')) // https://docs.astro.build/en/guides/typescript/#vue-components-are-mistakenly-typed-by-the-typesreact-package-when-installed + ) { + info( + logging, + null, + red( + ` ${bold( + 'Caution:' + )} Using Vue together with a JSX framework can lead to type checking issues inside Vue files.\n More information: https://docs.astro.build/en/guides/typescript/#vue-components-are-mistakenly-typed-by-the-typesreact-package-when-installed\n` + ) + ); + } + + if (await askToContinue({ flags })) { + await fs.writeFile(inputConfig?.path ?? path.join(cwd, 'tsconfig.json'), output, { + encoding: 'utf-8', + }); + debug('add', `Updated ${configFileName} file`); + return UpdateResult.updated; + } else { + return UpdateResult.cancelled; + } +} + function parseIntegrationName(spec: string) { const result = parseNpmName(spec); if (!result) return; @@ -755,3 +876,29 @@ async function askToContinue({ flags }: { flags: yargs.Arguments }): Promise (ln ? green(ln) : '')) + .join('\n'); + diffed = diffed.replace(newContent, coloredOutput); + } + + return diffed; +} diff --git a/packages/astro/src/core/config/index.ts b/packages/astro/src/core/config/index.ts index 4984b3b916..195ab14306 100644 --- a/packages/astro/src/core/config/index.ts +++ b/packages/astro/src/core/config/index.ts @@ -7,4 +7,4 @@ export { } from './config.js'; export type { AstroConfigSchema } from './schema'; export { createSettings } from './settings.js'; -export { loadTSConfig } from './tsconfig.js'; +export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js'; diff --git a/packages/astro/src/core/config/tsconfig.ts b/packages/astro/src/core/config/tsconfig.ts index ddfa72300c..e0ec7578b0 100644 --- a/packages/astro/src/core/config/tsconfig.ts +++ b/packages/astro/src/core/config/tsconfig.ts @@ -1,11 +1,100 @@ +import { deepmerge } from 'deepmerge-ts'; import * as tsr from 'tsconfig-resolver'; +import { existsSync } from 'fs'; +import { join } from 'path'; -export function loadTSConfig(cwd: string | undefined): tsr.TsConfigResult | undefined { - for (const searchName of ['tsconfig.json', 'jsconfig.json']) { - const config = tsr.tsconfigResolverSync({ cwd, searchName }); - if (config.exists) { - return config; +export const defaultTSConfig: tsr.TsConfigJson = { extends: 'astro/tsconfigs/base' }; + +export type frameworkWithTSSettings = 'vue' | 'react' | 'preact' | 'solid-js'; +// The following presets unfortunately cannot be inside the specific integrations, as we need +// them even in cases where the integrations are not installed +export const presets = new Map([ + [ + 'vue', // Settings needed for template intellisense when using Volar + { + compilerOptions: { + jsx: 'preserve', + }, + }, + ], + [ + 'react', // Default TypeScript settings, but we need to redefine them in case the users changed them previously + { + compilerOptions: { + jsx: 'react-jsx', + jsxImportSource: 'react', + }, + }, + ], + [ + 'preact', // https://preactjs.com/guide/v10/typescript/#typescript-configuration + { + compilerOptions: { + jsx: 'react-jsx', + jsxImportSource: 'preact', + }, + }, + ], + [ + 'solid-js', // https://www.solidjs.com/guides/typescript#configuring-typescript + { + compilerOptions: { + jsx: 'preserve', + jsxImportSource: 'solid-js', + }, + }, + ], +]); + +/** + * Load a tsconfig.json or jsconfig.json is the former is not found + * @param cwd Directory to start from + * @param resolve Determine if the function should go up directories like TypeScript would + */ +export function loadTSConfig(cwd: string | undefined, resolve = true): tsr.TsConfigResult { + cwd = cwd ?? process.cwd(); + let config = tsr.tsconfigResolverSync({ + cwd, + filePath: resolve ? undefined : cwd, + }); + + // When a direct filepath is provided to `tsconfigResolver`, it'll instead return invalid-config even when + // the file does not exists. We'll manually handle this so we can provide better errors to users + if (!resolve && config.reason === 'invalid-config' && !existsSync(join(cwd, 'tsconfig.json'))) { + config = { reason: 'not-found', path: undefined, exists: false }; + } else { + return config; + } + + // If we couldn't find a tsconfig.json, try to load a jsconfig.json instead + if (config.reason === 'not-found') { + const jsconfig = tsr.tsconfigResolverSync({ + cwd, + filePath: resolve ? undefined : cwd, + searchName: 'jsconfig.json', + }); + + if ( + !resolve && + jsconfig.reason === 'invalid-config' && + !existsSync(join(cwd, 'jsconfig.json')) + ) { + return { reason: 'not-found', path: undefined, exists: false }; + } else { + return jsconfig; } } - return undefined; + + return config; +} + +export function updateTSConfigForFramework( + target: tsr.TsConfigJson, + framework: frameworkWithTSSettings +): tsr.TsConfigJson { + if (!presets.has(framework)) { + return target; + } + + return deepmerge(target, presets.get(framework)!); } diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 70cd7d2acb..ed049ff416 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -226,8 +226,8 @@ export function resolveJsToTs(filePath: string) { } export const AggregateError = - typeof globalThis.AggregateError !== 'undefined' - ? globalThis.AggregateError + typeof (globalThis as any).AggregateError !== 'undefined' + ? (globalThis as any).AggregateError : class extends Error { errors: Array = []; constructor(errors: Iterable, message?: string | undefined) { diff --git a/packages/astro/test/fixtures/tsconfig-handling/invalid/tsconfig.json b/packages/astro/test/fixtures/tsconfig-handling/invalid/tsconfig.json new file mode 100644 index 0000000000..0cad030489 --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/invalid/tsconfig.json @@ -0,0 +1,3 @@ +{ + "buildOptions": +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/jsconfig/jsconfig.json b/packages/astro/test/fixtures/tsconfig-handling/jsconfig/jsconfig.json new file mode 100644 index 0000000000..ee8c7cd81e --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/jsconfig/jsconfig.json @@ -0,0 +1,3 @@ +{ + "files": ["im-a-test-js"] +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/missing/.gitkeep b/packages/astro/test/fixtures/tsconfig-handling/missing/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/astro/test/fixtures/tsconfig-handling/nested-folder/.gitkeep b/packages/astro/test/fixtures/tsconfig-handling/nested-folder/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/astro/test/fixtures/tsconfig-handling/tsconfig.json b/packages/astro/test/fixtures/tsconfig-handling/tsconfig.json new file mode 100644 index 0000000000..c676c0234f --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/tsconfig.json @@ -0,0 +1,3 @@ +{ + "files": ["im-a-test"] +} diff --git a/packages/astro/test/units/config/config-tsconfig.test.js b/packages/astro/test/units/config/config-tsconfig.test.js new file mode 100644 index 0000000000..3e53278fae --- /dev/null +++ b/packages/astro/test/units/config/config-tsconfig.test.js @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import { fileURLToPath } from 'url'; +import { loadTSConfig, updateTSConfigForFramework } from '../../../dist/core/config/index.js'; +import * as path from 'path'; +import * as tsr from 'tsconfig-resolver'; + +const cwd = fileURLToPath(new URL('../../fixtures/tsconfig-handling/', import.meta.url)); + +describe('TSConfig handling', () => { + beforeEach(() => { + // `tsconfig-resolver` has a weird internal cache that only vaguely respect its own rules when not resolving + // so we need to clear it before each test or we'll get false positives. This should only be relevant in tests. + tsr.clearCache(); + }); + + describe('tsconfig / jsconfig loading', () => { + it('can load tsconfig.json', () => { + const config = loadTSConfig(cwd); + + expect(config.exists).to.equal(true); + expect(config.config.files).to.deep.equal(['im-a-test']); + }); + + it('can resolve tsconfig.json up directories', () => { + const config = loadTSConfig(path.join(cwd, 'nested-folder')); + + expect(config.exists).to.equal(true); + expect(config.path).to.equal(path.join(cwd, 'tsconfig.json')); + expect(config.config.files).to.deep.equal(['im-a-test']); + }); + + it('can fallback to jsconfig.json if tsconfig.json does not exists', () => { + const config = loadTSConfig(path.join(cwd, 'jsconfig'), false); + + expect(config.exists).to.equal(true); + expect(config.path).to.equal(path.join(cwd, 'jsconfig', 'jsconfig.json')); + expect(config.config.files).to.deep.equal(['im-a-test-js']); + }); + + it('properly return errors when not resolving', () => { + const invalidConfig = loadTSConfig(path.join(cwd, 'invalid'), false); + const missingConfig = loadTSConfig(path.join(cwd, 'missing'), false); + + expect(invalidConfig.exists).to.equal(false); + expect(invalidConfig.reason).to.equal('invalid-config'); + + expect(missingConfig.exists).to.equal(false); + expect(missingConfig.reason).to.equal('not-found'); + }); + }); + + describe('tsconfig / jsconfig updates', () => { + it('can update a tsconfig with a framework config', () => { + const config = loadTSConfig(cwd); + const updatedConfig = updateTSConfigForFramework(config.config, 'react'); + + expect(config.config).to.not.equal('react-jsx'); + expect(updatedConfig.compilerOptions.jsx).to.equal('react-jsx'); + }); + + it('produce no changes on invalid frameworks', () => { + const config = loadTSConfig(cwd); + const updatedConfig = updateTSConfigForFramework(config.config, 'doesnt-exist'); + + expect(config.config).to.deep.equal(updatedConfig); + }); + }); +}); diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 7b7b7644fe..980752eec5 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -47,7 +47,8 @@ }, "peerDependencies": { "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" + "react-dom": "^17.0.2 || ^18.0.0", + "@types/react": "^17.0.50 || ^18.0.21" }, "engines": { "node": "^14.18.0 || >=16.12.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67501c8385..b9258f1bb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,8 @@ lockfileVersion: 5.4 +overrides: + tsconfig-resolver>type-fest: 3.0.0 + packageExtensionsChecksum: 01871422d489547c532184effb134b35 patchedDependencies: @@ -405,6 +408,7 @@ importers: common-ancestor-path: ^1.0.1 cookie: ^0.5.0 debug: ^4.3.4 + deepmerge-ts: ^4.2.2 diff: ^5.1.0 eol: ^0.9.1 es-module-lexer: ^0.10.5 @@ -475,6 +479,7 @@ importers: common-ancestor-path: 1.0.1 cookie: 0.5.0 debug: 4.3.4 + deepmerge-ts: 4.2.2 diff: 5.1.0 eol: 0.9.1 es-module-lexer: 0.10.5 @@ -11257,6 +11262,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge-ts/4.2.2: + resolution: {integrity: sha512-Ka3Kb21tiWjvQvS9U+1Dx+aqFAHsdTnMdYptLTmC2VAmDFMugWMY1e15aTODstipmCun8iNuqeSfcx6rsUUk0Q==} + engines: {node: '>=12.4.0'} + dev: false + /deepmerge/4.2.2: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} @@ -17177,7 +17187,8 @@ packages: json5: 2.2.1 resolve: 1.22.1 strip-bom: 4.0.0 - type-fest: 0.13.1 + type-fest: 3.0.0 + dev: false /tsconfig/7.0.0: resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} @@ -17402,6 +17413,11 @@ packages: engines: {node: '>=12.20'} dev: false + /type-fest/3.0.0: + resolution: {integrity: sha512-MINvUN5ug9u+0hJDzSZNSnuKXI8M4F5Yvb6SQZ2CYqe7SgKXKOosEcU5R7tRgo85I6eAVBbkVF7TCvB4AUK2xQ==} + engines: {node: '>=14.16'} + dev: false + /type-is/1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'}