Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions packages/astro/src/cli/add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 2 additions & 4 deletions packages/astro/src/core/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
157 changes: 82 additions & 75 deletions packages/astro/src/core/config/tsconfig.ts
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -53,81 +47,94 @@ export const presets = new Map<frameworkWithTSSettings, TSConfig>([
],
]);

type TSConfigResult<T = object> = 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,
Copy link
Copy Markdown
Contributor Author

@ocavue ocavue Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers:

findUp is never passed; it was always false. I removed findUp in this PR.

): Promise<TSConfigResult<{ rawConfig: TSConfig }>> {
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<TSConfigResult> {
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),
Copy link
Copy Markdown
Contributor Author

@ocavue ocavue Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers:

Here I use normalize to return the backward slash \ on Windows. This aligns with the previous behavior. I don't see any issue with returning / on Windows, but it's good to be cautious just in case.

};
}

export function updateTSConfigForFramework(
Expand Down Expand Up @@ -186,7 +193,7 @@ type StripEnums<T extends Record<string, any>> = {
export interface TSConfig {
compilerOptions?: StripEnums<CompilerOptions>;
compileOnSave?: boolean;
extends?: string;
extends?: string | string[];
Copy link
Copy Markdown
Contributor Author

@ocavue ocavue Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers:

extends can be an array since TS 5.0

files?: string[];
include?: string[];
exclude?: string[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"compilerOptions": { "strict": true }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"compilerOptions": { "target": "es2022" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["./a.json", "./b.json"],
"files": ["own"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"compilerOptions": { "strict": true }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./grandparent.json",
"include": ["lib"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./parent.json",
"files": ["child"]
}
48 changes: 35 additions & 13 deletions packages/astro/test/units/config/config-tsconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof loadTSConfig>>,
): asserts config is Exclude<typeof config, string> {
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', () => {
Expand All @@ -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', () => {
Expand Down
Loading
Loading