diff --git a/.eslintrc b/.eslintrc index d004ea9..7e815e6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,8 +1,7 @@ { - "extends": [ - "eslint-config-unjs" - ], + "extends": ["eslint-config-unjs"], "rules": { - "unicorn/prevent-abbreviations": 0 + "unicorn/prevent-abbreviations": 0, + "@typescript-eslint/no-non-null-assertion": 0 } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3db5858..e1ff5e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,6 @@ jobs: - run: pnpm install - run: pnpm lint - run: pnpm build + - run: pnpm test:types - run: pnpm vitest --coverage - uses: codecov/codecov-action@v3 diff --git a/package.json b/package.json index da1deba..6303352 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "lint:fix": "eslint --ext .ts,.js,.mjs,.cjs . --fix && prettier -w src test", "prepack": "unbuild", "release": "changelogen --release && npm publish && git push --follow-tags", - "test": "vitest run --coverage" + "test": "vitest run --coverage && pnpm test:types", + "test:types": "tsc --noEmit" }, "dependencies": { "defu": "^6.1.2", @@ -43,6 +44,7 @@ "changelogen": "^0.5.1", "eslint": "^8.36.0", "eslint-config-unjs": "^0.1.0", + "expect-type": "^0.15.0", "prettier": "^2.8.4", "typescript": "^4.9.5", "unbuild": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38144f8..00d67c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ specifiers: dotenv: ^16.0.3 eslint: ^8.36.0 eslint-config-unjs: ^0.1.0 + expect-type: ^0.15.0 giget: ^1.1.2 jiti: ^1.17.2 mlly: ^1.2.0 @@ -33,6 +34,7 @@ devDependencies: changelogen: 0.5.1 eslint: 8.36.0 eslint-config-unjs: 0.1.0_vgl77cfdswitgr47lm5swmv43m + expect-type: 0.15.0 prettier: 2.8.4 typescript: 4.9.5 unbuild: 1.1.2 @@ -2085,6 +2087,10 @@ packages: strip-final-newline: 3.0.0 dev: true + /expect-type/0.15.0: + resolution: {integrity: sha512-yWnriYB4e8G54M5/fAFj7rCIBiKs1HAACaY13kCz6Ku0dezjS9aMcfcdVK2X8Tv2tEV1BPz/wKfQ7WA4S/d8aA==} + dev: true + /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true diff --git a/src/dotenv.ts b/src/dotenv.ts index 3b93fff..2d54873 100644 --- a/src/dotenv.ts +++ b/src/dotenv.ts @@ -65,7 +65,7 @@ export async function setupDotenv(options: DotenvOptions): Promise { export async function loadDotenv(options: DotenvOptions): Promise { const environment = Object.create(null); - const dotenvFile = resolve(options.cwd, options.fileName); + const dotenvFile = resolve(options.cwd, options.fileName!); if (existsSync(dotenvFile)) { const parsed = dotenv.parse(await fsp.readFile(dotenvFile, "utf8")); @@ -73,7 +73,7 @@ export async function loadDotenv(options: DotenvOptions): Promise { } // Apply process.env - if (!options.env._applied) { + if (!options.env?._applied) { Object.assign(environment, options.env); environment._applied = true; } @@ -97,7 +97,7 @@ function interpolate( return source[key] !== undefined ? source[key] : target[key]; } - function interpolate(value: unknown, parents: string[] = []) { + function interpolate(value: unknown, parents: string[] = []): any { if (typeof value !== "string") { return value; } @@ -105,17 +105,17 @@ function interpolate( return parse( // eslint-disable-next-line unicorn/no-array-reduce matches.reduce((newValue, match) => { - const parts = /(.?)\${?([\w:]+)?}?/g.exec(match); + const parts = /(.?)\${?([\w:]+)?}?/g.exec(match) || []; const prefix = parts[1]; let value, replacePart: string; if (prefix === "\\") { - replacePart = parts[0]; + replacePart = parts[0] || ""; value = replacePart.replace("\\$", "$"); } else { const key = parts[2]; - replacePart = parts[0].slice(prefix.length); + replacePart = (parts[0] || "").slice(prefix.length); // Avoid recursion if (parents.includes(key)) { diff --git a/src/index.ts b/src/index.ts index 645fb3d..a4aa8cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from "./dotenv"; export * from "./loader"; +export * from "./types"; diff --git a/src/loader.ts b/src/loader.ts index bf45fc3..223e229 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -2,92 +2,26 @@ import { existsSync } from "node:fs"; import { rmdir } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve, extname, dirname } from "pathe"; -import createJiti, { JITI } from "jiti"; +import createJiti from "jiti"; import * as rc9 from "rc9"; import { defu } from "defu"; import { findWorkspaceDir, readPackageJSON } from "pkg-types"; -import type { JITIOptions } from "jiti/dist/types"; -import { DotenvOptions, setupDotenv } from "./dotenv"; - -export type UserInputConfig = Record; - -export interface ConfigLayerMeta { - name?: string; - [key: string]: any; -} - -export interface C12InputConfig { - $test?: UserInputConfig; - $development?: UserInputConfig; - $production?: UserInputConfig; - $env?: Record; - $meta?: ConfigLayerMeta; -} - -export interface InputConfig extends C12InputConfig, UserInputConfig {} - -export interface SourceOptions { - meta?: ConfigLayerMeta; - overrides?: UserInputConfig; - [key: string]: any; -} - -export interface ConfigLayer { - config: T | null; - source?: string; - sourceOptions?: SourceOptions; - meta?: ConfigLayerMeta; - cwd?: string; - configFile?: string; -} - -export interface ResolvedConfig - extends ConfigLayer { - layers?: ConfigLayer[]; - cwd?: string; -} - -export interface ResolveConfigOptions { - cwd: string; -} - -export interface LoadConfigOptions { - name?: string; - cwd?: string; - - configFile?: string; - - rcFile?: false | string; - globalRc?: boolean; - - dotenv?: boolean | DotenvOptions; - - envName?: string | false; - - packageJson?: boolean | string | string[]; - - defaults?: T; - defaultConfig?: T; - overrides?: T; - - resolve?: ( - id: string, - options: LoadConfigOptions - ) => null | ResolvedConfig | Promise; - - jiti?: JITI; - jitiOptions?: JITIOptions; - - extend?: - | false - | { - extendKey?: string | string[]; - }; -} - -export async function loadConfig( - options: LoadConfigOptions -): Promise> { +import { setupDotenv } from "./dotenv"; + +import type { + UserInputConfig, + ConfigLayerMeta, + LoadConfigOptions, + ResolvedConfig, + ConfigLayer, + SourceOptions, + InputConfig, +} from "./types"; + +export async function loadConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +>(options: LoadConfigOptions): Promise> { // Normalize options options.cwd = resolve(process.cwd(), options.cwd || "."); options.name = options.name || "config"; @@ -106,7 +40,7 @@ export async function loadConfig( // Create jiti instance options.jiti = options.jiti || - createJiti(undefined, { + createJiti(undefined as unknown as string, { interopDefault: true, requireCache: false, esmResolve: true, @@ -114,7 +48,7 @@ export async function loadConfig( }); // Create context - const r: ResolvedConfig = { + const r: ResolvedConfig = { config: {} as any, cwd: options.cwd, configFile: resolve(options.cwd, options.configFile), @@ -188,7 +122,7 @@ export async function loadConfig( await extendConfig(r.config, options); r.layers = r.config._layers; delete r.config._layers; - r.config = defu(r.config, ...r.layers.map((e) => e.config)) as T; + r.config = defu(r.config, ...r.layers!.map((e) => e.config)) as T; } // Preserve unmerged sources as layers @@ -201,8 +135,9 @@ export async function loadConfig( { config, configFile: options.configFile, cwd: options.cwd }, options.rcFile && { config: configRC, configFile: options.rcFile }, options.packageJson && { config: pkgJson, configFile: "package.json" }, - ].filter((l) => l && l.config) as ConfigLayer[]; - r.layers = [...baseLayers, ...r.layers]; + ].filter((l) => l && l.config) as ConfigLayer[]; + + r.layers = [...baseLayers, ...r.layers!]; // Apply defaults if (options.defaults) { @@ -213,8 +148,11 @@ export async function loadConfig( return r; } -async function extendConfig(config, options: LoadConfigOptions) { - config._layers = config._layers || []; +async function extendConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +>(config: InputConfig, options: LoadConfigOptions) { + (config as any)._layers = config._layers || []; if (!options.extend) { return; } @@ -223,7 +161,7 @@ async function extendConfig(config, options: LoadConfigOptions) { keys = [keys]; } const extendSources = []; - for (const key of keys) { + for (const key of keys as string[]) { extendSources.push( ...(Array.isArray(config[key]) ? config[key] : [config[key]]).filter( Boolean @@ -276,11 +214,14 @@ const GIT_PREFIXES = ["github:", "gitlab:", "bitbucket:", "https://"]; const NPM_PACKAGE_RE = /^(@[\da-z~-][\d._a-z~-]*\/)?[\da-z~-][\d._a-z~-]*($|\/.*)/; -async function resolveConfig( +async function resolveConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +>( source: string, - options: LoadConfigOptions, - sourceOptions: SourceOptions = {} -): Promise { + options: LoadConfigOptions, + sourceOptions: SourceOptions = {} +): Promise> { // Custom user resolver if (options.resolve) { const res = await options.resolve(source, options); @@ -309,26 +250,31 @@ async function resolveConfig( // Try resolving as npm package if (NPM_PACKAGE_RE.test(source)) { try { - source = options.jiti.resolve(source, { paths: [options.cwd] }); + source = options.jiti!.resolve(source, { paths: [options.cwd!] }); } catch {} } // Import from local fs const isDir = !extname(source); - const cwd = resolve(options.cwd, isDir ? source : dirname(source)); + const cwd = resolve(options.cwd!, isDir ? source : dirname(source)); if (isDir) { - source = options.configFile; + source = options.configFile!; } - const res: ResolvedConfig = { config: undefined, cwd, source, sourceOptions }; + const res: ResolvedConfig = { + config: undefined as unknown as T, + cwd, + source, + sourceOptions, + }; try { - res.configFile = options.jiti.resolve(resolve(cwd, source), { + res.configFile = options.jiti!.resolve(resolve(cwd, source), { paths: [cwd], }); } catch {} - if (!existsSync(res.configFile)) { + if (!existsSync(res.configFile!)) { return res; } - res.config = options.jiti(res.configFile); + res.config = options.jiti!(res.configFile!); if (res.config instanceof Function) { res.config = await res.config(); } @@ -336,8 +282,8 @@ async function resolveConfig( // Extend env specific config if (options.envName) { const envConfig = { - ...res.config["$" + options.envName], - ...res.config.$env?.[options.envName], + ...res.config!["$" + options.envName], + ...res.config!.$env?.[options.envName], }; if (Object.keys(envConfig).length > 0) { res.config = defu(envConfig, res.config); @@ -345,12 +291,12 @@ async function resolveConfig( } // Meta - res.meta = defu(res.sourceOptions.meta, res.config.$meta); - delete 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 = defu(res.sourceOptions.overrides, res.config); + if (res.sourceOptions!.overrides) { + res.config = defu(res.sourceOptions!.overrides, res.config) as T; } return res; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8ad888d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,108 @@ +import type { JITI } from "jiti"; +import type { JITIOptions } from "jiti/dist/types"; +import type { DotenvOptions } from "./dotenv"; + +export interface ConfigLayerMeta { + name?: string; + [key: string]: any; +} + +export type UserInputConfig = Record; + +export interface C12InputConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> { + $test?: T; + $development?: T; + $production?: T; + $env?: Record; + $meta?: MT; +} + +export type InputConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> = C12InputConfig & T; + +export interface SourceOptions< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> { + meta?: MT; + overrides?: T; + [key: string]: any; +} + +export interface ConfigLayer< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> { + config: T | null; + source?: string; + sourceOptions?: SourceOptions; + meta?: MT; + cwd?: string; + configFile?: string; +} + +export interface ResolvedConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> extends ConfigLayer { + layers?: ConfigLayer[]; + cwd?: string; +} + +export interface LoadConfigOptions< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> { + name?: string; + cwd?: string; + + configFile?: string; + + rcFile?: false | string; + globalRc?: boolean; + + dotenv?: boolean | DotenvOptions; + + envName?: string | false; + + packageJson?: boolean | string | string[]; + + defaults?: T; + defaultConfig?: T; + overrides?: T; + + resolve?: ( + id: string, + options: LoadConfigOptions + ) => + | null + | undefined + | ResolvedConfig + | Promise | undefined | null>; + + jiti?: JITI; + jitiOptions?: JITIOptions; + + extend?: + | false + | { + extendKey?: string | string[]; + }; +} + +export type DefineConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> = (input: InputConfig) => InputConfig; + +export function createDefineConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +>(): DefineConfig { + return (input: InputConfig) => input; +} diff --git a/test/index.test.ts b/test/index.test.ts index b250eff..05bee0f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,13 +2,19 @@ import { fileURLToPath } from "node:url"; import { expect, it, describe } from "vitest"; import { loadConfig } from "../src"; -const r = (path) => fileURLToPath(new URL(path, import.meta.url)); -const transformPaths = (object) => +const r = (path: string) => fileURLToPath(new URL(path, import.meta.url)); +const transformPaths = (object: object) => JSON.parse(JSON.stringify(object).replaceAll(r("."), "/")); describe("c12", () => { it("load fixture config", async () => { - const { config, layers } = await loadConfig({ + type UserConfig = Partial<{ + virtual: boolean; + overriden: boolean; + defaultConfig: boolean; + extends: string[]; + }>; + const { config, layers } = await loadConfig({ cwd: r("./fixture"), dotenv: true, packageJson: ["c12", "c12-alt"], @@ -33,7 +39,7 @@ describe("c12", () => { }, }); - expect(transformPaths(config)).toMatchInlineSnapshot(` + expect(transformPaths(config!)).toMatchInlineSnapshot(` { "$env": { "test": { @@ -71,7 +77,7 @@ describe("c12", () => { } `); - expect(transformPaths(layers)).toMatchInlineSnapshot(` + expect(transformPaths(layers!)).toMatchInlineSnapshot(` [ { "config": { @@ -201,7 +207,7 @@ describe("c12", () => { }, }); - expect(transformPaths(config)).toMatchInlineSnapshot(` + expect(transformPaths(config!)).toMatchInlineSnapshot(` { "$test": { "envConfig": true, diff --git a/test/test.ts b/test/test.ts index 7024d5b..a46cabf 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from "node:url"; import { loadConfig } from "../src"; -const r = (path) => fileURLToPath(new URL(path, import.meta.url)); +const r = (path: string) => fileURLToPath(new URL(path, import.meta.url)); async function main() { const fixtureDir = r("./fixture"); diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 0000000..4832bb1 --- /dev/null +++ b/test/types.ts @@ -0,0 +1,31 @@ +import { expectTypeOf } from "expect-type"; +import { loadConfig, InputConfig, createDefineConfig } from "../src"; + +interface MyConfig { + foo: string; +} + +interface MyMeta { + metaFoo: string; +} + +const defineMyConfig = createDefineConfig(); + +const userConfig = defineMyConfig({ + foo: "bar", + $meta: { + metaFoo: "bar", + }, + $development: { + foo: "bar", + }, +}); + +expectTypeOf(userConfig.$production!.foo).toEqualTypeOf(); +expectTypeOf(userConfig.$meta!.metaFoo).toEqualTypeOf(); + +async function main() { + const config = await loadConfig({}); + expectTypeOf(config.config!.foo).toEqualTypeOf(); + expectTypeOf(config.meta!.metaFoo).toEqualTypeOf(); +} diff --git a/tsconfig.json b/tsconfig.json index 3f2c847..7d08257 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,10 +3,9 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "Node", - "esModuleInterop": true + "esModuleInterop": true, + "strict": true }, - "include": [ - "src", - "test" - ] + "include": ["src", "test"], + "exclude": ["node_modules"] }