diff --git a/README.md b/README.md index 36f1806..01ec562 100644 --- a/README.md +++ b/README.md @@ -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 +const configPath = await resolveConfigPath({}); +``` + ## Loading priority c12 merged config sources with [unjs/defu](https://github.com/unjs/defu) by below order: diff --git a/src/index.ts b/src/index.ts index f274ee9..b874a2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/loader.ts b/src/loader.ts index 1c3561a..e7872f3 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -55,19 +55,7 @@ export async function loadConfig< MT extends ConfigLayerMeta = ConfigLayerMeta, >(options: LoadConfigOptions): Promise> { // 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; @@ -223,6 +211,25 @@ export async function loadConfig< return r; } +export async function resolveConfigPath< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta, +>(options: LoadConfigOptions): Promise { + normalizeLoadOptions(options); + + // Load dotenv + if (options.dotenv) { + await setupDotenv({ + cwd: options.cwd, + ...(options.dotenv === true ? {} : options.dotenv), + }); + } + + const res = await resolveSources(".", options); + + return res.configFile; +} + async function extendConfig< T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta, @@ -297,6 +304,33 @@ 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, +): asserts options is Omit, NormalizedKeys> & + Record { + 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, @@ -304,6 +338,69 @@ async function resolveConfig< source: string, options: LoadConfigOptions, sourceOptions: SourceOptions = {}, +): Promise> { + 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 + )(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, + sourceOptions: SourceOptions = {}, ): Promise> { // Custom user resolver if (options.resolve) { @@ -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 || {}, @@ -378,15 +472,8 @@ async function resolveConfig< if (isDir) { source = options.configFile!; } - const res: ResolvedConfig = { - 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$/, "")), @@ -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 - )(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; } -// --- internal --- - function tryResolve(id: string, options: LoadConfigOptions) { const res = resolveModulePath(id, { try: true, diff --git a/test/loader.test.ts b/test/loader.test.ts index 9538d01..1ad2374 100644 --- a/test/loader.test.ts +++ b/test/loader.test.ts @@ -2,12 +2,13 @@ import { fileURLToPath } from "node:url"; import { expect, it, describe } from "vitest"; import { normalize } from "pathe"; import type { ConfigLayer, ConfigLayerMeta, UserInputConfig } from "../src"; -import { loadConfig } from "../src"; +import { loadConfig, resolveConfigPath } from "../src"; const r = (path: string) => normalize(fileURLToPath(new URL(path, import.meta.url))); const transformPaths = (object: object) => JSON.parse(JSON.stringify(object).replaceAll(r("."), "/")); +const transformPath = (path: string) => path.replaceAll(r("."), "/"); describe("loader", () => { it("load fixture config", async () => { @@ -338,4 +339,161 @@ describe("loader", () => { }), ).rejects.toThrowError("Required config (CUSTOM) cannot be resolved."); }); + + describe("resolveConfigPath", () => { + it("resolves config file path for existing config", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "test", + }); + + expect(transformPath(configPath!)).toBe("/fixture/.config/test.ts"); + }); + + it("resolves config file path with custom configFile option", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + configFile: "test.config.dev", + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/test.config.dev.ts", + ); + }); + + it("resolves config file path from .config directory", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture/theme"), + name: "test", + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/theme/.config/test.config.json5", + ); + }); + + it("resolves config file path from base directory", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture/.base"), + name: "test", + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/.base/test.config.jsonc", + ); + }); + + it("returns fallback path for non-existent config file", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "nonexistent", + }); + + // When config doesn't exist, it returns the fallback filename + expect(configPath).toBe("nonexistent.config"); + }); + + it("resolves with dotenv loading", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "test", + dotenv: true, + }); + + expect(transformPath(configPath!)).toBe("/fixture/.config/test.ts"); + }); + + it("resolves config path with custom name", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "custom", + configFile: "test.config.dev", + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/test.config.dev.ts", + ); + }); + + it("handles missing cwd gracefully", async () => { + const configPath = await resolveConfigPath({ + name: "test", + }); + + // Should return a fallback path + expect(configPath).toBe("test.config"); + }); + + it("resolves config path with normalized options", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + }); + + // Should use default name "config" and return fallback since config.ts doesn't exist + expect(configPath).toBe("config"); + }); + + it("resolves custom config path", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "test", + resolve: (id, options) => { + if (id === ".") { + return Promise.resolve({ + config: {} as any, + configFile: r("./fixture/custom.config.ts"), + cwd: options.cwd!, + }); + } + return undefined; + }, + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/custom.config.ts", + ); + }); + + it("resolves absolute paths correctly", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "test", + configFile: r("./fixture/test.config.dev"), + }); + + expect(transformPath(configPath!)).toBe( + "/fixture/test.config.dev.ts", + ); + }); + + it("resolves config with different supported extensions", async () => { + const jsonConfigPath = await resolveConfigPath({ + cwd: r("./fixture/theme"), + configFile: "test.config", + }); + + expect(transformPath(jsonConfigPath!)).toBe( + "/fixture/theme/.config/test.config.json5", + ); + + const jsoncConfigPath = await resolveConfigPath({ + cwd: r("./fixture/.base"), + configFile: "test.config", + }); + + expect(transformPath(jsoncConfigPath!)).toBe( + "/fixture/.base/test.config.jsonc", + ); + }); + + it("resolves with specific environment name", async () => { + const configPath = await resolveConfigPath({ + cwd: r("./fixture"), + name: "test", + envName: "development", + }); + + expect(transformPath(configPath!)).toBe("/fixture/.config/test.ts"); + }); + }); });