diff --git a/README.md b/README.md index aa0a37c..3176723 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,27 @@ await loadConfig({ When using the `native` loader and `fresh` is enabled, `dependencies` contains absolute paths for files imported by the config file. +### withConfigMeta + +Preserve the original config path and dependencies when loading through an adapter: + +```ts +import { withConfigMeta } from '@rstackjs/load-config'; + +const config = { name: 'my-tool' }; + +export default withConfigMeta(config, { + filePath: '/project/project.config.ts', + dependencies: ['/project/shared.ts'], +}); +``` + +`loadConfig` uses the supplied `filePath` and merges `dependencies` with the adapter file and its collected dependencies, removing duplicates. + +- Use absolute paths. `filePath: null` means no underlying config was found; `dependencies` is optional. +- The helper modifies and returns the original config object. Repeated calls replace its metadata. Frozen or non-extensible objects are not supported. +- Call it on the final config object: object spread and JSON serialization discard the metadata. + ## License [MIT](./LICENSE). diff --git a/src/index.ts b/src/index.ts index 93f2d44..9c8d65f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { getConfigExport, isConfigFunction } from './helpers.js'; import { loadWithJiti } from './jiti.js'; +import { getConfigMeta } from './meta.js'; import { JS_CONFIG_REGEXP, loadWithNative } from './native.js'; import { resolveConfigPath } from './resolve.js'; import type { @@ -8,8 +9,10 @@ import type { LoadConfigResult, } from './types.js'; +export { withConfigMeta } from './meta.js'; export type { ConfigDefinition, + ConfigFileMeta, ConfigLoader, LoadConfigOptions, LoadConfigResult, @@ -84,6 +87,7 @@ export async function loadConfig< } const { configExport, dependencies } = loadedConfig; + let content: Config; if (isConfigFunction(configExport)) { const result = await configExport(...configParams); @@ -92,16 +96,24 @@ export async function loadConfig< throw new Error('The config function must return a config object.'); } - return { - content: result, - filePath: configPath, - dependencies, - }; + content = result; + } else { + content = configExport; } - return { - content: configExport, + const meta = getConfigMeta(content); + const result: LoadConfigResult = { + content, filePath: configPath, dependencies, }; + + if (meta) { + result.filePath = meta.filePath; + result.dependencies = [ + ...new Set([...(meta.dependencies ?? []), configPath, ...dependencies]), + ]; + } + + return result; } diff --git a/src/meta.ts b/src/meta.ts new file mode 100644 index 0000000..0beef15 --- /dev/null +++ b/src/meta.ts @@ -0,0 +1,35 @@ +import type { ConfigFileMeta } from './types.js'; + +// Share the key across fresh imports and separately bundled copies of the loader. +const CONFIG_META = Symbol.for('@rstackjs/load-config/meta'); + +/** + * Attach file metadata in place and return the original configuration object. + * Repeated calls replace the metadata. Requires an extensible, unfrozen object. + * Call this after merging the config: object spread does not preserve metadata. + */ +export function withConfigMeta( + config: Config, + meta: ConfigFileMeta, +): Config { + Object.defineProperty(config, CONFIG_META, { + configurable: true, + value: { + filePath: meta.filePath, + dependencies: [...(meta.dependencies ?? [])], + } satisfies ConfigFileMeta, + }); + + return config; +} + +export function getConfigMeta(config: unknown): ConfigFileMeta | undefined { + if ( + config !== null && + typeof config === 'object' && + Object.hasOwn(config, CONFIG_META) + ) { + return (config as { [CONFIG_META]: ConfigFileMeta })[CONFIG_META]; + } + return undefined; +} diff --git a/src/types.ts b/src/types.ts index eb96cc8..b086142 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,19 +50,34 @@ export type LoadConfigOptions = { fresh?: boolean; }; +export type ConfigFileMeta = { + /** + * Absolute path of the actual configuration file. + * Use `null` when no underlying configuration file was found. + */ + filePath: string | null; + /** + * Absolute paths of additional configuration dependencies. + * These are merged with the adapter file and its collected dependencies. + */ + dependencies?: readonly string[]; +}; + export type LoadConfigResult = { /** * The loaded configuration object. */ content: Config; /** - * The path to the loaded configuration file. - * Return `null` if the configuration file is not found. + * The path to the loaded configuration file, or the source set by `withConfigMeta`. + * Returns `null` if no configuration file was found, including an explicit + * `null` source set by `withConfigMeta`. */ filePath: string | null; /** - * Absolute file paths of statically imported (relative) dependencies of the - * config file. + * Absolute paths of collected configuration dependencies. When `withConfigMeta` + * is used, also includes explicit dependencies and the loaded adapter file, + * with duplicates removed. */ dependencies: string[]; }; diff --git a/tests/config-meta/actual.config.mjs b/tests/config-meta/actual.config.mjs new file mode 100644 index 0000000..682d1af --- /dev/null +++ b/tests/config-meta/actual.config.mjs @@ -0,0 +1,3 @@ +import { name } from './shared.mjs'; + +export default { name }; diff --git a/tests/config-meta/adapter.config.ts b/tests/config-meta/adapter.config.ts new file mode 100644 index 0000000..aff0c14 --- /dev/null +++ b/tests/config-meta/adapter.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url'; +import { withConfigMeta } from '../../src/meta.ts'; +import config from './actual.config.mjs'; + +const filePath = fileURLToPath(new URL('./actual.config.mjs', import.meta.url)); +const dependency = fileURLToPath(new URL('./shared.mjs', import.meta.url)); +const meta = { filePath, dependencies: [dependency, dependency] }; + +// Repeated calls replace metadata on the same object. +const result = withConfigMeta({ ...config }, { filePath: null }); +withConfigMeta(result, meta); +export default result; + +export const sync = (name: string) => withConfigMeta({ name }, meta); +export const asyncConfig = async (name: string) => sync(name); +export const missing = withConfigMeta({}, { filePath: null }); +export const ordinary = { + content: 'ordinary', + filePath: 'ordinary', + dependencies: [], +}; diff --git a/tests/config-meta/index.test.ts b/tests/config-meta/index.test.ts new file mode 100644 index 0000000..f0fd866 --- /dev/null +++ b/tests/config-meta/index.test.ts @@ -0,0 +1,80 @@ +import { join } from 'node:path'; +import { expect, test } from 'rstack/test'; +import { loadConfig, withConfigMeta } from '../../src/index'; + +const configPath = join(import.meta.dirname, 'adapter.config.ts'); +const sourcePath = join(import.meta.dirname, 'actual.config.mjs'); +const dependencyPath = join(import.meta.dirname, 'shared.mjs'); + +const load = (exportName: string | false) => + loadConfig({ path: configPath, exportName }); + +test('attaches metadata in place without changing enumerable fields', () => { + const config = { name: 'app' }; + const result = withConfigMeta(config, { filePath: sourcePath }); + + expect(result).toBe(config); + expect(Object.keys(result)).toEqual(['name']); + expect(JSON.stringify(result)).toBe('{"name":"app"}'); + expect(Object.getOwnPropertySymbols({ ...result })).toEqual([]); +}); + +test.each(['native', 'jiti'] as const)( + '%s: loads metadata from config exports', + async (loader) => { + for (const [exportName, name] of [ + ['default', 'shared'], + ['sync', 'from params'], + ['asyncConfig', 'from params'], + ]) { + const result = await loadConfig({ + path: configPath, + loader, + exportName, + configParams: ['from params'], + }); + + expect(result).toEqual({ + content: { name }, + filePath: sourcePath, + dependencies: [dependencyPath, configPath], + }); + } + }, +); + +test('preserves null sources and omitted dependencies', async () => { + expect(await load('missing')).toEqual({ + content: {}, + filePath: null, + dependencies: [configPath], + }); +}); + +test('ignores ordinary metadata-like fields and disabled exports', async () => { + expect(await load('ordinary')).toEqual({ + content: { content: 'ordinary', filePath: 'ordinary', dependencies: [] }, + filePath: configPath, + dependencies: [], + }); + expect(await load(false)).toEqual({ + content: {}, + filePath: configPath, + dependencies: [], + }); +}); + +test('merges collected dependencies across fresh helper instances', async () => { + const result = await loadConfig({ path: configPath, fresh: true }); + + expect(result).toEqual({ + content: { name: 'shared' }, + filePath: sourcePath, + dependencies: [ + dependencyPath, + configPath, + join(import.meta.dirname, '../../src/meta.ts'), + sourcePath, + ], + }); +}); diff --git a/tests/config-meta/shared.mjs b/tests/config-meta/shared.mjs new file mode 100644 index 0000000..13760c4 --- /dev/null +++ b/tests/config-meta/shared.mjs @@ -0,0 +1 @@ +export const name = 'shared';