@hi18n/cli
Version:
Message internationalization meets immutability and type-safety - command line tool
279 lines (251 loc) • 8.22 kB
text/typescript
import path from "node:path";
import { cosmiconfig } from "cosmiconfig";
import { ParserServices, TSESLint, TSESTree } from "@typescript-eslint/utils";
import resolve from "resolve";
import { Connector } from "@hi18n/tools-core";
import * as jsonMfConnector from "./json-mf-connector";
const explorer = cosmiconfig("hi18n");
export type ParserSpec = string | ParserDependency;
export type ParserDependency = {
definition: ParserDefinition;
filePath: string;
};
export type ParserDefinition = ESLintParser | GenericParser;
export type ESLintParser = {
parseForESLint: (
source: string,
options: TSESLint.ParserOptions
) => ESLintParserResult<TSESTree.Program>;
};
export type GenericParser = {
parse: (source: string, options: TSESLint.ParserOptions) => TSESTree.Program;
};
export type ESLintParserResult<T> = {
ast: T;
services?: ParserServices | undefined;
visitorKeys?: Record<string, string[]> | undefined;
scopeManager?: TSESLint.Scope.ScopeManager | undefined;
};
export type ConnectorSpec = string | ConnectorDependency;
export type ConnectorDependency = {
connector: Connector;
};
const DEFAULT_EXTENSIONS = [
".js",
".cjs",
".mjs",
".ts",
".cts",
".mts",
".jsx",
".tsx",
];
const DEFAULT_EXTENSIONS_TO_REMOVE = [".js", ".cjs", ".mjs"];
const DEFAULT_PARSER_OPTIONS: TSESLint.ParserOptions = {
ecmaVersion: "latest",
sourceType: "module",
};
const configKeys = [
"include",
"exclude",
"parser",
"parserOptions",
"extensions",
"extensionsToRemove",
"baseUrl",
"paths",
"connector",
"connectorOptions",
];
export type Config = {
configPath: string;
include?: string[] | undefined;
exclude?: string[] | undefined;
parser: ParserSpec;
parserOptions: TSESLint.ParserOptions;
extensions: string[];
extensionsToRemove: string[];
baseUrl?: string | undefined;
paths?: Record<string, string[]> | undefined;
connector: ConnectorDependency | undefined;
connectorOptions: unknown;
};
export async function loadConfig(cwd: string): Promise<Config> {
const cosmiconfigResult = (await explorer.search(cwd)) ?? {
config: {},
filepath: path.join(cwd, ".hi18nrc.json"),
isEmpty: true,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { config, filepath } = cosmiconfigResult;
if (!isObject(config)) {
throw new Error("config: not an object");
}
if (!optional(isArrayOf(isString))(config["include"])) {
throw new Error("config.include: not an array of strings");
}
if (!optional(isArrayOf(isString))(config["exclude"])) {
throw new Error("config.exclude: not an array of strings");
}
if (!optional(oneof(isString, isParserDependency))(config["parser"])) {
throw new Error("config.parser: not a string nor a parser object");
}
if (!optional(isObject)(config["parserOptions"])) {
throw new Error("config.parserOptions: not an object");
}
if (!optional(isArrayOf(isString))(config["extensions"])) {
throw new Error("config.extensions: not an array of strings");
}
if (!optional(isArrayOf(isString))(config["extensionsToRemove"])) {
throw new Error("config.extensionsToRemove: not an array of strings");
}
if (!optional(isString)(config["baseUrl"])) {
throw new Error("config.baseUrl: not a string");
}
if (!optional(isRecordOf(isArrayOf(isString)))(config["paths"])) {
throw new Error("config.paths: not a record of arrays of strings");
}
if (!optional(oneof(isString, isConnectorDependency))(config["connector"])) {
throw new Error("config.connector: not a string nor a parser object");
}
if (!optional(isObject)(config["connectorOptions"])) {
throw new Error("config.connectorOptions: not an object");
}
for (const key of Object.keys(config)) {
if (!configKeys.includes(key)) {
throw new Error(`Unrecognized config: ${key}`);
}
}
const include = config["include"];
const exclude = config["exclude"];
const parser = resolveParser(config["parser"], filepath);
const parserOptions =
(config["parserOptions"] as TSESLint.ParserOptions | undefined) ??
DEFAULT_PARSER_OPTIONS;
const extensions = expandExtensions(config["extensions"]);
const extensionsToRemove = expandExtensionsToRemove(
config["extensionsToRemove"]
);
const baseUrl = expandBaseUrl(config["baseUrl"], filepath);
const paths = config["paths"];
if (paths && !baseUrl) {
throw new Error("baseUrl must be specified");
}
const connector = resolveConnector(config["connector"], filepath);
const connectorOptions = config["connectorOptions"] ?? {};
return {
configPath: filepath,
include,
exclude,
parser,
parserOptions,
extensions,
extensionsToRemove,
baseUrl,
paths,
connector,
connectorOptions,
};
}
function resolveParser(
parser: ParserSpec | undefined,
filepath: string
): ParserDependency {
if (typeof parser === "string") {
const parserPath = resolve.sync(parser, {
basedir: path.dirname(filepath),
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { definition: require(parserPath), filePath: parserPath };
} else if (typeof parser === "object") {
return parser;
}
return {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
definition: require("@babel/eslint-parser"),
filePath: require.resolve("@babel/eslint-parser"),
};
}
function resolveConnector(
connector: ConnectorSpec | undefined,
filepath: string
): ConnectorDependency | undefined {
if (connector === "@hi18n/cli/json-mf-connector") {
return jsonMfConnector;
} else if (typeof connector === "string") {
const connectorPath = resolve.sync(connector, {
basedir: path.dirname(filepath),
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return require(connectorPath);
} else if (typeof connector === "object") {
return connector;
}
return undefined;
}
function expandExtensions(extensions: string[] | undefined): string[] {
if (extensions === undefined) return DEFAULT_EXTENSIONS;
return extensions.flatMap((ext) =>
ext === "..." ? DEFAULT_EXTENSIONS : [ext]
);
}
function expandExtensionsToRemove(
extensionsToRemove: string[] | undefined
): string[] {
if (extensionsToRemove === undefined) return DEFAULT_EXTENSIONS_TO_REMOVE;
return extensionsToRemove.flatMap((ext) =>
ext === "..." ? DEFAULT_EXTENSIONS_TO_REMOVE : [ext]
);
}
function expandBaseUrl(
baseUrl: string | undefined,
filepath: string
): string | undefined {
if (baseUrl === undefined) return undefined;
return path.resolve(path.dirname(filepath), baseUrl);
}
function isObject(x: unknown): x is object & Record<string, unknown> {
return typeof x === "object";
}
function isString(x: unknown): x is string {
return typeof x === "string";
}
// eslint-disable-next-line @typescript-eslint/ban-types
function isFunction(x: unknown): x is Function {
return typeof x === "function";
}
function isArrayOf<T>(pred: (x: unknown) => x is T) {
return function isArrayOf(x: unknown): x is T[] {
return Array.isArray(x) && x.every((value) => pred(value));
};
}
function isRecordOf<T>(pred: (x: unknown) => x is T) {
return function isRecordOf(x: unknown): x is Record<string, T> {
return isObject(x) && Object.values(x).every((value) => pred(value));
};
}
function optional<T>(pred: (x: unknown) => x is T) {
return function optional(x: unknown): x is T | undefined {
return x === undefined || pred(x);
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function oneof<Types extends any[]>(
...preds: { [K in keyof Types]: (x: unknown) => x is Types[K] }
) {
return function oneof(x: unknown): x is Types[number] {
return preds.some((pred) => pred(x));
};
}
function isParserDependency(x: unknown): x is ParserDependency {
return (
isObject(x) &&
isString(x["filePath"]) &&
isObject(x["definition"]) &&
(isFunction(x["definition"]["parseForESLint"]) ||
isFunction(x["definition"]["parse"]))
);
}
function isConnectorDependency(x: unknown): x is ConnectorDependency {
return isObject(x) && isFunction(x["connector"]);
}