import fs from 'node:fs';
import path from 'node:path';
import gettext_parser from "gettext-parser";
const config = {};
const DEFAULT_WRAPPER = 'cockpit.locale(PO_DATA);';
const RTL_LANGS = new Set(["ar", "fa", "he", "ur"]);
function get_po_files() {
try {
return fs.readdirSync(config.podir)
.filter(file => file.endsWith('.po'))
.map(file => path.join(config.podir, file));
} catch (error) {
return [];
}
}
* @param {string} statement - a Plural-Forms header value, e.g. "nplurals=2; plural=n != 1;"
* @returns {string} a JS arrow function wrapping the plural expression, e.g. "((n) => n != 1)"
* @throws {Error} if the statement is not a valid Plural-Forms header
*/
function plural_forms_as_arrow(statement) {
const match = statement.match(/^nplurals=[1-9]; plural=([^;]*);?$/);
if (!match) {
throw Error(`invalid Plural-Forms: ${JSON.stringify(statement)}`);
}
const expr = match[1].trim();
*
* The string following plural is an expression which is using the C
* language syntax. Exceptions are that no negative numbers are allowed,
* numbers must be decimal, and the only variable allowed is n. Spaces
* are allowed in the expression, but backslash-newlines are not.
*
* https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html
*
* The described language also happens to be valid JavaScript, but we need
* to make sure that the user is following the rules. That's relatively
* easy to do in a single pass if we keep track of open brackets and don't
* care about order of operations.
*/
let i = 0;
const consume = str => {
if (expr.startsWith(str, i)) {
i += str.length;
return true;
}
return false;
};
let have_rvalue = false;
const stack = [];
while (i < expr.length) {
if (/\s/.test(expr[i])) {
i++;
} else if (have_rvalue) {
if (['==', '!=', '<=', '>=', '&&', '||', '<<', '>>',
'<', '>', '%', '+', '-', '&', '|', '^', '*', '/'].some(consume)) {
have_rvalue = false;
} else if (consume('?')) {
have_rvalue = false;
stack.push('?');
} else if (consume(':')) {
if (stack.pop() !== '?') {
throw Error(`unexpected ':' at position ${i - 1}`);
}
have_rvalue = false;
} else if (consume(')')) {
if (stack.pop() !== '(') {
throw Error(`unexpected ')' at position ${i - 1}`);
}
} else {
throw Error(`expected operator near ${JSON.stringify(expr.slice(i))}`);
}
} else {
if (consume('++') || consume('--')) {
throw Error(`increment/decrement not permitted`);
} else if (['!', '~', '+', '-'].some(consume)) {
} else if (consume('(')) {
stack.push('(');
} else if (consume('n')) {
have_rvalue = true;
} else if (/\d/.test(expr[i])) {
do {
i++;
} while (/\d/.test(expr[i]));
have_rvalue = true;
} else {
throw Error(`expected rvalue near ${JSON.stringify(expr.slice(i))}`);
}
}
}
if (!have_rvalue) {
throw Error('incomplete expression');
}
if (stack.length !== 0) {
throw Error(`unclosed '${stack.pop()}'`);
}
return `((n) => ${expr})`;
}
function parsePo(po_file) {
const po_data = fs.readFileSync(po_file, 'utf8')
.split('\n')
.filter(line => !line.startsWith('#~'))
.join('\n');
const parsed = gettext_parser.po.parse(po_data, { defaultCharset: 'utf8', validation: true });
delete parsed.translations[""][""];
const dir = RTL_LANGS.has(parsed.headers.Language) ? "rtl" : "ltr";
let plural_forms;
try {
plural_forms = plural_forms_as_arrow(parsed.headers['Plural-Forms']);
} catch (e) {
throw Error(`${po_file}: Plural-Forms: ${e.message}`);
}
return { parsed, dir, plural_forms };
}
function buildFile(data, subdir, filename, filter) {
const parsed = data.parsed;
const dir = data.dir;
const chunks = [
'{\n',
' "": {\n',
` "plural-forms": ${data.plural_forms},\n`,
` "language": ${JSON.stringify(parsed.headers.Language)},\n`,
` "language-direction": "${dir}"\n`,
' }'
];
for (const [msgctxt, context] of Object.entries(parsed.translations)) {
const context_prefix = msgctxt ? msgctxt + '\u0004' : '';
for (const [msgid, translation] of Object.entries(context)) {
const references = translation.comments.reference.split(/\s/);
if (!references.some(str => str.startsWith(`pkg/${subdir}`) || str.startsWith(config.src_directory) || str.startsWith(`pkg/lib`)))
continue;
if (translation.comments.flag?.match(/\bfuzzy\b/))
continue;
if (!references.some(filter))
continue;
const key = JSON.stringify(context_prefix + msgid);
chunks.push(`,\n ${key}: [\n null`);
for (const str of translation.msgstr) {
chunks.push(',\n ' + JSON.stringify(str));
}
chunks.push('\n ]');
}
}
chunks.push('\n}');
const wrapper = config.wrapper?.(subdir) || DEFAULT_WRAPPER;
const output = wrapper.replace('PO_DATA', chunks.join('')) + '\n';
const out_path = path.join(subdir ? (subdir + '/') : '', filename);
fs.writeFileSync(path.resolve(config.outdir, out_path), output);
}
function init(options) {
config.podir = options.podir || path.resolve(process.env.SRCDIR || './', 'po');
config.subdirs = options.subdirs || [''];
config.src_directory = options.src_directory || 'src';
config.wrapper = options.wrapper;
config.outdir = options.outdir || './dist';
}
function run() {
const parsedPoFiles = get_po_files().map(po_file => ({
data: parsePo(po_file),
lang: path.basename(po_file).slice(0, -3),
}));
for (const subdir of config.subdirs) {
for (const { data, lang } of parsedPoFiles) {
buildFile(data, subdir, `po.${lang}.js`, str => !str.includes('manifest.json'));
buildFile(data, subdir, `po.manifest.${lang}.js`, str => str.includes('manifest.json'));
}
}
}
export const cockpitPoEsbuildPlugin = options => ({
name: 'cockpitPoEsbuildPlugin',
setup(build) {
init({ ...options, outdir: build.initialOptions.outdir });
build.onEnd(result => { result.errors.length === 0 && run() });
},
});
function plural_forms_self_test() {
const pf = expr => `nplurals=2; plural=${expr};`;
const cases = [
[true, pf('0')],
[true, pf('1')],
[true, pf('n')],
[true, pf('n != 1')],
[true, pf('n > 1')],
[true, pf('(n>1)')],
[true, pf('(n != 1)')],
[true, pf('n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5')],
[true, pf('(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2')],
[true, pf('n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2')],
[true, pf('(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && n % 10 == 0) ? 2 : 3))')],
[true, pf('n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2')],
[true, pf('n & 1')],
[true, pf('n | 1')],
[true, pf('n ^ 1')],
[true, pf('n << 1')],
[true, pf('n >> 1')],
[true, pf('n * 2')],
[true, pf('n / 2')],
[true, pf('n + 1')],
[true, pf('n - 1')],
[true, pf('!n')],
[true, pf('~n')],
[true, pf('+n')],
[true, pf('-n')],
[true, pf('!!n')],
[true, pf('~~~n')],
[true, pf('+-~!n')],
[true, pf('((n))')],
[true, pf('(((n)))')],
[true, pf('n ? n : n')],
[true, pf('n ? n ? n : n : n')],
[true, pf('(n ? n : n)')],
[true, pf(' n ')],
[true, pf('\tn\t')],
[true, pf('n\n!=\n1')],
[false, pf('n; process.exit(1)')],
[false, pf('n + require("fs")')],
[false, pf('(function(){})()')],
[false, pf('eval(n)')],
[false, pf('n[0]')],
[false, pf('n[1]')],
[false, pf('n]')],
[false, pf('{n}')],
[false, pf('n}')],
[false, pf('n, 1')],
[false, pf('n.length')],
[false, pf('foo')],
[false, pf('bar + n')],
[false, pf('$n')],
[false, pf('_n')],
[false, pf('window.n')],
[false, pf('n || (x=1)')],
[false, pf('nn')],
[false, pf('nnn')],
[false, pf('n n')],
[false, pf('')],
[false, pf('?')],
[false, pf(':')],
[false, pf(')')],
[false, pf('(')],
[false, pf('()')],
[false, pf('n ?')],
[false, pf('n ? 1')],
[false, pf('(n')],
[false, pf('n)')],
[false, pf('n ? (1 : 2)')],
[false, pf('(n ? 1) : 2')],
[false, pf('n ? 1 : 2 : 3')],
[false, pf(')n(')],
[false, pf('((n)')],
[false, pf('n ? ? 1 : 2')],
[false, pf('n = 1')],
[false, pf('n === 1')],
[false, pf('n += 1')],
[false, pf('n++')],
[false, pf('++n')],
[false, pf('n ** 2')],
[false, pf('n >>> 1')],
[false, pf('n;')],
[false, pf('1.5')],
[false, pf('0x10')],
[false, pf('1e5')],
[false, pf('"n"')],
[false, pf("'n'")],
[false, pf('`n`')],
[false, 'bad'],
[false, 'plural=n;'],
[false, 'nplurals=0; plural=n;'],
[false, 'nplurals=2; plural=n; extra;'],
];
for (const [expected, stmt] of cases) {
let actual;
try {
plural_forms_as_arrow(stmt);
actual = true;
} catch (e) {
actual = false;
}
if (actual !== expected) {
throw Error(`plural_forms_as_arrow self-test failed for ${JSON.stringify(stmt)}: expected ${expected}`);
}
}
}
plural_forms_self_test();