#!/usr/bin/env node /** * A plugin's strings live in its own locales/en.json. * * A plugin's i18next namespace falls back to core's, so a plugin can use * `common.*` without copying it. Any other key a plugin uses has to be in the * plugin's own en.json: one that only resolves through the fallback breaks * the moment core renames or drops it, and core cannot tell it is in use. * * Reads t("...") calls and i18n key props (titleKey, labelKey and the rest) * in plugins//src, and the *Key values in plugins//manifest.json. * A key with a namespace ("docker:hosts.x") is checked against that plugin. * * --list prints what is missing per plugin, with whether core has the key. */ const fs = require("node:fs"); const path = require("node:path"); const ROOT = path.resolve(__dirname, ".."); const PLUGINS = path.join(ROOT, "plugins"); const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json"); const SHARED_PREFIXES = ["common."]; const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/; const KEY_PROPS = new Set([ "titleKey", "labelKey", "descriptionKey", "noHostMessageKey", "paletteKey", "hintKey", "messageKey", "placeholderKey", "tooltipKey", "i18nKey", "enableLabelKey", "enableDescriptionKey", "consequenceKey", "groupKey", "emptyKey", "nameKey", ]); const PLURAL_SUFFIXES = [ "_zero", "_one", "_two", "_few", "_many", "_other", "_plural", ]; function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } /** * Whether a dotted key resolves in a locale tree, the way i18next finds it: * a segment may itself hold dots ("services.use"), and a last segment may be * a plural form. */ function hasKey(tree, key) { const find = (node, parts) => { if (parts.length === 0) return node !== undefined; if (!node || typeof node !== "object") return false; for (let end = parts.length; end >= 1; end--) { const name = parts.slice(0, end).join("."); if (name in node && find(node[name], parts.slice(end))) return true; if ( end === parts.length && PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node) ) { return true; } } return false; }; return find(tree, key.split(".")); } function isShared(key) { return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix)); } function walk(dir, out = []) { if (!fs.existsSync(dir)) return out; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full, out); else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full); } return out; } /** Drops comments, so an example in one is not read as a call. */ function stripComments(source) { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } /** * Every literal i18n key a source file uses. A prefix a key is built from at * run time ends in ".*". */ function keysInSource(text) { const source = stripComments(text); const keys = new Set(); for (const match of source.matchAll( /\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g, )) { keys.add(match[2]); } for (const match of source.matchAll( /\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g, )) { if (KEY_PROPS.has(match[1])) keys.add(match[3]); } // A key built at run time: `hosts.sharing.levels.${level}.label` needs the // whole hosts.sharing.levels subtree. A one-segment prefix says too little // to check. for (const match of source.matchAll( /\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g, )) { keys.add(`${match[1]}*`); } return [...keys].filter((key) => KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")), ); } function keysInManifest(value, out = new Set()) { if (Array.isArray(value)) { for (const item of value) keysInManifest(item, out); } else if (value && typeof value === "object") { for (const [name, child] of Object.entries(value)) { if (KEY_PROPS.has(name) && typeof child === "string") out.add(child); else keysInManifest(child, out); } } return out; } function pluginIds(pluginsDir) { return fs .readdirSync(pluginsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json"))) .sort(); } /** * Keys each plugin uses but does not define, other than the shared ones. * Returns { [pluginId]: [{ key, file, inCore }] }. */ function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) { const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {}; const ids = pluginIds(pluginsDir); const locales = new Map( ids.map((id) => { const file = path.join(pluginsDir, id, "locales", "en.json"); return [id, fs.existsSync(file) ? readJson(file) : {}]; }), ); const missing = {}; const check = (owner, rawKey, file) => { const colon = rawKey.indexOf(":"); const namespace = colon > 0 ? rawKey.slice(0, colon) : owner; const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey; if (isShared(key)) return; const tree = locales.get(namespace); const lookup = key.endsWith(".*") ? key.slice(0, -2) : key; if (tree && hasKey(tree, lookup)) return; const inCore = hasKey(core, lookup); // A bare word that is nowhere is a local helper named t, not a string. if (!key.includes(".") && !inCore) return; const list = (missing[owner] ??= []); if (!list.some((entry) => entry.key === rawKey)) { list.push({ key: rawKey, file: path.relative(ROOT, file).replaceAll("\\", "/"), inCore, }); } }; for (const id of ids) { const manifestFile = path.join(pluginsDir, id, "manifest.json"); for (const key of keysInManifest(readJson(manifestFile))) { if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) { check(id, key, manifestFile); } } for (const file of walk(path.join(pluginsDir, id, "src"))) { for (const key of keysInSource(fs.readFileSync(file, "utf8"))) { check(id, key, file); } } } for (const list of Object.values(missing)) { list.sort((a, b) => a.key.localeCompare(b.key)); } return missing; } function main() { const missing = scan(); if (process.argv.includes("--list")) { console.log(JSON.stringify(missing, null, 2)); return; } const entries = Object.entries(missing); if (entries.length === 0) return; console.error( "Plugins use i18n keys their own locales/en.json does not define:", ); for (const [id, list] of entries) { for (const { key, file } of list) { console.error(` ${id}: ${key} (${file})`); } } process.exit(1); } if (require.main === module) main(); module.exports = { scan, keysInSource, hasKey };