forked from Fediversity/fediversity.eu
456 lines
11 KiB
JavaScript
456 lines
11 KiB
JavaScript
|
// @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<import('./index').Options | import('./index').OptionsSync>}
|
||
|
*/
|
||
|
function getOptions(name, options, sync) {
|
||
|
/** @type {Required<import('./index').Options>} */
|
||
|
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<string, any>) => 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) => <T>(c: Map<string, T>, 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<string>} */
|
||
|
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<string>} */
|
||
|
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();
|
||
|
}
|
||
|
},
|
||
|
};
|
||
|
};
|