@hi18n/cli
Version:
Message internationalization meets immutability and type-safety - command line tool
228 lines • 9.62 kB
JavaScript
import { TSESLint } from "@typescript-eslint/utils";
import fs from "node:fs";
import { glob } from "glob";
import path from "node:path";
import resolve from "resolve";
import { getCollectBookDefinitionsRule, getCollectCatalogDefinitionsRule, getCollectTranslationIdsRule, noMissingTranslationIdsInTypesRule, noMissingTranslationIdsRule, noUnusedTranslationIdsInTypesRule, noUnusedTranslationIdsRule, serializedLocations, serializeReference, } from "@hi18n/eslint-plugin/internal-rules";
import { loadConfig } from "./config.js";
export async function sync(options) {
const { cwd: projectPath, include: includeFromOpt, exclude: excludeFromOpt, } = options;
const config = await loadConfig(projectPath);
const include = config.include ?? includeFromOpt;
const exclude = config.exclude ?? excludeFromOpt;
if (include === undefined || include.length === 0) {
throw new Error("No include specified");
}
const translationUsages = [];
const bookDefs = [];
const catalogDefs = [];
const linterConfig = {
languageOptions: {
parser: config.parser,
parserOptions: config.parserOptions,
},
plugins: {
"@hi18n": {
rules: {
"collect-translation-ids": getCollectTranslationIdsRule((record) => translationUsages.push(record)),
"collect-book-definitions": getCollectBookDefinitionsRule((record) => bookDefs.push(record)),
"collect-catalog-definitions": getCollectCatalogDefinitionsRule((record) => catalogDefs.push(record)),
"no-missing-translation-ids": noMissingTranslationIdsRule,
"no-unused-translation-ids": noUnusedTranslationIdsRule,
"no-missing-translation-ids-in-types": noMissingTranslationIdsInTypesRule,
"no-unused-translation-ids-in-types": noUnusedTranslationIdsInTypesRule,
},
},
},
};
const linter = new TSESLint.Linter({ cwd: projectPath });
const files = [];
for (const includeGlob of include) {
files.push(...(await glob(includeGlob, {
cwd: projectPath,
nodir: true,
ignore: exclude ?? [],
})));
}
for (const relative of files) {
const filename = path.join(projectPath, relative);
const source = await fs.promises.readFile(filename, "utf-8");
const messages = linter.verify(source, [
{ files: ["**"] },
{
...linterConfig,
rules: {
"@hi18n/collect-translation-ids": "error",
"@hi18n/collect-book-definitions": "error",
"@hi18n/collect-catalog-definitions": "error",
},
},
], { filename });
checkMessages(relative, messages);
}
const linkage = {};
const usedTranslationIds = {};
const rewriteTargetFiles = new Set();
for (const u of translationUsages) {
const loc = u.bookLocation;
if (loc.path !== undefined) {
const { resolved } = await resolveWithFallback(removeExtension(loc.path, config.extensionsToRemove), {
basedir: path.dirname(loc.base),
extensions: config.extensions,
}, config.baseUrl, config.paths);
loc.path = resolved;
}
const locName = serializeReference(loc);
if (hasOwn(usedTranslationIds, locName)) {
usedTranslationIds[locName].push(u.id);
}
else {
setRecordValue(usedTranslationIds, locName, [u.id]);
}
rewriteTargetFiles.add(loc.path !== undefined ? loc.path : loc.base);
}
for (const bookDef of bookDefs) {
const bookLocNames = serializedLocations(bookDef.bookLocation);
const concatenatedTranslationIds = bookLocNames.flatMap((locName) => hasOwn(usedTranslationIds, locName) ? usedTranslationIds[locName] : []);
const uniqueTranslationIds = Array.from(new Set(concatenatedTranslationIds)).sort();
for (const locName of bookLocNames) {
setRecordValue(usedTranslationIds, locName, uniqueTranslationIds);
}
const primaryName = bookLocNames[0];
for (const catalogLink of bookDef.catalogLinks) {
const loc = catalogLink.catalogLocation;
if (loc.path !== undefined) {
const { resolved } = await resolveWithFallback(removeExtension(loc.path, config.extensionsToRemove), {
basedir: path.dirname(loc.base),
extensions: config.extensions,
}, config.baseUrl, config.paths);
loc.path = resolved;
}
setRecordValue(linkage, serializeReference(loc), primaryName);
rewriteTargetFiles.add(loc.path !== undefined ? loc.path : loc.base);
}
rewriteTargetFiles.add(bookDef.bookLocation.path);
}
const valueHints = {};
// Set up passive importing
if (config.connector) {
const c = config.connector.connector(config.configPath, config.connectorOptions);
if (c.importData) {
const data = await c.importData();
for (const [locale, catalog] of Object.entries(data.translations)) {
const vhCatalog = (valueHints[locale] ??= {});
for (const [id, msg] of Object.entries(catalog)) {
vhCatalog[id] ??= msg.raw;
}
}
}
}
for (const rewriteTargetFile of Array.from(rewriteTargetFiles).sort()) {
const original = await fs.promises.readFile(rewriteTargetFile, "utf-8");
let current = await fs.promises.readFile(rewriteTargetFile, "utf-8");
function applyFix(rules) {
const report = linter.verifyAndFix(current, [
{ files: ["**"] },
{
...linterConfig,
rules,
settings: {
"@hi18n/linkage": linkage,
"@hi18n/used-translation-ids": usedTranslationIds,
"@hi18n/value-hints": valueHints,
},
},
], { filename: rewriteTargetFile });
checkMessages(rewriteTargetFile, report.messages);
if (report.fixed) {
current = report.output;
}
return report.fixed;
}
applyFix({ "@hi18n/no-unused-translation-ids": "warn" });
applyFix({ "@hi18n/no-unused-translation-ids-in-types": "warn" });
applyFix({ "@hi18n/no-missing-translation-ids": "warn" });
applyFix({ "@hi18n/no-missing-translation-ids-in-types": "warn" });
if (current !== original) {
if (options.checkOnly) {
throw new Error(`Found diff in ${path.relative(projectPath, rewriteTargetFile)}`);
}
await fs.promises.writeFile(rewriteTargetFile, current, "utf-8");
}
}
}
function checkMessages(filepath, messages) {
for (const message of messages) {
if (/^Definition for rule .* was not found\.$/.test(message.message)) {
// We load ESLint with minimal rules. Ignore the "missing rule" error.
continue;
}
if (message.severity >= 2) {
throw new Error(`Error on ${filepath}: ${message.message}`);
}
else if (message.severity >= 1) {
console.warn(`Warning on ${filepath}: ${message.message}`);
}
}
}
function removeExtension(id, extensionsToRemove) {
for (const ext of extensionsToRemove) {
if (id.endsWith(ext)) {
return id.substring(0, id.length - ext.length);
}
}
return id;
}
async function resolveWithFallback(id, opts, baseUrl, paths) {
if (baseUrl && isPackageLikePath(id)) {
const matchers = Object.entries(paths ?? {});
matchers.push(["*", ["*"]]);
for (const [matcher, candidates] of matchers) {
let replacement = undefined;
if (id === matcher) {
replacement = "";
}
else if (matcher.endsWith("*") &&
id.startsWith(matcher.substring(0, matcher.length - 1))) {
replacement = id.substring(matcher.length - 1);
}
if (replacement === undefined)
continue;
for (const candidate of candidates) {
try {
return await resolveAsPromise(path.resolve(baseUrl, candidate.replace("*", replacement)), opts);
}
catch (_e) {
// Likely MODULE_NOT_FOUND
}
}
}
}
return await resolveAsPromise(id, opts);
}
function resolveAsPromise(id, opts) {
return new Promise((resolvePromise, rejectPromise) => {
resolve(id, opts, (err, resolved, pkg) => {
if (err)
rejectPromise(err);
else
resolvePromise({ resolved: resolved, pkg });
});
});
}
function isPackageLikePath(p) {
const firstSegment = p.split(/[/\\]/)[0];
return firstSegment !== "." && firstSegment !== ".." && !path.isAbsolute(p);
}
function hasOwn(record, key) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function setRecordValue(record, key, value) {
Object.defineProperty(record, key, {
value,
writable: true,
configurable: true,
enumerable: true,
});
}
//# sourceMappingURL=sync.js.map