// @ts-check const path = require('path'); const fs = require('fs'); const os = require('os'); const fsReadFileAsync = fs.promises.readFile; /** @type {(name: string, sync: boolean) => string[]} */ function getDefaultSearchPlaces(name, sync) { return [ 'package.json', `.${name}rc.json`, `.${name}rc.js`, `.${name}rc.cjs`, ...(sync ? [] : [`.${name}rc.mjs`]), `.config/${name}rc`, `.config/${name}rc.json`, `.config/${name}rc.js`, `.config/${name}rc.cjs`, ...(sync ? [] : [`.config/${name}rc.mjs`]), `${name}.config.js`, `${name}.config.cjs`, ...(sync ? [] : [`${name}.config.mjs`]), ]; } /** * @type {(p: string) => string} * * see #17 * On *nix, if cwd is not under homedir, * the last path will be '', ('/build' -> '') * but it should be '/' actually. * And on Windows, this will never happen. ('C:\build' -> 'C:') */ function parentDir(p) { return path.dirname(p) || path.sep; } /** @type {import('./index').LoaderSync} */ const jsonLoader = (_, content) => JSON.parse(content); /** @type {import('./index').LoadersSync} */ const defaultLoadersSync = Object.freeze({ '.js': require, '.json': require, '.cjs': require, noExt: jsonLoader, }); module.exports.defaultLoadersSync = defaultLoadersSync; /** @type {import('./index').Loader} */ const dynamicImport = async id => { try { const mod = await import(id); return mod.default; } catch (e) { try { return require(id); } catch (/** @type {any} */ requireE) { if ( requireE.code === 'ERR_REQUIRE_ESM' || (requireE instanceof SyntaxError && requireE .toString() .includes('Cannot use import statement outside a module')) ) { throw e; } throw requireE; } } }; /** @type {import('./index').Loaders} */ const defaultLoaders = Object.freeze({ '.js': dynamicImport, '.mjs': dynamicImport, '.cjs': dynamicImport, '.json': jsonLoader, noExt: jsonLoader, }); module.exports.defaultLoaders = defaultLoaders; /** * @param {string} name * @param {import('./index').Options | import('./index').OptionsSync} options * @param {boolean} sync * @returns {Required} */ function getOptions(name, options, sync) { /** @type {Required} */ const conf = { stopDir: os.homedir(), searchPlaces: getDefaultSearchPlaces(name, sync), ignoreEmptySearchPlaces: true, cache: true, transform: x => x, packageProp: [name], ...options, loaders: { ...(sync ? defaultLoadersSync : defaultLoaders), ...options.loaders, }, }; conf.searchPlaces.forEach(place => { const key = path.extname(place) || 'noExt'; const loader = conf.loaders[key]; if (!loader) { throw new Error(`Missing loader for extension "${place}"`); } if (typeof loader !== 'function') { throw new Error( `Loader for extension "${place}" is not a function: Received ${typeof loader}.`, ); } }); return conf; } /** @type {(props: string | string[], obj: Record) => unknown} */ function getPackageProp(props, obj) { if (typeof props === 'string' && props in obj) return obj[props]; return ( (Array.isArray(props) ? props : props.split('.')).reduce( (acc, prop) => (acc === undefined ? acc : acc[prop]), obj, ) || null ); } /** @param {string} filepath */ function validateFilePath(filepath) { if (!filepath) throw new Error('load must pass a non-empty string'); } /** @type {(loader: import('./index').Loader, ext: string) => void} */ function validateLoader(loader, ext) { if (!loader) throw new Error(`No loader specified for extension "${ext}"`); if (typeof loader !== 'function') throw new Error('loader is not a function'); } /** @type {(enableCache: boolean) => (c: Map, filepath: string, res: T) => T} */ const makeEmplace = enableCache => (c, filepath, res) => { if (enableCache) c.set(filepath, res); return res; }; /** @type {import('./index').lilconfig} */ module.exports.lilconfig = function lilconfig(name, options) { const { ignoreEmptySearchPlaces, loaders, packageProp, searchPlaces, stopDir, transform, cache, } = getOptions(name, options ?? {}, false); const searchCache = new Map(); const loadCache = new Map(); const emplace = makeEmplace(cache); return { async search(searchFrom = process.cwd()) { /** @type {import('./index').LilconfigResult} */ const result = { config: null, filepath: '', }; /** @type {Set} */ const visited = new Set(); let dir = searchFrom; dirLoop: while (true) { if (cache) { const r = searchCache.get(dir); if (r !== undefined) { for (const p of visited) searchCache.set(p, r); return r; } visited.add(dir); } for (const searchPlace of searchPlaces) { const filepath = path.join(dir, searchPlace); try { await fs.promises.access(filepath); } catch { continue; } const content = String(await fsReadFileAsync(filepath)); const loaderKey = path.extname(searchPlace) || 'noExt'; const loader = loaders[loaderKey]; // handle package.json if (searchPlace === 'package.json') { const pkg = await loader(filepath, content); const maybeConfig = getPackageProp(packageProp, pkg); if (maybeConfig != null) { result.config = maybeConfig; result.filepath = filepath; break dirLoop; } continue; } // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) continue; if (isEmpty) { result.isEmpty = true; result.config = undefined; } else { validateLoader(loader, loaderKey); result.config = await loader(filepath, content); } result.filepath = filepath; break dirLoop; } if (dir === stopDir || dir === parentDir(dir)) break dirLoop; dir = parentDir(dir); } const transformed = // not found result.filepath === '' && result.config === null ? transform(null) : transform(result); if (cache) { for (const p of visited) searchCache.set(p, transformed); } return transformed; }, async load(filepath) { validateFilePath(filepath); const absPath = path.resolve(process.cwd(), filepath); if (cache && loadCache.has(absPath)) { return loadCache.get(absPath); } const {base, ext} = path.parse(absPath); const loaderKey = ext || 'noExt'; const loader = loaders[loaderKey]; validateLoader(loader, loaderKey); const content = String(await fsReadFileAsync(absPath)); if (base === 'package.json') { const pkg = await loader(absPath, content); return emplace( loadCache, absPath, transform({ config: getPackageProp(packageProp, pkg), filepath: absPath, }), ); } /** @type {import('./index').LilconfigResult} */ const result = { config: null, filepath: absPath, }; // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) return emplace( loadCache, absPath, transform({ config: undefined, filepath: absPath, isEmpty: true, }), ); // cosmiconfig returns undefined for empty files result.config = isEmpty ? undefined : await loader(absPath, content); return emplace( loadCache, absPath, transform(isEmpty ? {...result, isEmpty, config: undefined} : result), ); }, clearLoadCache() { if (cache) loadCache.clear(); }, clearSearchCache() { if (cache) searchCache.clear(); }, clearCaches() { if (cache) { loadCache.clear(); searchCache.clear(); } }, }; }; /** @type {import('./index').lilconfigSync} */ module.exports.lilconfigSync = function lilconfigSync(name, options) { const { ignoreEmptySearchPlaces, loaders, packageProp, searchPlaces, stopDir, transform, cache, } = getOptions(name, options ?? {}, true); const searchCache = new Map(); const loadCache = new Map(); const emplace = makeEmplace(cache); return { search(searchFrom = process.cwd()) { /** @type {import('./index').LilconfigResult} */ const result = { config: null, filepath: '', }; /** @type {Set} */ const visited = new Set(); let dir = searchFrom; dirLoop: while (true) { if (cache) { const r = searchCache.get(dir); if (r !== undefined) { for (const p of visited) searchCache.set(p, r); return r; } visited.add(dir); } for (const searchPlace of searchPlaces) { const filepath = path.join(dir, searchPlace); try { fs.accessSync(filepath); } catch { continue; } const loaderKey = path.extname(searchPlace) || 'noExt'; const loader = loaders[loaderKey]; const content = String(fs.readFileSync(filepath)); // handle package.json if (searchPlace === 'package.json') { const pkg = loader(filepath, content); const maybeConfig = getPackageProp(packageProp, pkg); if (maybeConfig != null) { result.config = maybeConfig; result.filepath = filepath; break dirLoop; } continue; } // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) continue; if (isEmpty) { result.isEmpty = true; result.config = undefined; } else { validateLoader(loader, loaderKey); result.config = loader(filepath, content); } result.filepath = filepath; break dirLoop; } if (dir === stopDir || dir === parentDir(dir)) break dirLoop; dir = parentDir(dir); } const transformed = // not found result.filepath === '' && result.config === null ? transform(null) : transform(result); if (cache) { for (const p of visited) searchCache.set(p, transformed); } return transformed; }, load(filepath) { validateFilePath(filepath); const absPath = path.resolve(process.cwd(), filepath); if (cache && loadCache.has(absPath)) { return loadCache.get(absPath); } const {base, ext} = path.parse(absPath); const loaderKey = ext || 'noExt'; const loader = loaders[loaderKey]; validateLoader(loader, loaderKey); const content = String(fs.readFileSync(absPath)); if (base === 'package.json') { const pkg = loader(absPath, content); return transform({ config: getPackageProp(packageProp, pkg), filepath: absPath, }); } const result = { config: null, filepath: absPath, }; // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) return emplace( loadCache, absPath, transform({ filepath: absPath, config: undefined, isEmpty: true, }), ); // cosmiconfig returns undefined for empty files result.config = isEmpty ? undefined : loader(absPath, content); return emplace( loadCache, absPath, transform(isEmpty ? {...result, isEmpty, config: undefined} : result), ); }, clearLoadCache() { if (cache) loadCache.clear(); }, clearSearchCache() { if (cache) searchCache.clear(); }, clearCaches() { if (cache) { loadCache.clear(); searchCache.clear(); } }, }; };