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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ const { config } = await loadConfig({});
const { config, configFile, layers } = await loadConfig({});
```

Resolve configuration file path:

```js
// Resolve the path to the configuration file without loading it
Copy link
Member

@pi0 pi0 Aug 15, 2025

Choose a reason for hiding this comment

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

If layers are used resolveConfigPath > resolveSources might also download layer which is not mentioned (also loading dotenv which is a side-effect) ~> #262 (comment)

const configPath = await resolveConfigPath({});
```

## Loading priority

c12 merged config sources with [unjs/defu](https://github.com/unjs/defu) by below order:
Expand Down
26 changes: 22 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
export * from "./dotenv";
export * from "./loader";
export * from "./types";
export * from "./watch";
export { createDefineConfig } from "./types";
export type { DotenvOptions, Env } from "./dotenv";
export type { ConfigWatcher, WatchConfigOptions } from "./watch";
export type {
C12InputConfig,
ConfigFunctionContext,
ConfigLayer,
ConfigLayerMeta,
ConfigSource,
DefineConfig,
InputConfig,
LoadConfigOptions,
ResolvableConfig,
ResolvableConfigContext,
ResolvedConfig,
SourceOptions,
UserInputConfig,
} from "./types";

export { loadDotenv, setupDotenv } from "./dotenv";
export { SUPPORTED_EXTENSIONS, loadConfig, resolveConfigPath } from "./loader";
export { watchConfig } from "./watch";
192 changes: 118 additions & 74 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,7 @@ export async function loadConfig<
MT extends ConfigLayerMeta = ConfigLayerMeta,
>(options: LoadConfigOptions<T, MT>): Promise<ResolvedConfig<T, MT>> {
// Normalize options
options.cwd = resolve(process.cwd(), options.cwd || ".");
options.name = options.name || "config";
options.envName = options.envName ?? process.env.NODE_ENV;
options.configFile =
options.configFile ??
(options.name === "config" ? "config" : `${options.name}.config`);
options.rcFile = options.rcFile ?? `.${options.name}rc`;
if (options.extend !== false) {
options.extend = {
extendKey: "extends",
...options.extend,
};
}
normalizeLoadOptions(options);

// Custom merger
const _merger = options.merger || defu;
Expand Down Expand Up @@ -223,6 +211,25 @@ export async function loadConfig<
return r;
}

export async function resolveConfigPath<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta,
>(options: LoadConfigOptions<T, MT>): Promise<string | undefined> {
normalizeLoadOptions(options);

// Load dotenv
if (options.dotenv) {
await setupDotenv({
Copy link
Member

@pi0 pi0 Aug 15, 2025

Choose a reason for hiding this comment

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

Do we really need this? It might be helpful for cloning private layers i guess during resoluition but both are hidden side-effects specially if users pass same configuration they expect to pass to resolver and assume it will only try to resolve (not download or filling global env)

I am feeling we could make it opt-in with something like { sideEffect: true } or { loadLayers: true } or avoid side-effects from this function.

cwd: options.cwd,
...(options.dotenv === true ? {} : options.dotenv),
});
}

const res = await resolveSources(".", options);

return res.configFile;
Copy link
Member

@pi0 pi0 Aug 15, 2025

Choose a reason for hiding this comment

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

I feel we could make function more generic like resolePaths or resolveConfig returning an object either full res or a subset, cwd and configFile are particulary useful. If we also resolve rc config, it can be useful for automation scripts to know where to edit/append it. We also have information about layers alrady.

}

async function extendConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta,
Expand Down Expand Up @@ -297,13 +304,103 @@ const GIGET_PREFIXES = [
const NPM_PACKAGE_RE =
/^(@[\da-z~-][\d._a-z~-]*\/)?[\da-z~-][\d._a-z~-]*($|\/.*)/;

// --- internal ---

type NormalizedKeys = "cwd" | "name" | "envName" | "configFile" | "rcFile";

function normalizeLoadOptions<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta,
>(
options: LoadConfigOptions<T, MT>,
): asserts options is Omit<LoadConfigOptions<T, MT>, NormalizedKeys> &
Record<NormalizedKeys, string> {
options.cwd = resolve(process.cwd(), options.cwd || ".");
options.name = options.name || "config";
options.envName = options.envName ?? process.env.NODE_ENV;
options.configFile =
options.configFile ??
(options.name === "config" ? "config" : `${options.name}.config`);
options.rcFile = options.rcFile ?? `.${options.name}rc`;

if (options.extend !== false) {
options.extend = {
extendKey: "extends",
...options.extend,
};
}
}

async function resolveConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta,
>(
source: string,
options: LoadConfigOptions<T, MT>,
sourceOptions: SourceOptions<T, MT> = {},
): Promise<ResolvedConfig<T, MT>> {
const res = await resolveSources(source, options, sourceOptions);

if (!existsSync(res.configFile!)) {
return res;
}

res._configFile = res.configFile;

const configFileExt = extname(res.configFile!) || "";
if (configFileExt in ASYNC_LOADERS) {
const asyncLoader =
await ASYNC_LOADERS[configFileExt as keyof typeof ASYNC_LOADERS]();
const contents = await readFile(res.configFile!, "utf8");
res.config = asyncLoader(contents);
} else {
res.config = (await options.jiti!.import(res.configFile!, {
default: true,
})) as T;
}
if (typeof res.config === "function") {
res.config = await (
res.config as (ctx?: ConfigFunctionContext) => Promise<any>
)(options.context);
}

// Custom merger
const _merger = options.merger || defu;

// Extend env specific config
if (options.envName) {
const envConfig = {
...res.config!["$" + options.envName],
...res.config!.$env?.[options.envName],
};
if (Object.keys(envConfig).length > 0) {
res.config = _merger(envConfig, res.config);
}
}

// Meta
res.meta = defu(res.sourceOptions!.meta, res.config!.$meta) as MT;
delete res.config!.$meta;

// Overrides
if (res.sourceOptions!.overrides) {
res.config = _merger(res.sourceOptions!.overrides, res.config) as T;
}

// Always windows paths
res.configFile = _normalize(res.configFile);
res.source = _normalize(res.source);

return res;
}

async function resolveSources<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta,
>(
source: string,
options: LoadConfigOptions<T, MT>,
sourceOptions: SourceOptions<T, MT> = {},
): Promise<ResolvedConfig<T, MT>> {
// Custom user resolver
if (options.resolve) {
Expand All @@ -313,9 +410,6 @@ async function resolveConfig<
}
}

// Custom merger
const _merger = options.merger || defu;

// Download giget URIs and resolve to local path
const customProviderKeys = Object.keys(
sourceOptions.giget?.providers || {},
Expand Down Expand Up @@ -378,15 +472,8 @@ async function resolveConfig<
if (isDir) {
source = options.configFile!;
}
const res: ResolvedConfig<T, MT> = {
config: undefined as unknown as T,
configFile: undefined,
cwd,
source,
sourceOptions,
};

res.configFile =
const configFile =
tryResolve(resolve(cwd, source), options) ||
tryResolve(
resolve(cwd, ".config", source.replace(/\.config$/, "")),
Expand All @@ -395,58 +482,15 @@ async function resolveConfig<
tryResolve(resolve(cwd, ".config", source), options) ||
source;

if (!existsSync(res.configFile!)) {
return res;
}

res._configFile = res.configFile;

const configFileExt = extname(res.configFile!) || "";
if (configFileExt in ASYNC_LOADERS) {
const asyncLoader =
await ASYNC_LOADERS[configFileExt as keyof typeof ASYNC_LOADERS]();
const contents = await readFile(res.configFile!, "utf8");
res.config = asyncLoader(contents);
} else {
res.config = (await options.jiti!.import(res.configFile!, {
default: true,
})) as T;
}
if (typeof res.config === "function") {
res.config = await (
res.config as (ctx?: ConfigFunctionContext) => Promise<any>
)(options.context);
}

// Extend env specific config
if (options.envName) {
const envConfig = {
...res.config!["$" + options.envName],
...res.config!.$env?.[options.envName],
};
if (Object.keys(envConfig).length > 0) {
res.config = _merger(envConfig, res.config);
}
}

// Meta
res.meta = defu(res.sourceOptions!.meta, res.config!.$meta) as MT;
delete res.config!.$meta;

// Overrides
if (res.sourceOptions!.overrides) {
res.config = _merger(res.sourceOptions!.overrides, res.config) as T;
}

// Always windows paths
res.configFile = _normalize(res.configFile);
res.source = _normalize(res.source);

return res;
return {
config: undefined as unknown as T,
configFile,
cwd,
source,
sourceOptions,
} satisfies ResolvedConfig<T, MT>;
}

// --- internal ---

function tryResolve(id: string, options: LoadConfigOptions<any, any>) {
const res = resolveModulePath(id, {
try: true,
Expand Down
Loading
Loading