diff --git a/packages/astro/package.json b/packages/astro/package.json index f264f2f69645..d38337ec60ff 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -141,10 +141,12 @@ "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", @@ -163,7 +165,6 @@ "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", - "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index 1f716b43b08f..a09c9fd90550 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -1089,14 +1089,17 @@ async function updateTSConfig( let inputConfig = await loadTSConfig(cwd); let inputConfigText = ''; - if (inputConfig === 'invalid-config' || inputConfig === 'unknown-error') { + if (inputConfig.error === 'invalid-config') { + logger.warn(`add`, `Couldn't parse tsconfig.json or jsconfig.json: ${inputConfig.message}`); return 'failure'; - } else if (inputConfig === 'missing-config') { + } else if (inputConfig.error === 'missing-config') { logger.debug('add', "Couldn't find tsconfig.json or jsconfig.json, generating one"); + const tsconfigFile = path.join(cwd, 'tsconfig.json'); inputConfig = { tsconfig: defaultTSConfig, - tsconfigFile: path.join(cwd, 'tsconfig.json'), + tsconfigFile: tsconfigFile, rawConfig: defaultTSConfig, + sources: [tsconfigFile], }; } else { inputConfigText = JSON.stringify(inputConfig.rawConfig, null, 2); diff --git a/packages/astro/src/core/config/index.ts b/packages/astro/src/core/config/index.ts index 269be68bf97b..3698e429ae4e 100644 --- a/packages/astro/src/core/config/index.ts +++ b/packages/astro/src/core/config/index.ts @@ -5,4 +5,9 @@ export { } from './config.js'; export { mergeConfig } from './merge.js'; export { createSettings } from './settings.js'; -export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js'; +export { + loadTSConfig, + updateTSConfigForFramework, + type TSConfigLoadedResult, + type TSConfigResult, +} from './tsconfig.js'; diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index c672d0c84d55..52c767711a74 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -177,10 +177,8 @@ export async function createSettings( watchFiles.push(fileURLToPath(new URL('./package.json', pathToFileURL(cwd)))); } - if (typeof tsconfig !== 'string') { - watchFiles.push( - ...[tsconfig.tsconfigFile, ...(tsconfig.extended ?? []).map((e) => e.tsconfigFile)], - ); + if (!tsconfig.error) { + watchFiles.push(...tsconfig.sources); settings.tsConfig = tsconfig.tsconfig; settings.tsConfigPath = tsconfig.tsconfigFile; } diff --git a/packages/astro/src/core/config/tsconfig.ts b/packages/astro/src/core/config/tsconfig.ts index ed28b9a931d0..0e495de7ca5d 100644 --- a/packages/astro/src/core/config/tsconfig.ts +++ b/packages/astro/src/core/config/tsconfig.ts @@ -1,13 +1,7 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { - find, - parse, - TSConfckParseError, - type TSConfckParseOptions, - type TSConfckParseResult, - toJson, -} from 'tsconfck'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, normalize } from 'node:path'; +import { readTsconfig, type TsconfigResult } from 'get-tsconfig'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; import type { CompilerOptions, TypeAcquisition } from 'typescript'; export const defaultTSConfig: TSConfig = { extends: 'astro/tsconfigs/base' }; @@ -53,81 +47,94 @@ export const presets = new Map([ ], ]); -type TSConfigResult = Promise< - (TSConfckParseResult & T) | 'invalid-config' | 'missing-config' | 'unknown-error' ->; +export interface TSConfigLoadedResult { + error?: undefined; + /** Absolute path of the root tsconfig/jsconfig file that was loaded. */ + tsconfigFile: string; + /** The merged/resolved config (after `extends` are walked). */ + tsconfig: TSConfig; + /** The user-written, un-merged config. Used by `astro add` to round-trip. */ + rawConfig: TSConfig; + /** + * Every tsconfig file that contributed via `extends`, root-first. + * Includes `tsconfigFile`. Used to populate the dev-server watch list. + */ + sources: string[]; +} + +export type TSConfigResult = + | TSConfigLoadedResult + | { error: 'invalid-config'; message: string } + | { error: 'missing-config' }; /** - * Load a tsconfig.json or jsconfig.json is the former is not found - * @param root The root directory to search in, defaults to `process.cwd()`. - * @param findUp Whether to search for the config file in parent directories, by default only the root directory is searched. + * Load a tsconfig.json or jsconfig.json if the former is not found. + * @param root The directory to search in, defaults to `process.cwd()`. */ -export async function loadTSConfig( - root: string | undefined, - findUp = false, -): Promise> { - const safeCwd = root ?? process.cwd(); - - const [jsconfig, tsconfig] = await Promise.all( - ['jsconfig.json', 'tsconfig.json'].map((configName) => - // `tsconfck` expects its first argument to be a file path, not a directory path, so we'll fake one - find(join(safeCwd, './dummy.txt'), { - root: findUp ? undefined : root, - configName: configName, - }), - ), - ); - - // If we have both files, prefer tsconfig.json - if (tsconfig) { - const parsedConfig = await safeParse(tsconfig, { root: root }); - - if (typeof parsedConfig === 'string') { - return parsedConfig; - } - - // tsconfck does not return the original config, so we need to parse it ourselves - // https://github.com/dominikg/tsconfck/issues/138 - const rawConfig = await readFile(tsconfig, 'utf-8') - .then(toJson) - .then((content) => JSON.parse(content) as TSConfig); - - return { ...parsedConfig, rawConfig }; +export async function loadTSConfig(root: string | undefined): Promise { + const safeCwd = root || process.cwd(); + let tsconfigPath: string | undefined; + + // Find the json file path. Prefer tsconfig.json over jsconfig.json. + for (const configName of ['tsconfig.json', 'jsconfig.json']) { + const possiblePath = join(safeCwd, configName); + if (existsSync(possiblePath)) { + tsconfigPath = possiblePath; + break; + } } - - if (jsconfig) { - const parsedConfig = await safeParse(jsconfig, { root: root }); - - if (typeof parsedConfig === 'string') { - return parsedConfig; - } - - const rawConfig = await readFile(jsconfig, 'utf-8') - .then(toJson) - .then((content) => JSON.parse(content) as TSConfig); - - return { ...parsedConfig, rawConfig: rawConfig }; + if (!tsconfigPath) { + return { + error: 'missing-config', + }; } - return 'missing-config'; -} - -async function safeParse(tsconfigPath: string, options: TSConfckParseOptions = {}): TSConfigResult { + // Read the raw json file + let rawConfig: TSConfig | undefined; try { - const parseResult = await parse(tsconfigPath, options); - - if (parseResult.tsconfig == null) { - return 'missing-config'; + const text = readFileSync(tsconfigPath, 'utf-8'); + const errors: ParseError[] = []; + const parsed = parseJsonc(text, errors, { allowTrailingComma: true }) as TSConfig; + if (errors.length > 0) { + const first = errors[0]; + return { + error: 'invalid-config', + message: `Failed to parse ${tsconfigPath}: Malformed JSONC (error code ${first.error}) at offset ${first.offset}`, + }; } - - return parseResult; + rawConfig = parsed; } catch (e) { - if (e instanceof TSConfckParseError) { - return 'invalid-config'; - } + const message = e instanceof Error ? e.message : String(e); + return { + error: 'invalid-config', + message: `Failed to parse ${tsconfigPath}: ${message}`, + }; + } + if (!rawConfig) { + return { + error: 'invalid-config', + message: `Failed to parse ${tsconfigPath}: Unknown error`, + }; + } - return 'unknown-error'; + // Resolve the tsconfig via `extends` + let resolved: TsconfigResult | undefined; + try { + resolved = readTsconfig(tsconfigPath); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + error: 'invalid-config', + message: `Failed to resolve ${tsconfigPath}: ${message}`, + }; } + + return { + tsconfigFile: normalize(resolved.path), + tsconfig: resolved.config satisfies TSConfig, + rawConfig, + sources: (resolved.sources || [resolved.path]).map(normalize), + }; } export function updateTSConfigForFramework( @@ -186,7 +193,7 @@ type StripEnums> = { export interface TSConfig { compilerOptions?: StripEnums; compileOnSave?: boolean; - extends?: string; + extends?: string | string[]; files?: string[]; include?: string[]; exclude?: string[]; diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-array/a.json b/packages/astro/test/fixtures/tsconfig-handling/extends-array/a.json new file mode 100644 index 000000000000..26f214064a91 --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-array/a.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true } +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-array/b.json b/packages/astro/test/fixtures/tsconfig-handling/extends-array/b.json new file mode 100644 index 000000000000..9702c26820a9 --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-array/b.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "target": "es2022" } +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-array/tsconfig.json b/packages/astro/test/fixtures/tsconfig-handling/extends-array/tsconfig.json new file mode 100644 index 000000000000..a351928b8fec --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-array/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": ["./a.json", "./b.json"], + "files": ["own"] +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-chain/grandparent.json b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/grandparent.json new file mode 100644 index 000000000000..26f214064a91 --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/grandparent.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true } +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-chain/parent.json b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/parent.json new file mode 100644 index 000000000000..0ba1fca07da1 --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/parent.json @@ -0,0 +1,4 @@ +{ + "extends": "./grandparent.json", + "include": ["lib"] +} diff --git a/packages/astro/test/fixtures/tsconfig-handling/extends-chain/tsconfig.json b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/tsconfig.json new file mode 100644 index 000000000000..87ee10bd6f3d --- /dev/null +++ b/packages/astro/test/fixtures/tsconfig-handling/extends-chain/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./parent.json", + "files": ["child"] +} diff --git a/packages/astro/test/units/config/config-tsconfig.test.ts b/packages/astro/test/units/config/config-tsconfig.test.ts index e85e51ef7a38..b3e622768b8a 100644 --- a/packages/astro/test/units/config/config-tsconfig.test.ts +++ b/packages/astro/test/units/config/config-tsconfig.test.ts @@ -3,17 +3,20 @@ import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { toJson } from 'tsconfck'; -import { loadTSConfig, updateTSConfigForFramework } from '../../../dist/core/config/index.js'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { + loadTSConfig, + updateTSConfigForFramework, + type TSConfigLoadedResult, + type TSConfigResult, +} from '../../../dist/core/config/index.js'; import type { frameworkWithTSSettings } from '../../../dist/core/config/tsconfig.js'; const cwd = fileURLToPath(new URL('../../fixtures/tsconfig-handling/', import.meta.url)); /** Assert that loadTSConfig returned a valid result (not an error string). */ -function assertValidConfig( - config: Awaited>, -): asserts config is Exclude { - assert.ok(typeof config !== 'string', `Expected a valid config but got error: ${config}`); +function assertValidConfig(config: TSConfigResult): asserts config is TSConfigLoadedResult { + assert.ok(!config.error, `Expected a valid config but got error: ${JSON.stringify(config)}`); } describe('TSConfig handling', () => { @@ -27,33 +30,52 @@ describe('TSConfig handling', () => { const config = await loadTSConfig(cwd); assertValidConfig(config); assert.equal(config.tsconfigFile, path.join(cwd, 'tsconfig.json')); - assert.deepEqual(config.tsconfig.files, ['im-a-test']); + assert.deepEqual(config.tsconfig.files, ['./im-a-test']); }); it('can fall back to jsconfig.json if tsconfig.json does not exist', async () => { const config = await loadTSConfig(path.join(cwd, 'jsconfig')); assertValidConfig(config); assert.equal(config.tsconfigFile, path.join(cwd, 'jsconfig', 'jsconfig.json')); - assert.deepEqual(config.tsconfig.files, ['im-a-test-js']); + assert.deepEqual(config.tsconfig.files, ['./im-a-test-js']); }); it('properly return errors when not resolving', async () => { const invalidConfig = await loadTSConfig(path.join(cwd, 'invalid')); const missingConfig = await loadTSConfig(path.join(cwd, 'missing')); - assert.equal(invalidConfig, 'invalid-config'); - assert.equal(missingConfig, 'missing-config'); + assert.equal(invalidConfig.error, 'invalid-config'); + assert.equal(missingConfig.error, 'missing-config'); }); it('does not change baseUrl in raw config', async () => { const loadedConfig = await loadTSConfig(path.join(cwd, 'baseUrl')); assertValidConfig(loadedConfig); - const rawConfig = await readFile(path.join(cwd, 'baseUrl', 'tsconfig.json'), 'utf-8') - .then(toJson) - .then((content) => JSON.parse(content)); + const rawContent = await readFile(path.join(cwd, 'baseUrl', 'tsconfig.json'), 'utf-8'); + const rawConfig = parseJsonc(rawContent, [], { allowTrailingComma: true }); assert.deepEqual(loadedConfig.rawConfig, rawConfig); }); + + it('populates `sources` with the full extends chain, root first', async () => { + const dir = path.join(cwd, 'extends-chain'); + const config = await loadTSConfig(dir); + assertValidConfig(config); + assert.equal(config.sources.length, 3); + assert.ok(config.sources[0].endsWith('tsconfig.json')); + assert.ok(config.sources.some((p) => p.endsWith('parent.json'))); + assert.ok(config.sources.some((p) => p.endsWith('grandparent.json'))); + }); + + it('resolves extends array form (TS 5.0+)', async () => { + const dir = path.join(cwd, 'extends-array'); + const config = await loadTSConfig(dir); + assertValidConfig(config); + assert.equal(config.tsconfig.compilerOptions?.strict, true); + assert.equal(config.tsconfig.compilerOptions?.target, 'es2022'); + assert.deepEqual(config.tsconfig.files, ['./own']); + assert.equal(config.sources.length, 3); + }); }); describe('tsconfig / jsconfig updates', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50f0d8b24b4..fbffa8f61cae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,9 @@ importers: fontace: specifier: ~0.4.1 version: 0.4.1 + get-tsconfig: + specifier: 5.0.0-beta.4 + version: 5.0.0-beta.4 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -589,6 +592,9 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 magic-string: specifier: ^0.30.21 version: 0.30.21 @@ -643,9 +649,6 @@ importers: tinyglobby: specifier: ^0.2.15 version: 0.2.16 - tsconfck: - specifier: ^3.1.6 - version: 3.1.6(typescript@5.9.3) ultrahtml: specifier: ^1.6.0 version: 1.6.0 @@ -6966,6 +6969,9 @@ importers: esbuild: specifier: ^0.27.3 version: 0.27.3 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -6975,9 +6981,6 @@ importers: tinyglobby: specifier: ^0.2.15 version: 0.2.16 - tsconfck: - specifier: ^3.1.6 - version: 3.1.6(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -12141,6 +12144,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -14990,16 +14997,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -21362,6 +21359,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: optional: true @@ -24913,10 +24914,6 @@ snapshots: dependencies: typescript: 5.9.3 - tsconfck@3.1.6(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - tslib@2.8.1: {} tsx@4.21.0: diff --git a/scripts/package.json b/scripts/package.json index 280314075792..5f03431cd587 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -11,8 +11,8 @@ "esbuild": "^0.27.3", "p-limit": "^7.3.0", "piccolore": "^0.1.3", + "jsonc-parser": "^3.3.1", "tinyglobby": "^0.2.15", - "tsconfck": "^3.1.6", "tsx": "^4.21.0" } } diff --git a/scripts/smoke/check.js b/scripts/smoke/check.js index 1f199aac21a1..898ab35f8b0e 100644 --- a/scripts/smoke/check.js +++ b/scripts/smoke/check.js @@ -3,8 +3,8 @@ import { spawn } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; import pLimit from 'p-limit'; -import { toJson } from 'tsconfck'; const skippedExamples = ['toolbar-app', 'component', 'server-islands']; @@ -73,7 +73,7 @@ function prepareExample(examplePath) { if (!existsSync(tsconfigPath)) return const originalConfig = readFileSync(tsconfigPath, 'utf-8'); - const tsconfig = JSON.parse(toJson(originalConfig)); + const tsconfig = parseJsonc(originalConfig, [], { allowTrailingComma: true }); // Swap to strictest config to make sure it also passes tsconfig.extends = 'astro/tsconfigs/strictest';