UNPKG

@hi18n/cli

Version:

Message internationalization meets immutability and type-safety - command line tool

279 lines (251 loc) 8.22 kB
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"]); }