json.macro
Version:
Directly load json files into your code via babel macros.
552 lines (469 loc) • 13.9 kB
JavaScript
import { parseExpression } from '@babel/parser';
import is from '@sindresorhus/is';
import { createMacro, MacroError } from 'babel-plugin-macros';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import glob from 'globby';
import JSON5 from 'json5';
import get from 'lodash.get';
import { dirname, resolve } from 'path';
import findPackageJson from 'pkg-up';
import { parse } from 'semver';
import {
CacheStrategy,
DEFAULT_SEARCH_NAME,
tsconfigResolverSync,
} from 'tsconfig-resolver';
/**
* @typedef { import('babel-plugin-macros').MacroParams } MacroParams
* @typedef { import('@babel/core').Node } Node
* @typedef { import('@babel/core').NodePath<Node> } NodePath
* @typedef {{ reference: NodePath, babel: MacroParams['babel'], state:
* MacroParams['state'] }} MethodParams
* @typedef { (options: MethodParams) => void } MethodCall
* @typedef {Object} CheckReferenceExistsParameter
* @property {string} name - The reference name to check for
* @property {MethodCall} method - the method called for each instance of the
* reference.
* @property {MacroParams} macroParameter - the reference to check.
*/
/**
* Provides a custom error for this macro.
*/
class JsonMacroError extends MacroError {
/**
* @param {string} message
*/
constructor(message) {
super(message);
this.name = 'JsonMacroError';
this.stack = '';
}
}
/**
* Checks if a value is a string or is undefined.
*
* @param {unknown} value
* @returns {value is string}
*/
function isStringOrUndefined(value) {
return is.string(value) || is.undefined(value);
}
/**
* Check whether the directory structure for a file path exists, and create it
* if it doesn't.
*
* @param {string} filePath
*
* @returns {void}
*/
function ensureDirectoryExists(filePath) {
const dir = dirname(filePath);
if (existsSync(dir)) {
return;
}
ensureDirectoryExists(dir);
mkdirSync(dir);
}
/**
* Prints readable error messages for when loading a json file fails.
* @param {NodePath} path
* @param {string} message
*
* @returns {never}
*/
function frameError(path, message) {
throw path.buildCodeFrameError(`\n\n${message}\n\n`, JsonMacroError);
}
/**
* Evaluates the value matches the provided `predicate`.
* @template Type
* @param {Object} options
* @param {NodePath | undefined} options.node
* @param {NodePath} options.parentPath
* @param {(value: unknown) => value is Type } options.predicate
*
* @returns {Type}
*/
function evaluateNodeValue({ parentPath, node, predicate }) {
let value;
try {
value = node?.evaluate().value;
} catch {
/* istanbul ignore next */
frameError(
parentPath,
`There was a problem evaluating the value of the argument for the code: ${parentPath.getSource()}. If the value is dynamic, please make sure that its value is statically deterministic.`,
);
}
if (!predicate(value)) {
frameError(
parentPath,
`Invalid argument passed to function call. Received unsupported type '${is(
value,
)}'.`,
);
}
return value;
}
/**
* Get the node for the first argument of a function call. Will throw an error
* if more than one argument.
*
* @param {Object} options
* @param {NodePath} options.parentPath
* @param {boolean} [options.required=true] - whether the argument must be provided
* @param {number} [options.index=0] - the argument index to get
* @param {number} [options.maxArguments=1] - maximum number of arguments accepted
*
* @returns {NodePath | undefined}
*/
function getArgumentNode({
parentPath,
required = true,
index = 0,
maxArguments = 1,
}) {
const nodes = parentPath.get('arguments');
const nodeArray = Array.isArray(nodes) ? nodes : [nodes];
if (nodeArray.length > maxArguments) {
frameError(
parentPath,
`Too many arguments provided to the function call: ${parentPath.getSource()}. This method only supports one or less.`,
);
}
const node = nodeArray?.[index];
if (node === undefined && required) {
frameError(
parentPath,
`No arguments were provided when one is required: ${parentPath.getSource()}.`,
);
}
return node;
}
/**
* Loads a file, parses it's as a json string and throws an error if there is
* problem doing do.
*
* @template Type
*
* @param {Object} options
* @param {string} options.filePath
* @param {NodePath} options.parentPath
*
* @returns {Type}
*/
function loadAndParseJsonFile({ filePath, parentPath }) {
let jsonValue;
try {
const fileContent = readFileSync(filePath, { encoding: 'utf-8' });
jsonValue = JSON5.parse(fileContent);
} catch {
frameError(
parentPath,
`There was a problem loading the provided JSON file: '${filePath}'. Please make sure the file exists and you have provided valid JSON content.`,
);
}
return jsonValue;
}
/**
* @param {any} state
*
* @returns {string}
*/
function getFileName(state) {
const fileName = state.file.opts.filename;
if (!fileName) {
throw new JsonMacroError(
'json.macro methods can only be used on files and no filename was found',
);
}
return fileName;
}
/**
* Loads the nearest `package.json` file throws an error if there is
* problem doing do.
*
* @param {Object} options
* @param {string} options.cwd - the current working directory
* @param {NodePath} options.parentPath
*
* @returns {import('type-fest').PackageJson}
*/
function loadAndParsePackageJsonFile(options) {
const { cwd, parentPath } = options;
const filePath = findPackageJson.sync({ cwd });
if (!filePath) {
frameError(
parentPath,
`No package.json file could be loaded from your current directory. '${cwd}'`,
);
}
return loadAndParseJsonFile({ filePath, parentPath });
}
/**
* @param {Object} options
* @param {unknown} options.value
* @param {MacroParams['babel']} options.babel - the babel object
* @param {NodePath} options.parentPath
*/
function replaceParentExpression(options) {
const { babel, parentPath, value } = options;
const expression = babel.types.parenthesizedExpression(
parseExpression(`[${JSON.stringify(value)}][0]`, {}),
);
parentPath.replaceWith(expression);
}
/**
* Loads the version from the nearest package.json file.
*
* @param {MethodParams} options
*/
function getVersion({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const cwd = dirname(filename);
const node = getArgumentNode({ parentPath, required: false });
const shouldLoadObject = node && node?.evaluate().value === true;
const jsonValue = loadAndParsePackageJsonFile({ cwd, parentPath });
const stringVersion = jsonValue.version;
if (!stringVersion) {
frameError(
parentPath,
'No version found for the resolved `package.json` file.',
);
}
/** @type {string | import('../types').SemanticVersion} */
let value = stringVersion;
/** @type {import('../types').SemanticVersion | null} */
const semver = parse(stringVersion);
if (!semver) {
frameError(
parentPath,
`A semantic versioning object could not be parsed from the invalid string: '${stringVersion}'`,
);
}
if (shouldLoadObject) {
value = {
build: semver.build,
loose: semver.loose,
major: semver.major,
minor: semver.minor,
patch: semver.patch,
prerelease: semver.prerelease,
raw: semver.raw,
version: semver.version,
};
}
replaceParentExpression({ babel, parentPath, value });
}
/**
* Loads the nearest package.json file.
*
* @param {MethodParams} options
*/
function loadPackageJson({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const cwd = dirname(filename);
const node = getArgumentNode({ parentPath, required: false });
const key = node
? evaluateNodeValue({
node,
parentPath,
predicate: isStringOrUndefined,
})
: undefined;
const jsonValue = loadAndParsePackageJsonFile({ cwd, parentPath });
const value = key ? jsonValue[key] ?? null : jsonValue;
replaceParentExpression({ babel, parentPath, value });
}
/**
* Loads the nearest package.json file.
*
* @param {MethodParams} options
*/
function loadTsConfigJson({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const cwd = dirname(filename);
const node = getArgumentNode({ parentPath, required: false });
const searchName = node
? evaluateNodeValue({
node,
parentPath,
predicate: isStringOrUndefined,
})
: DEFAULT_SEARCH_NAME;
const result = tsconfigResolverSync({
cwd,
cache: CacheStrategy.Directory,
searchName,
});
if (!result.exists) {
frameError(
parentPath,
`No '${searchName}' file could be loaded from your current file. ${filename}`,
);
}
replaceParentExpression({ babel, parentPath, value: result.config });
}
/**
* Handles writing a single json file with an optional object path parameter.
*
* @param {MethodParams} options
*/
function writeJson({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const dir = dirname(filename);
const json = evaluateNodeValue({
node: getArgumentNode({
parentPath,
required: true,
maxArguments: 2,
index: 0,
}),
parentPath,
predicate: is.plainObject,
});
const relativeFilePath = evaluateNodeValue({
node: getArgumentNode({
parentPath,
required: true,
maxArguments: 2,
index: 1,
}),
parentPath,
predicate: is.string,
});
const filePath = resolve(dir, relativeFilePath);
// Make sure the file path exists.
ensureDirectoryExists(filePath);
// Write to the provided filePath.
writeFileSync(filePath, JSON.stringify(json, null, 2), { encoding: 'utf8' });
replaceParentExpression({ babel, parentPath, value: json });
}
/**
* Handles loading a single json file with an optional object path parameter.
*
* @param {MethodParams} options
*/
function loadJson({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const dir = dirname(filename);
const rawFilePath = evaluateNodeValue({
node: getArgumentNode({
parentPath,
required: true,
maxArguments: 2,
index: 0,
}),
parentPath,
predicate: is.string,
});
const path = evaluateNodeValue({
node: getArgumentNode({
parentPath,
required: false,
maxArguments: 2,
index: 1,
}),
parentPath,
predicate: isStringOrUndefined,
});
/** @type {string} */
let filePath;
try {
filePath = require.resolve(rawFilePath, { paths: [dir] });
} catch {
frameError(
parentPath,
`The provided path: '${rawFilePath}' does not exist`,
);
}
const jsonValue = loadAndParseJsonFile({ filePath, parentPath });
const value = path ? get(jsonValue, path) : jsonValue;
replaceParentExpression({ babel, parentPath, value });
}
/**
* Handles loading multiple json files by their glob pattern.
*
* @param {MethodParams} options
*/
function loadJsonFiles({ reference, state, babel }) {
const filename = getFileName(state);
const { parentPath } = reference;
const callExpressionPath = reference.parentPath;
const dir = dirname(filename);
const args = callExpressionPath.get('arguments');
const argsArray = Array.isArray(args) ? args : [args];
if (argsArray.length === 0) {
frameError(
parentPath,
`You must provide at least one file pattern string to the function call: '${parentPath.getSource()}'. If the value is dynamic, please make sure that its value is statically deterministic.`,
);
}
const globs = argsArray.map((node) => {
return evaluateNodeValue({
node,
parentPath,
predicate: is.string,
});
});
const files = glob.sync(globs, { cwd: dir });
if (files.length === 0) {
frameError(
parentPath,
`The file patterns provided didn't match any files: '${parentPath.getSource()}'. If the value is dynamic, please make sure that its value is statically deterministic.`,
);
}
const value = files.map((relativePath) =>
loadAndParseJsonFile({ filePath: resolve(dir, relativePath), parentPath }),
);
replaceParentExpression({ babel, parentPath, value });
}
/**
* Check to see if the provided reference name is used in this file. When it's
* available call the function for every occurrence.
*
* @param {CheckReferenceExistsParameter} options
*
* @returns {void}
*/
function checkReferenceExists(options) {
const { method, name, macroParameter } = options;
const { babel, references, state } = macroParameter;
const namedReferences = references[name];
if (!namedReferences) {
return;
}
for (const reference of namedReferences) {
const { parentPath } = reference;
if (!parentPath.isCallExpression()) {
throw frameError(
parentPath,
`'${name}' called from 'json.macro' must be used as a function call.`,
);
}
method({ babel, reference, state });
}
}
/** The supported methods for this macro */
const supportedMethods = [
{ name: 'writeJson', method: writeJson },
{ name: 'loadJson', method: loadJson },
{ name: 'loadJsonFiles', method: loadJsonFiles },
{ name: 'getVersion', method: getVersion },
{ name: 'loadPackageJson', method: loadPackageJson },
{ name: 'loadTsConfigJson', method: loadTsConfigJson },
];
/**
* The macro which is created and exported for usage in your project.
*/
export default createMacro((macroParameter) => {
for (const supportedMethod of supportedMethods) {
const { name, method } = supportedMethod;
checkReferenceExists({ name, method, macroParameter });
}
});