Added support for updating tsconfig.json when using astro add (#4959)

* Added support for updating tsconfig.json when using astro add

* Refactor

* Remove unneeded change

* Fix build failling due to type difference

* Extend changeset description
This commit is contained in:
Erika 2022-10-12 15:11:25 -03:00 committed by GitHub
parent a5e3ecc803
commit 0ea6187f95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 388 additions and 36 deletions

View file

@ -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"
}
}
```

View file

@ -53,6 +53,9 @@
}
}
},
"overrides": {
"tsconfig-resolver>type-fest": "3.0.0"
},
"peerDependencyRules": {
"ignoreMissing": [
"rollup",

View file

@ -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",

View file

@ -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<Inte
}
}
async function updateTSConfig(
cwd = process.cwd(),
logging: LogOptions,
integrationsInfo: IntegrationInfo[],
flags: yargs.Arguments
): Promise<UpdateResult> {
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<boo
return Boolean(response.askToContinue);
}
function getDiffContent(input: string, output: string): string | null {
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) {
return null;
}
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);
}
return diffed;
}

View file

@ -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';

View file

@ -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<frameworkWithTSSettings, tsr.TsConfigJson>([
[
'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)!);
}

View file

@ -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<any> = [];
constructor(errors: Iterable<any>, message?: string | undefined) {

View file

@ -0,0 +1,3 @@
{
"buildOptions":
}

View file

@ -0,0 +1,3 @@
{
"files": ["im-a-test-js"]
}

View file

@ -0,0 +1,3 @@
{
"files": ["im-a-test"]
}

View file

@ -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);
});
});
});

View file

@ -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"

View file

@ -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'}